diff --git a/changelog.md b/changelog.md index 0d3214b..83a735a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,12 +1,17 @@ # EAS Python client ## [0.18.0] - UNRELEASED ### Breaking Changes -* None. +* Added `load_overrides` to both `FixedTime` and `TimePeriod` which consist of a list of `FixedTimeLoadOverride` and `TimePeriodLoadOverride` +* `WorkPackageConfig` has some of its variables moved into the new classes `ForecastConfig` and `FeederConfig`. + * Moved `feeders`, `years`, `scenarios` and `load_time`. + * `WorkPackageConfig` now has a new variable `syf_config` consist of a Union of `ForecastConfig`, and list of `FeederConfig`. + * This is to support feeder specific load override events ### New Features * Update `ModelConfig` to contain an optional `transformer_tap_settings` field to specify a set of distribution transformer tap settings to be applied by the model-processor. * Added basic client method to run a hosting capacity calibration and method to query its status. * Added basic client method to run a hosting capacity work package cost estimation. +* Added `FixedTimeLoadOverride` and `TimePeriodLoadOverride` class ### Enhancements * Added work package config documentation. diff --git a/src/zepben/eas/client/eas_client.py b/src/zepben/eas/client/eas_client.py index d900a04..36da373 100644 --- a/src/zepben/eas/client/eas_client.py +++ b/src/zepben/eas/client/eas_client.py @@ -17,7 +17,7 @@ from zepben.eas.client.study import Study from zepben.eas.client.util import construct_url -from zepben.eas.client.work_package import WorkPackageConfig, FixedTime, TimePeriod +from zepben.eas.client.work_package import WorkPackageConfig, FixedTime, TimePeriod, ForecastConfig, FeederConfigs __all__ = ["EasClient"] @@ -211,15 +211,46 @@ async def async_get_work_package_cost_estimation(self, work_package: WorkPackage "variables": { "workPackageName": work_package.name, "input": { - "feeders": work_package.feeders, - "years": work_package.years, - "scenarios": work_package.scenarios, - "fixedTime": work_package.load_time.time.isoformat() - if isinstance(work_package.load_time, FixedTime) else None, - "timePeriod": { - "startTime": work_package.load_time.start_time.isoformat(), - "endTime": work_package.load_time.end_time.isoformat(), - } if isinstance(work_package.load_time, TimePeriod) else None, + "feederConfigs": { + "configs": [ + { + "feeder": config.feeder, + "years": config.years, + "scenarios": config.scenarios, + "timePeriod": { + "startTime": config.load_time.start_time.isoformat(), + "endTime": config.load_time.end_time.isoformat(), + "overrides": config.load_time.load_overrides and { + key: value.__dict__ + for key, value in config.load_time.load_overrides.items()} + } if isinstance(config.load_time, TimePeriod) else None, + "fixedTime": config.load_time and { + "loadTime": config.load_time.time.isoformat(), + "overrides": config.load_time.load_overrides and { + key: value.__dict__ + for key, value in config.load_time.load_overrides.items()} + } if isinstance(config.load_time, FixedTime) else None, + } for config in work_package.syf_config.configs + ] + } if isinstance(work_package.syf_config, FeederConfigs) else None, + "forecastConfig": { + "feeders": work_package.syf_config.feeders, + "years": work_package.syf_config.years, + "scenarios": work_package.syf_config.scenarios, + "timePeriod": { + "startTime": work_package.syf_config.load_time.start_time.isoformat(), + "endTime": work_package.syf_config.load_time.end_time.isoformat(), + "overrides": work_package.syf_config.load_time.load_overrides and { + key: value.__dict__ + for key, value in work_package.syf_config.load_time.load_overrides.items()} + } if isinstance(work_package.syf_config.load_time, TimePeriod) else None, + "fixedTime": work_package.syf_config.load_time and { + "loadTime": work_package.syf_config.load_time.fetch_load_time.isoformat(), + "overrides": work_package.syf_config.load_time.load_overrides and { + key: value.__dict__ + for key, value in work_package.syf_config.load_time.load_overrides.items()} + } if isinstance(work_package.syf_config.load_time, FixedTime) else None + } if isinstance(work_package.syf_config, ForecastConfig) else None, "qualityAssuranceProcessing": work_package.quality_assurance_processing, "generatorConfig": work_package.generator_config and { "model": work_package.generator_config.model and { @@ -273,7 +304,7 @@ async def async_get_work_package_cost_estimation(self, work_package: WorkPackage "defaultGenWatts": work_package.generator_config.model.default_gen_watts, "defaultLoadVar": work_package.generator_config.model.default_load_var, "defaultGenVar": work_package.generator_config.model.default_gen_var, - "transformerTapSettings": work_package.generator_config.model.transformer_tap_settings + "transformerTapSettings": work_package.generator_config.model.transformer_tap_settings, }, "solve": work_package.generator_config.solve and { "normVMinPu": work_package.generator_config.solve.norm_vmin_pu, @@ -399,15 +430,46 @@ async def async_run_hosting_capacity_work_package(self, work_package: WorkPackag "variables": { "workPackageName": work_package.name, "input": { - "feeders": work_package.feeders, - "years": work_package.years, - "scenarios": work_package.scenarios, - "fixedTime": work_package.load_time.time.isoformat() - if isinstance(work_package.load_time, FixedTime) else None, - "timePeriod": { - "startTime": work_package.load_time.start_time.isoformat(), - "endTime": work_package.load_time.end_time.isoformat(), - } if isinstance(work_package.load_time, TimePeriod) else None, + "feederConfigs": { + "configs": [ + { + "feeder": config.feeder, + "years": config.years, + "scenarios": config.scenarios, + "timePeriod": { + "startTime": config.load_time.start_time.isoformat(), + "endTime": config.load_time.end_time.isoformat(), + "overrides": config.load_time.load_overrides and { + key: value.__dict__ + for key, value in config.load_time.load_overrides.items()} + } if isinstance(config.load_time, TimePeriod) else None, + "fixedTime": config.load_time and { + "loadTime": config.load_time.time.isoformat(), + "overrides": config.load_time.load_overrides and { + key: value.__dict__ + for key, value in config.load_time.load_overrides.items()} + } if isinstance(config.load_time, FixedTime) else None, + } for config in work_package.syf_config.configs + ] + } if isinstance(work_package.syf_config, FeederConfigs) else None, + "forecastConfig": { + "feeders": work_package.syf_config.feeders, + "years": work_package.syf_config.years, + "scenarios": work_package.syf_config.scenarios, + "timePeriod": { + "startTime": work_package.syf_config.load_time.start_time.isoformat(), + "endTime": work_package.syf_config.load_time.end_time.isoformat(), + "overrides": work_package.syf_config.load_time.load_overrides and { + key: value.__dict__ + for key, value in work_package.syf_config.load_time.load_overrides.items()} + } if isinstance(work_package.syf_config.load_time, TimePeriod) else None, + "fixedTime": work_package.syf_config.load_time and { + "loadTime": work_package.syf_config.load_time.time.isoformat(), + "overrides": work_package.syf_config.load_time.load_overrides and { + key: value.__dict__ + for key, value in work_package.syf_config.load_time.load_overrides.items()} + } if isinstance(work_package.syf_config.load_time, FixedTime) else None + } if isinstance(work_package.syf_config, ForecastConfig) else None, "qualityAssuranceProcessing": work_package.quality_assurance_processing, "generatorConfig": work_package.generator_config and { "model": work_package.generator_config.model and { diff --git a/src/zepben/eas/client/work_package.py b/src/zepben/eas/client/work_package.py index a2d869d..9d26048 100644 --- a/src/zepben/eas/client/work_package.py +++ b/src/zepben/eas/client/work_package.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime from enum import Enum -from typing import List, Optional, Union +from typing import List, Optional, Union, Dict __all__ = [ "SwitchClass", @@ -38,6 +38,11 @@ "WriterOutputConfig", "WriterConfig", "YearRange", + "FixedTimeLoadOverride", + "TimePeriodLoadOverride", + "ForecastConfig", + "FeederConfig", + "FeederConfigs", ] @@ -62,14 +67,89 @@ class SwitchMeterPlacementConfig: """ +@dataclass +class FixedTimeLoadOverride: + + load_watts: Optional[float] + """ + The reading to be used to override load watts + """ + + gen_watts: Optional[float] + """ + The reading to be used to override gen watts + """ + + load_var: Optional[float] + """ + The reading to be used to override load var + """ + + gen_var: Optional[float] + """ + The reading to be used to override gen var + """ + + # def __str__(self): + + +@dataclass +class TimePeriodLoadOverride: + + load_watts: Optional[List[float]] + """ + A list of readings to be used to override load watts. + Can be either a yearly or daily profile. + The number of entries must match the number of entries in load_var, and the expected number for the configured load_interval_length_hours. + For load_interval_length_hours: + 0.25: 96 entries for daily and 35040 for yearly + 0.5: 48 entries for daily and 17520 for yearly + 1.0: 24 entries for daily and 8760 for yearly + """ + + gen_watts: Optional[List[float]] + """ + A list of readings to be used to override gen watts. + Can be either a yearly or daily profile. + The number of entries must match the number of entries in gen_var, and the expected number for the configured load_interval_length_hours. + For load_interval_length_hours: + 0.25: 96 entries for daily and 35040 for yearly + 0.5: 48 entries for daily and 17520 for yearly + 1.0: 24 entries for daily and 8760 for yearly + """ + + load_var: Optional[List[float]] + """ + A list of readings to be used to override load var. + Can be either a yearly or daily profile. + The number of entries must match the number of entries in load_watts, and the expected number for the configured load_interval_length_hours. + For load_interval_length_hours: + 0.25: 96 entries for daily and 35040 for yearly + 0.5: 48 entries for daily and 17520 for yearly + 1.0: 24 entries for daily and 8760 for yearly + """ + + gen_var: Optional[List[float]] + """ + A list of readings to be used to override gen var. + Can be either a yearly or daily profile. + The number of entries must match the number of entries in gen_watts, and the expected number for the configured load_interval_length_hours. + For load_interval_length_hours: + 0.25: 96 entries for daily and 35040 for yearly + 0.5: 48 entries for daily and 17520 for yearly + 1.0: 24 entries for daily and 8760 for yearly + """ + + class FixedTime: """ A single point in time to model. Should be precise to the minute, and load data must be present for the provided time in the load database for accurate results. """ - def __init__(self, time: datetime): + def __init__(self, time: datetime, load_overrides: Optional[Dict[str, FixedTimeLoadOverride]] = None): self.time = time.replace(tzinfo=None) + self.load_overrides = load_overrides class TimePeriod: @@ -82,11 +162,13 @@ class TimePeriod: def __init__( self, start_time: datetime, - end_time: datetime + end_time: datetime, + load_overrides: Optional[Dict[str, TimePeriodLoadOverride]] = None ): self._validate(start_time, end_time) self.start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) self.end_time = end_time.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + self.load_overrides = load_overrides @staticmethod def _validate(start_time: datetime, end_time: datetime): @@ -688,9 +770,7 @@ class InterventionConfig: @dataclass -class WorkPackageConfig: - """ A data class representing the configuration for a hosting capacity work package """ - name: str +class ForecastConfig(object): feeders: List[str] """The feeders to process in this work package""" @@ -712,6 +792,48 @@ class WorkPackageConfig: result in inaccurate results. """ + +@dataclass +class FeederConfig(object): + feeder: str + """The feeder to process in this work package""" + + years: List[int] + """ + The years to process for the specified feeders in this work package. + The years should be configured in the input database forecasts for all supplied scenarios. + """ + + scenarios: List[str] + """ + The scenarios to model. These should be configured in the input.scenario_configuration table. + """ + + load_time: Union[TimePeriod, FixedTime] + """ + The time to use for the base load data. The provided time[s] must be available in the + load database for accurate results. Specifying an invalid time (i.e one with no load data) will + result in inaccurate results. + """ + + +@dataclass +class FeederConfigs(object): + configs: list[FeederConfig] + """The feeder to process in this work package""" + + +@dataclass +class WorkPackageConfig: + """ A data class representing the configuration for a hosting capacity work package """ + name: str + syf_config: Union[ForecastConfig, FeederConfigs] + """ + The configuration of the scenario, years, and feeders to run. Use ForecastConfig + for the same scenarios and years applied across all feeders, and the more in depth FeederConfig + if configuration varies per feeder. + """ + quality_assurance_processing: Optional[bool] = None """Whether to enable QA processing""" diff --git a/test/test_eas_client.py b/test/test_eas_client.py index 7796bc7..b534db0 100644 --- a/test/test_eas_client.py +++ b/test/test_eas_client.py @@ -16,9 +16,10 @@ from werkzeug import Response from zepben.auth import ZepbenTokenFetcher -from zepben.eas import EasClient, Study +from zepben.eas import EasClient, Study, FeederConfig, ForecastConfig, FixedTimeLoadOverride from zepben.eas.client.study import Result -from zepben.eas.client.work_package import WorkPackageConfig, TimePeriod +from zepben.eas.client.work_package import WorkPackageConfig, TimePeriod, FeederConfigs, TimePeriodLoadOverride, \ + FixedTime mock_host = ''.join(random.choices(string.ascii_lowercase, k=10)) mock_port = random.randrange(1024) @@ -179,12 +180,16 @@ def test_get_work_package_cost_estimation_no_verify_success(httpserver: HTTPServ res = eas_client.get_work_package_cost_estimation( WorkPackageConfig( "wp_name", - ["feeder"], - [1], - ["scenario"], - TimePeriod( - datetime(2022, 1, 1), - datetime(2022, 1, 2)) + ForecastConfig( + ["feeder"], + [1], + ["scenario"], + TimePeriod( + datetime(2022, 1, 1), + datetime(2022, 1, 2), + None + ) + ) ) ) httpserver.check_assertions() @@ -206,12 +211,16 @@ def test_get_work_package_cost_estimation_invalid_certificate_failure(ca: trustm eas_client.get_work_package_cost_estimation( WorkPackageConfig( "wp_name", - ["feeder"], - [1], - ["scenario"], - TimePeriod( - datetime(2022, 1, 1), - datetime(2022, 1, 2)) + ForecastConfig( + ["feeder"], + [1], + ["scenario"], + TimePeriod( + datetime(2022, 1, 1), + datetime(2022, 1, 2), + None + ) + ) ) ) @@ -230,12 +239,17 @@ def test_get_work_package_cost_estimation_valid_certificate_success(ca: trustme. res = eas_client.get_work_package_cost_estimation( WorkPackageConfig( "wp_name", - ["feeder"], - [1], - ["scenario"], - TimePeriod( - datetime(2022, 1, 1), - datetime(2022, 1, 2)) + FeederConfigs( + [FeederConfig( + "feeder", + [1], + ["scenario"], + FixedTime( + datetime(2022, 1, 1), + {"meter": FixedTimeLoadOverride(1, 2, 3, 4)} + ) + )] + ) ) ) httpserver.check_assertions() @@ -253,12 +267,16 @@ def test_run_hosting_capacity_work_package_no_verify_success(httpserver: HTTPSer res = eas_client.run_hosting_capacity_work_package( WorkPackageConfig( "wp_name", - ["feeder"], - [1], - ["scenario"], - TimePeriod( - datetime(2022, 1, 1), - datetime(2022, 1, 2)) + ForecastConfig( + ["feeder"], + [1], + ["scenario"], + TimePeriod( + datetime(2022, 1, 1), + datetime(2022, 1, 2), + None + ) + ) ) ) httpserver.check_assertions() @@ -280,12 +298,16 @@ def test_run_hosting_capacity_work_package_invalid_certificate_failure(ca: trust eas_client.run_hosting_capacity_work_package( WorkPackageConfig( "wp_name", - ["feeder"], - [1], - ["scenario"], - TimePeriod( - datetime(2022, 1, 1), - datetime(2022, 1, 2)) + ForecastConfig( + ["feeder"], + [1], + ["scenario"], + TimePeriod( + datetime(2022, 1, 1), + datetime(2022, 1, 2), + None + ) + ) ) ) @@ -304,12 +326,16 @@ def test_run_hosting_capacity_work_package_valid_certificate_success(ca: trustme res = eas_client.run_hosting_capacity_work_package( WorkPackageConfig( "wp_name", - ["feeder"], - [1], - ["scenario"], - TimePeriod( - datetime(2022, 1, 1), - datetime(2022, 1, 2)) + ForecastConfig( + ["feeder"], + [1], + ["scenario"], + TimePeriod( + datetime(2022, 1, 1), + datetime(2022, 1, 2), + {"meter1": TimePeriodLoadOverride([1.0], [2.0], [3.0], [4.0])} + ) + ) ) ) httpserver.check_assertions() @@ -684,7 +710,8 @@ def hosting_capacity_run_calibration_with_calibration_time_request_handler(reque query = " ".join(actual_body['query'].split()) assert query == "mutation runCalibration($calibrationName: String!, $calibrationTimeLocal: LocalDateTime) { runCalibration(calibrationName: $calibrationName, calibrationTimeLocal: $calibrationTimeLocal) }" - assert actual_body['variables'] == {"calibrationName": "TEST CALIBRATION", "calibrationTimeLocal": "1992-01-28T00:00:20"} + assert actual_body['variables'] == {"calibrationName": "TEST CALIBRATION", + "calibrationTimeLocal": "1992-01-28T00:00:20"} return Response(json.dumps({"result": "success"}), status=200, content_type="application/json") @@ -725,4 +752,4 @@ def test_get_hosting_capacity_calibration_sets_no_verify_success(httpserver: HTT get_hosting_capacity_calibration_sets_request_handler) res = eas_client.get_hosting_capacity_calibration_sets() httpserver.check_assertions() - assert res == ["one", "two", "three"] \ No newline at end of file + assert res == ["one", "two", "three"]