#!/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())