Files
cariflex/scripts/fm_scheduling_service.py

236 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""
Cariflex - FlexMeasures Scheduling Service
Creates EV charging schedules based on OpenADR price signals.
Sends schedules to CitrineOS via OCPP profiles.
"""
import json, logging, os, re, sys
from datetime import datetime, timezone, timedelta
import requests
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("cariflex-scheduling")
# Config
FM_HOST = os.getenv("FM_HOST", "https://cariflex.digitribe.fr")
FM_EMAIL = os.getenv("FM_EMAIL", "admin@digitribe.fr")
FM_PWD = os.getenv("FM_PWD", "Digitribe972")
CITRINEOS_URL = os.getenv("CITRINEOS_URL", "http://cariflex-citrineos-server:8080")
# EV Charge Point -> FM SensorID -> OCPP connector
EVSE_CONFIG = {
# sensor_id: (charge_point_id, connector_id, max_power_kw)
61: ("CP001", 1, 22),
62: ("CP002", 1, 22),
63: ("CP003", 1, 22),
64: ("CP004", 1, 22),
65: ("CP005", 1, 22),
66: ("CP006", 1, 11),
67: ("CP007", 1, 11),
68: ("CP008", 1, 11),
69: ("CP009", 1, 11),
70: ("CP010", 1, 11),
}
fm_session = None
def fm_login():
global fm_session
try:
s = requests.Session(); s.verify = False
r = s.get(f"{FM_HOST}/login", timeout=15)
m = re.search(r"csrf_token[^>]*value=[\"\\']([^\"\\']+)", r.text)
if m:
r = s.post(f"{FM_HOST}/login", data={"email":FM_EMAIL,"password":FM_PWD,"csrf_token":m.group(1),"remember":"y"}, allow_redirects=True, timeout=15)
if "dashboard" in r.url or r.status_code == 200:
fm_session = s
return True
except Exception as e:
logger.error(f"FM login: {e}")
return False
def get_price_forecast():
"""Get latest price forecast from FM (sensor 84 - OpenADR prices)."""
if not fm_session:
return None
try:
r = fm_session.get(f"{FM_HOST}/api/v3_0/sensors/84/data?limit=48", timeout=30)
if r.status_code == 200:
return r.json()
except Exception as e:
logger.error(f"Price forecast: {e}")
return None
def get_load_control_signal():
"""Get latest load control signal from FM (sensor 86)."""
if not fm_session:
return None
try:
r = fm_session.get(f"{FM_HOST}/api/v3_0/sensors/86/data?limit=1", timeout=30)
if r.status_code == 200:
data = r.json()
if data and len(data) > 0:
return float(data[-1].get("event_value", 0))
except Exception as e:
logger.error(f"Load control: {e}")
return 0
def get_ev_soc(sensor_id):
"""Get current SOC for an EV sensor."""
try:
r = fm_session.get(f"{FM_HOST}/api/v3_0/sensors/{sensor_id}/data?limit=1", timeout=30)
if r.status_code == 200:
data = r.json()
if data and len(data) > 0:
return float(data[-1].get("event_value", 0))
except Exception as e:
logger.error(f"EV SOC: {e}")
return 50.0 # Default 50%
def calculate_charging_schedule(prices, load_control, current_soc, max_power_kw):
"""
Calculate optimal charging schedule based on prices and load control.
Returns list of (timestamp, power_kw) tuples.
"""
schedule = []
now = datetime.now(timezone.utc)
# Sort price periods by cheapest first
sorted_prices = sorted(prices, key=lambda x: x.get("event_value", 0))
# Load control factor: 0=normal, 0.5=reduce, 1=cut
lc_factor = 1.0 - load_control # 1.0 = full, 0.5 = half, 0 = cut
# SOC target: charge to 80% during cheap hours
target_soc = 80
soc_needed = max(0, target_soc - current_soc)
if soc_needed <= 0:
return schedule # Already charged
# Calculate how many hours needed at max power
hours_needed = (soc_needed * 0.6) / max_power_kw # Rough estimate (60% of 100kWh battery)
# Select cheapest hours
selected_hours = sorted_prices[:int(hours_needed) + 1]
for price_point in selected_hours:
ts = price_point.get("start", now.isoformat())
price = price_point.get("event_value", 0)
# Calculate power based on price and load control
if price < 50: # Cheap
power = max_power_kw * lc_factor
elif price < 100: # Medium
power = (max_power_kw * 0.5) * lc_factor
else: # Expensive
power = 0 # Don't charge during expensive hours
if power > 0:
schedule.append((ts, power))
return schedule
def send_ocpp_charging_profile(charge_point_id, connector_id, power_limit_kw, start_time):
"""Send charging profile to EVSE via CitrineOS (OCPP)."""
try:
payload = {
"connectorId": connector_id,
"csChargingProfiles": {
"chargingProfileId": 1,
"stackLevel": 0,
"chargingProfilePurpose": "TxDefaultProfile",
"chargingProfileKind": "Absolute",
"chargingSchedule": {
"startSchedule": start_time,
"chargingRateUnit": "W",
"chargingSchedulePeriod": [
{
"startPeriod": 0,
"limit": int(power_limit_kw * 1000) # kW -> W
},
{
"startPeriod": 3600, # After 1 hour
"limit": int(power_limit_kw * 1000)
}
]
}
}
}
# CitrineOS uses OCPP 2.0.1 API
r = requests.put(
f"{CITRINEOS_URL}/api/v1/ocpp-charge-points/{charge_point_id}/set-charging-profile",
json=payload, timeout=10
)
logger.info(f"OCPP profile -> {charge_point_id}: {power_limit_kw}kW @ {start_time} -> {r.status_code}")
return r.status_code in [200, 201, 204]
except Exception as e:
logger.error(f"OCPP profile error: {e}")
return False
def run_scheduling():
"""Main scheduling loop: OpenADR prices → FM schedules → OCPP profiles."""
logger.info("Running scheduling cycle...")
# 1. Get price forecast from OpenADR
prices = get_price_forecast()
if not prices:
logger.warning("No price data available")
return
logger.info(f"Price forecast: {len(prices)} periods available")
# 2. Get load control signal
load_control = get_load_control_signal()
logger.info(f"Load control signal: {load_control}")
# 3. For each EVSE, calculate optimal schedule
for sensor_id, (cp_id, connector_id, max_power) in EVSE_CONFIG.items():
# Get current SOC
current_soc = get_ev_soc(sensor_id)
# Calculate schedule
schedule = calculate_charging_schedule(prices, load_control, current_soc, max_power)
if schedule:
# Send to CitrineOS via OCPP
for ts, power in schedule:
send_ocpp_charging_profile(cp_id, connector_id, power, ts)
logger.info(f"Schedule {cp_id}: {power} kW at {ts}")
else:
logger.info(f"No charging needed for {cp_id} (SOC: {current_soc}%)")
async def main():
logger.info("Cariflex Scheduling Service starting")
logger.info(f"FM: {FM_HOST}")
logger.info(f"CitrineOS: {CITRINEOS_URL}")
logger.info(f"EVSEs: {len(EVSE_CONFIG)}")
fm_login()
# Run scheduling every 5 minutes
while True:
try:
run_scheduling()
except Exception as e:
logger.error(f"Scheduling error: {e}")
fm_login()
await asyncio.sleep(300) # 5 minutes
if __name__ == "__main__":
asyncio.run(main())