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 all 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
5 changes: 3 additions & 2 deletions documentation/changelog.rst
Expand Up @@ -12,13 +12,14 @@ New features
* Three new CLI commands for cleaning up your database: delete 1) unchanged beliefs, 2) NaN values or 3) a sensor and all of its time series data [see `PR #328 <http://www.github.com/FlexMeasures/flexmeasures/pull/328>`_]
* Add CLI option to pass a data unit when reading in time series data from CSV, so data can automatically be converted to the sensor unit [see `PR #341 <http://www.github.com/FlexMeasures/flexmeasures/pull/341>`_]
* Add CLI option to specify custom strings that should be interpreted as NaN values when reading in time series data from CSV [see `PR #357 <http://www.github.com/FlexMeasures/flexmeasures/pull/357>`_]
* Add CLI commands ``flexmeasures add sensor``, ``flexmeasures add asset-type``, ``flexmeasures add beliefs`` (which were experimental features before). [see `PR #337 <http://www.github.com/FlexMeasures/flexmeasures/pull/337>`_]
* Add CLI commands ``flexmeasures add sensor``, ``flexmeasures add asset-type``, ``flexmeasures add beliefs`` (which were experimental features before) [see `PR #337 <http://www.github.com/FlexMeasures/flexmeasures/pull/337>`_]
* Add CLI commands for showing data [see `PR #339 <http://www.github.com/FlexMeasures/flexmeasures/pull/339>`_]

* Add CLI command for attaching annotations to assets: ``flexmeasures add holidays`` adds public holidays [see `PR #343 <http://www.github.com/FlexMeasures/flexmeasures/pull/343>`_]
* Add CLI command for resampling existing sensor data to new resolution [see `PR #360 <http://www.github.com/FlexMeasures/flexmeasures/pull/360>`_]
* Add CLI command to edit/add an attribute on an asset or sensor. [see `PR #380 <http://www.github.com/FlexMeasures/flexmeasures/pull/380>`_]
* Add CLI command to add a toy account for tutorials and trying things [see `PR #368 <http://www.github.com/FlexMeasures/flexmeasures/pull/368>`_].
* Add CLI command to add a toy account for tutorials and trying things [see `PR #368 <http://www.github.com/FlexMeasures/flexmeasures/pull/368>`_]
* Add CLI command to create a charging schedule [see `PR #372 <http://www.github.com/FlexMeasures/flexmeasures/pull/372>`_]
* Support for percent (%) and permille (‰) sensor units [see `PR #359 <http://www.github.com/FlexMeasures/flexmeasures/pull/359>`_]

Bugfixes
Expand Down
1 change: 1 addition & 0 deletions documentation/cli/change_log.rst
Expand Up @@ -11,6 +11,7 @@ since v0.9.0 | January 26, 2022
* Add ``flexmeasures edit resample-data`` CLI command to resample sensor data to a different resolution.
* Add ``flexmeasures edit attribute`` CLI command to edit/add an attribute on an asset or sensor.
* Add ``flexmeasures add toy-account`` for tutorials and trying things.
* Add ``flexmeasures add schedule`` to create a new schedule for a given power sensor.
* Rename ``flexmeasures add structure`` to ``flexmeasures add initial-structure``.


Expand Down
1 change: 1 addition & 0 deletions documentation/cli/commands.rst
Expand Up @@ -34,6 +34,7 @@ of which some are referred to in this documentation.
``flexmeasures add external-weather-forecasts`` Collect weather forecasts from the DarkSky API.
``flexmeasures add beliefs`` Load beliefs from file.
``flexmeasures add forecasts`` Create forecasts.
``flexmeasures add schedule`` Create a charging schedule.
``flexmeasures add toy-account`` Create a toy account, for tutorials and trying things.
================================================= =======================================

Expand Down
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
178 changes: 174 additions & 4 deletions flexmeasures/cli/data_add.py
@@ -1,9 +1,11 @@
"""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

from marshmallow import validate
import numpy as np
import pandas as pd
import pytz
from flask import current_app as app
Expand All @@ -13,6 +15,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,14 +24,20 @@
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.units import QuantityField
from flexmeasures.data.schemas.generic_assets import (
GenericAssetSchema,
GenericAssetTypeSchema,
Expand All @@ -41,7 +50,7 @@
)
from flexmeasures.utils import flexmeasures_inflection
from flexmeasures.utils.time_utils import server_now
from flexmeasures.utils.unit_utils import convert_units
from flexmeasures.utils.unit_utils import convert_units, ur


@click.group("add")
Expand Down Expand Up @@ -132,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 @@ -410,7 +419,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 @@ -756,6 +765,167 @@ 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(
"--optimization-context-id",
"optimization_context_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=QuantityField("%", validate=validate.Range(min=0, max=1)),
required=True,
help="State of charge (e.g 32.8%, or 0.328) 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=[QuantityField("%", validate=validate.Range(min=0, max=1)), str]
),
multiple=True,
required=False,
help="Target state of charge (e.g 100%, or 1) 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 100% 2022-02-23T13:40:52+00:00",
)
@click.option(
"--soc-min",
"soc_min",
type=QuantityField("%", validate=validate.Range(min=0, max=1)),
required=False,
help="Minimum state of charge (e.g 20%, or 0.2) for the schedule. Use --soc-unit to set a different unit.",
)
@click.option(
"--soc-max",
"soc_max",
type=QuantityField("%", validate=validate.Range(min=0, max=100)),
required=False,
help="Maximum state of charge (e.g 80%, or 0.8) for the schedule. Use --soc-unit to set a different unit.",
)
@click.option(
"--roundtrip-efficiency",
"roundtrip_efficiency",
type=QuantityField("%", validate=validate.Range(min=0, max=1)),
required=False,
default=1,
help="Round-trip efficiency (e.g. 85% or 0.85) to use for the schedule. Defaults to 100% (no losses).",
)
def create_schedule(
power_sensor_id: int,
optimization_context_sensor_id: int,
start_str: str,
duration_str: str,
soc_at_start: ur.Quantity,
soc_target_strings: List[Tuple[ur.Quantity, str]],
soc_min: Optional[ur.Quantity] = None,
soc_max: Optional[ur.Quantity] = None,
roundtrip_efficiency: Optional[ur.Quantity] = None,
):
"""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()
optimization_context_sensor: Sensor = Sensor.query.filter(
Sensor.id == optimization_context_sensor_id
).one_or_none()
if optimization_context_sensor is None:
click.echo(f"No sensor found with ID {optimization_context_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")
)

# Convert round-trip efficiency to dimensionless
if roundtrip_efficiency is not None:
roundtrip_efficiency = roundtrip_efficiency.to(
ur.Quantity("dimensionless")
).magnitude

# Convert SoC units to MWh, given the storage capacity
capacity_str = f"{power_sensor.get_attribute('max_soc_in_mwh')} MWh"
soc_at_start = convert_units(soc_at_start.magnitude, soc_at_start.units, "MWh", capacity=capacity_str) # type: ignore
for soc_target_tuple in soc_target_strings:
soc_target_value_str, soc_target_dt_str = soc_target_tuple
soc_target_value = convert_units(
soc_target_value_str.magnitude,
str(soc_target_value_str.units),
"MWh",
capacity=capacity_str,
)
soc_target_datetime = pd.Timestamp(soc_target_dt_str)
soc_targets.loc[soc_target_datetime] = soc_target_value
if soc_min is not None:
soc_min = convert_units(soc_min.magnitude, str(soc_min.units), "MWh", capacity=capacity_str) # type: ignore
if soc_max is not None:
soc_max = convert_units(soc_max.magnitude, str(soc_max.units), "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=optimization_context_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