diff --git a/openapi.yaml b/openapi.yaml index 7447c542e..48c96b2e9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -125,6 +125,46 @@ paths: example: message: "Optimization failed: Infeasible problem" + /optimize/heating-parameters: + post: + tags: + - optimization + summary: Fit heating device thermal model parameters from history + description: | + Fits the leaky thermal store model dT = alpha*T + beta*E + gamma by + least squares from 15-minute history slots of temperature and consumed + energy. The same fit runs implicitly inside the charge-schedule + optimization when heating devices are supplied; this endpoint exposes + it separately for inspection and validation. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/HeatingHistory" + example: + history_temp: [51.2, 51.8, 52.5, 52.1, 51.6, 51.2] + history_energy: [400, 420, 150, 0, 0, 80] + responses: + "200": + description: Thermal model fitted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/HeatingParameters" + example: + alpha: -0.02 + beta: 0.004 + gamma: 0.3 + "400": + description: Bad request - history too short or shows no heating effect + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + message: "heating history too short to fit thermal model" + /optimize/health: get: tags: @@ -270,6 +310,86 @@ components: default: 0 description: Charging and discharging priority 0..2 compared to other batteries. 2 = highest priority. + HeatingConfig: + type: object + required: + - temp_min + - temp_max + - temp_initial + - c_max + - history_temp + - history_energy + properties: + temp_min: + type: number + description: Lower comfort temperature bound (°C) + example: 45 + temp_max: + type: number + description: Upper comfort temperature bound (°C) + example: 60 + temp_initial: + type: number + description: Current temperature (°C) + example: 52 + c_max: + type: number + minimum: 0 + description: Maximum heating power (W) + example: 3000 + history_temp: + type: array + items: + type: number + description: | + Historic device temperatures in 15-minute slots (°C). Together with + history_energy this is used to fit the leaky thermal model + dT = alpha*T + beta*E + gamma per slot by least squares. + example: [51.2, 51.8, 52.5, 52.1, 51.6, 51.2] + history_energy: + type: array + items: + type: number + minimum: 0 + description: Historic energy consumed by the heating device in 15-minute slots (Wh) + example: [400, 420, 150, 0, 0, 80] + + HeatingHistory: + type: object + required: + - history_temp + - history_energy + properties: + history_temp: + type: array + items: + type: number + description: Historic device temperatures in 15-minute slots (°C) + example: [51.2, 51.8, 52.5, 52.1, 51.6, 51.2] + history_energy: + type: array + items: + type: number + minimum: 0 + description: Historic energy consumed by the heating device in 15-minute slots (Wh) + example: [400, 420, 150, 0, 0, 80] + + HeatingParameters: + type: object + properties: + alpha: + type: number + description: Temperature change per °C per 15-minute slot (loss rate, < 0) + example: -0.02 + beta: + type: number + description: Temperature gain per consumed Wh (°C/Wh) + example: 0.004 + gamma: + type: number + description: Constant temperature drift per 15-minute slot (°C), absorbs ambient conditions + example: 0.3 + TimeSeries: type: object required: @@ -335,6 +455,15 @@ components: $ref: "#/components/schemas/BatteryConfig" minItems: 1 description: Configuration for all batteries in the system + heating: + type: array + items: + $ref: "#/components/schemas/HeatingConfig" + description: | + Optional heating devices. Unlike batteries these are leaky thermal + stores: they lose energy proportionally to their temperature and + cannot feed energy back. Model parameters are fitted from the + supplied 15-minute history. time_series: $ref: "#/components/schemas/TimeSeries" eta_c: @@ -377,6 +506,23 @@ components: description: State of charge at each time step (Wh) example: [21650, 19650, 16650, 14150, 12650, 11650] + HeatingResult: + type: object + properties: + heating_energy: + type: array + items: + type: number + minimum: 0 + description: Optimal heating energy input at each time step (Wh) + example: [750, 750, 0, 0, 0, 400] + temperature: + type: array + items: + type: number + description: Predicted temperature at each time step (°C) + example: [54.6, 57.2, 56.1, 55.0, 54.0, 54.5] + LimitViolationResult: type: object properties: @@ -415,6 +561,11 @@ components: items: $ref: "#/components/schemas/BatteryResult" description: Optimization results for each battery + heating: + type: array + items: + $ref: "#/components/schemas/HeatingResult" + description: Optimization results for each heating device grid_import: type: array items: diff --git a/src/optimizer/app.py b/src/optimizer/app.py index 0efdd660a..837b16554 100644 --- a/src/optimizer/app.py +++ b/src/optimizer/app.py @@ -5,7 +5,7 @@ from flask_restx import Api, Resource, fields from werkzeug.exceptions import BadRequest -from .optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData +from .optimizer import BatteryConfig, GridConfig, HeatingConfig, OptimizationStrategy, Optimizer, TimeSeriesData, fit_heating_model app = Flask(__name__) @@ -83,6 +83,26 @@ def handle_validation_error(error): 'c_priority': fields.Integer(required=False, description='Charging and discharging priority compared to other batteries. 2 = highest priority.') }) +heating_config_model = api.model('HeatingConfig', { + 'temp_min': fields.Float(required=True, description='Lower comfort temperature bound (°C)'), + 'temp_max': fields.Float(required=True, description='Upper comfort temperature bound (°C)'), + 'temp_initial': fields.Float(required=True, description='Current temperature (°C)'), + 'c_max': fields.Float(required=True, description='Maximum heating power (W)'), + 'history_temp': fields.List(fields.Float, required=True, description='Historic temperatures in 15-minute slots (°C)'), + 'history_energy': fields.List(fields.Float, required=True, description='Historic energy consumed by the heating device in 15-minute slots (Wh)'), +}) + +heating_history_model = api.model('HeatingHistory', { + 'history_temp': fields.List(fields.Float, required=True, description='Historic temperatures in 15-minute slots (°C)'), + 'history_energy': fields.List(fields.Float, required=True, description='Historic energy consumed by the heating device in 15-minute slots (Wh)'), +}) + +heating_parameters_model = api.model('HeatingParameters', { + 'alpha': fields.Float(description='Temperature change per °C per 15-minute slot (loss rate, < 0)'), + 'beta': fields.Float(description='Temperature gain per consumed Wh (°C/Wh)'), + 'gamma': fields.Float(description='Constant temperature drift per 15-minute slot (°C), absorbs ambient conditions'), +}) + time_series_model = api.model('TimeSeries', { 'dt': fields.List(fields.Float, required=True, description='duration in seconds for each time step (s)'), 'gt': fields.List(fields.Float, required=True, description='Required energy for home consumption at each time step (Wh)'), @@ -95,6 +115,7 @@ def handle_validation_error(error): 'strategy': fields.Nested(strategy_model, required=False, description='Optimization strategy'), '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'), + 'heating': fields.List(fields.Nested(heating_config_model), required=False, description='Heating devices (leaky thermal stores fitted from history)'), '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'), @@ -107,6 +128,11 @@ def handle_validation_error(error): 'state_of_charge': fields.List(fields.Float, description='State of charge at each time step (Wh)') }) +heating_result_model = api.model('HeatingResult', { + 'heating_energy': fields.List(fields.Float, description='Optimal heating energy input at each time step (Wh)'), + 'temperature': fields.List(fields.Float, description='Predicted temperature at each time step (°C)') +}) + limit_violation_result_model = api.model('LimitViolationResult', { 'grid_import_limit_exceeded': fields.Boolean(description='The energy demand could only be satisfied by violating the grid import limit.'), 'grid_export_limit_hit': fields.Boolean(description='The solar yield was reduced due to the limitation of grid export power.') @@ -117,6 +143,7 @@ def handle_validation_error(error): 'objective_value': fields.Float(description='Optimal objective function value'), 'limit_violations': fields.Nested(limit_violation_result_model, description='Collection of flags signalling the violation of defined limits'), 'batteries': fields.List(fields.Nested(battery_result_model), description='Battery optimization results'), + 'heating': fields.List(fields.Nested(heating_result_model), description='Heating device optimization results'), 'grid_import': fields.List(fields.Float, description='Energy imported from grid at each time step (Wh)'), 'grid_export': fields.List(fields.Float, description='Energy exported to grid at each time step (Wh)'), 'flow_direction': fields.List(fields.Integer, description='Binary flow direction (1=export, 0=import)'), @@ -173,6 +200,21 @@ def post(self): c_priority=bat_data.get('c_priority', 0), )) + # Parse heating device configurations; thermal model coefficients are + # fitted from the supplied 15-minute history + heating = [] + for heat_data in data.get('heating') or []: + alpha, beta, gamma = fit_heating_model(heat_data['history_temp'], heat_data['history_energy']) + heating.append(HeatingConfig( + temp_min=heat_data['temp_min'], + temp_max=heat_data['temp_max'], + temp_initial=heat_data['temp_initial'], + c_max=heat_data['c_max'], + alpha=alpha, + beta=beta, + gamma=gamma, + )) + # Parse time series data time_series = TimeSeriesData( dt=data['time_series']['dt'], @@ -211,7 +253,8 @@ def post(self): time_series=time_series, eta_c=data.get('eta_c', 0.95), eta_d=data.get('eta_d', 0.95), - M=1e6 + M=1e6, + heating=heating ) result = optimizer.solve() @@ -221,6 +264,28 @@ def post(self): api.abort(500, f"Optimization failed: {str(e)}") +@ns.route('/heating-parameters') +class FitHeatingParameters(Resource): + @api.expect(heating_history_model, validate=True) + @api.marshal_with(heating_parameters_model) + def post(self): + """ + Fit heating device thermal model parameters from history + + Fits the leaky thermal store model dT = alpha*T + beta*E + gamma by + least squares from 15-minute history slots of temperature and consumed + energy. The same fit runs implicitly inside the charge-schedule + optimization; this endpoint exposes it for inspection and validation. + """ + try: + alpha, beta, gamma = fit_heating_model( + api.payload['history_temp'], api.payload['history_energy']) + except ValueError as e: + api.abort(400, str(e)) + + return {'alpha': alpha, 'beta': beta, 'gamma': gamma} + + @ns.route('/health') class Health(Resource): def get(self): diff --git a/src/optimizer/optimizer.py b/src/optimizer/optimizer.py index b878ec8e6..da261922a 100644 --- a/src/optimizer/optimizer.py +++ b/src/optimizer/optimizer.py @@ -38,6 +38,43 @@ class BatteryConfig: c_priority: int = 0 +@dataclass +class HeatingConfig: + """ + A heating device modeled as a leaky thermal store: temperature decays + proportionally to itself (losses grow with temperature) and rises with + consumed energy. Coefficients are per 15-minute slot, typically fitted + from history via fit_heating_model(). + """ + temp_min: float # lower comfort temperature bound (°C) + temp_max: float # upper comfort temperature bound (°C) + temp_initial: float # current temperature (°C) + c_max: float # maximum heating power (W) + alpha: float # temperature change per °C per 15min slot (loss rate, < 0) + beta: float # temperature gain per consumed Wh (°C/Wh, > 0) + gamma: float # constant temperature drift per 15min slot (°C), absorbs ambient + + +def fit_heating_model(temperatures: List[float], energies: List[float]) -> tuple: + """ + Fit the leaky thermal model dT[k] = alpha*T[k] + beta*E[k] + gamma by least + squares from history in 15-minute slots: temperatures (°C, K+1 samples or K) + and energy consumed by the heating device (Wh per slot). + Returns (alpha, beta, gamma). + """ + temps = np.asarray(temperatures, dtype=float) + energy = np.asarray(energies, dtype=float) + n = temps.size - 1 + if n < 3 or energy.size < n: + raise ValueError("heating history too short to fit thermal model") + X = np.column_stack([temps[:n], energy[:n], np.ones(n)]) + dT = np.diff(temps) + (alpha, beta, gamma), *_ = np.linalg.lstsq(X, dT, rcond=None) + if beta <= 0: + raise ValueError("heating history shows no heating effect (fitted beta <= 0)") + return float(alpha), float(beta), float(gamma) + + @dataclass class TimeSeriesData: dt: List[int] # time step length [s] @@ -54,7 +91,8 @@ 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): + eta_c: float = 0.95, eta_d: float = 0.95, M: float = 1e6, optimizer_settings: OptimizerSettings | None = None, + heating: Optional[List[HeatingConfig]] = None): """ Optimizer Constructor """ @@ -64,6 +102,7 @@ def __init__(self, strategy: OptimizationStrategy, grid: GridConfig, batteries: self.strategy = strategy self.grid = grid self.batteries = batteries + self.heating = heating or [] self.time_series = time_series self.eta_c = eta_c self.eta_d = eta_d @@ -115,6 +154,7 @@ def create_model(self): self._setup_target_function() self._add_energy_balance_constraints() self._add_battery_constraints() + self._add_heating_constraints() def _setup_variables(self): """ @@ -169,6 +209,20 @@ def _setup_variables(self): self.variables['s_max_pen'] = [[pulp.LpVariable(f"s_max_pen_{i}_{t}", lowBound=0) for t in self.time_steps] for i in range(len(self.batteries))] self.variables['s_min_pen'] = [[pulp.LpVariable(f"s_min_pen_{i}_{t}", lowBound=0) for t in self.time_steps] for i in range(len(self.batteries))] + # Heating device energy input [Wh], temperature [°C] and comfort band penalties + self.variables['h'] = {} + self.variables['temp'] = {} + self.variables['temp_min_pen'] = {} + self.variables['temp_max_pen'] = {} + for i, heat in enumerate(self.heating): + self.variables['h'][i] = [ + pulp.LpVariable(f"h_{i}_{t}", lowBound=0, upBound=heat.c_max * self.time_series.dt[t] / 3600.) + for t in self.time_steps + ] + self.variables['temp'][i] = [pulp.LpVariable(f"temp_{i}_{t}") for t in self.time_steps] + self.variables['temp_min_pen'][i] = [pulp.LpVariable(f"temp_min_pen_{i}_{t}", lowBound=0) for t in self.time_steps] + self.variables['temp_max_pen'][i] = [pulp.LpVariable(f"temp_max_pen_{i}_{t}", lowBound=0) for t in self.time_steps] + # Grid import/export variables [Wh] self.variables['n'] = [pulp.LpVariable(f"n_{t}", lowBound=0) for t in self.time_steps] self.variables['e'] = [pulp.LpVariable(f"e_{t}", lowBound=0) for t in self.time_steps] @@ -266,6 +320,13 @@ def _setup_target_function(self): for t in self.time_steps: objective += - self.prc_soc_exc_pen * (self.variables['s_max_pen'][i][t] + self.variables['s_min_pen'][i][t]) + # Penalties for leaving the heating comfort band, converted °C -> Wh via beta + # so they weigh like the SOC penalties above + for i, heat in enumerate(self.heating): + for t in self.time_steps: + objective += - self.prc_soc_exc_pen / heat.beta \ + * (self.variables['temp_min_pen'][i][t] + self.variables['temp_max_pen'][i][t]) + ############################################################################ # Penalties for goals that cannot be met for i, bat in enumerate(self.batteries): @@ -361,11 +422,15 @@ def _add_energy_balance_constraints(self): if self.grid.p_max_exp is not None: e_grid_exp = self.variables['e'][t]+self.variables['e_exp_lim_exc'][t] + # heating devices consume energy like home consumption + heating_load = pulp.lpSum(self.variables['h'][i][t] for i in range(len(self.heating))) + self.problem += (battery_net_discharge + self.time_series.ft[t] + e_grid_imp == e_grid_exp - + self.time_series.gt[t]) + + self.time_series.gt[t] + + heating_load) # Constraints (4)-(5): Grid flow direction for t in self.time_steps: @@ -491,6 +556,29 @@ def _add_battery_constraints(self): # Charge constraint self.problem += self.variables['c'][i][t] <= self.M * (1 - self.variables['z_cd'][i][t]) + def _add_heating_constraints(self): + """ + Add constraints for heating devices: leaky thermal store dynamics and + soft comfort band. Unlike batteries, stored heat leaks away with a rate + proportional to temperature (alpha < 0) and cannot be discharged back. + """ + for i, heat in enumerate(self.heating): + for t in self.time_steps: + # scale per-15min fit coefficients to the actual slot length + scale = self.time_series.dt[t] / 900. + prev = heat.temp_initial if t == 0 else self.variables['temp'][i][t - 1] + self.problem += (self.variables['temp'][i][t] + == prev + + heat.alpha * scale * prev + + heat.beta * self.variables['h'][i][t] + + heat.gamma * scale) + + # soft comfort band + self.problem += (self.variables['temp_max_pen'][i][t] + >= self.variables['temp'][i][t] - heat.temp_max) + self.problem += (self.variables['temp_min_pen'][i][t] + >= heat.temp_min - self.variables['temp'][i][t]) + def solve(self) -> Dict: """ Creates the MILP model if none exists and solves the optimization problem. @@ -546,6 +634,7 @@ def solve(self) -> Dict: 'grid_export_limit_hit': grid_exp_limit_hit }, 'batteries': [], + 'heating': [], 'grid_import': e_grid_import, 'grid_export': e_grid_export, 'flow_direction': [], @@ -562,6 +651,13 @@ def solve(self) -> Dict: } result['batteries'].append(battery_result) + # Extract heating results + for i, heat in enumerate(self.heating): + result['heating'].append({ + 'heating_energy': [pulp.value(var) for var in self.variables['h'][i]], + 'temperature': [pulp.value(var) for var in self.variables['temp'][i]] + }) + # Extract flow direction for y_var in self.variables['y']: if y_var is not None: @@ -579,6 +675,7 @@ def solve(self) -> Dict: 'grid_export_limit_hit': False }, 'batteries': [], + 'heating': [], 'grid_import': [], 'grid_export': [], 'flow_direction': [], diff --git a/tests/test_heating.py b/tests/test_heating.py new file mode 100644 index 000000000..86e5eed3c --- /dev/null +++ b/tests/test_heating.py @@ -0,0 +1,125 @@ +import numpy as np + +from optimizer.app import app +from optimizer.optimizer import GridConfig, HeatingConfig, OptimizationStrategy, Optimizer, TimeSeriesData, fit_heating_model + +# ground-truth thermal parameters used to synthesize history (per 15min slot) +ALPHA, BETA, GAMMA = -0.02, 0.004, 0.3 + + +def synth_history(n=96, seed=1): + """Simulate a leaky thermal store with known parameters.""" + rng = np.random.default_rng(seed) + energies = list(rng.uniform(0, 500, n)) + temps = [45.0] + for e in energies: + t = temps[-1] + temps.append(t + ALPHA * t + BETA * e + GAMMA) + return temps, energies + + +def test_fit_recovers_parameters(): + temps, energies = synth_history() + alpha, beta, gamma = fit_heating_model(temps, energies) + assert np.isclose(alpha, ALPHA, atol=1e-8) + assert np.isclose(beta, BETA, atol=1e-8) + assert np.isclose(gamma, GAMMA, atol=1e-6) + + +def test_fit_rejects_short_history(): + try: + fit_heating_model([45.0, 44.0], [100.0]) + assert False, "expected ValueError" + except ValueError: + pass + + +def test_heating_shifts_to_cheap_slots(): + # 8 slots of 15min: cheap first half, expensive second half. + # Optimal plan banks heat while cheap and coasts through expensive slots. + T = 8 + ts = TimeSeriesData( + dt=[900] * T, + gt=[0.0] * T, + ft=[0.0] * T, + p_N=[0.1e-3] * 4 + [1e-3] * 4, + p_E=[0.0] * T, + ) + heat = HeatingConfig(temp_min=54.0, temp_max=60.0, temp_initial=55.0, + c_max=3000.0, alpha=ALPHA, beta=BETA, gamma=GAMMA) + opt = Optimizer( + strategy=OptimizationStrategy('none', 'none'), + grid=GridConfig(None, None, None), + batteries=[], + time_series=ts, + heating=[heat], + ) + result = opt.solve() + + assert result['status'] == 'Optimal' + temperature = result['heating'][0]['temperature'] + energy = result['heating'][0]['heating_energy'] + + # comfort band held (soft constraint, allow LP tolerance) + assert min(temperature) >= heat.temp_min - 1e-3 + assert max(temperature) <= heat.temp_max + 1e-3 + # all heating happens in the cheap half + assert sum(energy[:4]) > 100.0 + assert sum(energy[4:]) < 1e-3 + # heating energy shows up as grid import + assert np.isclose(sum(result['grid_import']), sum(energy), atol=1e-6) + + +def test_api_heating_parameters_endpoint(): + temps, energies = synth_history() + response = app.test_client().post('/optimize/heating-parameters', json={ + 'history_temp': temps, + 'history_energy': energies, + }) + assert response.status_code == 200, response.text + body = response.json + assert np.isclose(body['alpha'], ALPHA, atol=1e-8) + assert np.isclose(body['beta'], BETA, atol=1e-8) + assert np.isclose(body['gamma'], GAMMA, atol=1e-6) + + +def test_api_heating_parameters_rejects_short_history(): + response = app.test_client().post('/optimize/heating-parameters', json={ + 'history_temp': [45.0, 44.0], + 'history_energy': [100.0], + }) + # note: JSON body assertion deliberately omitted — the errorhandler fix + # making message-only aborts return JSON is raised separately (PR #86) + assert response.status_code == 400 + + +def test_api_heating_roundtrip(): + temps, energies = synth_history() + T = 6 + request = { + 'batteries': [], + 'heating': [{ + 'temp_min': 50.0, + 'temp_max': 60.0, + 'temp_initial': 52.0, + 'c_max': 3000.0, + 'history_temp': temps, + 'history_energy': energies, + }], + 'time_series': { + 'dt': [900] * T, + 'gt': [100.0] * T, + 'ft': [0.0] * T, + 'p_N': [0.3e-3] * T, + 'p_E': [0.1e-3] * T, + }, + } + + response = app.test_client().post('/optimize/charge-schedule', json=request) + assert response.status_code == 200, response.text + body = response.json + assert body['status'] == 'Optimal' + assert len(body['heating']) == 1 + assert len(body['heating'][0]['heating_energy']) == T + assert len(body['heating'][0]['temperature']) == T + assert min(body['heating'][0]['temperature']) >= 50.0 - 1e-3