diff --git a/scripts/fm_scheduling.py b/scripts/fm_scheduling.py new file mode 100644 index 0000000..d470ee9 --- /dev/null +++ b/scripts/fm_scheduling.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Cariflex - Automatic Scheduling Service +Creates battery/EV schedules based on OpenADR price signals and flexibility events. +""" + +import json, logging, os, sys +from datetime import datetime, timezone, timedelta + +# Add FM path +sys.path.insert(0, '/app') + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("cariflex-scheduling") + +# FM imports +from flexmeasures.data.models.planning.storage import StorageScheduler +from flexmeasures.data.services.scheduling import make_schedule +from flexmeasures.data.models.generic_asset import GenericAsset +from flexmeasures.data.models.sensor import Sensor +from flexmeasures.data import db + +# Scheduling config +SCHEDULING_CONFIG = { + "battery_sensors": list(range(51, 61)), # Bat_01 to Bat_10 + "ev_sensors": list(range(61, 71)), # EV_01 to EV_10 + "price_sensor": 84, # consumption_price (OpenADR) + "load_control_sensor": 86, # load_control_signal (OpenADR) + "demand_response_sensor": 87, # demand_response_signal (OpenADR) + "planning_horizon_hours": 24, + "resolution_minutes": 15, +} + + +def get_sensor_data(sensor_id, start, end): + """Get sensor data from FM database.""" + sensor = db.session.query(Sensor).filter(Sensor.id == sensor_id).first() + if not sensor: + return None + + beliefs = db.session.query( + TimedBelief.event_start, TimedBelief.event_value + ).filter( + TimedBelief.sensor_id == sensor_id, + TimedBelief.event_start >= start, + TimedBelief.event_start <= end + ).order_by(TimedBelief.event_start).all() + + return beliefs + + +def create_battery_schedule(sensor_id, start_time, duration_hours=24): + """Create a schedule for a battery based on price signals.""" + from flexmeasures.data.models.sensor import Sensor + from flexmeasures.data.models.generic_asset import GenericAsset + + sensor = db.session.query(Sensor).filter(Sensor.id == sensor_id).first() + if not sensor: + logger.warning(f"Sensor {sensor_id} not found") + return None + + asset = db.session.query(GenericAsset).filter(GenericAsset.id == sensor.generic_asset_id).first() + if not asset: + logger.warning(f"Asset for sensor {sensor_id} not found") + return None + + logger.info(f"Creating schedule for {asset.name} (sensor {sensor_id})") + + # Get price data + end_time = start_time + timedelta(hours=duration_hours) + price_sensor = db.session.query(Sensor).filter(Sensor.id == SCHEDULING_CONFIG["price_sensor"]).first() + + if not price_sensor: + logger.warning("Price sensor not found") + return None + + # Create schedule using FM's built-in scheduler + try: + schedule = make_schedule( + sensor=sensor, + start=start_time, + end=end_time, + resolution=timedelta(minutes=SCHEDULING_CONFIG["resolution_minutes"]), + flex_model={ + "soc-min": "0.1 MWh", + "soc-max": "1 MWh", + "power-capacity": "0.05 MW", + "charging-efficiency": "0.95", + "discharging-efficiency": "0.95", + } + ) + logger.info(f"Schedule created for {asset.name}: {len(schedule) if schedule else 0} points") + return schedule + except Exception as e: + logger.error(f"Scheduling error for {asset.name}: {e}") + return None + + +def run_scheduling(): + """Run scheduling for all batteries and EVs.""" + logger.info("Starting automatic scheduling...") + + now = datetime.now(timezone.utc) + start_time = now.replace(minute=0, second=0, microsecond=0) + + # Schedule batteries + for sensor_id in SCHEDULING_CONFIG["battery_sensors"]: + try: + create_battery_schedule(sensor_id, start_time) + except Exception as e: + logger.error(f"Error scheduling battery {sensor_id}: {e}") + + # Schedule EVs + for sensor_id in SCHEDULING_CONFIG["ev_sensors"]: + try: + create_battery_schedule(sensor_id, start_time) + except Exception as e: + logger.error(f"Error scheduling EV {sensor_id}: {e}") + + logger.info("Scheduling complete") + + +if __name__ == "__main__": + # Run scheduling every 15 minutes + import time + while True: + try: + run_scheduling() + except Exception as e: + logger.error(f"Scheduling error: {e}") + time.sleep(900) # 15 minutes diff --git a/scripts/s2_service.py b/scripts/s2_service.py new file mode 100644 index 0000000..aedbfb3 --- /dev/null +++ b/scripts/s2_service.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Cariflex - S2 Protocol Service +Converts S2 messages to FlexMeasures scheduling commands. +Supports FRBC (Flexibility Resource Bank Control) for batteries/EVs. +""" + +import asyncio, json, logging, os, re +from datetime import datetime, timezone, timedelta +from typing import Optional +import requests + +# S2 Protocol imports +from s2python.s2_parser import S2Parser +from s2python.common import FRBCInstruction, FRBCSystemDescription, FRBCActuatorStatus +from s2python.common import FRBCStorageStatus, FRBCUsageForecast, FRBCFillLevelTargetProfile + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("cariflex-s2") + +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") + +# S2 Resource mapping: S2 resource_id -> FM sensor_id +S2_RESOURCE_MAP = { + "battery_01": 51, # Bat_01 + "battery_02": 52, # Bat_02 + "battery_03": 53, # Bat_03 + "battery_04": 54, # Bat_04 + "battery_05": 55, # Bat_05 + "ev_01": 61, # EV_01 + "ev_02": 62, # EV_02 + "ev_03": 63, # EV_03 + "ev_04": 64, # EV_04 + "ev_05": 65, # EV_05 +} + +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 post_sensor_data(sensor_id, value, unit, start, duration="PT1H"): + if not fm_session: + return False + try: + r = fm_session.post(f"{FM_HOST}/api/v3_0/sensors/{sensor_id}/data", + json={"values":[value],"start":start,"duration":duration,"unit":unit}, timeout=30) + return r.status_code in [200,201,202] + except Exception as e: + logger.error(f"Post: {e}") + return False + + +def handle_frbc_instruction(instruction: FRBCInstruction): + """Handle FRBC instruction from S2 protocol.""" + logger.info(f"FRBC Instruction received: {instruction}") + + resource_id = getattr(instruction, 'resource_id', None) + if not resource_id: + logger.warning("No resource_id in FRBC instruction") + return + + fm_sensor_id = S2_RESOURCE_MAP.get(resource_id) + if not fm_sensor_id: + logger.warning(f"Unknown S2 resource: {resource_id}") + return + + # Extract power setpoint from instruction + power_setpoint = getattr(instruction, 'power_setpoint', None) + if power_setpoint is None: + logger.warning("No power_setpoint in FRBC instruction") + return + + # Convert to FM units (kW) + power_kw = float(power_setpoint) + + now = datetime.now(timezone.utc) + success = post_sensor_data(fm_sensor_id, power_kw, "kW", now.isoformat()) + logger.info(f"S2 FRBC -> FM sensor {fm_sensor_id}: {power_kw} kW: {'OK' if success else 'FAIL'}") + + +def handle_s2_message(raw_message: str): + """Parse and handle an S2 message.""" + try: + parser = S2Parser() + message = parser.parse(raw_message) + + if isinstance(message, FRBCInstruction): + handle_frbc_instruction(message) + elif isinstance(message, FRBCSystemDescription): + logger.info(f"FRBC System Description: {message}") + elif isinstance(message, FRBCActuatorStatus): + logger.info(f"FRBC Actuator Status: {message}") + else: + logger.info(f"S2 message type: {type(message).__name__}") + + except Exception as e: + logger.error(f"S2 message handling error: {e}") + + +def generate_s2_system_description(): + """Generate S2 System Description for Cariflex resources.""" + resources = [] + for s2_id, fm_id in S2_RESOURCE_MAP.items(): + if fm_id <= 60: # Batteries + resources.append({ + "resource_id": s2_id, + "resource_type": "battery", + "min_power_kw": -50, + "max_power_kw": 50, + "capacity_kwh": 100, + }) + else: # EVs + resources.append({ + "resource_id": s2_id, + "resource_type": "ev", + "min_power_kw": -22, + "max_power_kw": 22, + "capacity_kwh": 60, + }) + return resources + + +def main(): + logger.info("Cariflex S2 Protocol Service starting") + logger.info(f"FM: {FM_HOST}") + logger.info(f"Resources: {len(S2_RESOURCE_MAP)} mapped") + + fm_login() + + # Generate S2 system description + resources = generate_s2_system_description() + logger.info(f"S2 System Description: {len(resources)} resources") + + # Main loop: listen for S2 messages (via OpenADR events) + logger.info("S2 service ready, listening for instructions...") + + # For now, simulate S2 instructions from OpenADR load_control events + # In production, this would listen on an S2 WebSocket/MQTT endpoint + while True: + try: + # Check for new OpenADR events that contain S2 instructions + if fm_session: + r = fm_session.get(f"{FM_HOST}/api/v3_0/sensors/86/data", timeout=30) + if r.status_code == 200: + data = r.json() + if data and len(data) > 0: + latest = data[-1] + value = latest.get("event_value", 0) + if value > 0: + # Convert load control to S2 FRBC instruction + logger.info(f"Load control signal: {value}%") + # TODO: Convert to proper S2 FRBC instruction + except Exception as e: + logger.error(f"S2 loop error: {e}") + + asyncio.run(asyncio.sleep(10)) + + +if __name__ == "__main__": + main()