Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 371 cli command for creating a schedule #372

Merged
merged 43 commits into from Mar 4, 2022
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
b64089c
Rename variable
Flix6x Feb 23, 2022
05a534f
Allow passing an explicit sensor id to find prices
Flix6x Feb 23, 2022
3632560
Add CLI command to create a schedule
Flix6x Feb 23, 2022
e71fd31
Add docstring
Flix6x Feb 23, 2022
2e46702
Expose soc_min, soc_max and roundtrip_efficiency parameters as CLI op…
Flix6x Feb 23, 2022
c213f94
Allow setting SoC targets
Flix6x Feb 23, 2022
de85f30
Fix attribute
Flix6x Feb 23, 2022
2b42784
Allow use of make_schedule outside of job context
Flix6x Feb 23, 2022
95d1208
Fix SoC target input and corresponding type annotation
Flix6x Feb 23, 2022
6d1e5ee
Add note about current limitations
Flix6x Feb 23, 2022
becf304
Add notes about units
Flix6x Feb 23, 2022
1794c67
Remove print statement
Flix6x Feb 23, 2022
bf8cfe2
Switch the schedule to the resolution of the power sensor
Flix6x Feb 23, 2022
e2b12e0
Rename variable in tests, too
Flix6x Feb 23, 2022
a103b40
check parameters: do sensors exist, do we have soc-min and soc-max in…
nhoening Feb 25, 2022
32db57a
Actually the code expects these soc attributes to be on the sensor
nhoening Feb 25, 2022
82221cf
use utility function to check attribute
nhoening Feb 25, 2022
c5a0ac7
identify sensor by instance, as two may have the same name across assets
nhoening Feb 25, 2022
558f23a
Merge branch 'main' into Issue-371_CLI_command_for_creating_a_schedule
nhoening Feb 26, 2022
b7acb68
Consistent capitalization
Flix6x Feb 25, 2022
15913f5
Allow unit conversion for individual int/float values
Flix6x Feb 28, 2022
0211ecb
Allow unit conversion to and from a percentage of some capacity
Flix6x Feb 28, 2022
4e925fa
Test unit conversion to and from a percentage of some capacity
Flix6x Feb 28, 2022
7b42db0
CLI scheduling command uses % SoC units by default
Flix6x Feb 28, 2022
47b0bef
Round charging schedules
Flix6x Feb 28, 2022
b6603de
Fix calculation of belief time (by handing the problem over to timely…
Flix6x Feb 28, 2022
135c0d6
flake8
Flix6x Feb 28, 2022
c998fe1
fix (thanks mypy)
Flix6x Feb 28, 2022
95d44e5
Fix timezone issue for trimming planning window
Flix6x Feb 28, 2022
9351944
Fix timezone issue for setting SoC targets for schedules crossing DST…
Flix6x Feb 28, 2022
0a42036
turn --end parameter into --duration
nhoening Mar 1, 2022
525a303
Switch efficiency input to % values and add input validation using ma…
Flix6x Mar 3, 2022
c3c6a81
Force users to be specific upon providing input of percentage values …
Flix6x Mar 4, 2022
efea48c
Fix percentage range and add range example for power quantity
Flix6x Mar 4, 2022
dc8f1a9
Switch validation to return Quantity objects instead of magnitudes
Flix6x Mar 4, 2022
ec3035a
Switch SOC input to percentages only
Flix6x Mar 4, 2022
abc7ac6
Rename factor_id to optimization_context_id
Flix6x Mar 4, 2022
a86ab94
Delete obsolete class
Flix6x Mar 4, 2022
a12ecef
Rename validator class
Flix6x Mar 4, 2022
6d0e9b8
flake8 and mypy
Flix6x Mar 4, 2022
56aab77
Changelog entries
Flix6x Mar 4, 2022
6ec52f9
Merge branch 'main' into Issue-371_CLI_command_for_creating_a_schedule
Flix6x Mar 4, 2022
96c2364
Update CLI commands listing
Flix6x Mar 4, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion flexmeasures/api/v1_3/tests/test_api_v1_3.py
Expand Up @@ -74,7 +74,7 @@ def test_post_udi_event_and_get_device_message(
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"] == sensor.id
assert job.kwargs["sensor_id"] == sensor.id
assert job.kwargs["start"] == parse_datetime(message["datetime"])
assert job.id == message["event"]

Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/api/v1_3/tests/test_api_v1_3_fresh_db.py
Expand Up @@ -38,7 +38,7 @@ def test_post_udi_event_and_get_device_message_with_unknown_prices(
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"] == sensor.id
assert job.kwargs["sensor_id"] == sensor.id
assert job.kwargs["start"] == parse_datetime(message["datetime"])
assert job.id == message["event"]
assert (
Expand Down
169 changes: 166 additions & 3 deletions flexmeasures/cli/data_add.py
@@ -1,9 +1,10 @@
"""CLI Tasks for populating the database - most useful in development"""

from datetime import timedelta
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
import json

import numpy as np
import pandas as pd
import pytz
from flask import current_app as app
Expand All @@ -13,6 +14,7 @@
from sqlalchemy.exc import IntegrityError
import timely_beliefs as tb
from workalendar.registry import registry as workalendar_registry
import isodate

from flexmeasures.data import db
from flexmeasures.data.scripts.data_gen import (
Expand All @@ -21,12 +23,17 @@
add_default_asset_types,
)
from flexmeasures.data.services.forecasting import create_forecasting_jobs
from flexmeasures.data.services.scheduling import make_schedule
from flexmeasures.data.services.users import create_user
from flexmeasures.data.models.user import Account, AccountRole, RolesAccounts
from flexmeasures.data.models.time_series import (
Sensor,
TimedBelief,
)
from flexmeasures.data.models.validation_utils import (
check_required_attributes,
MissingAttributeException,
)
from flexmeasures.data.models.annotations import Annotation, get_or_create_annotation
from flexmeasures.data.schemas.sensors import SensorSchema
from flexmeasures.data.schemas.generic_assets import (
Expand Down Expand Up @@ -134,7 +141,7 @@ def new_user(
raise click.Abort
account = db.session.query(Account).get(account_id)
if account is None:
print(f"No account with id {account_id} found!")
print(f"No account with ID {account_id} found!")
raise click.Abort
pwd1 = getpass.getpass(prompt="Please enter the password:")
pwd2 = getpass.getpass(prompt="Please repeat the password:")
Expand Down Expand Up @@ -456,7 +463,7 @@ def add_beliefs(
"""
sensor = Sensor.query.filter(Sensor.id == sensor_id).one_or_none()
if sensor is None:
print(f"Failed to create beliefs: no sensor found with id {sensor_id}.")
print(f"Failed to create beliefs: no sensor found with ID {sensor_id}.")
return
if source.isdigit():
_source = get_source_or_none(int(source), source_type="CLI script")
Expand Down Expand Up @@ -802,6 +809,162 @@ def create_forecasts(
)


@fm_add_data.command("schedule")
@with_appcontext
@click.option(
"--sensor-id",
"power_sensor_id",
required=True,
help="Create schedule for this sensor. Follow up with the sensor's ID.",
)
@click.option(
"--factor-id",
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
"factor_sensor_id",
required=True,
help="Optimize against this sensor, which measures a price factor or CO₂ intensity factor. Follow up with the sensor's ID.",
)
@click.option(
"--from",
"start_str",
required=True,
help="Schedule starts at this datetime. Follow up with a timezone-aware datetime in ISO 6801 format.",
)
@click.option(
"--duration",
"duration_str",
required=True,
help="Duration of schedule, after --from. Follow up with a duration in ISO 6801 format, e.g. PT1H (1 hour) or PT45M (45 minutes).",
)
@click.option(
"--soc-at-start",
"soc_at_start",
type=float,
required=True,
help="State of charge (in %) at the start of the schedule. Use --soc-unit to set a different unit.",
)
@click.option(
"--soc-target",
"soc_target_strings",
type=click.Tuple(types=[float, str]),
multiple=True,
required=False,
help="Target state of charge (in %) at some datetime. Follow up with a float value and a timezone-aware datetime in ISO 6081 format."
" Use --soc-unit to set a different unit."
" This argument can be given multiple times."
" For example: --soc-target 32.8 2022-02-23T13:40:52+00:00",
)
@click.option(
"--soc-min",
"soc_min",
type=float,
required=False,
help="Minimum state of charge (in %) for the schedule. Use --soc-unit to set a different unit.",
)
@click.option(
"--soc-max",
"soc_max",
type=float,
required=False,
help="Maximum state of charge (in %) for the schedule. Use --soc-unit to set a different unit.",
)
@click.option(
"--roundtrip-efficiency",
"roundtrip_efficiency",
type=float,
required=False,
help="Round-trip efficiency (e.g. 0.85) to use for the schedule. Defaults to 1 (no losses).",
)
@click.option(
"--soc-unit",
"soc_unit",
default="%",
help="Unit of the passed SoC values, such as 'MWh', 'kWh' or '%' (the default).",
)
def create_schedule(
power_sensor_id: int,
factor_sensor_id: int,
start_str: str,
duration_str: str,
soc_at_start: float,
soc_target_strings: List[Tuple[float, str]],
soc_min: Optional[float],
soc_max: Optional[float],
roundtrip_efficiency: Optional[float],
soc_unit: str = "%",
):
"""Create a new schedule for a given power sensor.

Current limitations:
- only supports battery assets and Charge Points
- only supports datetimes on the hour or a multiple of the sensor resolution thereafter
"""

# Parse input
power_sensor: Sensor = Sensor.query.filter(
Sensor.id == power_sensor_id
).one_or_none()
if power_sensor is None:
click.echo(f"No sensor found with ID {power_sensor_id}.")
raise click.Abort()
if not power_sensor.measures_power:
click.echo(f"Sensor with ID {power_sensor_id} is not a power sensor.")
raise click.Abort()
factor_sensor: Sensor = Sensor.query.filter(
Sensor.id == factor_sensor_id
).one_or_none()
if factor_sensor is None:
click.echo(f"No sensor found with ID {factor_sensor_id}.")
raise click.Abort()
start = pd.Timestamp(start_str)
end = start + isodate.parse_duration(duration_str)
for attribute in ("min_soc_in_mwh", "max_soc_in_mwh"):
try:
check_required_attributes(power_sensor, [(attribute, float)])
except MissingAttributeException:
click.echo(f"{power_sensor} has no {attribute} attribute.")
raise click.Abort()
soc_targets = pd.Series(
np.nan,
index=pd.date_range(
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
pd.Timestamp(start).tz_convert(power_sensor.timezone),
pd.Timestamp(end).tz_convert(power_sensor.timezone),
freq=power_sensor.event_resolution,
closed="right",
), # note that target values are indexed by their due date (i.e. closed="right")
)
for soc_target_tuple in soc_target_strings:
soc_target_value_str, soc_target_dt_str = soc_target_tuple
soc_target_value = float(soc_target_value_str)
soc_target_datetime = pd.Timestamp(soc_target_dt_str)
soc_targets.loc[soc_target_datetime] = soc_target_value

# Convert SoC units if needed
if soc_unit != "MWh":
capacity_str = f"{power_sensor.get_attribute('max_soc_in_mwh')} MWh"
soc_at_start = convert_units(soc_at_start, soc_unit, "MWh", capacity=capacity_str) # type: ignore
soc_targets = convert_units(soc_targets, soc_unit, "MWh", capacity=capacity_str)
if soc_min is not None:
soc_min = convert_units(soc_min, soc_unit, "MWh", capacity=capacity_str) # type: ignore
if soc_max is not None:
soc_max = convert_units(soc_max, soc_unit, "MWh", capacity=capacity_str) # type: ignore

success = make_schedule(
sensor_id=power_sensor_id,
start=start,
end=end,
belief_time=server_now(),
resolution=power_sensor.event_resolution,
soc_at_start=soc_at_start,
soc_targets=soc_targets,
soc_min=soc_min,
soc_max=soc_max,
roundtrip_efficiency=roundtrip_efficiency,
price_sensor=factor_sensor,
)
if success:
print("New schedule is stored.")


@fm_add_data.command("toy-account")
@with_appcontext
@click.option(
Expand Down
15 changes: 14 additions & 1 deletion flexmeasures/data/models/planning/battery.py
Expand Up @@ -25,6 +25,8 @@ def schedule_battery(
soc_max: Optional[float] = None,
roundtrip_efficiency: Optional[float] = None,
prefer_charging_sooner: bool = True,
price_sensor: Optional[Sensor] = None,
round_to_decimals: Optional[int] = 6,
) -> Union[pd.Series, None]:
"""Schedule a battery asset based directly on the latest beliefs regarding market prices within the specified time
window.
Expand Down Expand Up @@ -55,10 +57,17 @@ def schedule_battery(

# Check for known prices or price forecasts, trimming planning window accordingly
prices, (start, end) = get_prices(
sensor, (start, end), resolution, allow_trimmed_query_window=True
(start, end),
resolution,
price_sensor=price_sensor,
sensor=sensor,
allow_trimmed_query_window=True,
)
start = pd.Timestamp(start).tz_convert("UTC")
end = pd.Timestamp(end).tz_convert("UTC")
if soc_targets is not None:
# soc targets are at the end of each time slot, while prices are indexed by the start of each time slot
soc_targets = soc_targets.tz_convert("UTC")
soc_targets = soc_targets[start + resolution : end]

# Add tiny price slope to prefer charging now rather than later, and discharging later rather than now.
Expand Down Expand Up @@ -131,4 +140,8 @@ def schedule_battery(
else:
battery_schedule = ems_schedule[0]

# Round schedule
if round_to_decimals:
battery_schedule = battery_schedule.round(round_to_decimals)

return battery_schedule
22 changes: 16 additions & 6 deletions flexmeasures/data/models/planning/charging_station.py
@@ -1,7 +1,7 @@
from typing import Optional, Union
from datetime import datetime, timedelta

from pandas import Series, Timestamp
import pandas as pd

from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data.models.planning.solver import device_scheduler
Expand All @@ -20,12 +20,14 @@ def schedule_charging_station(
end: datetime,
resolution: timedelta,
soc_at_start: float,
soc_targets: Series,
soc_targets: pd.Series,
soc_min: Optional[float] = None,
soc_max: Optional[float] = None,
roundtrip_efficiency: Optional[float] = None,
prefer_charging_sooner: bool = True,
) -> Union[Series, None]:
price_sensor: Optional[Sensor] = None,
round_to_decimals: Optional[int] = 6,
) -> Union[pd.Series, None]:
"""Schedule a charging station asset based directly on the latest beliefs regarding market prices within the specified time
window.
For the resulting consumption schedule, consumption is defined as positive values.
Expand All @@ -52,12 +54,16 @@ def schedule_charging_station(

# Check for known prices or price forecasts, trimming planning window accordingly
prices, (start, end) = get_prices(
sensor, (start, end), resolution, allow_trimmed_query_window=True
(start, end),
resolution,
price_sensor=price_sensor,
sensor=sensor,
allow_trimmed_query_window=True,
)
# soc targets are at the end of each time slot, while prices are indexed by the start of each time slot
soc_targets = soc_targets.tz_convert("UTC")
start = Timestamp(start).tz_convert("UTC")
end = Timestamp(end).tz_convert("UTC")
start = pd.Timestamp(start).tz_convert("UTC")
end = pd.Timestamp(end).tz_convert("UTC")
soc_targets = soc_targets[start + resolution : end]

# Add tiny price slope to prefer charging now rather than later, and discharging later rather than now.
Expand Down Expand Up @@ -134,4 +140,8 @@ def schedule_charging_station(
else:
charging_station_schedule = ems_schedule[0]

# Round schedule
if round_to_decimals:
charging_station_schedule = charging_station_schedule.round(round_to_decimals)

return charging_station_schedule
16 changes: 11 additions & 5 deletions flexmeasures/data/models/planning/utils.py
Expand Up @@ -68,21 +68,27 @@ def get_market(sensor: Sensor) -> Sensor:


def get_prices(
sensor: Sensor,
query_window: Tuple[datetime, datetime],
resolution: timedelta,
price_sensor: Optional[Sensor] = None,
sensor: Optional[Sensor] = None,
allow_trimmed_query_window: bool = True,
) -> Tuple[pd.DataFrame, Tuple[datetime, datetime]]:
"""Check for known prices or price forecasts, trimming query window accordingly if allowed.
todo: set a horizon to avoid collecting prices that are not known at the time of constructing the schedule
(this may require implementing a belief time for scheduling jobs).
"""

# Look for the applicable market sensor
sensor = get_market(sensor)
# Look for the applicable price sensor
if price_sensor is None:
if sensor is None:
raise UnknownMarketException(
"Missing price sensor cannot be derived from a missing sensor"
)
price_sensor = get_market(sensor)

price_bdf: tb.BeliefsDataFrame = TimedBelief.search(
sensor.name,
price_sensor,
event_starts_after=query_window[0],
event_ends_before=query_window[1],
resolution=to_offset(resolution).freqstr,
Expand All @@ -97,7 +103,7 @@ def get_prices(
nan_prices.any()
or pd.Timestamp(price_df.index[0]).tz_convert("UTC")
!= pd.Timestamp(query_window[0]).tz_convert("UTC")
or pd.Timestamp(price_df.index[-1]).tz_convert("UTC")
or pd.Timestamp(price_df.index[-1]).tz_convert("UTC") + resolution
!= pd.Timestamp(query_window[-1]).tz_convert("UTC")
):
if allow_trimmed_query_window:
Expand Down