Non-Negative Stocks, Biflows and Conveyors
system dynamics, systemdynamics, sd dsl, non-negative stock, biflow, outflow priority, conveyor, agecount, fifo, bptk, bptk-py, python, business simulation
Non-Negative Stocks, Biflows and Conveyors
A stock is the sum of everything that has flowed in, less everything that has flowed out. Nothing in that arithmetic stops it at zero: if the outflows ask for more than the stock holds, the stock goes negative. For a bank balance that may be what you want. A warehouse, a population or a forest cannot hold less than nothing.
A non-negative stock solves this the way Stella and XMILE do: not by holding the stock at zero, but by cutting back its outflows so that they take no more than there is. This page shows how to build one, how the outflows share what is left, how biflows behave at such a stock, and why simply writing max(0, ...) is not the same thing.
The page has three parts: non-negative stocks, biflows - flows that run either way, and what they do at a non-negative stock - and conveyors, stocks that work like a conveyor belt.
Non-Negative Stocks
Inflows and Outflows as Lists
Usually a stock’s equation is written as an expression, inflows minus outflows. A stock can also be given its flows as two lists. The equation is then written for you - here receipts - shipments - and the depot grows by one unit a day:
The two lists are what a non-negative stock needs: it has to know which flows leave it, and in which order to serve them.
A Warehouse That Runs Empty
The warehouse below starts with 60 units and receives a delivery every day. Two outflows draw on it: sales, and scrap - goods that are written off. Together they want more than is delivered, so the warehouse runs empty after a while.
The model holds four versions of the warehouse side by side, all driven by the same constants:
warehouseis non-negative and serves sales firstwarehouse_scrap_firstis non-negative too, with the outflows in the other orderwarehouse_unboundedis an ordinary stockwarehouse_clampedis held at zero withmax, the way an approximation would do it
The last three stocks keep the books: received is everything that ever arrived, the initial 60 included, and the two shipped stocks add up what left the non-negative and the clamped warehouse.
What the tabs show, with the default settings:
- Stock. The unbounded warehouse goes negative once it is empty. The non-negative one and the clamped one both stay at zero - on this chart they look the same.
- Outflows. Once the warehouse is empty, on day 30, the outflows of the non-negative warehouse share what is delivered each day, 10 units: sales gets its 8 in full, scrap the remaining 2.
- Priority. With the order reversed, scrap gets its 4 and sales only 6. The order of the
outflowslist decides who goes short. - Books. Here the two zero lines part. The non-negative warehouse never ships more than it received. The clamped one goes on reporting 12 units leaving per day while only 10 arrive: from the day it runs empty, the gap between
shipped_clampedandreceivedis material that came out of nothing.
How the Outflows Are Cut Back
In each timestep an outflow of a non-negative stock takes what its equation asks for, but at most
\[ \frac{stock}{dt} + \sum inflows - \sum outflows\ served\ before\ it \]
The first outflow in the list is served first, the second gets what is left, and so on. The flows report what they actually took - sales and scrap above are the cut-back values, not the demand. That is the difference to max(0, ...): holding the stock at zero hides the shortage in the stock, while the outflows go on as if nothing had happened.
The limit is a rate. With a smaller dt the same stock supports larger outflows for a shorter time; what leaves within one timestep never exceeds what the stock held plus what arrived.
Three rules come with it:
- A non-negative stock needs its lists. A stock whose equation was written directly raises an error when it is made non-negative, and writing the equation of a non-negative stock raises as well.
- An outflow that depends on another outflow of the same stock comes after it. The other way round it would have to wait for itself; the error names both flows.
- An inflow that depends on an outflow of the same stock does not count towards what the outflows may take. Only the stock’s content does, as in Stella.
Arrayed Stocks
An arrayed stock is cut back cell by cell. Its outflows have its shape, and each cell serves its own outflows in the order of the list. Here two warehouses start with different stock and face the same demand:
The south warehouse ships its 2 units on the first day and nothing after; the north one ships 3 a day until its last unit goes on day three.
Non-negative stocks run on the Python engine for now. Asking for backend="rust" raises RustBackendError naming the stock. The details of the API are on the Stock page.
Biflows
A biflow can move material in either direction, so which list it is in does not tell on its own whether it fills the stock or drains it. The sign does. In the inflows list a positive biflow brings material in, as any inflow would; a negative one takes material out - it is an outflow in effect. In the outflows list it is the other way round: positive, it takes material out; negative, it brings material in - an inflow in effect.
A non-negative stock goes by what the biflow actually does at the moment:
| Listed in | Value | What it does | At a non-negative stock |
|---|---|---|---|
inflows |
positive | fills | counts towards what the outflows may take |
inflows |
negative | drains | cut back, served after every outflow |
outflows |
positive | drains | cut back, served at its place in the list |
outflows |
negative | fills | counts towards what the outflows may take |
A biflow that fills the stock is never cut back - nothing can make a stock negative by adding to it. A biflow that drains it is cut back like any outflow. Among the outflows it already has a place in the order; among the inflows it has none, so it comes last, after every outflow, and several of them are served in the order of the inflows list.
The store below shows all four cases. It receives a supply of 5 units a day and sells 3. Two biflows are attached to it:
returns, among the inflows: goods coming back from customers when positive, goods sent back to the supplier when negativetransfer, among the outflows, after sales: goods sent to a branch when positive, goods received from the branch when negative
With the default settings the store sends 4 units a day back to the supplier and 2 to the branch, more than the supply leaves, so it runs empty on day 5. From then on the 5 units supplied each day go to sales first (3), then to the transfer, the next outflow in the list (2), and the returns, which drain the stock from the inflows list, come last and get nothing.
Things to try:
- Set the transfer to -4: the branch now sends goods, the transfer fills the store and counts as supply. With the returns at -10 the store is empty again, and the returns get what sales leaves of the 9 units available: 6.
- Set the returns to +4 and the transfer to +8: the returns now fill the store and count, 9 units are available, sales takes 3 and the transfer is cut back to 6 once the store is empty.
Conveyors
A conveyor is a stock that works like a conveyor belt: material put on at one end travels along it and comes off at the other end after the transit time - every unit stays exactly that long. Leakage flows take a share of it on the way.
Take the age group from 20 to 40. Those turning 20 enter it; 20 years later they leave as 40-year-olds, and on the way some die. An ordinary stock with an outflow of stock / 20 gets the average right but nothing else: some of its members “turn 40” after a year, others after sixty. A conveyor keeps the age exact.
The conveyor computes its outflow and its leakage itself, so those flows are created without an equation. The leakage gets its share in leaks:
For comparison the same model holds an ordinary stock that loses pool / years and pool * mortality a year:
The model starts empty, and from year 10 on 150 instead of 100 turn 20 each year. What the tabs show:
- Leaving. Nobody leaves the conveyor for the first 20 years - nobody has been on it long enough. Then those who survived the 20 years leave, and the rise of year 10 arrives at the front exactly 20 years later, as a step. The ordinary stock lets people leave from the first year on, and its outflow bends towards the new level instead of stepping.
- Dying. The conveyor’s deaths grow while the belt fills and stop growing once it is full; with exponential leakage they are 2 % of everyone on the belt.
- On the belt.
aged_20_to_25isagecount(cohort, 0, 5)- the material that has been on the belt for less than five years.
Leakage, linear or exponential
What a share in leaks means depends on exponential_leak:
exponential_leak |
The share is | 0.2 means |
|---|---|---|
False - the default, as in XMILE |
the part of each cohort that leaks out over its whole time on the belt, in equal amounts each timestep | 20 % of what entered leak out before the rest leaves |
True |
a rate per time unit, taken from what is still on the belt | 20 % of the remainder leak out each time unit |
Several linear leaks are served in the order of leaks, and a later one may get less once the first ones have taken everything. Several exponential leaks add up.
When the transit time changes: FIFO
The transit time can be an expression and is read anew every timestep. What a change does depends on fifo:
fifo = False, the default: material keeps the transit time it entered with. When the transit time shrinks, newer material overtakes older material.fifo = True: first in, first out. The whole belt moves at the current transit time, so a shorter transit time speeds up everything on it at once.
With a constant transit time the two are the same. Below, two conveyors carry 250 units a year with a transit time of 4 years until year 8, and the value of the slider after that:
With the transit time halved to 2, the FIFO belt doubles its outflow at once, until the material that was on it in year 8 has left; the traditional belt goes on at 250 until year 10, when the first material entered under the new transit time arrives beside the old. Set the slider above 4 and the belts slow down: the FIFO belt lets less out at once, the traditional one carries on until year 12 and then lets nothing out until the first new material arrives.
What else to know
- The start.
initial_valueas a number is the total on the belt, spread in the equilibrium its leakage implies. As a list it gives one value per time unit, the first leaving first. - Arrays. An arrayed conveyor has a belt per cell. A scalar outflow or leakage flow gets the conveyor’s shape; transit time and shares can be arrayed elements.
- What is refused. An equation on the conveyor or on a flow it drives, a biflow among its inflows, and more than one outflow - leakage goes into
leaks. - Conveyors and
agecountrun on the Python engine only for now; the Rust engine raisesRustBackendErrornaming the conveyor.
The details are on the Conveyor page.