Continuous quantities: levels, flows and time-series inputs¶
Some things in a system are not entities. Energy in a battery, volume in a tank, units on a shelf
and money in an account are continuous quantities that rise and fall at a rate. The engine
models them with three sections that sit beside resources in the model file, all of them data
rather than blocks:
| Section | What it is | Read from an expression with |
|---|---|---|
timeseries_inputs |
a named profile the model reads: a PV curve, a load, a tariff by hour | TS("name"), TS("name", t) |
levels |
a continuous quantity with bounds | LEVEL("name") |
flows |
a rate-based transfer between levels | FLOW("name") |
The one rule that makes this cheap¶
A flow rate is an expression that is re-evaluated only at events. Between two events it is
constant, so every level moves along a straight line, so the instant it reaches a bound or a
threshold is (target - level) / rate -- solved in closed form and scheduled as one event. There
is no ODE solver and no fixed time step: a week of a battery on an hourly tariff costs about 170
events, not 10 080.
The events that re-evaluate the rates are:
- a time-series change instant (the next knot of a profile),
- a level reaching its capacity, its minimum, or one of its thresholds,
- an ASSIGN writing a model variable, or a TRANSFER block moving a lump,
- a pending TRANSFER demand becoming servable.
Time-series inputs¶
Two authoring shapes:
"timeseries_inputs": [
{"name": "pv", "start": 0, "step": 1, "time_unit": "hours",
"values": [0, 0, 0.8, 2.8, 4.2, 3.5, 1.6, 0],
"interpolation": "step", "beyond": "repeat"},
{"name": "price", "times": [0, 8, 18, 22], "values": [0.15, 0.25, 0.40, 0.15]}
]
| Field | Meaning |
|---|---|
start + step |
a regular profile: 24 hourly values, 96 quarter-hourly ones |
times |
an irregular one; strictly increasing, one per value. Exclusive with step |
time_unit |
the unit the times are in; base (the default) means the model's own |
interpolation |
step (piecewise constant, the default) or linear |
beyond |
past the last sample: hold the last value, repeat the series, or error |
period |
what repeat cycles on; defaults to step * len(values) -- 24 hourly values = one day |
Below the first time the first value always applies.
step vs linear for a flow rate
A step series driving a flow rate is exact: the rate really is constant between knots.
A linear one is sampled at the knots and held constant in between, because a continuously
varying rate would make the level a curve rather than a line. TS() read from an ordinary
expression still interpolates. Use step, or add knots, when the difference matters. Under
simulation.sd a TS() rate is also re-read at every grid point, so a linear series is
integrated at dt.
Levels¶
"levels": [
{"name": "Battery", "unit": "kWh", "capacity": 10, "min": 0, "initial": 2,
"max_inflow": 3, "max_outflow": 3, "rate_time_unit": "hours",
"thresholds": [{"value": 1, "direction": "falling", "signal": "battery_low",
"variable": "low_since"}]}
]
max_inflow and max_outflow are power limits: the largest total rate that may be added to or
removed from the level (a charge and a discharge rate). Leave capacity out for an unbounded
accumulator.
A threshold is a level value that fires discrete logic when the level crosses it. Crossing it
succeeds the named signal -- which releases every HOLD block waiting on it, exactly as a SIGNAL
block would -- and writes the level into the named variable. That is how a reorder point, a
low-battery alarm or a high-water alarm reaches the entity side of the model.
Flows¶
"flows": [
{"name": "charge", "to": "Battery", "efficiency": 0.95,
"rate": "max(TS(\"pv\") - TS(\"load\"), 0)", "rate_time_unit": "hours"},
{"name": "grid_import", "rate": "max(TS(\"load\") - TS(\"pv\"), 0)",
"price": "TS(\"tariff\")", "emission_factor": 0.35}
]
from and to name levels; leaving one out means a source or sink outside the model (the sun,
the grid, demand). A flow with neither end on a level is an accounting flow: it accumulates
its own total, cost and impact and nothing else -- which is exactly what a grid import or a
metered purchase is.
efficiency is the loss: out = in * efficiency. rate is the quantity drawn per time unit at
the source side.
What the engine enforces: saturation¶
You never have to write "and stop when it is full". At every re-evaluation the engine scales flows down, proportionally within the limited side, until every bound holds:
max_inflow/max_outflowcap the total rate in or out;- a full level scales its inflows to its outflows -- the difference is Spilled;
- an empty level scales its outflows to its inflows -- the difference is Unmet.
Two flows competing for the same bound keep their ratio. If you need a merit order instead, write
it into the rate expressions -- FLOW("name") reads what another flow actually managed, after
saturation, so an accounting flow can be defined in terms of a physical one.
The site bus: FLOWSUM¶
FLOW("name") needs a name. When you do not know how many contributors there are -- two PV arrays,
three loads, a battery someone adds later -- give the flows a tag and read the total:
{"name": "PV", "rate": "TS(\"sun\") * 6", "tag": "generation"},
{"name": "House", "rate": "TS(\"load\") * 2", "tag": "demand"}
| Call | What it returns |
|---|---|
FLOWSUM("generation") |
the realised rates of every flow tagged generation, added up |
FLOWSUM("storage_in", 2) |
only those with priority below 2 -- what the devices ahead of you in the merit order actually took |
An unknown tag is 0, not an error: a site with no generator has no generation. That is the whole
mechanism behind the bundled energy models: each device adds its power to a
named tag, the dispatchable assets read the totals, and a second battery with a higher merit number
sees exactly the surplus the first one left. Nothing is wired and nothing is renamed.
Spilled is 'the destination refused it', not 'it was destroyed'
Spilled counts quantity a level could not accept. Where it actually went is the model's business: in the PV example the surplus the battery refuses is exported, and the export flow accounts for it.
Losses¶
Only constant-rate losses are built in, deliberately: a proportional loss dL/dt = -kL is
exponential, which would break the straight-line exactness the whole design rests on. A constant
leak is a flow out of the level with a constant rate. A self-discharge proportional to the level
is a flow whose rate reads LEVEL("name"); it is exact at every event and a zero-order hold in
between, and you choose the resolution by adding thresholds or a decision clock.
Moving a lump: the TRANSFER block¶
Everything above is rate-based. The one place a discrete entity touches a level is the TRANSFER block: a truck unloading 20 t, a customer drawing 5 units.
| Param | Meaning |
|---|---|
level |
the level to change |
amount |
an expression -- 20, entity.quantity |
mode |
remove or add |
wait |
queue until the level can pay the full amount (the backorder), or move what it can |
With wait off, the shortfall is recorded as the level's Unmet -- a lost sale, an overflow. With
wait on, the entity queues FIFO and the engine schedules the exact instant the level reaches
the amount, so the wait is solved, not polled. An amount the level never reaches waits for the rest
of the run, the same contract as HOLD's "Infinite" mode.
Delayed conversion with a yield curve¶
A digester fed today releases biogas over the next ten days; a curing oven, a fermentation and a
compost windrow are the same shape. That is not a flow -- the output rate now depends on the whole
history of what went in, per feedstock. It is its own section, conversions.
{"name": "Digester", "cohort_interval": 1, "time_unit": "hours", "unit": "Nm3",
"substrates": [
{"name": "maize", "feed": "FLOW(\"Feed maize\")", "yield": 200.0,
"half_life": 60, "steps": 12, "components": {"ch4": 0.52}},
{"name": "slurry", "feed": "FLOW(\"Feed slurry\")", "yield": 25.0,
"curve": [[24, 0.5], [72, 0.85], [180, 1.0]], "components": {"ch4": 0.60}}]}
| Field | Meaning |
|---|---|
feed |
the substrate's feed RATE, an expression -- usually FLOW("<feeder>") |
yield |
product per unit of feed (200 Nm3 of biogas per tonne) |
curve |
the release curve as [age, CUMULATIVE share] knots, ending at 1.0 |
half_life |
instead of a curve: a first-order release, cut into steps equal-width steps |
components |
the share of THIS substrate's product that is each component (see below) |
initial_inventory |
feed already inside at t=0, released as one cohort |
initial_feed_rate |
start in the STEADY STATE for that feed rate -- no start-up transient |
The product is read by CONV("name") and enters the model as an ordinary flow's rate, which is
what gives it a destination level, saturation and spill when that level is full, a bus tag, a price
and an impact factor for free:
{"name": "Biogas", "to": "Gas holder", "rate": "CONV(\"Digester\")"}
Mass leaving the source in the product is the same pattern: a second flow out of the digester level
whose rate reads FLOW("Biogas") * density.
Why it stays exact and cheap. Feed is accumulated and closed into a cohort at a fixed
cohort_interval, and every curve knot age is rounded onto that same grid. So the product rate
changes only at grid instants, and it is piecewise constant like every other rate in the engine. A
cohort close is steps + 1 dictionary writes -- the live cohorts are never scanned -- so a YEAR of
hourly cohorts with three substrates and 12-step curves costs 8760 events, not 3 x 8760 x 12, and
runs in about 0.2 s.
The two approximations, stated. A cohort releases from its CLOSE, so the mean delay of what
went into it is longer by up to half an interval (exactly half for a constant feed); halving
cohort_interval halves it. Knot ages round to the grid, at most half an interval each. Neither
touches conservation: Total In x yield = Produced + Unreleased, exactly, and Unreleased is
tracked as a state, so it survives a warm-up that resets the other statistics.
A half_life curve spans tail half-lives (6 by default, 98.4% of the yield) and the remainder is
renormalised in rather than lost -- so the yield is conserved and the tail releases very slightly
early.
Blended quality: components on a level¶
A gas holder is not just a volume, it is a volume at some methane share. Declare the components a level tracks and the engine keeps their ABSOLUTE content, not a ratio:
{"name": "Gas holder", "capacity": 1000, "initial": 300,
"components": [{"name": "ch4", "initial_fraction": 0.53}]}
- an inflow carries the share it declares (
"components": {"ch4": 0.52}, a number or an expression such asFRACTION("Digester", "ch4")), or, with nothing declared, the blend of the level it draws FROM -- so a chain of blended tanks needs no annotation at all; - an outflow draws at the level's fraction as of the last evaluation (a zero-order hold);
FRACTION("level", "component")reads the share now, andFRACTION("conversion", "component")the blend a converter is releasing now.FLOW("gas") * FRACTION("Holder", "ch4") * 9.97is the kWh of methane a CHP is burning.
Conservation is exact, the hold is not. The level loses exactly what its outflows carry away,
so the component can never be created or destroyed whatever the hold is worth. The hold's error is
bounded by blend_tolerance (default 0.005, absolute): both content and level are linear between
events, so the fraction is a ratio of two linear functions and the instant it drifts that far is
solved in closed form and scheduled. With no inflow the hold is EXACT -- a draining tank keeps its
blend to the last unit and never re-evaluates.
A TRANSFER lump carries the level's own blend, so it moves the content and leaves the fraction alone.
Equipment state and flow thresholds¶
AVAILABLE("resource") is a resource's capacity in force right now: the maximum, minus what a
capacity schedule or a failure has taken out of service. Units an entity happens to hold are NOT
deducted -- a CHP is not seized by anybody -- so it is the right number for continuous equipment
that a flow merely uses. (IDLE("resource") is the same minus what is seized, for the rarer case
where entities and a flow share a machine.)
{"name": "CHP gas", "from": "Gas holder", "min_rate": 200,
"rate": "AVAILABLE(\"CHP engine\") * 250",
"thresholds": [{"value": 20, "direction": "rising", "cost": 25,
"cost_item": "CHP starts", "category": "Operations"}]}
A rate reading AVAILABLE() is re-evaluated the instant that resource's state changes -- the
engine is woken by the failure or the schedule itself, never polled -- so a 12 h outage stops the
flow for exactly 12 h.
Flow thresholds mirror a level's, on the REALISED rate: they fire a signal, write a
variable, and accrue one lump cost per crossing into cost_item (see Costing).
Rising crossings are counted as the flow's Starts, so a generator's start cost is
Starts x cost and both are reported. Put the threshold just above zero (or at the minimum load)
-- the crossing test is half-open at the arriving end, so a threshold AT 0 can never rise onto it.
min_rate makes a flow semi-continuous: it runs at 0 or at least min_rate, never in between.
The gate is applied to the rate the expression ASKED for, before saturation, so it schedules
nothing of its own and cannot chatter -- the flow can only switch at an event that was going to
happen anyway. For a minimum on-time, build the hysteresis in the model: two level thresholds
writing one variable (on above X, off below Y) that the rate multiplies by. The
the bundled model biogas_digester_demo does exactly that.
Control¶
A rate is an ordinary expression, so a rule-based dispatch is just arithmetic over TS(),
LEVEL() and model variables. For a trainable policy, use the existing Policy-mode DECIDE as a
decision clock: a CREATE block ticking at the decision interval feeds a Policy DECIDE whose
observation reads LEVEL(...), each branch an ASSIGN that writes a setpoint variable the flow
rates read. Writing a variable re-evaluates the rates immediately, so the setpoint takes effect at
that instant. nagasim.rl.SimEnv drives that loop unchanged.
What you get back¶
| Frame | Columns |
|---|---|
level_summary |
Average (time-weighted over the straight-line curve), Min, Max, Final, Time Empty %, Time Full %, Total In, Total Out, Spilled, Unmet |
flow_summary |
Total, Avg Rate, Peak Rate (the highest rate actually held -- a demand charge's peak power), Time Limited %, Cost, Impact, Starts |
conversion_summary |
one row per <conversion>/<substrate>: Total In, Produced, Unreleased, Avg Rate, Peak Rate |
component_summary |
one row per <level>/<component>: Avg Fraction (mass-weighted, exact), Min, Max, Final Fraction, Total In, Total Out, Final Content |
cost_summary |
a peak row per flow that carries a peak_price: its Peak Rate times the tariff, billed once (see Costing) |
system |
Total Flow Cost and Total Impact, present only for a model that has flows; flow cost joins Total Cost, and so does the demand charge |
timeseries |
a level/<name> curve per level |
Every one of those is addressable as a scenario or optimizer response:
level/Battery/Average, flow/grid_import/Total, system/Total Cost.
Worked examples¶
- the bundled model
energy_pv_battery-- profiles, a tariff, a battery with power limits and round-trip efficiency, a rule dispatch, and battery sizing with the optimizer. - the bundled model
inventory_sS-- a level as stock, a threshold as the reorder point, a TRANSFER that waits. - the bundled model
energy_site_library-- the same site as four submodels meeting on the bus, sized with the optimizer. - the bundled model
supply_chain_library-- stock as a level, customers as entities, and one echelon's lead time as the next one's queue. - the bundled model
biogas_digester_demo-- a delayed conversion with a yield curve per feedstock, a blended gas holder, and a CHP stopped by its own maintenance.