Skip to content
Draft
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
151 changes: 151 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
69 changes: 67 additions & 2 deletions src/optimizer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)'),
Expand All @@ -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'),
Expand All @@ -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.')
Expand All @@ -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)'),
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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()
Expand All @@ -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):
Expand Down
Loading
Loading