Skip to content

Commit f906fbe

Browse files
committed
fix
1 parent 3b212c3 commit f906fbe

File tree

7 files changed

+254
-120
lines changed

7 files changed

+254
-120
lines changed

dependencies.xml

Lines changed: 0 additions & 17 deletions
This file was deleted.
File renamed without changes.

src/dove/components.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
from dataclasses import dataclass, field
88
from abc import ABC
9-
from typing import Literal, Optional, NewType, Any
9+
from typing import Literal, Optional
10+
11+
DispatchFlexibility = Literal["independent"] | Literal["fixed"]
1012

1113

1214
@dataclass(frozen=True)
@@ -24,14 +26,24 @@ class TransferTerm:
2426

2527

2628
@dataclass
27-
class CashFlow:
29+
class CashFlow(ABC):
2830
""" """
2931
name: str
3032
reference_price: float
3133
reference_driver: float = 1.0
3234
scaling_factor_x: float = 1.0
3335
price_is_levelized: bool = False
3436

37+
@dataclass
38+
class Cost(CashFlow):
39+
""" """
40+
sign: int = -1
41+
42+
@dataclass
43+
class Revenue(CashFlow):
44+
""" """
45+
sign: int = +1
46+
3547

3648
@dataclass(kw_only=True)
3749
class Component(ABC):
@@ -41,11 +53,11 @@ class Component(ABC):
4153
and a single CashFlowGroup which is a container for component associated cashflows.
4254
"""
4355
name: str
44-
capacity_var: Resource
4556
capacity: float | list[float]
46-
capacity_factor: float | list[float] | None = None
47-
minimum: float | list[float] | None = None
48-
dispatch_flexibility: str = "independent"
57+
capacity_factor: Optional[float | list[float]] = None
58+
minimum: Optional[float | list[float]] = None
59+
capacity_resource: Optional[Resource] = None
60+
dispatch_flexibility: Literal["independent", "fixed"] = "independent"
4961
cashflows: list[CashFlow] = field(default_factory=list)
5062
transfer_terms: list[TransferTerm] = field(default_factory=list)
5163
levelized_meta: dict = field(default_factory=dict)
@@ -64,6 +76,8 @@ class Source(Component):
6476
def __post_init__(self) -> None:
6577
""" """
6678
super().__post_init__()
79+
if self.capacity_resource is None:
80+
self.capacity_resource = self.produces
6781
self.transfer_terms = [TransferTerm(1.0, {self.produces: 1})]
6882

6983

@@ -76,6 +90,8 @@ class Sink(Component):
7690
def __post_init__(self) -> None:
7791
""" """
7892
super().__post_init__()
93+
if self.capacity_resource is None:
94+
self.capacity_resource = self.consumes
7995
self.transfer_terms = [TransferTerm(-1.0, {self.consumes: 1})]
8096

8197

@@ -88,6 +104,29 @@ class Converter(Component):
88104
ramp_freq: int = 0
89105
tracking_vars: list[str] = field(default_factory=lambda: ["production"])
90106

107+
def __post_init__(self) -> None:
108+
""" """
109+
super().__post_init__()
110+
extras = {c for c in self.consumes if c != self.produces}
111+
if extras and self.capacity_resource is None:
112+
raise ValueError(
113+
f"Ambiguity in Converter '{self.name}' consumes: {self.consumes} and "
114+
f"produces: {self.produces} please set 'capacity_resource'."
115+
)
116+
if extras and not self.transfer_terms:
117+
raise ValueError(f"Converter '{self.name}' consumes {self.consumes} but no transfer terms defined.")
118+
119+
# Auto‐adjust the sign of any transfer term that involves capacity_var:
120+
for term in self.transfer_terms:
121+
# Does this term actually involve our capacity resource?
122+
if term.exponent.get(self.capacity_resource, 0) != 0:
123+
# If capacity_var is in the consumes list → make coeff negative
124+
if self.capacity_resource in self.consumes:
125+
term.coeff = -abs(term.coeff)
126+
# Otherwise (it’s a produced resource) → make coeff positive
127+
else:
128+
term.coeff = +abs(term.coeff)
129+
91130

92131
@dataclass
93132
class Storage(Component):
@@ -98,5 +137,10 @@ class Storage(Component):
98137
max_discharge_rate: float = 1.0
99138
initial_stored: float = 0
100139
periodic_level: bool = True
101-
tracking_vars: list[str] = field(default_factory=lambda: ["level", "charge", "discharge",])
140+
tracking_vars: list[str] = field(default_factory=lambda: ["level", "charge", "discharge", ])
102141

142+
def __post_init__(self) -> None:
143+
""" """
144+
super().__post_init__()
145+
if self.capacity_resource is None:
146+
self.capacity_resource = self.resource

src/dove/pyomo_model_builder.py

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ def __init__(self) -> None:
1515
def build_model(self, system) -> pyo.ConcreteModel:
1616
""" """
1717

18-
self._normalize_time_series(system)
19-
2018
m = pyo.ConcreteModel()
2119

2220
self._add_sets(m, system)
2321

22+
self._add_params(m, system)
23+
2424
self._add_vars(m, system)
2525

2626
self._add_constraints(m, system)
@@ -29,21 +29,71 @@ def build_model(self, system) -> pyo.ConcreteModel:
2929

3030
return cast(pyo.ConcreteModel, m)
3131

32-
def solve_model(self, model):
32+
def solve_model(self, model) -> pyo.ConcreteModel:
3333
""" """
34-
pass
34+
opt = pyo.SolverFactory("cbc")
35+
opt.solve(model, tee=True, keepfiles=False)
36+
return model
3537

36-
def _normalize_time_series(self, system):
37-
pass
3838

3939
def _add_sets(self, m, system):
40-
pass
40+
m.COMPS = pyo.Set(initialize=list(system.comp_map.keys()), ordered=True)
41+
m.RESOURCES = pyo.Set(initialize=list(system.res_map.keys()), ordered=True)
42+
m.TIMES = pyo.Set(initialize=system.time_index, ordered=True)
43+
44+
def _add_params(self, m, system):
45+
def _cap_init(m, comp_name, t):
46+
comp = system.comp_map[comp_name]
47+
return comp.capacity[t]
48+
m.capacity = pyo.Param(m.COMPS, m.TIMES, initialize=_cap_init, mutable=False)
49+
50+
def _min_init(m, comp_name, t):
51+
comp = system.comp_map[comp_name]
52+
return comp.minimum[t]
53+
m.minimum = pyo.Param(m.COMPS, m.TIMES, initialize=_min_init, mutable=False)
54+
4155

4256
def _add_vars(self, m, system):
43-
pass
57+
m.dispatch = pyo.Var(m.COMPS, m.TIMES, within=pyo.NonNegativeReals)
58+
4459

4560
def _add_constraints(self, m, system):
46-
pass
61+
def _cap_rule(m, comp_name, t):
62+
return m.dispatch[comp_name, t] <= m.capacity[comp_name, t]
63+
64+
m.CapacityConstraint = pyo.Constraint(m.COMPS, m.TIMES, rule=_cap_rule)
65+
66+
def _min_rule(m, comp_name, t):
67+
return m.dispatch[comp_name, t] >= m.minimum[comp_name, t]
68+
m.MinimumConstraint = pyo.Constraint(m.COMPS, m.TIMES, rule=_min_rule)
69+
70+
def _flow_rule(m, res_name, t):
71+
resource = system.res_map[res_name]
72+
expr = None
73+
for comp_name in m.COMPS:
74+
comp = system.comp_map[comp_name]
75+
for term in comp.transfer_terms:
76+
exp = term.exponent.get(resource, 0)
77+
if exp != 0:
78+
term_expr = term.coeff * (m.dispatch[comp_name, t] ** exp)
79+
if expr is None:
80+
expr = term_expr
81+
else:
82+
expr += term_expr
83+
if expr is None:
84+
expr = pyo.Constraint.Skip
85+
86+
return expr == 0.0
87+
88+
m.FlowConstraint = pyo.Constraint(m.RESOURCES, m.TIMES, rule=_flow_rule)
89+
4790

4891
def _add_objective(self, m, system):
49-
pass
92+
expr = 0.0
93+
for comp in system.components:
94+
for cf in comp.cashflows:
95+
for t in m.TIMES:
96+
expr += cf.sign * cf.reference_price[t] * (m.dispatch[comp.name, t] / cf.reference_driver[t]) ** cf.scaling_factor_x[t]
97+
98+
m.Objective = pyo.Objective(expr=expr, sense=pyo.maximize)
99+

src/dove/storage_test.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
2+
"""
3+
"""
4+
import numpy as np
5+
import matplotlib.pyplot as plt
6+
import pyomo.environ as pyo
7+
import pandas as pd
8+
from .components import Resource, TransferTerm, Revenue, Cost, Source, Sink, Converter, Storage
9+
from .system import System
10+
11+
12+
if __name__ == '__main__':
13+
elec = Resource("electricity")
14+
steam = Resource("steam")
15+
16+
steamer = Source(
17+
name="steamer",
18+
produces=steam,
19+
capacity=100,
20+
dispatch_flexibility="fixed"
21+
)
22+
23+
steam_storage = Storage(
24+
name="steam_storage",
25+
resource=steam,
26+
capacity=100,
27+
rte=0.9,
28+
)
29+
30+
gen = Converter(
31+
name="generator",
32+
capacity_resource=steam,
33+
capacity=90,
34+
consumes=[steam],
35+
produces=elec,
36+
transfer_terms=[
37+
TransferTerm(1, {steam: 1}),
38+
TransferTerm(0.5, {elec: 1})
39+
]
40+
)
41+
42+
linear_price = np.arange(0.1, 2.2, 0.1).tolist() * 2
43+
market_linear = Sink(
44+
name="market_linear",
45+
consumes=elec,
46+
capacity=2,
47+
cashflows=[Revenue("esales", linear_price)],
48+
)
49+
50+
spike_price = np.zeros(len(linear_price))
51+
spike_price.put((0, 11, 20, 28, 29, 38), 10)
52+
market_spike = Sink(
53+
name="market_spike",
54+
consumes=elec,
55+
capacity=40,
56+
cashflows=[Revenue("esales", spike_price.tolist())],
57+
)
58+
59+
steam_offload = Sink(
60+
name="steam_offload",
61+
consumes=steam,
62+
capacity=100,
63+
cashflows=[Revenue("steam_offload", 0.01)],
64+
)
65+
66+
components = [steamer, steam_storage, gen, market_linear, market_spike, steam_offload]
67+
resources = [elec, steam]
68+
sys = System(components, resources, np.arange(0, len(linear_price)).tolist())
69+
model = sys.solve()
70+
71+
time_index = sys.time_index
72+
comp_names = list(sys.comp_map.keys())
73+
74+
# Extract dispatch into a DataFrame
75+
data = {
76+
comp: [pyo.value(model.dispatch[comp, t]) for t in time_index]
77+
for comp in comp_names
78+
}
79+
df = pd.DataFrame(data, index=time_index)
80+
print(df)
81+
82+
# Plot the dispatch schedules
83+
plt.figure()
84+
for comp in comp_names:
85+
plt.plot(time_index, df[comp], label=comp)
86+
plt.xlabel("Time")
87+
plt.ylabel("Dispatch")
88+
plt.title("Component Dispatch over Time")
89+
plt.legend()
90+
plt.show()
91+
92+
93+
94+

src/dove/system.py

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,35 @@
22
# ALL RIGHTS RESERVED
33
""" """
44

5-
from typing import Self
5+
from typing import Self, Union
6+
import numpy as np
7+
import pyomo.environ as pyo
8+
from .pyomo_model_builder import PyomoModelBuilder
9+
from .components import Component, Resource
610

11+
ArrayLike = Union[float, list[float], np.ndarray]
12+
13+
def _broadcast(x: ArrayLike, T: int) -> np.ndarray:
14+
"""Turn a scalar or length-T list/array into an np.ndarray of length T."""
15+
if isinstance(x, (int, float)):
16+
return np.full(T, float(x))
17+
arr = np.asarray(x, float)
18+
if arr.shape == (T,):
19+
return arr
20+
raise ValueError(f"Expected scalar or length-{T} array, got shape {arr.shape}")
721

822
class System:
923
""" """
1024

11-
def __init__(self, components: list, resources: list) -> None:
25+
def __init__(self, components, resources, time_index, builder=None) -> None:
1226
""" """
13-
self.components: list = components
14-
self.resources: list = resources
27+
self.components: list[Component] = components
28+
self.resources: list[Resource] = resources
29+
self.time_index = time_index
30+
self.builder = builder if builder else PyomoModelBuilder()
31+
self.comp_map = {comp.name: comp for comp in components}
32+
self.res_map = {res.name: res for res in resources}
33+
self._normalize_time_series()
1534

1635
def add_component(self, comp) -> Self:
1736
""" """
@@ -23,10 +42,32 @@ def add_resource(self, res) -> Self:
2342
self.resources.append(res)
2443
return self
2544

26-
def build(self) -> None:
45+
def build(self) -> pyo.ConcreteModel:
46+
""" """
47+
model = self.builder.build_model(self)
48+
return model
49+
50+
def solve(self) -> pyo.ConcreteModel:
2751
""" """
28-
pass
52+
model = self.builder.build_model(self)
53+
return self.builder.solve_model(model)
2954

30-
def solve(self) -> None:
55+
def _normalize_time_series(self) -> None:
3156
""" """
32-
pass
57+
for comp in self.components:
58+
comp.capacity = _broadcast(comp.capacity, len(self.time_index))
59+
60+
if comp.capacity_factor is not None:
61+
comp.capacity_factor = _broadcast(comp.capacity_factor, len(self.time_index))
62+
63+
if comp.dispatch_flexibility == "fixed":
64+
comp.minimum = comp.capacity
65+
elif comp.minimum is not None:
66+
comp.minimum = _broadcast(comp.minimum, len(self.time_index))
67+
else:
68+
comp.minimum = np.zeros(len(self.time_index), dtype=float)
69+
70+
for cf in comp.cashflows:
71+
cf.reference_price = _broadcast(cf.reference_price, len(self.time_index))
72+
cf.reference_driver = _broadcast(cf.reference_driver, len(self.time_index))
73+
cf.scaling_factor_x = _broadcast(cf.scaling_factor_x, len(self.time_index))

0 commit comments

Comments
 (0)