def test_charging_station_solver_day_2(target_soc, charging_station_name): """Starting with a state of charge 1 kWh, within 2 hours we should be able to reach any state of charge in the range [1, 5] kWh for a unidirectional station, or [0, 5] for a bidirectional station.""" soc_at_start = 1 duration_until_target = timedelta(hours=2) epex_da = Market.query.filter(Market.name == "epex_da").one_or_none() charging_station = Asset.query.filter( Asset.name == charging_station_name).one_or_none() start = as_server_time(datetime(2015, 1, 2)) end = as_server_time(datetime(2015, 1, 3)) resolution = timedelta(minutes=15) target_soc_datetime = start + duration_until_target soc_targets = pd.Series(np.nan, index=pd.date_range(start, end, freq=resolution, closed="right")) soc_targets.loc[target_soc_datetime] = target_soc consumption_schedule = schedule_charging_station(charging_station, epex_da, start, end, resolution, soc_at_start, soc_targets) soc_schedule = integrate_time_series(consumption_schedule, soc_at_start, decimal_precision=6) # Check if constraints were met assert min( consumption_schedule.values) >= charging_station.capacity_in_mw * -1 assert max(consumption_schedule.values) <= charging_station.capacity_in_mw print(consumption_schedule.head(12)) print(soc_schedule.head(12)) assert abs(soc_schedule.loc[target_soc_datetime] - target_soc) < 0.00001
def test_battery_solver_day_2(): epex_da = Market.query.filter(Market.name == "epex_da").one_or_none() battery = Asset.query.filter(Asset.name == "Test battery").one_or_none() start = as_server_time(datetime(2015, 1, 2)) end = as_server_time(datetime(2015, 1, 3)) resolution = timedelta(minutes=15) soc_at_start = battery.soc_in_mwh schedule = schedule_battery(battery, epex_da, start, end, resolution, soc_at_start) soc_schedule = integrate_time_series(schedule, soc_at_start, decimal_precision=6) with pd.option_context("display.max_rows", None, "display.max_columns", 3): print(soc_schedule) # Check if constraints were met assert min(schedule.values) >= battery.capacity_in_mw * -1 assert max(schedule.values) <= battery.capacity_in_mw for soc in soc_schedule.values: assert soc >= battery.min_soc_in_mwh assert soc <= battery.max_soc_in_mwh # Check whether the resulting soc schedule follows our expectations for 8 expensive, 8 cheap and 8 expensive hours assert (soc_schedule.iloc[-1] == battery.min_soc_in_mwh ) # Battery sold out at the end of its planning horizon assert (soc_schedule.loc[start + timedelta(hours=8)] == battery.min_soc_in_mwh ) # Sell what you begin with assert (soc_schedule.loc[start + timedelta(hours=16)] == battery.max_soc_in_mwh ) # Buy what you can to sell later
def test_fallback_to_unsolvable_problem(target_soc, charging_station_name): """Starting with a state of charge 10 kWh, within 2 hours we should be able to reach any state of charge in the range [10, 14] kWh for a unidirectional station, or [6, 14] for a bidirectional station, given a charging capacity of 2 kW. Here we test target states of charge outside that range, ones that we should be able to get as close to as 1 kWh difference. We want our scheduler to handle unsolvable problems like these with a sensible fallback policy. """ soc_at_start = 10 duration_until_target = timedelta(hours=2) expected_gap = 1 epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() charging_station = Sensor.query.filter( Sensor.name == charging_station_name).one_or_none() assert charging_station.get_attribute("capacity_in_mw") == 2 assert Sensor.query.get( charging_station.get_attribute("market_id")) == epex_da start = as_server_time(datetime(2015, 1, 2)) end = as_server_time(datetime(2015, 1, 3)) resolution = timedelta(minutes=15) target_soc_datetime = start + duration_until_target soc_targets = pd.Series(np.nan, index=pd.date_range(start, end, freq=resolution, closed="right")) soc_targets.loc[target_soc_datetime] = target_soc consumption_schedule = schedule_charging_station(charging_station, start, end, resolution, soc_at_start, soc_targets) soc_schedule = integrate_time_series(consumption_schedule, soc_at_start, decimal_precision=6) # Check if constraints were met assert (min(consumption_schedule.values) >= charging_station.get_attribute("capacity_in_mw") * -1) assert max(consumption_schedule.values) <= charging_station.get_attribute( "capacity_in_mw") print(consumption_schedule.head(12)) print(soc_schedule.head(12)) assert (abs( abs(soc_schedule.loc[target_soc_datetime] - target_soc) - expected_gap) < TOLERANCE)
def test_battery_solver_day_1(): epex_da = Market.query.filter(Market.name == "epex_da").one_or_none() battery = Asset.query.filter(Asset.name == "Test battery").one_or_none() start = as_server_time(datetime(2015, 1, 1)) end = as_server_time(datetime(2015, 1, 2)) resolution = timedelta(minutes=15) soc_at_start = battery.soc_in_mwh schedule = schedule_battery(battery, epex_da, start, end, resolution, soc_at_start) soc_schedule = integrate_time_series(schedule, soc_at_start, decimal_precision=6) with pd.option_context("display.max_rows", None, "display.max_columns", 3): print(soc_schedule) # Check if constraints were met assert min(schedule.values) >= battery.capacity_in_mw * -1 assert max(schedule.values) <= battery.capacity_in_mw for soc in soc_schedule.values: assert soc >= battery.min_soc_in_mwh assert soc <= battery.max_soc_in_mwh
def test_battery_solver_day_1(add_battery_assets): epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() assert Sensor.query.get(battery.get_attribute("market_id")) == epex_da start = as_server_time(datetime(2015, 1, 1)) end = as_server_time(datetime(2015, 1, 2)) resolution = timedelta(minutes=15) soc_at_start = battery.get_attribute("soc_in_mwh") schedule = schedule_battery(battery, start, end, resolution, soc_at_start) soc_schedule = integrate_time_series(schedule, soc_at_start, decimal_precision=6) with pd.option_context("display.max_rows", None, "display.max_columns", 3): print(soc_schedule) # Check if constraints were met assert (min(schedule.values) >= battery.get_attribute("capacity_in_mw") * -1 - TOLERANCE) assert max(schedule.values) <= battery.get_attribute("capacity_in_mw") for soc in soc_schedule.values: assert soc >= battery.get_attribute("min_soc_in_mwh") assert soc <= battery.get_attribute("max_soc_in_mwh")
def test_battery_solver_day_2(add_battery_assets, roundtrip_efficiency: float): """Check battery scheduling results for day 2, which is set up with 8 expensive, then 8 cheap, then again 8 expensive hours. If efficiency losses aren't too bad, we expect the scheduler to: - completely discharge within the first 8 hours - completely charge within the next 8 hours - completely discharge within the last 8 hours If efficiency losses are bad, the price difference is not worth cycling the battery, and so we expect the scheduler to only: - completely discharge within the last 8 hours """ epex_da = Sensor.query.filter(Sensor.name == "epex_da").one_or_none() battery = Sensor.query.filter(Sensor.name == "Test battery").one_or_none() assert Sensor.query.get(battery.get_attribute("market_id")) == epex_da start = as_server_time(datetime(2015, 1, 2)) end = as_server_time(datetime(2015, 1, 3)) resolution = timedelta(minutes=15) soc_at_start = battery.get_attribute("soc_in_mwh") soc_min = 0.5 soc_max = 4.5 schedule = schedule_battery( battery, start, end, resolution, soc_at_start, soc_min=soc_min, soc_max=soc_max, roundtrip_efficiency=roundtrip_efficiency, ) soc_schedule = integrate_time_series(schedule, soc_at_start, decimal_precision=6) with pd.option_context("display.max_rows", None, "display.max_columns", 3): print(soc_schedule) # Check if constraints were met assert min(schedule.values) >= battery.get_attribute("capacity_in_mw") * -1 assert max( schedule.values) <= battery.get_attribute("capacity_in_mw") + TOLERANCE for soc in soc_schedule.values: assert soc >= max(soc_min, battery.get_attribute("min_soc_in_mwh")) assert soc <= battery.get_attribute("max_soc_in_mwh") # Check whether the resulting soc schedule follows our expectations for 8 expensive, 8 cheap and 8 expensive hours assert soc_schedule.iloc[-1] == max( soc_min, battery.get_attribute("min_soc_in_mwh") ) # Battery sold out at the end of its planning horizon # As long as the roundtrip efficiency isn't too bad (I haven't computed the actual switch point) if roundtrip_efficiency > 0.9: assert soc_schedule.loc[start + timedelta(hours=8)] == max( soc_min, battery.get_attribute( "min_soc_in_mwh")) # Sell what you begin with assert soc_schedule.loc[start + timedelta(hours=16)] == min( soc_max, battery.get_attribute( "max_soc_in_mwh")) # Buy what you can to sell later else: # If the roundtrip efficiency is poor, best to stand idle assert soc_schedule.loc[start + timedelta( hours=8)] == battery.get_attribute("soc_in_mwh") assert soc_schedule.loc[start + timedelta( hours=16)] == battery.get_attribute("soc_in_mwh")
def test_trigger_and_get_schedule( app, add_market_prices, add_battery_assets, battery_soc_sensor, add_charging_station_assets, message, asset_name, ): # trigger a schedule through the /sensors/<id>/schedules/trigger [POST] api endpoint message["roundtrip-efficiency"] = 0.98 message["soc-min"] = 0 message["soc-max"] = 25 with app.test_client() as client: sensor = Sensor.query.filter(Sensor.name == asset_name).one_or_none() message[ "soc-sensor"] = f"ea1.2018-06.localhost:fm1.{battery_soc_sensor.id}" auth_token = get_auth_token(client, "*****@*****.**", "testtest") trigger_schedule_response = client.post( url_for("SensorAPI:trigger_schedule", id=sensor.id), json=message, headers={"Authorization": auth_token}, ) print("Server responded with:\n%s" % trigger_schedule_response.json) assert trigger_schedule_response.status_code == 200 job_id = trigger_schedule_response.json["schedule"] # look for scheduling jobs in queue assert (len(app.queues["scheduling"]) == 1 ) # only 1 schedule should be made for 1 asset job = app.queues["scheduling"].jobs[0] assert job.kwargs["sensor_id"] == sensor.id assert job.kwargs["start"] == parse_datetime(message["start"]) assert job.id == job_id # process the scheduling queue work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) assert (Job.fetch( job_id, connection=app.queues["scheduling"].connection).is_finished is True) # check results are in the database resolution = timedelta(minutes=15) scheduler_source = DataSource.query.filter_by( name="Seita", type="scheduling script").one_or_none() assert (scheduler_source is not None) # Make sure the scheduler data source is now there power_values = (TimedBelief.query.filter( TimedBelief.sensor_id == sensor.id).filter( TimedBelief.source_id == scheduler_source.id).all()) consumption_schedule = pd.Series( [-v.event_value for v in power_values], index=pd.DatetimeIndex([v.event_start for v in power_values], freq=resolution), ) # For consumption schedules, positive values denote consumption. For the db, consumption is negative assert (len(consumption_schedule) == app.config.get("FLEXMEASURES_PLANNING_HORIZON") / resolution) # check targets, if applicable if "targets" in message: start_soc = message["soc-at-start"] / 1000 # in MWh soc_schedule = integrate_time_series(consumption_schedule, start_soc, 6) print(consumption_schedule) print(soc_schedule) for target in message["targets"]: assert soc_schedule[ target["datetime"]] == target["soc-target"] / 1000 # try to retrieve the schedule through the /sensors/<id>/schedules/<job_id> [GET] api endpoint get_schedule_message = message_for_get_device_message( targets="soc-targets" in message) del get_schedule_message["type"] auth_token = get_auth_token(client, "*****@*****.**", "testtest") get_schedule_response = client.get( url_for("SensorAPI:get_schedule", id=sensor.id, uuid=job_id), query_string=get_schedule_message, headers={ "content-type": "application/json", "Authorization": auth_token }, ) print("Server responded with:\n%s" % get_schedule_response.json) assert get_schedule_response.status_code == 200 # assert get_schedule_response.json["type"] == "GetDeviceMessageResponse" assert len(get_schedule_response.json["values"]) == 192 # Test that a shorter planning horizon yields the same result for the shorter planning horizon get_schedule_message["duration"] = "PT6H" get_schedule_response_short = client.get( url_for("SensorAPI:get_schedule", id=sensor.id, uuid=job_id), query_string=get_schedule_message, headers={ "content-type": "application/json", "Authorization": auth_token }, ) assert (get_schedule_response_short.json["values"] == get_schedule_response.json["values"][0:24]) # Test that a much longer planning horizon yields the same result (when there are only 2 days of prices) get_schedule_message["duration"] = "PT1000H" get_schedule_response_long = client.get( url_for("SensorAPI:get_schedule", id=sensor.id, uuid=job_id), query_string=get_schedule_message, headers={ "content-type": "application/json", "Authorization": auth_token }, ) assert (get_schedule_response_long.json["values"][0:192] == get_schedule_response.json["values"])
def test_post_udi_event_and_get_device_message(app, message, asset_name): auth_token = None with app.test_client() as client: asset = Asset.query.filter(Asset.name == asset_name).one_or_none() asset_id = asset.id asset_owner_id = asset.owner_id message["event"] = message["event"] % (asset.owner_id, asset.id) auth_token = get_auth_token(client, "*****@*****.**", "testtest") post_udi_event_response = client.post( url_for("flexmeasures_api_v1_3.post_udi_event"), json=message, headers={"Authorization": auth_token}, ) print("Server responded with:\n%s" % post_udi_event_response.json) assert post_udi_event_response.status_code == 200 assert post_udi_event_response.json["type"] == "PostUdiEventResponse" # test asset state in database msg_dt = parse_datetime(message["datetime"]) asset = Asset.query.filter(Asset.name == asset_name).one_or_none() assert asset.soc_datetime == msg_dt assert asset.soc_in_mwh == message["value"] / 1000 assert asset.soc_udi_event_id == 204 # look for scheduling jobs in queue assert ( len(app.queues["scheduling"]) == 1 ) # only 1 schedule should be made for 1 asset job = app.queues["scheduling"].jobs[0] assert job.kwargs["asset_id"] == asset_id assert job.kwargs["start"] == parse_datetime(message["datetime"]) assert job.id == message["event"] # process the scheduling queue work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) assert ( Job.fetch( message["event"], connection=app.queues["scheduling"].connection ).is_finished is True ) # check results are in the database resolution = timedelta(minutes=15) scheduler_source = DataSource.query.filter_by( name="Seita", type="scheduling script" ).one_or_none() assert ( scheduler_source is not None ) # Make sure the scheduler data source is now there power_values = ( Power.query.filter(Power.asset_id == asset_id) .filter(Power.data_source_id == scheduler_source.id) .all() ) consumption_schedule = pd.Series( [-v.value for v in power_values], index=pd.DatetimeIndex([v.datetime for v in power_values], freq=resolution), ) # For consumption schedules, positive values denote consumption. For the db, consumption is negative assert ( len(consumption_schedule) == app.config.get("FLEXMEASURES_PLANNING_HORIZON") / resolution ) # check targets, if applicable if "targets" in message: start_soc = message["value"] / 1000 # in MWh soc_schedule = integrate_time_series(consumption_schedule, start_soc, 6) print(consumption_schedule) print(soc_schedule) for target in message["targets"]: assert soc_schedule[target["datetime"]] == target["value"] / 1000 # try to retrieve the schedule through the getDeviceMessage api endpoint get_device_message = message_for_get_device_message() get_device_message["event"] = get_device_message["event"] % ( asset_owner_id, asset_id, ) auth_token = get_auth_token(client, "*****@*****.**", "testtest") get_device_message_response = client.get( url_for("flexmeasures_api_v1_3.get_device_message"), query_string=get_device_message, headers={"content-type": "application/json", "Authorization": auth_token}, ) print("Server responded with:\n%s" % get_device_message_response.json) assert get_device_message_response.status_code == 200 assert get_device_message_response.json["type"] == "GetDeviceMessageResponse" assert len(get_device_message_response.json["values"]) == 192 # Test that a shorter planning horizon yields the same result for the shorter planning horizon get_device_message["duration"] = "PT6H" get_device_message_response_short = client.get( url_for("flexmeasures_api_v1_3.get_device_message"), query_string=get_device_message, headers={"content-type": "application/json", "Authorization": auth_token}, ) assert ( get_device_message_response_short.json["values"] == get_device_message_response.json["values"][0:24] ) # Test that a much longer planning horizon yields the same result (when there are only 2 days of prices) get_device_message["duration"] = "PT1000H" get_device_message_response_long = client.get( url_for("flexmeasures_api_v1_3.get_device_message"), query_string=get_device_message, headers={"content-type": "application/json", "Authorization": auth_token}, ) assert ( get_device_message_response_long.json["values"][0:192] == get_device_message_response.json["values"] ) # sending again results in an error, unless we increase the event ID with app.test_client() as client: next_msg_dt = msg_dt + timedelta(minutes=5) message["datetime"] = next_msg_dt.strftime("%Y-%m-%dT%H:%M:%S.%f%z") post_udi_event_response = client.post( url_for("flexmeasures_api_v1_3.post_udi_event"), json=message, headers={"Authorization": auth_token}, ) print("Server responded with:\n%s" % post_udi_event_response.json) assert post_udi_event_response.status_code == 400 assert post_udi_event_response.json["type"] == "PostUdiEventResponse" assert post_udi_event_response.json["status"] == "OUTDATED_UDI_EVENT" message["event"] = message["event"].replace("204", "205") post_udi_event_response = client.post( url_for("flexmeasures_api_v1_3.post_udi_event"), json=message, headers={"Authorization": auth_token}, ) print("Server responded with:\n%s" % post_udi_event_response.json) assert post_udi_event_response.status_code == 200 assert post_udi_event_response.json["type"] == "PostUdiEventResponse" # test database state asset = Asset.query.filter(Asset.name == asset_name).one_or_none() assert asset.soc_datetime == next_msg_dt assert asset.soc_in_mwh == message["value"] / 1000 assert asset.soc_udi_event_id == 205 # process the scheduling queue work_on_rq(app.queues["scheduling"], exc_handler=handle_scheduling_exception) # the job still fails due to missing prices for the last time slot, but we did test that the api and worker now processed the UDI event and attempted to create a schedule assert ( Job.fetch( message["event"], connection=app.queues["scheduling"].connection ).is_failed is True )