API reference¶
API documentation for the technoeconomics-app package.
technoeconomics
¶
backend
¶
preset
¶
Presets: curated default plants plus their presentation.
Each preset lives in its own module and subclasses
Preset. The registry below
discovers them so a route can look one up by name.
IndustrialHeat
¶
Bases: Preset
Industrial process heat: grid power, a heat pump vs an electric boiler, a battery, heat demand.
build
¶
build() -> Plant
Build the default industrial-heat plant.
Source code in src/technoeconomics/backend/preset/industrial_heat.py
Preset
¶
Bases: ABC
A named preset: a default plant plus its presentation.
The preset completes a model: alongside the default plant and its copy it
declares which results to present -- the plots and numbers the frontend should
show once the plant is solved.
A preset is a class, not a dataclass, so these are ordinary class attributes: a subclass
overrides them with a bare assignment and inherits the declared type from here. The
ClassVar on the two sequences is what marks them as shared class state rather than
per-instance defaults; the scalars need no restating.
Attributes:
-
name(str) –Stable identifier used to look the preset up (e.g. in a URL).
-
title(str) –Human-readable title.
-
description(str) –Short blurb shown above the model.
-
schematic(Path | None) –Path to an SVG drawing of the plant, or None for no drawing.
-
plots(tuple[Plot, ...]) –The charts to render from a solve, in display order. A tuple: it is shared by every instance of the preset, so it must not be mutable.
-
numbers(tuple[Number, ...]) –The headline numbers to render from a solve, in display order. A tuple, for the same reason as
plots.
schematic_svg
¶
schematic_svg() -> str
Read the schematic SVG so it can be inlined into the page.
Returns:
-
str–The SVG markup, or an empty string when the preset declares no schematic (the
-
str–client renders the drawing only when there is one).
Source code in src/technoeconomics/backend/preset/base.py
get
¶
Look a preset up by name.
Parameters:
Returns:
Raises:
-
KeyError–If no preset has that name.
Source code in src/technoeconomics/backend/preset/__init__.py
presets
¶
base
¶
Preset base: a curated preset pairing a default plant with how to present it.
Preset
¶
Bases: ABC
A named preset: a default plant plus its presentation.
The preset completes a model: alongside the default plant and its copy it
declares which results to present -- the plots and numbers the frontend should
show once the plant is solved.
A preset is a class, not a dataclass, so these are ordinary class attributes: a subclass
overrides them with a bare assignment and inherits the declared type from here. The
ClassVar on the two sequences is what marks them as shared class state rather than
per-instance defaults; the scalars need no restating.
Attributes:
-
name(str) –Stable identifier used to look the preset up (e.g. in a URL).
-
title(str) –Human-readable title.
-
description(str) –Short blurb shown above the model.
-
schematic(Path | None) –Path to an SVG drawing of the plant, or None for no drawing.
-
plots(tuple[Plot, ...]) –The charts to render from a solve, in display order. A tuple: it is shared by every instance of the preset, so it must not be mutable.
-
numbers(tuple[Number, ...]) –The headline numbers to render from a solve, in display order. A tuple, for the same reason as
plots.
schematic_svg
¶schematic_svg() -> str
Read the schematic SVG so it can be inlined into the page.
Returns:
-
str–The SVG markup, or an empty string when the preset declares no schematic (the
-
str–client renders the drawing only when there is one).
Source code in src/technoeconomics/backend/preset/base.py
industrial_heat
¶
The industrial process heat preset.
IndustrialHeat
¶
Bases: Preset
Industrial process heat: grid power, a heat pump vs an electric boiler, a battery, heat demand.
build
¶build() -> Plant
Build the default industrial-heat plant.
Source code in src/technoeconomics/backend/preset/industrial_heat.py
results
¶
Compute the results of a solved plant for the frontend.
numbers
¶
Compute the requested headline numbers from a solved network.
Parameters:
-
network(Network) –A solved, sanitised PyPSA network.
-
requested(list[Number]) –The numbers to compute, in display order.
Returns:
Source code in src/technoeconomics/backend/results.py
plots
¶
Compute the requested charts as identified ECharts options.
Parameters:
-
network(Network) –A solved, sanitised PyPSA network.
-
requested(list[Plot]) –The plots to compute, in display order.
Returns:
-
list[dict]–{"id", "option"}dicts, whereoptionis ready for -
list[dict]–echarts.init(el).setOption(option)andidis stable across solves so the -
list[dict]–client can update each chart in place. One request may yield several charts -- an
-
list[dict]–energy balance gives one per bus carrier.
Raises:
-
ValueError–If a requested plot is unknown.
Source code in src/technoeconomics/backend/results.py
solve
¶
Build, optimise, and read out a plant -- the one solve path shared by web and CLI.
SolveError
¶
Bases: RuntimeError
The optimisation ran but did not reach an optimal solution.
Distinct from an internal failure: this is a property of the plant the user submitted
(infeasible, unbounded, ...), so its message is written to be shown to them. Anything
else escaping solve is a bug and is reported generically.
solve
¶
Build a plant's network, optimise it, and read out the preset's results.
Reports progress per phase via progress.emit, which the
caller (e.g. a web run) may stream to the user; unheard when no sink is bound.
Parameters:
-
plant(Plant) –The plant to solve.
-
preset(Preset) –The preset whose
numbersandplotsto compute from the solution.
Returns:
-
dict–{"numbers": [...], "plots": [...]}-- ready for the page.
Raises:
-
SolveError–If the optimisation does not reach an optimal solution.
Source code in src/technoeconomics/backend/solve.py
data
¶
Data access: lazy, serialisable datasets resolved to scalars or series.
Constant
dataclass
¶
A single fixed value.
Attributes:
Dataset
dataclass
¶
Bases: ABC
Base for all datasets: a lazy, serialisable handle for one value.
The type parameter T is the resolved value's type -- float for a scalar
dataset, pandas.Series for a series. Concrete datasets are frozen dataclasses
subclassing Dataset[float] or Dataset[pd.Series] and implementing compute.
(De)serialisation is declared once, on the [ScalarDataset][technoeconomics.data.ScalarDataset]
/ [SeriesDataset][technoeconomics.data.SeriesDataset] aliases, via the class-name-tagged
codec from technoeconomics.serialise; a concrete dataset therefore stays a plain
pydantic-free @dataclass.
compute
abstractmethod
¶
resolve
¶
Return this dataset's value over snapshots, memoised when cache is set.
The resolution entry point, wrapping the author's
compute:
resolve_datasets calls it. When cache
is True the value is keyed by (self, snapshots) -- a frozen dataset with
hashable fields is its own key -- and computed once even under concurrent resolves;
otherwise compute runs every time.
Parameters:
-
snapshots(DatetimeIndex) –The horizon the value is aligned to.
Returns:
-
T–The computed value. A cached value is shared by reference, so callers must treat
-
T–it as read-only (resolution copies it into a component rather than mutating it).
Source code in src/technoeconomics/data/base.py
Sinusoidal
dataclass
¶
Sinusoidal(mean: Param[float, Magnitude], amplitude: Param[float, Magnitude], period: Param[float, Gt(0), Unit('h')] = 24.0, phase: Param[float, Unit('h')] = 0.0)
Bases: Dataset[Series]
A sine wave over the snapshots -- a stand-in for daily/seasonal shapes.
The value at time t is mean + amplitude * sin(2*pi * (h - phase) /
period), where h is the number of hours since the first snapshot.
Attributes:
-
mean(Param[float, Magnitude]) –Baseline the wave oscillates around. A magnitude: its unit and bounds come from the field holding this dataset.
-
amplitude(Param[float, Magnitude]) –Peak deviation from
mean. A magnitude, likemean. -
period(Param[float, Gt(0), Unit('h')]) –Oscillation period in hours (e.g.
24daily,8760yearly). -
phase(Param[float, Unit('h')]) –Horizontal shift in hours.
compute
¶
Return the sine wave sampled at snapshots.
Source code in src/technoeconomics/data/sources.py
resolve_datasets
¶
Return copies of objs with every Dataset field replaced by its value.
Parameters:
-
objs(Iterable[C]) –Dataclass instances (typically components) to resolve.
-
snapshots(DatetimeIndex) –The horizon series values are aligned to.
Returns:
-
list[C]–New instances with concrete values in place of datasets; instances with no
-
list[C]–dataset fields are returned as is.
Source code in src/technoeconomics/data/base.py
base
¶
Core data-access contract: the Dataset type and dataset resolution.
Dataset
dataclass
¶
Bases: ABC
Base for all datasets: a lazy, serialisable handle for one value.
The type parameter T is the resolved value's type -- float for a scalar
dataset, pandas.Series for a series. Concrete datasets are frozen dataclasses
subclassing Dataset[float] or Dataset[pd.Series] and implementing compute.
(De)serialisation is declared once, on the [ScalarDataset][technoeconomics.data.ScalarDataset]
/ [SeriesDataset][technoeconomics.data.SeriesDataset] aliases, via the class-name-tagged
codec from technoeconomics.serialise; a concrete dataset therefore stays a plain
pydantic-free @dataclass.
compute
abstractmethod
¶
resolve
¶
Return this dataset's value over snapshots, memoised when cache is set.
The resolution entry point, wrapping the author's
compute:
resolve_datasets calls it. When cache
is True the value is keyed by (self, snapshots) -- a frozen dataset with
hashable fields is its own key -- and computed once even under concurrent resolves;
otherwise compute runs every time.
Parameters:
-
snapshots(DatetimeIndex) –The horizon the value is aligned to.
Returns:
-
T–The computed value. A cached value is shared by reference, so callers must treat
-
T–it as read-only (resolution copies it into a component rather than mutating it).
Source code in src/technoeconomics/data/base.py
ResultCache
¶
Process-wide single-flight memoiser: one factory call per key, shared by racers.
A dataset resolved concurrently across the solve worker threads must compute once, not
once per thread. The first caller to miss stores a Future under the key and runs the
factory; callers arriving while it is in flight find that Future and block on its
result. The lock guards only the TTLCache bookkeeping -- which is not thread-safe, and
mutates even on a read here to refresh the idle timer -- while the factory runs unlocked,
so a slow compute never blocks a hit or an unrelated key.
Parameters:
-
maxsize(int, default:_MAXSIZE) –Distinct keys retained before the least-recently-used is evicted.
-
ttl(float, default:_TTL_SECONDS) –Seconds a key survives untouched (each hit resets the timer).
Source code in src/technoeconomics/data/base.py
get_or_make
¶
Return the value for key, computing it via factory on a miss.
Parameters:
-
key(Hashable) –A hashable key that fully determines the value.
-
factory(Callable[[], T]) –Produces the value on a miss; called at most once per in-flight key.
Returns:
-
T–The value for
key, freshly computed or replayed from an in-flight or earlier -
T–call. A
factorythat raises propagates to every waiter and is not cached, so a -
T–later call retries.
Source code in src/technoeconomics/data/base.py
resolve_datasets
¶
Return copies of objs with every Dataset field replaced by its value.
Parameters:
-
objs(Iterable[C]) –Dataclass instances (typically components) to resolve.
-
snapshots(DatetimeIndex) –The horizon series values are aligned to.
Returns:
-
list[C]–New instances with concrete values in place of datasets; instances with no
-
list[C]–dataset fields are returned as is.
Source code in src/technoeconomics/data/base.py
sources
¶
Concrete datasets.
Constant
dataclass
¶
A single fixed value.
Attributes:
Sinusoidal
dataclass
¶
Sinusoidal(mean: Param[float, Magnitude], amplitude: Param[float, Magnitude], period: Param[float, Gt(0), Unit('h')] = 24.0, phase: Param[float, Unit('h')] = 0.0)
Bases: Dataset[Series]
A sine wave over the snapshots -- a stand-in for daily/seasonal shapes.
The value at time t is mean + amplitude * sin(2*pi * (h - phase) /
period), where h is the number of hours since the first snapshot.
Attributes:
-
mean(Param[float, Magnitude]) –Baseline the wave oscillates around. A magnitude: its unit and bounds come from the field holding this dataset.
-
amplitude(Param[float, Magnitude]) –Peak deviation from
mean. A magnitude, likemean. -
period(Param[float, Gt(0), Unit('h')]) –Oscillation period in hours (e.g.
24daily,8760yearly). -
phase(Param[float, Unit('h')]) –Horizontal shift in hours.
compute
¶
Return the sine wave sampled at snapshots.
Source code in src/technoeconomics/data/sources.py
model
¶
component
¶
Component API and component directory for the model.
A model is composed of components connected through carriers.
from technoeconomics.data import Constant, Sinusoidal
heat_pump = HeatPump(
electricity_bus="electricity", heat_bus="heat", capex=Constant(900)
)
grid = GridElectricity(
bus="electricity", price=Sinusoidal(mean=120, amplitude=40, period=24)
)
AnyComponent
¶
A component (de)serialised through the class-name-tagged subclass registry.
Battery
dataclass
¶
Battery(*, id: str = '', enabled: bool = True, fixed: bool = False, plot_color: PlotColor | None = None, bus: Param[str, Hidden], max_hours: Param[Param[Scalar, Gt(0)] | ScalarDataset, Unit('h')] = 4.0, capex: Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('EUR/MW'), Advanced] = 12000.0, round_trip_efficiency: Param[Param[Scalar, Gt(0), Lt(1)] | ScalarDataset, Label('Round-trip efficiency'), Advanced] = 0.85)
Bases: Component
Electricity storage with a fixed energy-to-power ratio.
Attributes:
-
bus(Param[str, Hidden]) –Electricity bus the battery attaches to.
-
max_hours(Param[Param[Scalar, Gt(0)] | ScalarDataset, Unit('h')]) –Storage duration at rated power [h].
-
capex(Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('EUR/MW'), Advanced]) –Annuitised investment cost [EUR/MW].
-
round_trip_efficiency(Param[Param[Scalar, Gt(0), Lt(1)] | ScalarDataset, Label('Round-trip efficiency'), Advanced]) –Fraction of stored energy returned over a full charge-discharge cycle. Split evenly across the two directions (
sqrteach way) when building the PyPSAStorageUnit. Must be < 1 -- a lossless battery makes simultaneous charge and discharge free, leaving the dispatch split degenerate (non-physical "wash").
add_to_network
¶
Add a StorageUnit on the electricity bus.
Source code in src/technoeconomics/model/component.py
Component
dataclass
¶
Component(*, id: str = '', enabled: bool = True, fixed: bool = False, plot_color: PlotColor | None = None)
Bases: ABC
Base for all components: shared identity, serialisation, and the build contract.
Currently components are a thin layer on top of PyPSA Network Components.
Components can conceptually be divided into four main types:
- Input - inject energy into the system (e.g. grid electricity, gas connection, solar PV)
- Converter - turn one form of energy into another (e.g. heat pump, gas boiler)
- Storage - buffer energy in time (e.g. battery, thermal inertia of a house)
- Output - a demand or a loss, destroys energy (e.g. heat demand, heat loss in a house)
The mapping to PyPSA is:
| Component | PyPSA component |
|---|---|
Input |
Generator |
Converter |
Process |
Storage |
StorageUnit |
Output |
Load |
A component is a dataclass of technoeconomic parameters plus add_to_network()
-- a recipe for building a PyPSA component integrated into a model.
Parameter fields are annotated with the vocabulary from
technoeconomics.model.params (unit, bounds, visibility), which the form-spec
generator reads; a field that is not a numeric scalar or a dataset (e.g. a bus
reference) must be marked Hidden.
Bus references are held as bus ids (strings) by name convention: a field named
bus or ending in _bus is a reference to a bus in Plant.buses. The plant
checks these on construction (every reference must name an existing bus).
Attributes:
-
id(str) –Unique name within the plant; used as the PyPSA component name and as its own carrier, so results are attributable per component. Optional -- if left blank, the plant assigns one from the snake-case class name, enumerating (e.g.
heat_pump,heat_pump_2) when a type appears more than once. -
enabled(bool) –If False, the component is skipped when the network is built.
-
fixed(bool) –If True, this component may not be disabled -- a property of its role in a preset, not of its class (set at preset assembly, e.g. a demand whose removal would leave a degenerate problem). A fixed component gets no enable toggle in the form, and a submission that tries to switch it off is rejected.
-
plot_color(PlotColor | None) –Colour for this component's flows in result plots. If None, PyPSA assigns one when the network is sanitised.
add_to_network
abstractmethod
¶
Expand this component into one or more PyPSA elements on n.
Called on a copy whose dataset fields have already been resolved, so
self.<field> yields a concrete number or snapshot-aligned series.
Source code in src/technoeconomics/model/component.py
ElectricBoiler
dataclass
¶
ElectricBoiler(*, id: str = '', enabled: bool = True, fixed: bool = False, plot_color: PlotColor | None = None, electricity_bus: Param[str, Hidden], heat_bus: Param[str, Hidden], efficiency: Param[Scalar, Gt(0), Le(1)] | Timeseries | SeriesDataset = 0.99, capex: Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('EUR/MW'), Advanced] = 100.0)
Bases: Component
Electric resistance heating: cheap to install but ~unity efficiency.
Attributes:
-
electricity_bus(Param[str, Hidden]) –Bus the boiler draws electricity from (input).
-
heat_bus(Param[str, Hidden]) –Bus the boiler delivers heat to (output).
-
efficiency(Param[Scalar, Gt(0), Le(1)] | Timeseries | SeriesDataset) –Heat out per unit electricity in.
-
capex(Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('EUR/MW'), Advanced]) –Annuitised investment cost [EUR/MW of electricity input].
add_to_network
¶
Add a Process converting electricity to heat at efficiency.
Source code in src/technoeconomics/model/component.py
GridElectricity
dataclass
¶
GridElectricity(*, id: str = '', enabled: bool = True, fixed: bool = False, plot_color: PlotColor | None = None, bus: Param[str, Hidden], price: Param[Scalar | Timeseries | SeriesDataset, Unit('EUR/MWh')] = 120.0, max_capacity: Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('MW'), Advanced] = 1000.0, capex: Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('EUR/MW'), Advanced] = 0.0)
Bases: Component
Grid connection injecting electricity at a (possibly time-varying) price.
Attributes:
-
bus(Param[str, Hidden]) –Electricity bus to inject into.
-
price(Param[Scalar | Timeseries | SeriesDataset, Unit('EUR/MWh')]) –Marginal cost of electricity [EUR/MWh].
-
max_capacity(Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('MW'), Advanced]) –Maximum injection power [MW].
-
capex(Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('EUR/MW'), Advanced]) –Annuitised investment cost [EUR/MW].
add_to_network
¶
Add a Generator injecting electricity at price.
Source code in src/technoeconomics/model/component.py
HeatDemand
dataclass
¶
HeatDemand(*, id: str = '', enabled: bool = True, fixed: bool = False, plot_color: PlotColor | None = None, bus: Param[str, Hidden], load: Param[Param[Scalar, Ge(0)] | Timeseries | SeriesDataset, Unit('MW')] = 10.0)
HeatPump
dataclass
¶
HeatPump(*, id: str = '', enabled: bool = True, fixed: bool = False, plot_color: PlotColor | None = None, electricity_bus: Param[str, Hidden], heat_bus: Param[str, Hidden], cop: Param[Param[Scalar, Gt(0)] | Timeseries | SeriesDataset, Label('COP')] = 3.0, capex: Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('EUR/MW'), Advanced] = 900000.0)
Bases: Component
Electricity-to-heat conversion (a PyPSA Process) with a constant or varying COP.
Attributes:
-
electricity_bus(Param[str, Hidden]) –Bus the heat pump draws electricity from (input).
-
heat_bus(Param[str, Hidden]) –Bus the heat pump delivers heat to (output).
-
cop(Param[Param[Scalar, Gt(0)] | Timeseries | SeriesDataset, Label('COP')]) –Coefficient of performance.
-
capex(Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit('EUR/MW'), Advanced]) –Annuitised investment cost [EUR/MW of electricity input].
add_to_network
¶
Add a Process converting electricity (rate0=-1) to heat (rate1=cop).
Source code in src/technoeconomics/model/component.py
params
¶
The preset-author vocabulary: everything a parameter annotation needs, one import.
Preset authors describe a component or dataset field with a single Param annotation
carrying markers from this module -- unit, bounds, visibility -- and never import
typing, pydantic, or annotated_types themselves:
from technoeconomics.model.params import Advanced, Ge, Hidden, Param, Unit
period: Param[float, Gt(0), Unit("h")] = 24.0
capex: Param[Param[Scalar, Ge(0)] | ScalarDataset, Unit("EUR/MW"), Advanced] = 400.0
bus: Param[str, Hidden]
Param is typing.Annotated under a friendlier name, so type checkers see the plain
value type; the bounds (Ge/Gt/Le/Lt) are annotated_types re-exports, so
pydantic enforces them natively with per-field error locations.
Bounds constrain user-supplied scalars only -- their job is helpful input
validation, and pydantic cannot guarantee what a dataset will compute. On a field that
can also hold a dataset, the bound therefore goes on the scalar branch of the union
(Param[Scalar, Ge(0)] | ScalarDataset), never on the union itself: pydantic applies
an outer bound to whatever the union yields and raises a bare TypeError when that is
a dataset value. The form-spec generator (technoeconomics.web.spec) reads the
scalar-branch bound as the field's effective bound, and propagates it (with the unit)
to the Magnitude leaves of a dataset in
that field -- those leaves are user-supplied scalars too, checked by the solve
endpoint's spec-loop since they sit outside pydantic's reach.
A propagated bound constrains each leaf it lands on, not the value the dataset
computes. load: Param[Param[Scalar, Ge(0)] | SeriesDataset, Unit("MW")] holding a
Sinusoidal bounds mean >= 0 and amplitude >= 0 -- it does not stop
mean=1, amplitude=100 from resolving to a series that dips to -99 MW. Bounds are
input hygiene; a dataset that has a resolved-value invariant has to enforce it itself.
Advanced
module-attribute
¶
Render this field under the form's "Advanced" section rather than inline.
Hidden
module-attribute
¶
Exclude this field from the form entirely (bus references, non-input plumbing).
A hidden field gets no spec path, so it is not addressable by a submission at all: it keeps whatever the preset gave it.
Magnitude
module-attribute
¶
Mark a dataset field as carrying the dataset's magnitude.
A magnitude leaf takes its unit and bounds from the field holding the dataset (the
use), not from the dataset class -- mean is EUR/MWh in a price field and MW in a
demand field.
Param
module-attribute
¶
Param = Annotated
typing.Annotated under an author-friendly name: Param[float, Ge(0), Unit("h")].
Label
dataclass
¶
Label(text: str)
The human-readable name a field is shown under (e.g. Label("COP")).
Without one the form derives a label from the field's path (round_trip_efficiency,
price.mean), which cannot produce an initialism or a properly cased phrase.
On a component field holding a dataset the label is a prefix: the form renders each
leaf as "<field label> <leaf label>" (so Label("Heat demand") over a Sinusoidal
gives "Heat demand mean", "Heat demand amplitude", ...), where the leaf label is the
dataset field's own Label if it has one, else its name.
Attributes:
-
text(str) –The display text, e.g.
"Round-trip efficiency".
Unit
dataclass
¶
Unit(text: str)
The unit a numeric field is displayed and edited in (e.g. Unit("EUR/MWh")).
On a component's dataset field the unit is per-use: it propagates to the leaves the
dataset marks Magnitude, so one dataset
class can serve as a price in one field and a demand in another.
Attributes:
-
text(str) –The display text, e.g.
"EUR/MWh".
plant
¶
Plant: the intermediate representation of a model.
Plant
dataclass
¶
Editable description of a model -- the system of record.
Attributes:
-
name(str) –Human-readable model name.
-
snapshots(Snapshots) –Optimisation horizon (use [
annual_snapshots][] or any index). -
buses(list[Bus]) –Named nodes; several may share a carrier (e.g. multiple heat buses).
-
components(list[AnyComponent]) –Building blocks referencing
busesby id. Components may omitid; blanks are filled from the snake-case class name and enumerated (heat_pump,heat_pump_2, ...) so every component ends up uniquely named.
build_network
¶
Compile this plant into a disposable PyPSA network ready to optimise.
Registers a carrier for each bus (the energy carriers) and a distinct carrier per enabled component (named after its id), so per-component flows stay attributable. Adds the buses, then resolves the enabled components' dataset fields to concrete values.
Source code in src/technoeconomics/model/plant.py
from_dict
classmethod
¶
Reconstruct a plant from to_dict output.
The pydantic schema validates the structure and every field, and __post_init__
checks referential integrity, so an invalid dict is rejected here rather than
failing later at solve time.
Parameters:
Returns:
-
Plant–The reconstructed plant.
Raises:
-
ValueError–If
dis not a dict, carries an unsupported schema version, or is not a valid plant (pydantic'sValidationErroris itself aValueError).
Source code in src/technoeconomics/model/plant.py
to_dict
¶
to_dict() -> dict
Serialise to a JSON-able dict; round-trips through from_dict.
The whole plant is one declared pydantic schema: buses become plain dicts, bus
references stay bus ids, snapshots compress to {start, periods, freq}, and each
component/dataset is a class-name-tagged dict. The output is stamped with a schema
version so a dict written against an older layout is rejected by
from_dict rather than silently
mis-decoded.
Source code in src/technoeconomics/model/plant.py
annual_snapshots
¶
Snapshots spanning one calendar year (hourly by default).
Convenience for the common case; Plant.snapshots accepts any
pandas.DatetimeIndex.
Source code in src/technoeconomics/model/plant.py
structure
¶
Structural primitives of a model: the balancing nodes components attach to.
progress
¶
A tiny progress channel: components emit lines, the caller decides where they go.
Neutral and low-level -- it imports nothing from web/backend/model, so any layer (the
solver here, and later dataset retrieval deep in the model) can emit a progress line without
knowing who, if anyone, is listening. A listener is a sink bound around some work with
sink; a web run binds one that streams to the page
for the duration of a solve. With no sink bound -- the CLI, tests -- emit is a no-op.
The sink lives in a ContextVar, so emit anywhere in the dynamic scope of the sink(...)
block reaches it -- including deep inside a synchronous call tree like build_network.
emit
¶
emit(text: str) -> None
Report a progress line to the bound sink, if any.
A no-op when no sink is bound, so callers can emit unconditionally.
Parameters:
-
text(str) –The human-readable progress line (e.g.
"Building network…").
Source code in src/technoeconomics/progress.py
sink
¶
Bind fn as the progress sink for the duration of the with block.
Parameters:
Yields:
-
None–Nothing; the sink is bound until the block exits.
Source code in src/technoeconomics/progress.py
serialise
¶
Framework-internal (de)serialisation machinery, collected in one place.
Snapshots
¶
Snapshots = Annotated[InstanceOf[DatetimeIndex], PlainSerializer(_snapshots_to_dict), BeforeValidator(_snapshots_from_dict_or_passthrough)]
A DatetimeIndex serialised as a compact {start, periods, freq} (or explicit values).
concrete_subclasses
¶
Map class name -> every (recursive) subclass of base (for type-tag lookup).
Source code in src/technoeconomics/serialise.py
is_timeseries
¶
True for a value the Timeseries alias admits, so callers need not respell the union.
The alias's arms are not introspectable at runtime (it is a PEP 695 alias over an
Annotated), so the membership test lives here, next to the definition it mirrors.
Parameters:
-
value(object) –The candidate value.
Returns:
-
bool–True for an ndarray, a
pandas.Series, or a plain sequence of numbers (astr -
bool–is a sequence but never a timeseries).
Source code in src/technoeconomics/serialise.py
tagged_codec
¶
Symmetric class-name-tagged (de)serialisation for an open subclass registry.
The returned pair is attached to an Annotated alias of base: dumping writes a
{"type": <class name>, ...fields} dict, and loading reads that tag to pick the
concrete subclass and validate its fields. This keeps polymorphic model types (datasets,
components) as plain dataclasses that gain declarative (de)serialisation from the alias.
Parameters:
-
base(type) –The registry root whose concrete subclasses are the dispatch targets.
Returns:
-
tuple[WrapValidator, PlainSerializer]–A
(WrapValidator, PlainSerializer)pair to place in anAnnotated[base, ...].
Source code in src/technoeconomics/serialise.py
type_adapter
cached
¶
A cached pydantic validator/serialiser for one model class.
Built from the class's dataclass fields, so model classes (datasets, components, buses)
stay plain pydantic-free @dataclasses while gaining declarative field validation and
JSON (de)serialisation. Cached because building a TypeAdapter compiles a validator, and
there are few classes.
Parameters:
-
cls(type) –The concrete dataclass to (de)serialise.
Returns:
-
TypeAdapter[Any]–A
TypeAdapterforcls.
Source code in src/technoeconomics/serialise.py
web
¶
api
¶
The JSON API: registry-driven endpoints wiring the spec generator to the run store.
Overlay-solve throughout: the client posts {preset, overlay, enabled}; the server rebuilds
from the trusted preset default, validates and applies the whitelisted edits, and launches a
run. Results are a GET-able resource on the run; progress streams over SSE (progress-only, one
terminal poke, then the server closes the stream). No preset is hardcoded -- everything is keyed
off the preset registry, so a new preset is picked up with no changes here.
Throttling is day-one API shape:
- Single-flight on a client-generated UUID (
X-Client-Id): a new solve supersedes that client's own in-flight run -- a common path (two tabs share one UUID), handled politely, not a hardening measure. A request without the header is treated as its own one-shot client, so omitting it can neither share a single-flight slot nor hide from the caps below. - Caps spoofing can't bypass: a per-IP concurrency cap (
N > 1, tolerating campus NAT) and a global live-run cap, both 429 +Retry-After.
In-flight bookkeeping is pruned lazily against the run store on each solve, so it needs no completion callback and cannot leak entries.
create_share
¶
create_share(req: Envelope) -> ShareToken
Encode a {preset, overlay, enabled} envelope to a share token.
Source code in src/technoeconomics/web/api.py
get_preset
¶
get_preset(name: str) -> PresetDetail
A preset's presentation, default plant, and form spec for the client to render.
Source code in src/technoeconomics/web/api.py
get_run
¶
get_run(run_id: str) -> RunSnapshot
A run's status and, once done, its results ({numbers, charts}).
Source code in src/technoeconomics/web/api.py
read_share
¶
Decode a share token back to its {preset, overlay, enabled} envelope.
Source code in src/technoeconomics/web/api.py
run_events
async
¶
run_events(run_id: str, request: Request) -> AsyncIterator[ServerSentEvent]
Stream a run's progress as SSE, resuming from Last-Event-ID on reconnect.
Progress-only: the stream carries progress lines and one terminal
done/failed/cancelled poke, then ends (the client GETs the result). An
unknown/expired run yields one failed event
rather than a 404, so the browser's EventSource does not reconnect into a loop.
Source code in src/technoeconomics/web/api.py
solve
async
¶
solve(req: Envelope, request: Request, x_client_id: Annotated[str | None, Header()] = None) -> RunAccepted | JSONResponse
Validate and apply an overlay onto the preset default, then launch a run.
Returns 202 {run_id} on success; 422 {errors} for an invalid overlay path or an
out-of-bounds value; 429 if a cap is hit. A client's own in-flight run is superseded
rather than rejected.
Source code in src/technoeconomics/web/api.py
envelope
¶
The uniform client envelope: a preset plus the user's overlay edits and enable toggles.
One type for the whole client contract -- the solve and share request bodies and the share-link
codec all carry the same {preset, overlay, enabled}. A plain frozen dataclass validated at
the boundary by pydantic (FastAPI for request bodies, TypeAdapter for the share codec),
consistent with the model layer's plain-dataclass style.
Only the envelope's shape is checked here (preset is a string, overlay values are numbers,
enable toggles are booleans, no unknown keys). Its meaning -- that overlay paths are real spec
paths and values are in bounds -- is enforced at solve against the preset spec by
validate_edits, not here.
Envelope
dataclass
¶
main
¶
FastAPI application: the JSON API, plus the built Svelte frontend when served from here.
No gzip middleware: it buffers text/event-stream, which would break the progress SSE.
Compression of the static assets and JSON is the reverse proxy's job (Caddy), which must also
be configured not to buffer the SSE endpoint.
The frontend is a separate build artifact, not part of this Python package: the wheel
contains no static files and this module never reaches outside its own distribution to find
any. In production the container builds frontend/dist in its own stage and points
FRONTEND_DIST at it. In development the Vite dev server serves the frontend and proxies
/api here (see frontend/vite.config.ts), so the variable is unset and nothing is
mounted. If it is set and does not name a directory, StaticFiles raises at startup --
a mis-built image should fail loudly, not boot and serve 404s.
lifespan
async
¶
lifespan(app: FastAPI) -> AsyncIterator[None]
Set up logging on startup; drain in-flight runs on shutdown.
Source code in src/technoeconomics/web/main.py
runs
¶
Runs: launch a solve, stream its progress, store its result -- addressed by run_id.
A Run is addressable by an opaque run_id; there are no user sessions and no cross-run
history. Results are a GET-able resource stored on the run, and the SSE stream is
progress-only: it carries progress lines and one terminal poke (done, failed or
cancelled), after which the server closes the stream and the client GETs the result (no
reconnect-into-404 loop).
The lifecycle invariants that keep this correct, each small but load-bearing:
- Task pinning. The event loop holds a running task only weakly and the completed-run cache is evictable, so a running solve's task is pinned in a manager-level set until it finishes -- otherwise an eviction mid-solve could silently drop it.
- Result before terminal event. The result is stored on the run before the terminal SSE
event is emitted, so a client's
done-> GET can never race an empty run. - TTL from completion. A running run is pinned (never evicted); on completion it moves into
a bounded
TTLCachewhose clock starts at completion, so a long run cannot expire from under its own reconnect, and completed-run memory (which holds full chart payloads) stays bounded. - Reconnect replay. Progress events carry monotonic ids; on reconnect the browser sends
Last-Event-IDand the run replays only newer events, so the console is not duplicated. - Watchdog. A wedged solve is marked
failedafter a deadline so its accounting frees up. The worker thread and itsCapacityLimiterslot are unrecoverable (threads can't be interrupted) but the user is not locked out until restart.
Run
¶
Run(id: str, manager: RunManager, preset_name: str)
One solve, addressed by id: its status, result, progress tail, and live subscribers.
Parameters:
-
id(str) –The opaque run id.
-
manager(RunManager) –The owning
RunManager, told when this run reaches a terminal state so it can move the run from the live pin into the completed-run TTL cache. -
preset_name(str) –The preset being solved, retained for completion telemetry.
Source code in src/technoeconomics/web/runs.py
aclose
async
¶
Cancel the run's task and close all subscriber streams (at application shutdown).
Source code in src/technoeconomics/web/runs.py
cancel
¶
cancel(reason: str) -> None
Terminate the run as superseded, poking any tab still streaming it; idempotent.
The worker thread and its CapacityLimiter slot are unrecoverable (threads can't be
killed), so this frees only the run's accounting -- releasing the client's
single-flight slot so a newer solve is not locked out by an abandoned one.
Source code in src/technoeconomics/web/runs.py
events
async
¶
events(last_id: int | None) -> AsyncIterator[ServerSentEvent]
Yield this run's events for one SSE connection: replay the tail, then live, then close.
Registering the subscriber and snapshotting the tail/status happen in one synchronous
block (no await between), so -- since emission runs on the same single-threaded loop
-- an event cannot slip in unseen or be delivered twice. On reconnect the browser's
Last-Event-ID becomes last_id and only newer tail events replay. The stream ends
after the terminal event: either it was in the replayed tail (run already finished) or it
arrives live.
Parameters:
-
last_id(int | None) –The last event id the client already has (from
Last-Event-ID), or None.
Yields:
-
AsyncIterator[ServerSentEvent]–Each
ServerSentEvent, replayed then live, up to and including the terminal one.
Source code in src/technoeconomics/web/runs.py
snapshot
¶
snapshot() -> RunSnapshot
The run's current state, as the DTO GET /api/runs/{id} returns.
_finish is the only writer of all three fields and keeps them consistent (a done
run has results and no error; a failed or cancelled one the reverse), so this is a
straight projection.
Source code in src/technoeconomics/web/runs.py
RunManager
¶
RunManager(*, maxsize: int = 256, ttl: float = 15 * 60, deadline: float = 300, on_complete: Callable[[Run], None] | None = None)
Holds live and recently-completed runs; launches solves and reaps them.
A running run is pinned in _live and never evicted; on completion it moves to _done, a
bounded TTLCache whose clock starts at completion. Tasks are pinned in _tasks so a
running solve is never garbage-collected out from under the loop.
Parameters:
-
maxsize(int, default:256) –Maximum completed runs retained before the least-recently-inserted is evicted.
-
ttl(float, default:15 * 60) –Seconds a completed run survives (covers reconnect and reload-restore).
-
deadline(float, default:300) –Seconds a solve may run before the watchdog marks it failed.
-
on_complete(Callable[[Run], None] | None, default:None) –Called with each run as it reaches a terminal state (loop-side) -- the telemetry hook. The outcome does not exist at 202-time, so this fires at completion rather than at launch.
Source code in src/technoeconomics/web/runs.py
live_count
property
¶
live_count: int
Number of runs currently solving (for the global live-run cap).
aclose_all
async
¶
Cancel every live run and dispose it; call once at application shutdown.
get
¶
launch
¶
Start solving plant in the background and return its Run at once.
Parameters:
-
plant(Plant) –The plant to solve (already validated and rebuilt from the preset).
-
preset(Preset) –The preset supplying which numbers and plots to compute.
Returns:
-
Run–The new run, pinned live until it reaches a terminal state.
Source code in src/technoeconomics/web/runs.py
retire
¶
retire(run: Run) -> None
Move a just-terminal run from the live pin into the completed-run TTL cache (loop-side).
Fires the completion telemetry hook once, after the run is stored so a hook that reads the run sees its final state.
Source code in src/technoeconomics/web/runs.py
schemas
¶
Response DTOs for the JSON API.
Plain dataclasses declared as the endpoints' return types so FastAPI emits a precise OpenAPI
schema -- the single source the frontend's TypeScript types are generated from
(openapi-typescript). Request bodies reuse Envelope;
the form spec reuses the ComponentSpec dataclasses,
so those shapes are declared once and flow through to the client unchanged.
Status
module-attribute
¶
Status = Literal['running', 'done', 'failed', 'cancelled']
A run's lifecycle state. Declared here, with the DTO that carries it to the client.
FieldError
dataclass
¶
One validation error, addressed to a spec path (or a component id).
PresetDetail
dataclass
¶
PresetDetail(title: str, description: str, schematic_svg: str, plant: dict[str, Any], form: list[ComponentSpec])
A preset's presentation, default plant, and form spec (GET /api/presets/{name}).
PresetSummary
dataclass
¶
A preset in the picker list.
RunResults
dataclass
¶
A solved run's readout: curated headline numbers and chart configs (opaque to the API).
RunSnapshot
dataclass
¶
RunSnapshot(status: Status, error: str | None = None, results: RunResults | None = None)
A run's current state (GET /api/runs/{id}): status plus error or results when terminal.
SolveErrors
dataclass
¶
SolveErrors(errors: list[FieldError])
The 422 body of a rejected solve: the field-addressed validation errors.
share
¶
Encode/decode an Envelope as a URL-safe token.
A token is base64url(gzip(json(envelope))) -- enough to reconstruct a shared model from a
link with no server-side storage. The envelope is the same {preset, overlay, enabled} the
client edits and solves, so loading a share is just an overlay over the named preset's default.
decode is deliberately defensive: a ?p= value is untrusted input, so every malformed,
stale, or oversized token raises ValueError rather than crashing the page or expanding without
bound (a gzip bomb). TypeAdapter(Envelope) validates the envelope's shape; that the overlay
paths exist and its values are in bounds is enforced at solve against the preset's spec.
decode
¶
Reconstruct an envelope from a token.
Parameters:
Returns:
-
Envelope–The reconstructed envelope (shape-validated, not solve-validated).
Raises:
-
ValueError–If the token is malformed, oversized, or not a well-formed envelope.
Source code in src/technoeconomics/web/share.py
encode
¶
Encode an envelope as a URL-safe token.
Parameters:
-
envelope(Envelope) –The envelope to encode.
Returns:
-
str–A
base64url(gzip(json(...)))token suitable for a?p=query value.
Source code in src/technoeconomics/web/share.py
spec
¶
The form spec and the submission boundary: describe editable params, apply user edits.
Two directions across the same spec:
plant_to_specturns a plant (the preset default) into a value-free description of its editable parameters. The client renders the form from this spec and binds each input to its path in the plant dict -- the spec says what is editable and how (label, unit, bounds), the values travel separately.validate_edits/apply_editsare the overlay-solve boundary. The client never posts a plant; it posts{preset, overlay: {path: value}, enabled: {id: bool}}. The server rebuilds from the trusted preset default and applies only whitelisted edits: overlay values at spec paths (any non-spec path is rejected -- structure is never client-controlled) and enable toggles on non-fixed components.
The spec generator reads the parameter vocabulary from technoeconomics.model.params
off each component's field annotations, recursing into dataset parameters so a Sinusoidal
price exposes price.mean, price.amplitude, ... as separate numeric inputs. It is
total-or-error: every author-added field must be projectable (a numeric scalar, a
timeseries, or a dataset recursed into) or explicitly marked Hidden -- anything else
raises ProjectionError naming the field,
rather than being silently dropped.
Per-use unit and bound propagation: the unit and (scalar-branch) bounds declared on a
component's dataset field propagate to the dataset leaves marked
Magnitude -- so one Sinusoidal class serves as
a price (EUR/MWh) in one field and a demand (MW) in another. Intrinsic dataset leaves
(period, phase) keep their own class-level unit and bounds. A propagated bound binds
each leaf it lands on, not the value the dataset ultimately computes (see
technoeconomics.model.params).
Division of labour on the submission path, so no rule is checked twice:
- pydantic, at the request boundary.
Envelopedeclaresoverlay: dict[str, float]andenabled: dict[str, bool], so by the timevalidate_editsruns the value types are already guaranteed. It does not re-check them. validate_edits, against the spec. The path whitelist (which is what keeps structure server-controlled), the numeric bounds -- propagated ones especially, which sit outside pydantic's reach entirely -- finiteness, and the fixed-component rule.- the
Plantvalidator, on the rebuilt plant. Class-level types and bounds, as a backstop. It is unreachable by construction oncevalidate_editspasses, soapply_editsdoes not soften anything for it: a spec path that fails to resolve raises rather than being silently dropped, because that can only mean the spec and the serialised plant have drifted apart.
Bound
dataclass
¶
ComponentSpec
dataclass
¶
ComponentSpec(id: str, title: str, color: str | None, enabled: bool, fixed: bool, params: list[FieldSpec])
A component rendered as a fieldset: identity, an optional enable toggle, its params.
Attributes:
-
id(str) –The component's id (the first segment of its params' paths).
-
title(str) –Human-readable heading derived from the id.
-
color(str | None) –The component's plot colour (a hex string), or None.
-
enabled(bool) –Whether the component is currently enabled.
-
fixed(bool) –If True the component may not be disabled -- the client shows no enable toggle and a submission cannot switch it off.
-
params(list[FieldSpec]) –The editable numeric leaves, in declaration order.
FieldSpec
dataclass
¶
FieldSpec(path: str, kind: Literal['number', 'series'], label: str, unit: str | None, min: Bound | None, max: Bound | None, step: float | None, advanced: bool)
One leaf of a component (possibly nested inside a dataset) surfaced in the form.
Value-free: the current value lives in the plant dict, reached by path.
Attributes:
-
path(str) –Id-rooted dotted path into the plant dict, e.g.
"grid.price.mean". -
kind(Literal['number', 'series']) –The input kind.
"number"is an editable numeric input;"series"is a timeseries, shown read-only -- the whitelist invalidate_editsadmits an overlay value only for a"number", so a series is surfaced but not yet writable. Further kinds are additive. -
label(str) –Display name -- from
Labelwhen the author gave one, else derived from the path ("price.mean"). -
unit(str | None) –Display/edit unit, e.g.
"EUR/MWh", or None if dimensionless. For a"series"this is the unit of each point. -
min(Bound | None) –Lower bound, or None if unbounded below.
-
max(Bound | None) –Upper bound, or None if unbounded above.
-
step(float | None) –Suggested input step, or None. (No source in v1; reserved for a future marker.)
-
advanced(bool) –Render under the form's "Advanced" section rather than inline.
ProjectionError
¶
Bases: TypeError
An author-added field is neither projectable (scalar/dataset) nor marked Hidden.
apply_edits
¶
Rebuild the preset default plant with a validated submission's edits applied.
Overlays each value at its spec path and sets each component's enabled flag, operating
on the serialised dict (datasets are frozen), then reparses through Plant.from_dict as a
class-level backstop.
Assumes the submission passed
validate_edits, and does not soften anything
on that assumption: a path or component id that fails to resolve raises, because after
validation it can only mean the spec and the serialised plant have drifted apart.
Parameters:
-
plant(Plant) –The trusted preset default plant.
-
overlay(Mapping[str, float]) –Validated
{path: value}edits. -
enabled(Mapping[str, bool]) –Validated
{component_id: bool}toggles.
Returns:
-
Plant–A new plant with the edits applied.
Raises:
-
KeyError–If a validated path or component id does not resolve (a spec/plant drift bug, not a bad submission).
Source code in src/technoeconomics/web/spec.py
plant_to_spec
¶
plant_to_spec(plant: Plant) -> list[ComponentSpec]
Describe a plant's editable parameters, one ComponentSpec per component.
Propagates ProjectionError from _leaves
when an author-added field is neither projectable nor Hidden -- the totality rule.
Parameters:
-
plant(Plant) –The plant supplying the structure (which datasets are present, which components are fixed).
Returns:
-
list[ComponentSpec]–The form spec: one entry per component, each carrying its editable numeric leaves.
Source code in src/technoeconomics/web/spec.py
validate_edits
¶
validate_edits(spec: Iterable[ComponentSpec], overlay: Mapping[str, float], enabled: Mapping[str, bool]) -> list[dict[str, str]]
Validate a submission's edits against spec; return all path-addressed errors.
An overlay entry must name an editable (kind="number") spec path and carry a finite,
in-bounds value; an enabled entry must name a component and may switch off only a non-fixed
one. Rejecting non-spec overlay paths is what keeps structure server-controlled: the client
can edit values and toggle optional components, nothing else.
The value types are guaranteed by Envelope at
the request boundary, so they are not re-checked here.
Parameters:
-
spec(Iterable[ComponentSpec]) –The named preset's form spec.
-
overlay(Mapping[str, float]) –Submitted
{path: value}edits (shape-validated, meaning untrusted). -
enabled(Mapping[str, bool]) –Submitted
{component_id: bool}toggles (likewise).
Returns: