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
142 changes: 142 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,53 @@ paths:
example:
message: "Optimization failed: Infeasible problem"

/optimize/level-schedule:
post:
tags:
- optimization
summary: Optimize battery charging using the Level algorithm
description: |
Finds the optimal flat export ceiling that charges a battery from
soc_start to soc_target using solar surplus, while minimizing peak
grid export. Respects CC-CV taper profiles for SoC-dependent charge
power limits.

Returns the same response format as /optimize/charge-schedule so
consumers can process both endpoints with identical code.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/LevelInput"
example:
charge_profile: lifepo4_conservative
capacity_wh: 42500
soc_start: 60
soc_target: 95
solar_w: [0, 0, 200, 500, 1000, 1800, 2800, 4000, 5500, 7000, 8500, 9500, 10000, 10200, 10000, 9500, 8500, 7000, 5500, 4000, 2800, 1800, 1000, 500]
consumption_w: 800
dt_seconds: 900
responses:
"200":
description: Optimization completed successfully
content:
application/json:
schema:
$ref: "#/components/schemas/OptimizationResult"
"400":
description: Bad request - Invalid input data
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
description: Internal server error - Optimization failed
content:
application/json:
schema:
$ref: "#/components/schemas/Error"

/optimize/health:
get:
tags:
Expand Down Expand Up @@ -269,6 +316,39 @@ components:
maximum: 2
default: 0
description: Charging and discharging priority 0..2 compared to other batteries. 2 = highest priority.
charge_profile:
type: string
description: |
Named charge profile for SoC-dependent charge power limiting
(CC-CV taper). When set, the MILP solver uses iterative constraint
tightening to enforce realistic charge power limits at high SoC.
Available profiles: lifepo4_conservative, lifepo4_moderate.
Individual parameters can be overridden with charge_knee,
charge_k, charge_c_rate_float.
example: lifepo4_conservative
charge_knee:
type: number
minimum: 0
maximum: 100
description: |
SoC percentage where charge power tapering begins.
With charge_profile: overrides the profile default.
Without charge_profile: requires charge_k to build a custom profile.
example: 65
charge_k:
type: number
minimum: 0
description: |
Exponential decay constant for the CC-CV taper curve.
Higher values mean steeper power reduction above the knee.
example: 0.052
charge_c_rate_float:
type: number
minimum: 0
description: |
Minimum C-rate at the tail end of charging (float/balancing phase).
Default: 0.01 (C/100).
example: 0.01

TimeSeries:
type: object
Expand Down Expand Up @@ -315,6 +395,68 @@ components:
description: Grid export remuneration per Wh at each time step (currency units/Wh)
example: [0.15, 0.12, 0.10, 0.11, 0.14, 0.16]

LevelInput:
type: object
required:
- capacity_wh
- soc_start
- soc_target
- solar_w
properties:
charge_profile:
type: string
description: Named CC-CV taper profile (e.g. lifepo4_conservative).
example: lifepo4_conservative
charge_knee:
type: number
minimum: 0
maximum: 100
description: SoC% where taper begins. Overrides profile default.
charge_k:
type: number
minimum: 0
description: Exponential decay constant for the taper curve.
charge_c_rate_float:
type: number
minimum: 0
description: Minimum C-rate at float (default 0.01).
capacity_wh:
type: number
minimum: 0
description: Battery capacity in Wh
example: 42500
soc_start:
type: number
minimum: 0
maximum: 100
description: Starting state of charge (0-100)
example: 60
soc_target:
type: number
minimum: 0
maximum: 100
description: Target state of charge (0-100)
example: 95
solar_w:
type: array
items:
type: number
minimum: 0
description: Solar power forecast per time slot (watts)
example: [0, 500, 1000, 2000, 4000, 6000, 8000, 6000, 4000, 2000]
consumption_w:
type: number
minimum: 0
default: 800
description: Household consumption in watts
example: 800
dt_seconds:
type: number
minimum: 0
default: 900
description: Slot duration in seconds
example: 900

OptimizationInput:
type: object
required:
Expand Down
190 changes: 187 additions & 3 deletions src/optimizer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from flask_restx import Api, Resource, fields
from werkzeug.exceptions import BadRequest

from .charging_profiles import find_optimal_level, get_profile
from .optimizer import BatteryConfig, GridConfig, OptimizationStrategy, Optimizer, TimeSeriesData

app = Flask(__name__)
Expand Down Expand Up @@ -80,7 +81,11 @@ 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.'),
'charge_profile': fields.String(required=False, description='Named CC-CV taper profile (e.g. lifepo4_conservative).'),
'charge_knee': fields.Float(required=False, description='SoC% where taper begins. Overrides profile default.'),
'charge_k': fields.Float(required=False, description='Exponential decay constant for the CC-CV taper curve.'),
'charge_c_rate_float': fields.Float(required=False, description='Minimum C-rate at float / tail end of charging (default 0.01).'),
})

time_series_model = api.model('TimeSeries', {
Expand Down Expand Up @@ -125,6 +130,54 @@ def handle_validation_error(error):
})


def _level_to_milp_format(level_result, capacity_wh, dt_seconds):
"""Convert level algorithm output to MILP-compatible response format.

Maps the level schedule (per-slot watts + SoC%) into the same structure
the MILP endpoint returns (per-slot Wh + SoC in Wh absolute), so
consumers can process both endpoints with identical code.
"""
schedule = level_result.get('schedule', [])
dt_hours = dt_seconds / 3600.0

charging_power = []
discharging_power = []
state_of_charge = []
grid_export = []
grid_import = []
flow_direction = []

for slot in schedule:
charge_wh = slot['charge_w'] * dt_hours
export_wh = slot['export_w'] * dt_hours

charging_power.append(round(charge_wh, 2))
discharging_power.append(0.0)
state_of_charge.append(round(slot['soc'] / 100.0 * capacity_wh, 1))
grid_export.append(round(export_wh, 2))
grid_import.append(0.0)
flow_direction.append(1 if export_wh > 0 else 0)

return {
'status': level_result.get('status', 'Optimal'),
'objective_value': 0.0,
'limit_violations': {
'grid_import_limit_exceeded': False,
'grid_export_limit_hit': False,
},
'batteries': [{
'charging_power': charging_power,
'discharging_power': discharging_power,
'state_of_charge': state_of_charge,
}],
'grid_import': grid_import,
'grid_export': grid_export,
'flow_direction': flow_direction,
'grid_import_overshoot': [],
'grid_export_overshoot': [],
}


@ns.route('/charge-schedule')
class OptimizeCharging(Resource):
@api.expect(optimization_input_model, validate=True)
Expand Down Expand Up @@ -157,20 +210,50 @@ def post(self):
# Parse battery configurations
batteries = []
for bat_data in data['batteries']:
# Build optional CC-CV taper profile
profile = None
profile_name = bat_data.get('charge_profile')
charge_knee = bat_data.get('charge_knee')
charge_k = bat_data.get('charge_k')
charge_c_rate_float = bat_data.get('charge_c_rate_float')

s_capacity = bat_data.get('s_capacity', bat_data['s_max'])
c_max = bat_data['c_max']

if profile_name:
# Named profile with optional overrides
overrides = {}
if charge_knee is not None:
overrides['knee'] = charge_knee
if charge_k is not None:
overrides['k'] = charge_k
if charge_c_rate_float is not None:
overrides['c_rate_float'] = charge_c_rate_float
profile = get_profile(profile_name, **overrides)
elif charge_knee is not None and charge_k is not None:
# Custom profile: derive c_rate_max from c_max / s_capacity
profile = {
'c_rate_max': c_max / s_capacity if s_capacity > 0 else 0.25,
'knee': charge_knee,
'k': charge_k,
'c_rate_float': charge_c_rate_float if charge_c_rate_float is not None else 0.01,
}

batteries.append(BatteryConfig(
charge_from_grid=bat_data.get('charge_from_grid', False),
discharge_to_grid=bat_data.get('discharge_to_grid', False),
s_capacity=bat_data.get('s_capacity', bat_data['s_max']),
s_capacity=s_capacity,
s_min=bat_data['s_min'],
s_max=bat_data['s_max'],
s_initial=bat_data['s_initial'],
p_demand=bat_data.get('p_demand'),
s_goal=bat_data.get('s_goal'),
c_min=bat_data['c_min'],
c_max=bat_data['c_max'],
c_max=c_max,
d_max=bat_data['d_max'],
p_a=bat_data['p_a'],
c_priority=bat_data.get('c_priority', 0),
charge_profile=profile,
))

# Parse time series data
Expand Down Expand Up @@ -221,6 +304,107 @@ def post(self):
api.abort(500, f"Optimization failed: {str(e)}")


# Input model for level schedule
level_input_model = api.model('LevelInput', {
'charge_profile': fields.String(required=False, description='Named CC-CV taper profile.'),
'charge_knee': fields.Float(required=False, description='SoC% where taper begins.'),
'charge_k': fields.Float(required=False, description='Exponential decay constant.'),
'charge_c_rate_float': fields.Float(required=False, description='Minimum C-rate at float.'),
'capacity_wh': fields.Float(required=True, description='Battery capacity in Wh'),
'soc_start': fields.Float(required=True, description='Starting SoC (0-100)'),
'soc_target': fields.Float(required=True, description='Target SoC (0-100)'),
'solar_w': fields.List(fields.Float, required=True, description='Solar power per slot (W)'),
'consumption_w': fields.Float(required=False, default=800, description='Consumption (W)'),
'dt_seconds': fields.Float(required=False, default=900, description='Slot duration (s)'),
})


@ns.route('/level-schedule')
class OptimizeLevel(Resource):
@api.expect(level_input_model, validate=True)
@api.marshal_with(optimization_result_model)
def post(self):
"""
Compute an optimal charge schedule using the Level algorithm.

Finds the flat export ceiling that charges the battery from soc_start
to soc_target using solar surplus, while minimizing peak grid export.
Respects the CC-CV taper profile for SoC-dependent charge power limits.

Returns the same format as /optimize/charge-schedule so consumers can
process both endpoints with identical code.
"""
try:
data = api.payload

# Build profile
profile = None
profile_name = data.get('charge_profile')
charge_knee = data.get('charge_knee')
charge_k = data.get('charge_k')
charge_c_rate_float = data.get('charge_c_rate_float')
capacity_wh = data['capacity_wh']

if profile_name:
overrides = {}
if charge_knee is not None:
overrides['knee'] = charge_knee
if charge_k is not None:
overrides['k'] = charge_k
if charge_c_rate_float is not None:
overrides['c_rate_float'] = charge_c_rate_float
profile = get_profile(profile_name, **overrides)
elif charge_knee is not None and charge_k is not None:
profile = {
'c_rate_max': 0.25,
'knee': charge_knee,
'k': charge_k,
'c_rate_float': charge_c_rate_float or 0.01,
}
else:
profile = get_profile('lifepo4_conservative')

dt_seconds = data.get('dt_seconds', 900)

result = find_optimal_level(
profile=profile,
capacity_wh=capacity_wh,
soc_start=data['soc_start'],
soc_target=data['soc_target'],
solar_w=data['solar_w'],
consumption_w=data.get('consumption_w', 800),
dt_seconds=dt_seconds,
)

if result is None:
return {
'status': 'Optimal',
'objective_value': 0.0,
'limit_violations': {
'grid_import_limit_exceeded': False,
'grid_export_limit_hit': False,
},
'batteries': [{
'charging_power': [],
'discharging_power': [],
'state_of_charge': [],
}],
'grid_import': [],
'grid_export': [],
'flow_direction': [],
'grid_import_overshoot': [],
'grid_export_overshoot': [],
}

result['status'] = 'Optimal'
return _level_to_milp_format(result, capacity_wh, dt_seconds)

except ValueError as e:
api.abort(400, str(e))
except Exception as e:
api.abort(500, f"Level optimization failed: {str(e)}")


@ns.route('/health')
class Health(Resource):
def get(self):
Expand Down
Loading
Loading