From 8e6909402d493d6c7124f7b0875d06c917c8acf3 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 22 Jul 2026 10:44:59 +0200 Subject: [PATCH] feat: per-battery charge/discharge efficiency Batteries can now carry their own eta_c/eta_d, overriding the top-level values which become defaults for batteries that omit them. The optimizer reads efficiency from BatteryConfig instead of a single global pair. Co-Authored-By: Claude Opus 4.8 (1M context) --- client/client.gen.go | 10 ++++++++-- openapi.yaml | 16 ++++++++++++++-- src/optimizer/app.py | 14 +++++++++----- src/optimizer/optimizer.py | 14 +++++++------- tests/test_app.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/client/client.gen.go b/client/client.gen.go index cf50a8c0f..8f440d1bd 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -68,6 +68,12 @@ type BatteryConfig struct { // - False: (default) The battery cannot be discharged while power is exported to the grid. DischargeToGrid bool `json:"discharge_to_grid,omitempty"` + // EtaC Charging efficiency (0 to 1). Overrides the top level eta_c for this battery. + EtaC float32 `json:"eta_c,omitempty"` + + // EtaD Discharging efficiency (0 to 1). Overrides the top level eta_d for this battery. + EtaD float32 `json:"eta_d,omitempty"` + // PA Monetary value of the stored energy per Wh at end of time horizon PA float32 `json:"p_a"` @@ -139,10 +145,10 @@ type OptimizationInput struct { // Batteries Configuration for all batteries in the system Batteries []BatteryConfig `json:"batteries"` - // EtaC Charging efficiency (0 to 1) + // EtaC Default charging efficiency (0 to 1) for batteries without their own eta_c EtaC float32 `json:"eta_c,omitempty"` - // EtaD Discharging efficiency (0 to 1) + // EtaD Default discharging efficiency (0 to 1) for batteries without their own eta_d EtaD float32 `json:"eta_d,omitempty"` Grid GridConfig `json:"grid,omitempty"` Strategy OptimizerStrategy `json:"strategy,omitempty"` diff --git a/openapi.yaml b/openapi.yaml index 7447c542e..e02f06b89 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -269,6 +269,18 @@ components: maximum: 2 default: 0 description: Charging and discharging priority 0..2 compared to other batteries. 2 = highest priority. + eta_c: + type: number + minimum: 0 + maximum: 1 + description: Charging efficiency (0 to 1). Overrides the top level eta_c for this battery. + example: 0.95 + eta_d: + type: number + minimum: 0 + maximum: 1 + description: Discharging efficiency (0 to 1). Overrides the top level eta_d for this battery. + example: 0.95 TimeSeries: type: object @@ -342,14 +354,14 @@ components: minimum: 0 maximum: 1 default: 0.95 - description: Charging efficiency (0 to 1) + description: Default charging efficiency (0 to 1) for batteries without their own eta_c example: 0.95 eta_d: type: number minimum: 0 maximum: 1 default: 0.95 - description: Discharging efficiency (0 to 1) + description: Default discharging efficiency (0 to 1) for batteries without their own eta_d example: 0.95 BatteryResult: diff --git a/src/optimizer/app.py b/src/optimizer/app.py index 0efdd660a..6bc30c94e 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -80,7 +80,9 @@ def handle_validation_error(error): 'c_max': fields.Float(required=True, description='Maximum charge power (W)'), 'd_max': fields.Float(required=True, description='Maximum discharge power (W)'), 'p_a': fields.Float(required=True, description='Monetary value per Wh at end of the optimization horizon'), - 'c_priority': fields.Integer(required=False, description='Charging and discharging priority compared to other batteries. 2 = highest priority.') + 'c_priority': fields.Integer(required=False, description='Charging and discharging priority compared to other batteries. 2 = highest priority.'), + 'eta_c': fields.Float(required=False, description='Charging efficiency (0 to 1), overrides the top level default'), + 'eta_d': fields.Float(required=False, description='Discharging efficiency (0 to 1), overrides the top level default') }) time_series_model = api.model('TimeSeries', { @@ -96,8 +98,8 @@ def handle_validation_error(error): 'grid': fields.Nested(grid_model, required=False, description='Grid import and export configuration'), 'batteries': fields.List(fields.Nested(battery_config_model), required=True, description='Battery configurations'), 'time_series': fields.Nested(time_series_model, required=True, description='Time series data'), - 'eta_c': fields.Float(required=False, default=0.95, description='Charging efficiency'), - 'eta_d': fields.Float(required=False, default=0.95, description='Discharging efficiency'), + 'eta_c': fields.Float(required=False, default=0.95, description='Default charging efficiency for batteries without their own eta_c'), + 'eta_d': fields.Float(required=False, default=0.95, description='Default discharging efficiency for batteries without their own eta_d'), }) # Output models @@ -155,6 +157,8 @@ def post(self): ) # Parse battery configurations + eta_c_default = data.get('eta_c', 0.95) + eta_d_default = data.get('eta_d', 0.95) batteries = [] for bat_data in data['batteries']: batteries.append(BatteryConfig( @@ -171,6 +175,8 @@ def post(self): d_max=bat_data['d_max'], p_a=bat_data['p_a'], c_priority=bat_data.get('c_priority', 0), + eta_c=bat_data.get('eta_c', eta_c_default), + eta_d=bat_data.get('eta_d', eta_d_default), )) # Parse time series data @@ -209,8 +215,6 @@ def post(self): grid=grid, batteries=batteries, time_series=time_series, - eta_c=data.get('eta_c', 0.95), - eta_d=data.get('eta_d', 0.95), M=1e6 ) diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index b878ec8e6..7705a5977 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -36,6 +36,8 @@ class BatteryConfig: p_demand: Optional[List[float]] = None # Minimum charge demand (Wh) s_goal: Optional[List[float]] = None # Goal state of charge (Wh) c_priority: int = 0 + eta_c: float = 0.95 # Charging efficiency (0 to 1) + eta_d: float = 0.95 # Discharging efficiency (0 to 1) @dataclass @@ -54,7 +56,7 @@ class Optimizer: """ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: List[BatteryConfig], time_series: TimeSeriesData, - eta_c: float = 0.95, eta_d: float = 0.95, M: float = 1e6, optimizer_settings: OptimizerSettings | None = None): + M: float = 1e6, optimizer_settings: OptimizerSettings | None = None): """ Optimizer Constructor """ @@ -65,8 +67,6 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: self.grid = grid self.batteries = batteries self.time_series = time_series - self.eta_c = eta_c - self.eta_d = eta_d self.M = M # number of time steps self.T = len(time_series.gt) @@ -427,15 +427,15 @@ def _add_battery_constraints(self): if len(self.time_steps) > 0: self.problem += (self.variables['s'][i][0] == bat.s_initial - + self.eta_c * self.variables['c'][i][0] - - (1 / self.eta_d) * self.variables['d'][i][0]) + + bat.eta_c * self.variables['c'][i][0] + - (1 / bat.eta_d) * self.variables['d'][i][0]) # State of charge evolution for t in range(1, self.T): self.problem += (self.variables['s'][i][t] == self.variables['s'][i][t - 1] - + self.eta_c * self.variables['c'][i][t] - - (1 / self.eta_d) * self.variables['d'][i][t]) + + bat.eta_c * self.variables['c'][i][t] + - (1 / bat.eta_d) * self.variables['d'][i][t]) # Constraint (6): Battery SOC goal constraints (for t > 0) if bat.s_goal is not None: diff --git a/tests/test_app.py b/tests/test_app.py index eb3488628..f4d7b3953 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -36,6 +36,37 @@ def test_optimizer(test_case: pathlib.Path): f"objective value: {actual_objective_value}, expected was: {expected_objective_value}" +def test_per_battery_efficiency(): + # both batteries are forced to charge the same energy, so their state of charge + # gain must scale with their individual charging efficiency + client = app.test_client() + battery = { + "charge_from_grid": True, + "s_min": 0, "s_max": 10000, "s_initial": 0, + "c_min": 0, "c_max": 2000, "d_max": 0, "p_a": 0.01, + "p_demand": [1000, 0], + } + request = { + "batteries": [ + {**battery, "eta_c": 1.0}, + {**battery, "eta_c": 0.5}, + ], + "time_series": { + "dt": [3600, 3600], + "gt": [0, 0], + "ft": [0, 0], + "p_N": [0.3, 0.3], + "p_E": [0.3, 0.3], + }, + } + response = client.post("/optimize/charge-schedule", json=request) + + assert response.status_code == 200 + assert response.json["status"] == "Optimal" + assert numpy.isclose(response.json["batteries"][0]["state_of_charge"][0], 1000) + assert numpy.isclose(response.json["batteries"][1]["state_of_charge"][0], 500) + + def test_abort_returns_json_message(): # message-only api.abort(400, ...) must return a JSON body, not an empty response client = app.test_client()