Skip to content

Commit

Permalink
add quantity or sensor storage scheduler (#966)
Browse files Browse the repository at this point in the history
* add QuantityOrSensor fields to the fm add storage for-schedule command

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* use storage efficiency quatity or sensor

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* add storage efficiency and help messages

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* fix issue with default value

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* add end-to-end test

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* add changelog

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* add docstring to fixture

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* add docstrings to tests

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* enhanced docstring for test_add_storage_scheduler

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* add changelog entries

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* improve docstring and clarify changelog

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* simplify re-serialization

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* comment failing test

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

* rename fixture sensors

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>

---------

Signed-off-by: Victor Garcia Reolid <victor@seita.nl>
Signed-off-by: Victor <victor@seita.nl>
  • Loading branch information
victorgarcia98 committed Feb 6, 2024
1 parent 199bd95 commit 248b6dc
Show file tree
Hide file tree
Showing 8 changed files with 328 additions and 9 deletions.
3 changes: 2 additions & 1 deletion documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ v0.19.0 | February xx, 2024
.. warning:: This version replaces FLASK_ENV with FLEXMEASURES_ENV (FLASK_ENV will still be used as a fallback).

New features
-------------
-------------

* Enable the use of QuantityOrSensor fields for the ``flexmeasures add schedule for-storage`` CLI command [see `PR #966 <https://github.com/FlexMeasures/flexmeasures/pull/966>`_]
* Support for defining the storage efficiency as a sensor or quantity for the ``StorageScheduler`` [see `PR #965 <https://github.com/FlexMeasures/flexmeasures/pull/965>`_]
* Support a less verbose way of setting the same :abbr:`SoC (state of charge)` constraint for a given time window [see `PR #899 <https://github.com/FlexMeasures/flexmeasures/pull/899>`_]

Expand Down
11 changes: 11 additions & 0 deletions documentation/cli/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ FlexMeasures CLI Changelog
since v0.19.0 | February xx, 2024
=======================================

* Enable the use of QuantityOrSensor fields for the ``flexmeasures add schedule for-storage`` CLI command:

* ``charging-efficiency``
* ``discharging-efficiency``
* ``soc-gain``
* ``soc-usage``
* ``power-capacity``
* ``production-capacity``
* ``consumption-capacity``
* ``storage-efficiency``

* Streamline CLI option naming by favoring ``--<entity>`` over ``--<entity>-id``. This affects the following options:

* ``--account-id`` -> ``--account``
Expand Down
112 changes: 105 additions & 7 deletions flexmeasures/cli/data_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
LongitudeField,
SensorIdField,
TimeIntervalField,
QuantityOrSensor,
)
from flexmeasures.data.schemas.sources import DataSourceIdField
from flexmeasures.data.schemas.times import TimeIntervalSchema
Expand Down Expand Up @@ -1168,14 +1169,84 @@ def create_schedule(ctx):
default=1,
help="Round-trip efficiency (e.g. 85% or 0.85) to use for the schedule. Defaults to 100% (no losses).",
)
@click.option(
"--charging-efficiency",
"charging_efficiency",
type=QuantityOrSensor("%"),
required=False,
default=None,
help="Storage charging efficiency to use for the schedule."
"Provide a quantity with units (e.g. 94%) or a sensor storing the value with the syntax sensor:<id> (e.g. sensor:20)."
"Defaults to 100% (no losses).",
)
@click.option(
"--discharging-efficiency",
"discharging_efficiency",
type=QuantityOrSensor("%"),
required=False,
default=None,
help="Storage discharging efficiency to use for the schedule."
"Provide a quantity with units (e.g. 94%) or a sensor storing the value with the syntax sensor:<id> (e.g. sensor:20)."
"Defaults to 100% (no losses).",
)
@click.option(
"--soc-gain",
"soc_gain",
type=QuantityOrSensor("MW"),
required=False,
default=None,
help="Specify the State of Charge (SoC) gain as a quantity in power units (e.g. 1 MW or 1000 kW)"
"or reference a sensor by using 'sensor:<id>' (e.g. sensor:34)."
"This represents the rate at which storage is charged from a different source.",
)
@click.option(
"--soc-usage",
"soc_usage",
type=QuantityOrSensor("MW"),
required=False,
default=None,
help="Specify the State of Charge (SoC) usage as a quantity in power units (e.g. 1 MW or 1000 kW) "
"or reference a sensor by using 'sensor:<id>' (e.g. sensor:34)."
"This represents the rate at which the storage is discharged from a different source.",
)
@click.option(
"--storage-power-capacity",
"storage_power_capacity",
type=QuantityField("MW"),
required=False,
default=None,
help="Storage consumption/production power capacity. Provide this as a quantity in power units (e.g. 1 MW or 1000 kW)."
"It defines both-ways maximum power capacity.",
)
@click.option(
"--storage-consumption-capacity",
"storage_consumption_capacity",
type=QuantityOrSensor("MW"),
required=False,
default=None,
help="Storage consumption power capacity. Provide this as a quantity in power units (e.g. 1 MW or 1000 kW)"
"or reference a sensor using 'sensor:<id>' (e.g. sensor:34)."
"It defines the storage maximum consumption (charging) capacity.",
)
@click.option(
"--storage-production-capacity",
"storage_production_capacity",
type=QuantityOrSensor("MW"),
required=False,
default=None,
help="Storage production power capacity. Provide this as a quantity in power units (e.g. 1 MW or 1000 kW)"
"or reference a sensor using 'sensor:<id>' (e.g. sensor:34)."
"It defines the storage maximum production (discharging) capacity.",
)
@click.option(
"--storage-efficiency",
"storage_efficiency",
type=EfficiencyField(),
type=QuantityOrSensor("%", default_src_unit="dimensionless"),
required=False,
default=1,
default="100%",
help="Storage efficiency (e.g. 95% or 0.95) to use for the schedule,"
" applied over each time step equal to the sensor resolution."
"This parameter also supports using a reference sensor as 'sensor:<id>' (e.g. sensor:34)."
" For example, a storage efficiency of 99 percent per (absolute) day, for scheduling a 1-hour resolution sensor, should be passed as a storage efficiency of 0.99**(1/24)."
" Defaults to 100% (no losses).",
)
Expand All @@ -1185,7 +1256,7 @@ def create_schedule(ctx):
help="Whether to queue a scheduling job instead of computing directly. "
"To process the job, run a worker (on any computer, but configured to the same databases) to process the 'scheduling' queue. Defaults to False.",
)
def add_schedule_for_storage(
def add_schedule_for_storage( # noqa C901
power_sensor: Sensor,
consumption_price_sensor: Sensor,
production_price_sensor: Sensor,
Expand All @@ -1194,11 +1265,18 @@ def add_schedule_for_storage(
start: datetime,
duration: timedelta,
soc_at_start: ur.Quantity,
charging_efficiency: ur.Quantity | Sensor | None,
discharging_efficiency: ur.Quantity | Sensor | None,
soc_gain: ur.Quantity | Sensor | None,
soc_usage: ur.Quantity | Sensor | None,
storage_power_capacity: ur.Quantity | Sensor | None,
storage_consumption_capacity: ur.Quantity | Sensor | None,
storage_production_capacity: ur.Quantity | Sensor | None,
soc_target_strings: list[tuple[ur.Quantity, str]],
soc_min: ur.Quantity | None = None,
soc_max: ur.Quantity | None = None,
roundtrip_efficiency: ur.Quantity | None = None,
storage_efficiency: ur.Quantity | None = None,
storage_efficiency: ur.Quantity | Sensor | None = None,
as_job: bool = False,
):
"""Create a new schedule for a storage asset.
Expand Down Expand Up @@ -1256,8 +1334,6 @@ def add_schedule_for_storage(
soc_max = convert_units(soc_max.magnitude, str(soc_max.units), "MWh", capacity=capacity_str) # type: ignore
if roundtrip_efficiency is not None:
roundtrip_efficiency = roundtrip_efficiency.magnitude / 100.0
if storage_efficiency is not None:
storage_efficiency = storage_efficiency.magnitude / 100.0

scheduling_kwargs = dict(
start=start,
Expand All @@ -1271,14 +1347,36 @@ def add_schedule_for_storage(
"soc-max": soc_max,
"soc-unit": "MWh",
"roundtrip-efficiency": roundtrip_efficiency,
"storage-efficiency": storage_efficiency,
},
flex_context={
"consumption-price-sensor": consumption_price_sensor.id,
"production-price-sensor": production_price_sensor.id,
"inflexible-device-sensors": [s.id for s in inflexible_device_sensors],
},
)

quantity_or_sensor_vars = {
"charging-efficiency": charging_efficiency,
"discharging-efficiency": discharging_efficiency,
"storage-efficiency": storage_efficiency,
"soc-gain": soc_gain,
"soc-usage": soc_usage,
"power-capacity": storage_power_capacity,
"consumption-capacity": storage_consumption_capacity,
"production-capacity": storage_production_capacity,
}

for field_name, value in quantity_or_sensor_vars.items():
if value is not None:
if "efficiency" in field_name:
unit = "%"
else:
unit = "MW"

scheduling_kwargs["flex_model"][field_name] = QuantityOrSensor(
unit
)._serialize(value, None, None)

if as_job:
job = create_scheduling_job(asset_or_sensor=power_sensor, **scheduling_kwargs)
if job:
Expand Down
75 changes: 75 additions & 0 deletions flexmeasures/cli/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,78 @@ def process_power_sensor(
db.session.commit()

yield power_sensor.id


@pytest.mark.skip_github
@pytest.fixture(scope="function")
def storage_schedule_sensors(
fresh_db,
app,
):
"""
Fixture to set up sensors for storage schedule testing, including power limit and efficiency sensors.
"""

start = datetime(2014, 12, 31, 23, 0, 0, tzinfo=utc)

db = fresh_db

data_storage_type = GenericAssetType(name="data storage")

db.session.add(data_storage_type)

data_storage = GenericAsset(
name="Data Storage", generic_asset_type=data_storage_type
)

db.session.add(data_storage)

beliefs = []
# generic power sensor to store power limit
power_capacity = Sensor(
"power",
generic_asset=data_storage,
event_resolution=timedelta(hours=1),
unit="MW",
)
db.session.add(power_capacity)

source = DataSource("source1")

for h in range(24):
beliefs.append(
TimedBelief(
event_start=start + timedelta(hours=h),
belief_time=start,
event_value=0.6,
sensor=power_capacity,
source=source,
)
)

# efficiency sensor
storage_efficiency = Sensor(
"storage_efficiency",
generic_asset=data_storage,
event_resolution=timedelta(hours=1),
unit="%",
)
db.session.add(storage_efficiency)

for h in range(24):
beliefs.append(
TimedBelief(
event_start=start + timedelta(hours=h),
belief_time=start,
event_value=90,
sensor=storage_efficiency,
source=source,
)
)

db.session.add_all(beliefs)
db.session.commit()

db.session.commit()

yield power_capacity.id, storage_efficiency.id
80 changes: 80 additions & 0 deletions flexmeasures/cli/tests/test_data_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from datetime import datetime
import pytz

from flexmeasures import Asset
from flexmeasures.cli.tests.utils import to_flags
from flexmeasures.data.models.annotations import (
Annotation,
Expand Down Expand Up @@ -447,3 +448,82 @@ def test_add_account(
else:
# fail because "Test ConsultancyClient Account" already exists
assert result.exit_code == 1


@pytest.mark.skip_github
@pytest.mark.parametrize("storage_power_capacity", ["sensor", "quantity", None])
@pytest.mark.parametrize("storage_efficiency", ["sensor", "quantity", None])
def test_add_storage_scheduler(
app,
add_market_prices_fresh_db,
storage_schedule_sensors,
storage_power_capacity,
storage_efficiency,
):
"""
Test the 'flexmeasures add schedule for-storage' CLI command for adding storage schedules.
This test evaluates the command's functionality in creating storage schedules for different configurations
of power capacity and storage efficiency. It uses a combination of sensor-based and manually specified values
for these parameters.
The test performs the following steps:
1. Simulates running the `flexmeasures add toy-account` command to set up a test account.
2. Configures CLI input parameters for scheduling, including the start time, duration, and sensor IDs.
The test also sets up parameters for state of charge at start and roundtrip efficiency.
3. Depending on the test parameters, adjusts power capacity and efficiency settings. These settings can be
either sensor-based (retrieved from storage_schedule_sensors fixture), manually specified quantities,
or left undefined.
4. Executes the 'add_schedule_for_storage' command with the configured parameters.
5. Verifies that the command executes successfully (exit code 0) and that the correct number of scheduled
values (48 for a 12-hour period with 15-minute resolution) are created for the power sensor.
"""
power_capacity_sensor, storage_efficiency_sensor = storage_schedule_sensors

from flexmeasures.cli.data_add import add_schedule_for_storage, add_toy_account

runner = app.test_cli_runner()
runner.invoke(add_toy_account)

toy_account = Account.query.filter_by(name="Toy Account").one_or_none()
battery = Asset.query.filter_by(name="toy-battery", owner=toy_account).one_or_none()
power_sensor = battery.sensors[0]
prices = add_market_prices_fresh_db["epex_da"]

cli_input_params = {
"start": "2014-12-31T23:00:00+00",
"duration": "PT12H",
"sensor": battery.sensors[0].id,
"consumption-price-sensor": prices.id,
"soc-at-start": "50%",
"roundtrip-efficiency": "90%",
}

if storage_power_capacity is not None:
if storage_power_capacity == "sensor":
cli_input_params[
"storage-consumption-capacity"
] = f"sensor:{power_capacity_sensor}"
cli_input_params[
"storage-production-capacity"
] = f"sensor:{power_capacity_sensor}"
else:

cli_input_params["storage-consumption-capacity"] = "700kW"
cli_input_params["storage-production-capacity"] = "700kW"

if storage_efficiency is not None:
if storage_efficiency == "sensor":
cli_input_params[
"storage-efficiency"
] = f"sensor:{storage_efficiency_sensor}"
else:

cli_input_params["storage-efficiency"] = "90%"

cli_input = to_flags(cli_input_params)

result = runner.invoke(add_schedule_for_storage, cli_input)

assert result.exit_code == 0
assert len(power_sensor.search_beliefs()) == 48
2 changes: 1 addition & 1 deletion flexmeasures/data/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
from .account import AccountIdField
from .generic_assets import GenericAssetIdField as AssetIdField
from .locations import LatitudeField, LongitudeField
from .sensors import SensorIdField
from .sensors import SensorIdField, QuantityOrSensor
from .sources import DataSourceIdField as SourceIdField
from .times import AwareDateTimeField, DurationField, TimeIntervalField

0 comments on commit 248b6dc

Please sign in to comment.