Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 52 additions & 18 deletions docs/guide/effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,37 +65,70 @@ co2 = Effect('co2', unit='kg', maximum_per_hour=[50, 40, 60, 50])

## Cross-Effect Contributions

An effect can include a weighted contribution from another effect using
`contribution_from`. This is useful for carbon pricing, primary energy factors,
or any chain where one tracked quantity feeds into another.
An effect can include a weighted contribution from another effect. Each domain
has its own field: `cross_temporal`, `cross_periodic`, and `cross_once`. This
is useful for carbon pricing, primary energy factors, or any chain where one
tracked quantity feeds into another.

### Scalar (temporal + periodic)
### Temporal (per-timestep)

A scalar factor applies to **both** domains — temporal (per-timestep) and
periodic (sizing, yearly costs):
`cross_temporal` links effects in the temporal domain (flow operation costs).
The factor can be scalar or time-varying:

```python
effects = [
Effect('cost', is_objective=True, contribution_from={'co2': 50}),
Effect('cost', is_objective=True, cross_temporal={'co2': 50}),
Effect('co2', unit='kg'),
]
```

Here, every kg of CO2 adds 50 to cost — both for temporal emissions
(from flow operation) and periodic emissions (e.g., from `Sizing.effects_per_size`).
Here, every kg of CO2 emitted per timestep adds 50 to the temporal cost.

### Time-Varying (temporal only)
Time-varying factors are also supported:

Use `contribution_from_per_hour` for time-varying factors that override the
scalar in the temporal domain:
```python
effects = [
Effect('cost', is_objective=True, cross_temporal={'co2': [40, 50, 60]}),
Effect('co2', unit='kg'),
]
```

### Periodic (recurring costs)

`cross_periodic` links effects in the periodic domain (sizing costs, fixed
annual O&M — anything that recurs and is weighted by period weights):

```python
effects = [
Effect('cost', is_objective=True, cross_periodic={'co2': 50}),
Effect('co2', unit='kg'),
]
```

### Once (one-time costs)

`cross_once` links effects in the once domain (one-time investment CAPEX,
decommissioning — costs that happen at a specific point, not recurring):

```python
effects = [
Effect('cost', is_objective=True, cross_once={'co2': 50}),
Effect('co2', unit='kg'),
]
```

### Combining domains

Typically you want the same factor in multiple domains. Set each explicitly:

```python
effects = [
Effect(
'cost',
is_objective=True,
contribution_from={'co2': 50}, # scalar for periodic domain
contribution_from_per_hour={'co2': [40, 50, 60]}, # time-varying for temporal domain
cross_temporal={'co2': 50},
cross_periodic={'co2': 50},
cross_once={'co2': 50},
),
Effect('co2', unit='kg'),
]
Expand All @@ -107,8 +140,8 @@ Contributions chain transitively. A PE -> CO2 -> cost chain is modeled as:

```python
effects = [
Effect('cost', is_objective=True, contribution_from={'co2': 50}),
Effect('co2', unit='kg', contribution_from={'pe': 0.3}),
Effect('cost', is_objective=True, cross_temporal={'co2': 50}),
Effect('co2', unit='kg', cross_temporal={'pe': 0.3}),
Effect('pe', unit='kWh'),
]
```
Expand Down Expand Up @@ -212,5 +245,6 @@ print(result.effect_totals)
| `minimum_total` | `float \| None` | `None` | Lower bound on total |
| `maximum_per_hour` | `TimeSeries \| None` | `None` | Upper bound per timestep |
| `minimum_per_hour` | `TimeSeries \| None` | `None` | Lower bound per timestep |
| `contribution_from` | `dict[str, float]` | `{}` | Cross-effect factor (both domains) |
| `contribution_from_per_hour` | `dict[str, TimeSeries]` | `{}` | Cross-effect factor (temporal domain only) |
| `cross_temporal` | `dict[str, TimeSeries]` | `{}` | Cross-effect factor (temporal domain) |
| `cross_periodic` | `dict[str, TimeSeries]` | `{}` | Cross-effect factor (periodic domain) |
| `cross_once` | `dict[str, TimeSeries]` | `{}` | Cross-effect factor (once domain) |
38 changes: 21 additions & 17 deletions docs/math/effects.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ periods (e.g., annual O&M for 5 years), while **once** costs happen at a specifi
differ — recurring costs scale with duration, one-time costs typically don't
(or use discount factors instead).

All domains support cross-effect chains via `contribution_from`.
All domains support cross-effect chains via `cross_temporal`, `cross_periodic`,
and `cross_once`.

In multi-period mode, all variables gain an optional `period` dimension.
See [Objective](objective.md) for how the domains are weighted in the objective.
Expand All @@ -37,8 +38,8 @@ Each effect accumulates contributions from all flows at each timestep:
The coefficient \(c_{f,k,t}\) specifies how much of effect \(k\) is produced per
flow-hour of flow \(f\) (e.g., €/MWh for cost, kg/MWh for emissions).

The cross-effect factor \(\alpha_{k,j,t}\) can be time-varying
(`contribution_from_per_hour`) or constant (`contribution_from`).
The cross-effect factor \(\alpha_{k,j,t}\) is set via `cross_temporal` and
can be time-varying or constant.
Because \(\Phi_{k,t}^{\text{temporal}}\) is a **variable**, the solver resolves
multi-level chains (e.g., PE → CO₂ → cost) automatically.

Expand All @@ -63,13 +64,14 @@ periodic domain just as it does through the temporal domain.

## Cross-Effect Contributions

An effect can include a weighted fraction of another effect's value via
`contribution_from`. This enables patterns like carbon pricing (CO₂ → cost)
An effect can include a weighted fraction of another effect's value. Each domain
has its own cross-effect field — `cross_temporal`, `cross_periodic`, and
`cross_once` — enabling patterns like carbon pricing (CO₂ → cost)
or transitive chains (PE → CO₂ → cost).

The scalar factor \(\alpha_{k,j}\) from `contribution_from` applies to **both**
domains. The time-varying factor from `contribution_from_per_hour` overrides the
temporal factor only.
The factor \(\alpha_{k,j}\) from `cross_periodic` applies to the periodic domain,
\(\alpha_{k,j,t}\) from `cross_temporal` applies to the temporal domain (and can
be time-varying), and \(\alpha_{k,j}\) from `cross_once` applies to the once domain.

### Validation

Expand All @@ -79,13 +81,14 @@ Self-references (\(\alpha_{k,k}\)) and circular dependencies
## Once Domain

One-time costs that should not be scaled by period weights (e.g., investment CAPEX,
decommissioning costs). Currently constrained to zero — a placeholder for future
investment modelling:
decommissioning costs):

\[
\Phi_{k(,p)}^{\text{once}} = 0 \quad \forall \, k
\Phi_k^{\text{once}} = \underbrace{\Phi_k^{\text{once,direct}}}_{\text{direct one-time costs}} + \underbrace{\sum_{j \in \mathcal{K}} \alpha^{\text{once}}_{k,j} \cdot \Phi_j^{\text{once}}}_{\text{cross-effect contributions}} \quad \forall \, k
\]

The cross-effect factor \(\alpha^{\text{once}}_{k,j}\) is set via `cross_once`.

## Total Aggregation

The total effect combines all three domains:
Expand Down Expand Up @@ -125,8 +128,9 @@ This enforces per-hour limits (e.g., maximum hourly emissions).
| \(\Phi_{k(,p)}^{\text{once}}\) | One-time effect variable | `effect_once[effect(, period)]` |
| \(\Phi_{k(,p)}\) | Total effect variable | `effect_total[effect(, period)]` |
| \(c_{f,k,t}\) | Effect coefficient per flow-hour | `Flow.effects_per_flow_hour` |
| \(\alpha_{k,j,t}\) | Cross-effect contribution factor (per hour) | `Effect.contribution_from_per_hour` |
| \(\alpha_{k,j}\) | Cross-effect contribution factor (scalar) | `Effect.contribution_from` |
| \(\alpha_{k,j,t}\) | Cross-effect factor (temporal, possibly time-varying) | `Effect.cross_temporal` |
| \(\alpha_{k,j}\) | Cross-effect factor (periodic) | `Effect.cross_periodic` |
| \(\alpha^{\text{once}}_{k,j}\) | Cross-effect factor (once) | `Effect.cross_once` |
| \(P_{f,t}\) | Flow rate variable | `flow_rate[flow, time]` |
| \(\Delta t_t\) | Timestep duration | dt |
| \(w_t\) | Timestep weight | weights |
Expand Down Expand Up @@ -161,13 +165,13 @@ At timestep \(t\) with \(P_{\text{gas},t} = 5\) MW and \(\Delta t = 1\) h:
- \(\Phi_{\text{cost},t} = 30 \times 5 \times 1 = 150\) €
- \(\Phi_{\text{CO₂},t} = 0.2 \times 5 \times 1 = 1.0\) kg

### Carbon pricing via `contribution_from`
### Carbon pricing via cross-effects

CO₂ priced at 50 €/t into the cost effect:
CO₂ priced at 50 €/t into the cost effect (temporal domain):

```python
effects = [
Effect("cost", is_objective=True, contribution_from={"co2": 50}),
Effect("cost", is_objective=True, cross_temporal={"co2": 50}),
Effect("co2", unit="kg"),
]
```
Expand All @@ -178,4 +182,4 @@ With \(\alpha_{\text{cost,co2}} = 50\), the per-timestep cost becomes:
\Phi_{\text{cost},t} = c_{\text{cost}} \cdot P_t \cdot \Delta t + 50 \cdot \Phi_{\text{co2},t}
\]

The CO₂ total itself is **not** affected — `contribution_from` is one-directional.
The CO₂ total itself is **not** affected — cross-effects are one-directional.
5 changes: 3 additions & 2 deletions docs/math/notation.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ Each symbol maps to a specific field or variable in the code.
| \(\underline{e}_s\) | `Storage.relative_minimum_level` | \([0, 1]\) | — | Relative min SOC |
| \(\bar{e}_s\) | `Storage.relative_maximum_level` | \([0, 1]\) | — | Relative max SOC |
| \(a_{f}\) | `Converter.conversion_factors` | \(\mathbb{R}\) | — | Conversion coefficient |
| \(\alpha_{k,j}\) | `Effect.contribution_from` | \(\mathbb{R}\) | varies | Cross-effect factor (scalar) |
| \(\alpha_{k,j,t}\) | `Effect.contribution_from_per_hour` | \(\mathbb{R}\) | varies | Cross-effect factor (time-varying) |
| \(\alpha_{k,j}\) | `Effect.cross_periodic` | \(\mathbb{R}\) | varies | Cross-effect factor (periodic) |
| \(\alpha_{k,j,t}\) | `Effect.cross_temporal` | \(\mathbb{R}\) | varies | Cross-effect factor (temporal, possibly time-varying) |
| \(\alpha^{\text{once}}_{k,j}\) | `Effect.cross_once` | \(\mathbb{R}\) | varies | Cross-effect factor (once) |
| \(S^-\) | `Sizing.min_size` | \(\geq 0\) | MW or MWh | Minimum invested size (flow or storage) |
| \(S^+\) | `Sizing.max_size` | \(\geq 0\) | MW or MWh | Maximum invested size (flow or storage) |
| \(\gamma_{f,k}\) | `Sizing.effects_per_size` | \(\mathbb{R}\) | varies | Per-size investment cost |
Expand Down
17 changes: 16 additions & 1 deletion src/fluxopt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@
from typing import Any

from fluxopt.components import Converter, Port
from fluxopt.elements import PENALTY_EFFECT_ID, Carrier, Effect, Flow, Investment, Sizing, Status, Storage
from fluxopt.elements import (
PENALTY_EFFECT_ID,
Carrier,
ConversionCurve,
Effect,
Flow,
Investment,
PiecewiseInvestment,
PiecewiseSizing,
Sizing,
Status,
Storage,
)
from fluxopt.model import FlowSystem
from fluxopt.model_data import Dims, ModelData
from fluxopt.results import Result
Expand Down Expand Up @@ -64,6 +76,7 @@ def optimize(
__all__ = [
'PENALTY_EFFECT_ID',
'Carrier',
'ConversionCurve',
'Converter',
'Dims',
'Effect',
Expand All @@ -72,6 +85,8 @@ def optimize(
'IdList',
'Investment',
'ModelData',
'PiecewiseInvestment',
'PiecewiseSizing',
'Port',
'Result',
'Sizing',
Expand Down
46 changes: 41 additions & 5 deletions src/fluxopt/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fluxopt.types import IdList

if TYPE_CHECKING:
from fluxopt.elements import Flow
from fluxopt.elements import ConversionCurve, Flow
from fluxopt.types import TimeSeries


Expand Down Expand Up @@ -39,25 +39,61 @@ def __post_init__(self) -> None:

@dataclass
class Converter:
"""Linear conversion between input and output flows.
"""Conversion between input and output flows.

Conversion equation (per equation index)::
Supports two modes:

sum_f(a_f * P_{f,t}) = 0 for all t
**Linear** (default): ``conversion_factors`` is a list of dicts, each
defining one equation ``sum_f(a_f * P_{f,t}) = 0``.

**Piecewise**: set ``conversion`` to a :class:`ConversionCurve`.
Breakpoint keys must match flow ``short_id`` values. Individual flows
must not carry ``size`` or ``status`` (the curve governs sizing/status
at the component level).
Comment on lines +49 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docstring overstates the size restriction for piecewise flows.

The new public doc says individual flows must not carry size, but __post_init__ only rejects Sizing/Investment-style wrappers and the added piecewise tests still use fixed Flow.size values. Please either tighten the validation to forbid scalar sizes too, or document that fixed per-flow bounds are still supported.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fluxopt/components.py` around lines 49 - 52, Update the Piecewise
doc/validation mismatch by tightening Piecewise.__post_init__: in addition to
rejecting Sizing/Investment wrappers, also detect and reject any flow with a
scalar Flow.size (i.e., if getattr(flow, "size", None) is not None) and raise a
clear ValueError referencing the curve-level sizing requirement; keep the
existing checks for Sizing and Investment classes. This ensures the Piecewise
class enforces the docstring rule that per-flow sizes are forbidden.

"""

id: str
inputs: list[Flow] | IdList[Flow]
outputs: list[Flow] | IdList[Flow]
conversion_factors: list[dict[str, TimeSeries]] = field(default_factory=list) # a_f
conversion: ConversionCurve | None = None
_short_to_id: dict[str, str] = field(init=False, default_factory=dict)

def __post_init__(self) -> None:
"""Qualify flow ids and build short→qualified mapping."""
"""Qualify flow ids, build short→qualified mapping, validate."""
self.inputs = _qualify_flows(self.id, list(self.inputs))
self.outputs = _qualify_flows(self.id, list(self.outputs))
self._short_to_id = {f.short_id: f.id for f in (*self.inputs, *self.outputs)}

if self.conversion is not None:
if self.conversion_factors:
msg = f'Converter {self.id!r}: cannot specify both conversion_factors and conversion'
raise ValueError(msg)
# Validate breakpoint keys match flow short_ids
bp_keys = set(self.conversion.breakpoints.keys())
flow_keys = set(self._short_to_id.keys())
if bp_keys != flow_keys:
unknown = bp_keys - flow_keys
missing = flow_keys - bp_keys
msg = (
f'Converter {self.id!r}: ConversionCurve breakpoints must cover every flow. '
f'Unknown keys: {unknown or set()}, missing keys: {missing or set()}'
)
raise ValueError(msg)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Validate no flow-level size or status on piecewise flows
from fluxopt.elements import Investment, PiecewiseInvestment, PiecewiseSizing, Sizing

for f in (*self.inputs, *self.outputs):
if f.short_id in bp_keys:
if isinstance(f.size, (Sizing, Investment, PiecewiseSizing, PiecewiseInvestment)):
msg = f'Converter {self.id!r}: flow {f.short_id!r} cannot have Sizing/Investment when using ConversionCurve'
raise ValueError(msg)
if f.status is not None:
msg = (
f'Converter {self.id!r}: flow {f.short_id!r} cannot have status when using ConversionCurve'
)
raise ValueError(msg)

@classmethod
def _single_io(cls, id: str, coefficient: TimeSeries, input_flow: Flow, output_flow: Flow) -> Converter:
"""Create a single-input/single-output converter: input * coefficient = output."""
Expand Down
Loading
Loading