diff --git a/.idea/copyright/MPLv2.xml b/.idea/copyright/MPLv2.xml index 34ce855..10616a8 100644 --- a/.idea/copyright/MPLv2.xml +++ b/.idea/copyright/MPLv2.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/changelog.md b/changelog.md index 83a735a..36a8df1 100644 --- a/changelog.md +++ b/changelog.md @@ -12,6 +12,7 @@ * 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 +* Added basic client method to run a opendss export, query its information and get a download url for the exported model. ### 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 36da373..7fb3389 100644 --- a/src/zepben/eas/client/eas_client.py +++ b/src/zepben/eas/client/eas_client.py @@ -7,6 +7,7 @@ import warnings from asyncio import get_event_loop from hashlib import sha256 +from http import HTTPStatus from json import dumps from typing import Optional @@ -15,6 +16,7 @@ from urllib3.exceptions import InsecureRequestWarning from zepben.auth import AuthMethod, ZepbenTokenFetcher, create_token_fetcher, create_token_fetcher_managed_identity +from zepben.eas.client.opendss import OpenDssConfig, GetOpenDssModelsFilterInput, GetOpenDssModelsSortCriteriaInput 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, ForecastConfig, FeederConfigs @@ -924,3 +926,344 @@ async def async_get_hosting_capacity_calibration_sets(self): else: response = await response.text() return response + + def run_opendss_export(self, config: OpenDssConfig): + """ + Send request to run an opendss export + :param config: The OpenDssConfig for running the export + :return: The HTTP response received from the Evolve App Server after attempting to run the opendss export + """ + return get_event_loop().run_until_complete(self.async_run_opendss_export(config)) + + async def async_run_opendss_export(self, config: OpenDssConfig): + """ + Send asynchronous request to run an opendss export + :param config: The OpenDssConfig for running the export + :return: The HTTP response received from the Evolve App Server after attempting to run the opendss export + """ + with warnings.catch_warnings(): + if not self._verify_certificate: + warnings.filterwarnings("ignore", category=InsecureRequestWarning) + json = { + "query": """ + mutation createOpenDssModel($input: OpenDssModelInput!) { + createOpenDssModel(input: $input) + } + """, + "variables": { + "input": { + "modelName": config.model_name, + "isPublic": config.is_public, + "generationSpec": { + "modelOptions": { + "feeder": config.feeder, + "scenario": config.scenario, + "year": config.year + }, + "modulesConfiguration": { + "common": { + "timeZone": config.time_zone.__str__(), + **({"fixedTime": config.load_time.time.isoformat()} + if isinstance(config.load_time, FixedTime) else {}), + **({"timePeriod": { + "start": config.load_time.start_time.isoformat(), + "end": config.load_time.end_time.isoformat(), + }} if isinstance(config.load_time, TimePeriod) else {}) + }, + **({"generator": { + **({"model": { + "vmPu": config.generator_config.model.vm_pu, + "vMinPu": config.generator_config.model.vmin_pu, + "vMaxPu": config.generator_config.model.vmax_pu, + "loadModel": config.generator_config.model.load_model, + "collapseSWER": config.generator_config.model.collapse_swer, + "calibration": config.generator_config.model.calibration, + "pFactorBaseExports": config.generator_config.model.p_factor_base_exports, + "pFactorForecastPv": config.generator_config.model.p_factor_forecast_pv, + "pFactorBaseImports": config.generator_config.model.p_factor_base_imports, + "fixSinglePhaseLoads": config.generator_config.model.fix_single_phase_loads, + "maxSinglePhaseLoad": config.generator_config.model.max_single_phase_load, + "fixOverloadingConsumers": config.generator_config.model.fix_overloading_consumers, + "maxLoadTxRatio": config.generator_config.model.max_load_tx_ratio, + "maxGenTxRatio": config.generator_config.model.max_gen_tx_ratio, + "fixUndersizedServiceLines": config.generator_config.model.fix_undersized_service_lines, + "maxLoadServiceLineRatio": config.generator_config.model.max_load_service_line_ratio, + "maxLoadLvLineRatio": config.generator_config.model.max_load_lv_line_ratio, + "collapseLvNetworks": config.generator_config.model.collapse_lv_networks, + "feederScenarioAllocationStrategy": config.generator_config.model.feeder_scenario_allocation_strategy and config.generator_config.model.feeder_scenario_allocation_strategy.name, + "closedLoopVRegEnabled": config.generator_config.model.closed_loop_v_reg_enabled, + "closedLoopVRegReplaceAll": config.generator_config.model.closed_loop_v_reg_replace_all, + "closedLoopVRegSetPoint": config.generator_config.model.closed_loop_v_reg_set_point, + "closedLoopVBand": config.generator_config.model.closed_loop_v_band, + "closedLoopTimeDelay": config.generator_config.model.closed_loop_time_delay, + "closedLoopVLimit": config.generator_config.model.closed_loop_v_limit, + "defaultTapChangerTimeDelay": config.generator_config.model.default_tap_changer_time_delay, + "defaultTapChangerSetPointPu": config.generator_config.model.default_tap_changer_set_point_pu, + "defaultTapChangerBand": config.generator_config.model.default_tap_changer_band, + "splitPhaseDefaultLoadLossPercentage": config.generator_config.model.split_phase_default_load_loss_percentage, + "splitPhaseLVKV": config.generator_config.model.split_phase_lv_kv, + "swerVoltageToLineVoltage": config.generator_config.model.swer_voltage_to_line_voltage, + "loadPlacement": config.generator_config.model.load_placement and config.generator_config.model.load_placement.name, + "loadIntervalLengthHours": config.generator_config.model.load_interval_length_hours, + "meterPlacementConfig": config.generator_config.model.meter_placement_config and { + "feederHead": config.generator_config.model.meter_placement_config.feeder_head, + "distTransformers": config.generator_config.model.meter_placement_config.dist_transformers, + "switchMeterPlacementConfigs": config.generator_config.model.meter_placement_config.switch_meter_placement_configs and [ + { + "meterSwitchClass": spc.meter_switch_class and spc.meter_switch_class.name, + "namePattern": spc.name_pattern + } for spc in + config.generator_config.model.meter_placement_config.switch_meter_placement_configs + ], + "energyConsumerMeterGroup": config.generator_config.model.meter_placement_config.energy_consumer_meter_group + }, + "seed": config.generator_config.model.seed, + "defaultLoadWatts": config.generator_config.model.default_load_watts, + "defaultGenWatts": config.generator_config.model.default_gen_watts, + "defaultLoadVar": config.generator_config.model.default_load_var, + "defaultGenVar": config.generator_config.model.default_gen_var, + "transformerTapSettings": config.generator_config.model.transformer_tap_settings + }} if config.generator_config.model else {}), + **({"solve": { + "normVMinPu": config.generator_config.solve.norm_vmin_pu, + "normVMaxPu": config.generator_config.solve.norm_vmax_pu, + "emergVMinPu": config.generator_config.solve.emerg_vmin_pu, + "emergVMaxPu": config.generator_config.solve.emerg_vmax_pu, + "baseFrequency": config.generator_config.solve.base_frequency, + "voltageBases": config.generator_config.solve.voltage_bases, + "maxIter": config.generator_config.solve.max_iter, + "maxControlIter": config.generator_config.solve.max_control_iter, + "mode": config.generator_config.solve.mode and config.generator_config.solve.mode.name, + "stepSizeMinutes": config.generator_config.solve.step_size_minutes + }} if config.generator_config.solve else {}), + **({"rawResults": { + "energyMeterVoltagesRaw": config.generator_config.raw_results.energy_meter_voltages_raw, + "energyMetersRaw": config.generator_config.raw_results.energy_meters_raw, + "resultsPerMeter": config.generator_config.raw_results.results_per_meter, + "overloadsRaw": config.generator_config.raw_results.overloads_raw, + "voltageExceptionsRaw": config.generator_config.raw_results.voltage_exceptions_raw + }} if config.generator_config.raw_results else {}) + }} if config.generator_config else {}) + } + } + } + } + } + if self._verify_certificate: + sslcontext = ssl.create_default_context(cafile=self._ca_filename) + + async with self.session.post( + construct_url(protocol=self._protocol, host=self._host, port=self._port, path="/api/graphql"), + headers=self._get_request_headers(), + json=json, + ssl=sslcontext if self._verify_certificate else False + ) as response: + if response.ok: + response = await response.json() + else: + response = await response.text() + return response + + def get_paged_opendss_models( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + query_filter: Optional[GetOpenDssModelsFilterInput] = None, + query_sort: Optional[GetOpenDssModelsSortCriteriaInput] = None): + """ + Retrieve a paginated opendss export run information + :param limit: The number of opendss export runs to retrieve + :param offset: The number of opendss export runs to skip + :param query_filter: The filter to apply to the query + :param query_sort: The sorting to apply to the query + :return: The HTTP response received from the Evolve App Server after requesting opendss export run information + """ + return get_event_loop().run_until_complete( + self.async_get_paged_opendss_models(limit, offset, query_filter, query_sort)) + + async def async_get_paged_opendss_models( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + query_filter: Optional[GetOpenDssModelsFilterInput] = None, + query_sort: Optional[GetOpenDssModelsSortCriteriaInput] = None): + """ + Retrieve a paginated opendss export run information + :param limit: The number of opendss export runs to retrieve + :param offset: The number of opendss export runs to skip + :param query_filter: The filter to apply to the query + :param query_sort: The sorting to apply to the query + :return: The HTTP response received from the Evolve App Server after requesting opendss export run information + """ + with warnings.catch_warnings(): + if not self._verify_certificate: + warnings.filterwarnings("ignore", category=InsecureRequestWarning) + json = { + "query": """ + query pagedOpenDssModels($limit: Int, $offset: Long, $filter: GetOpenDssModelsFilterInput, $sort: GetOpenDssModelsSortCriteriaInput) { + pagedOpenDssModels(limit: $limit, offset: $offset, filter: $filter,sort: $sort) { + totalCount + offset, + models { + id + name + createdAt + createdBy + state + downloadUrl + isPublic + errors + generationSpec { + modelOptions{ + scenario + year + feeder + } + modulesConfiguration { + common { + timePeriod { + start + end + } + } + generator { + model { + vmPu + vMinPu + vMaxPu + loadModel + collapseSWER + calibration + pFactorBaseExports + pFactorForecastPv + pFactorBaseImports + fixSinglePhaseLoads + maxSinglePhaseLoad + fixOverloadingConsumers + maxLoadTxRatio + maxGenTxRatio + fixUndersizedServiceLines + maxLoadServiceLineRatio + maxLoadLvLineRatio + collapseLvNetworks + feederScenarioAllocationStrategy + closedLoopVRegEnabled + closedLoopVRegReplaceAll + closedLoopVRegSetPoint + closedLoopVBand + closedLoopTimeDelay + closedLoopVLimit + defaultTapChangerTimeDelay + defaultTapChangerSetPointPu + defaultTapChangerBand + splitPhaseDefaultLoadLossPercentage + splitPhaseLVKV + swerVoltageToLineVoltage + loadPlacement + loadIntervalLengthHours + meterPlacementConfig { + feederHead + distTransformers + switchMeterPlacementConfigs { + meterSwitchClass + namePattern + } + energyConsumerMeterGroup + } + seed + defaultLoadWatts + defaultGenWatts + defaultLoadVar + defaultGenVar + transformerTapSettings + } + solve { + normVMinPu + normVMaxPu + emergVMinPu + emergVMaxPu + baseFrequency + voltageBases + maxIter + maxControlIter + mode + stepSizeMinutes + } + rawResults { + energyMeterVoltagesRaw + energyMetersRaw + resultsPerMeter + overloadsRaw + voltageExceptionsRaw + } + } + } + } + } + } + } + """, + "variables": { + **({"limit": limit} if limit is not None else {}), + **({"offset": offset} if offset is not None else {}), + **({"filter": { + "name": query_filter.name, + "isPublic": query_filter.is_public, + "state": query_filter.state and [state.name for state in query_filter.state] + }} if query_filter else {}), + **({"sort": { + "name": query_sort.name and query_sort.name.name, + "createdAt": query_sort.created_at and query_sort.created_at.name, + "state": query_sort.state and query_sort.state.name, + "isPublic": query_sort.is_public and query_sort.is_public.name + }} if query_sort else {}) + } + } + if self._verify_certificate: + sslcontext = ssl.create_default_context(cafile=self._ca_filename) + + async with self.session.post( + construct_url(protocol=self._protocol, host=self._host, port=self._port, path="/api/graphql"), + headers=self._get_request_headers(), + json=json, + ssl=sslcontext if self._verify_certificate else False + ) as response: + if response.ok: + response = await response.json() + else: + response = await response.text() + return response + + def get_opendss_model_download_url(self, run_id: int): + """ + Retrieve a download url for the specified opendss export run id + :param run_id: The opendss export run ID + :return: The HTTP response received from the Evolve App Server after requesting opendss export model download url + """ + return get_event_loop().run_until_complete(self.async_get_opendss_model_download_url(run_id)) + + async def async_get_opendss_model_download_url(self, run_id: int): + """ + Retrieve a download url for the specified opendss export run id + :param run_id: The opendss export run ID + :return: The HTTP response received from the Evolve App Server after requesting opendss export model download url + """ + with warnings.catch_warnings(): + if not self._verify_certificate: + warnings.filterwarnings("ignore", category=InsecureRequestWarning) + + if self._verify_certificate: + sslcontext = ssl.create_default_context(cafile=self._ca_filename) + + async with self.session.get( + construct_url(protocol=self._protocol, host=self._host, port=self._port, + path=f"/api/opendss-model/{run_id}"), + headers=self._get_request_headers(), + ssl=sslcontext if self._verify_certificate else False, + allow_redirects=False + ) as response: + if response.status == HTTPStatus.FOUND: + response = response.headers["Location"] + else: + response = await response.text() + return response diff --git a/src/zepben/eas/client/hc_commons.py b/src/zepben/eas/client/hc_commons.py index 5055177..a94a7f7 100644 --- a/src/zepben/eas/client/hc_commons.py +++ b/src/zepben/eas/client/hc_commons.py @@ -17,19 +17,20 @@ "BASIC_RESULTS_CONFIG", ] -from zepben.eas import StoredResultsConfig, RawResultsConfig, MetricsResultsConfig, ResultsConfig +from build.lib.zepben.eas.client.work_package import ResultsConfig +from zepben.eas import StoredResultsConfig, RawResultsConfig, MetricsResultsConfig STORED_RESULTS_CONFIG_STORE_NONE = StoredResultsConfig( energy_meters_raw=False, energy_meter_voltages_raw=False, - over_loads_raw=False, + overloads_raw=False, voltage_exceptions_raw=False ) STORED_RESULTS_CONFIG_STORE_ALL = StoredResultsConfig( energy_meters_raw=True, energy_meter_voltages_raw=True, - over_loads_raw=True, + overloads_raw=True, voltage_exceptions_raw=True ) @@ -37,7 +38,7 @@ energy_meters_raw=True, energy_meter_voltages_raw=True, results_per_meter=True, - over_loads_raw=True, + overloads_raw=True, voltage_exceptions_raw=True ) @@ -45,7 +46,7 @@ energy_meters_raw=True, energy_meter_voltages_raw=True, results_per_meter=True, - over_loads_raw=True, + overloads_raw=True, voltage_exceptions_raw=True ) @@ -69,7 +70,7 @@ STANDARD_RESULTS_CONFIG = ResultsConfig( raw_config=RAW_RESULTS_CONFIG_STANDARD, metrics_config=METRICS_RESULTS_CONFIG_CALCULATE_PERFORMANCE_METRICS, - stored_results_config=StoredResultsConfig(voltage_exceptions_raw=True, over_loads_raw=True) + stored_results_config=StoredResultsConfig(voltage_exceptions_raw=True, overloads_raw=True) ) BASIC_RESULTS_CONFIG = ResultsConfig( diff --git a/src/zepben/eas/client/opendss.py b/src/zepben/eas/client/opendss.py new file mode 100644 index 0000000..ae21075 --- /dev/null +++ b/src/zepben/eas/client/opendss.py @@ -0,0 +1,61 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +from dataclasses import dataclass +from datetime import tzinfo +from enum import Enum +from typing import Union, Optional, List + +from zepben.eas.client.work_package import GeneratorConfig, TimePeriod, FixedTime + +__all__ = [ + "OpenDssConfig", + "OpenDssModelState", + "GetOpenDssModelsFilterInput", + "Order", + "GetOpenDssModelsSortCriteriaInput" +] + + +@dataclass +class OpenDssConfig: + """ A data class representing the configuration for an opendss export """ + scenario: str + year: int + feeder: str + time_zone: tzinfo + load_time: Union[TimePeriod, FixedTime] + generator_config: Optional[GeneratorConfig] = None + model_name: Optional[str] = None + is_public: Optional[bool] = None + + +class OpenDssModelState(Enum): + COULD_NOT_START = "COULD_NOT_START" + CREATION = "CREATION" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + + +@dataclass +class GetOpenDssModelsFilterInput: + """ A data class representing the filter to apply to the opendss export run paginated query """ + name: Optional[str] = None + is_public: Optional[int] = None + state: Optional[List[OpenDssModelState]] = None + + +class Order(Enum): + ASC = "ASC" + DESC = "DESC" + + +@dataclass +class GetOpenDssModelsSortCriteriaInput: + """ A data class representing the sort criteria to apply to the opendss export run paginated query """ + name: Optional[Order] = None + created_at: Optional[Order] = None + state: Optional[Order] = None + is_public: Optional[Order] = None diff --git a/src/zepben/eas/client/work_package.py b/src/zepben/eas/client/work_package.py index 9d26048..d8f9d81 100644 --- a/src/zepben/eas/client/work_package.py +++ b/src/zepben/eas/client/work_package.py @@ -69,7 +69,6 @@ class SwitchMeterPlacementConfig: @dataclass class FixedTimeLoadOverride: - load_watts: Optional[float] """ The reading to be used to override load watts @@ -95,7 +94,6 @@ class FixedTimeLoadOverride: @dataclass class TimePeriodLoadOverride: - load_watts: Optional[List[float]] """ A list of readings to be used to override load watts. diff --git a/test/test_eas_client.py b/test/test_eas_client.py index b534db0..035b713 100644 --- a/test/test_eas_client.py +++ b/test/test_eas_client.py @@ -7,7 +7,8 @@ import random import ssl import string -from datetime import datetime +from datetime import datetime, timezone, timedelta +from http import HTTPStatus from unittest import mock import pytest @@ -16,10 +17,17 @@ from werkzeug import Response from zepben.auth import ZepbenTokenFetcher -from zepben.eas import EasClient, Study, FeederConfig, ForecastConfig, FixedTimeLoadOverride +from zepben.eas import EasClient, Study, SolveConfig +from zepben.eas import FeederConfig, ForecastConfig, FixedTimeLoadOverride +from zepben.eas.client.opendss import OpenDssConfig, GetOpenDssModelsFilterInput, OpenDssModelState, \ + GetOpenDssModelsSortCriteriaInput, \ + Order from zepben.eas.client.study import Result -from zepben.eas.client.work_package import WorkPackageConfig, TimePeriod, FeederConfigs, TimePeriodLoadOverride, \ +from zepben.eas.client.work_package import FeederConfigs, TimePeriodLoadOverride, \ FixedTime +from zepben.eas.client.work_package import WorkPackageConfig, TimePeriod, GeneratorConfig, ModelConfig, \ + FeederScenarioAllocationStrategy, LoadPlacement, MeterPlacementConfig, SwitchMeterPlacementConfig, SwitchClass, \ + SolveMode, RawResultsConfig mock_host = ''.join(random.choices(string.ascii_lowercase, k=10)) mock_port = random.randrange(1024) @@ -753,3 +761,483 @@ def test_get_hosting_capacity_calibration_sets_no_verify_success(httpserver: HTT res = eas_client.get_hosting_capacity_calibration_sets() httpserver.check_assertions() assert res == ["one", "two", "three"] + + +def run_opendss_export_request_handler(request): + actual_body = json.loads(request.data.decode()) + query = " ".join(actual_body['query'].split()) + + assert query == "mutation createOpenDssModel($input: OpenDssModelInput!) { createOpenDssModel(input: $input) }" + assert actual_body['variables'] == { + "input": { + "modelName": "TEST OPENDSS MODEL 1", + "isPublic": True, + "generationSpec": { + "modelOptions": { + "feeder": "feeder1", + "scenario": "scenario1", + "year": 2024 + }, + "modulesConfiguration": { + "common": { + "timeZone": "UTC+10:00", + "timePeriod": { + "start": "2022-04-01T00:00:00", + "end": "2023-04-01T00:00:00", + } + }, + "generator": { + "model": { + "vmPu": 1.0, + "vMinPu": 0.80, + "vMaxPu": 1.15, + "loadModel": 1, + "collapseSWER": False, + "calibration": False, + "pFactorBaseExports": 0.95, + "pFactorBaseImports": 0.90, + "pFactorForecastPv": 1.0, + "fixSinglePhaseLoads": True, + "maxSinglePhaseLoad": 30000.0, + "fixOverloadingConsumers": True, + "maxLoadTxRatio": 3.0, + "maxGenTxRatio": 10.0, + "fixUndersizedServiceLines": True, + "maxLoadServiceLineRatio": 1.5, + "maxLoadLvLineRatio": 2.0, + "collapseLvNetworks": False, + "feederScenarioAllocationStrategy": "ADDITIVE", + "closedLoopVRegEnabled": True, + "closedLoopVRegReplaceAll": True, + "closedLoopVRegSetPoint": 0.985, + "closedLoopVBand": 2.0, + "closedLoopTimeDelay": 100, + "closedLoopVLimit": 1.1, + "defaultTapChangerTimeDelay": 100, + "defaultTapChangerSetPointPu": 1.0, + "defaultTapChangerBand": 2.0, + "splitPhaseDefaultLoadLossPercentage": 0.4, + "splitPhaseLVKV": 0.25, + "swerVoltageToLineVoltage": [ + [230, 400], + [240, 415], + [250, 433], + [6350, 11000], + [6400, 11000], + [12700, 22000], + [19100, 33000] + ], + "loadPlacement": "PER_USAGE_POINT", + "loadIntervalLengthHours": 0.5, + "meterPlacementConfig": { + "feederHead": True, + "distTransformers": True, + "switchMeterPlacementConfigs": [ + { + "meterSwitchClass": "LOAD_BREAK_SWITCH", + "namePattern": ".*" + } + ], + "energyConsumerMeterGroup": "meter group 1" + }, + "seed": 42, + "defaultLoadWatts": [100.0, 200.0, 300.0], + "defaultGenWatts": [50.0, 150.0, 250.0], + "defaultLoadVar": [10.0, 20.0, 30.0], + "defaultGenVar": [5.0, 15.0, 25.0], + "transformerTapSettings": "tap-3" + }, + "solve": { + "normVMinPu": 0.9, + "normVMaxPu": 1.054, + "emergVMinPu": 0.8, + "emergVMaxPu": 1.1, + "baseFrequency": 50, + "voltageBases": [0.4, 0.433, 6.6, 11.0, 22.0, 33.0, 66.0, 132.0], + "maxIter": 25, + "maxControlIter": 20, + "mode": "YEARLY", + "stepSizeMinutes": 60 + }, + "rawResults": { + "energyMeterVoltagesRaw": True, + "energyMetersRaw": True, + "resultsPerMeter": True, + "overloadsRaw": True, + "voltageExceptionsRaw": True + } + } + } + } + } + } + + return Response(json.dumps({"result": "success"}), status=200, content_type="application/json") + + +OPENDSS_CONFIG = OpenDssConfig( + scenario="scenario1", + year=2024, + feeder="feeder1", + time_zone=timezone(timedelta(hours=10)), + load_time=TimePeriod( + datetime(2022, 4, 1), + datetime(2023, 4, 1) + ), + model_name="TEST OPENDSS MODEL 1", + generator_config=GeneratorConfig( + ModelConfig( + vm_pu=1.0, + vmin_pu=0.80, + vmax_pu=1.15, + load_model=1, + collapse_swer=False, + calibration=False, + p_factor_base_exports=0.95, + p_factor_base_imports=0.90, + p_factor_forecast_pv=1.0, + fix_single_phase_loads=True, + max_single_phase_load=30000.0, + fix_overloading_consumers=True, + max_load_tx_ratio=3.0, + max_gen_tx_ratio=10.0, + fix_undersized_service_lines=True, + max_load_service_line_ratio=1.5, + max_load_lv_line_ratio=2.0, + collapse_lv_networks=False, + feeder_scenario_allocation_strategy=FeederScenarioAllocationStrategy.ADDITIVE, + closed_loop_v_reg_enabled=True, + closed_loop_v_reg_replace_all=True, + closed_loop_v_reg_set_point=0.985, + closed_loop_v_band=2.0, + closed_loop_time_delay=100, + closed_loop_v_limit=1.1, + default_tap_changer_time_delay=100, + default_tap_changer_set_point_pu=1.0, + default_tap_changer_band=2.0, + split_phase_default_load_loss_percentage=0.4, + split_phase_lv_kv=0.25, + swer_voltage_to_line_voltage=[ + [230, 400], + [240, 415], + [250, 433], + [6350, 11000], + [6400, 11000], + [12700, 22000], + [19100, 33000] + ], + load_placement=LoadPlacement.PER_USAGE_POINT, + load_interval_length_hours=0.5, + meter_placement_config=MeterPlacementConfig( + True, + True, + [SwitchMeterPlacementConfig(SwitchClass.LOAD_BREAK_SWITCH, ".*")], + "meter group 1"), + seed=42, + default_load_watts=[100.0, 200.0, 300.0], + default_gen_watts=[50.0, 150.0, 250.0], + default_load_var=[10.0, 20.0, 30.0], + default_gen_var=[5.0, 15.0, 25.0], + transformer_tap_settings="tap-3" + ), + SolveConfig( + norm_vmin_pu=0.9, + norm_vmax_pu=1.054, + emerg_vmin_pu=0.8, + emerg_vmax_pu=1.1, + base_frequency=50, + voltage_bases=[0.4, 0.433, 6.6, 11.0, 22.0, 33.0, 66.0, 132.0], + max_iter=25, + max_control_iter=20, + mode=SolveMode.YEARLY, + step_size_minutes=60 + ), + RawResultsConfig( + energy_meter_voltages_raw=True, + energy_meters_raw=True, + results_per_meter=True, + overloads_raw=True, + voltage_exceptions_raw=True + ) + ), + is_public=True) + + +def test_run_opendss_export_no_verify_success(httpserver: HTTPServer): + eas_client = EasClient( + LOCALHOST, + httpserver.port, + verify_certificate=False + ) + + httpserver.expect_oneshot_request("/api/graphql").respond_with_handler(run_opendss_export_request_handler) + res = eas_client.run_opendss_export(OPENDSS_CONFIG) + httpserver.check_assertions() + assert res == {"result": "success"} + + +def test_run_opendss_export_invalid_certificate_failure(ca: trustme.CA, httpserver: HTTPServer): + with trustme.Blob(b"invalid ca").tempfile() as ca_filename: + eas_client = EasClient( + LOCALHOST, + httpserver.port, + verify_certificate=True, + ca_filename=ca_filename + ) + + httpserver.expect_oneshot_request("/api/graphql").respond_with_json({"result": "success"}) + with pytest.raises(ssl.SSLError): + eas_client.run_opendss_export(OPENDSS_CONFIG) + + +def test_run_opendss_export_valid_certificate_success(ca: trustme.CA, httpserver: HTTPServer): + with ca.cert_pem.tempfile() as ca_filename: + eas_client = EasClient( + LOCALHOST, + httpserver.port, + verify_certificate=True, + ca_filename=ca_filename + ) + + httpserver.expect_oneshot_request("/api/graphql").respond_with_handler( + run_opendss_export_request_handler) + res = eas_client.run_opendss_export(OPENDSS_CONFIG) + httpserver.check_assertions() + assert res == {"result": "success"} + + +get_paged_opendss_models_query = """ + query pagedOpenDssModels($limit: Int, $offset: Long, $filter: GetOpenDssModelsFilterInput, $sort: GetOpenDssModelsSortCriteriaInput) { + pagedOpenDssModels(limit: $limit, offset: $offset, filter: $filter,sort: $sort) { + totalCount + offset, + models { + id + name + createdAt + createdBy + state + downloadUrl + isPublic + errors + generationSpec { + modelOptions{ + scenario + year + feeder + } + modulesConfiguration { + common { + timePeriod { + start + end + } + } + generator { + model { + vmPu + vMinPu + vMaxPu + loadModel + collapseSWER + calibration + pFactorBaseExports + pFactorForecastPv + pFactorBaseImports + fixSinglePhaseLoads + maxSinglePhaseLoad + fixOverloadingConsumers + maxLoadTxRatio + maxGenTxRatio + fixUndersizedServiceLines + maxLoadServiceLineRatio + maxLoadLvLineRatio + collapseLvNetworks + feederScenarioAllocationStrategy + closedLoopVRegEnabled + closedLoopVRegReplaceAll + closedLoopVRegSetPoint + closedLoopVBand + closedLoopTimeDelay + closedLoopVLimit + defaultTapChangerTimeDelay + defaultTapChangerSetPointPu + defaultTapChangerBand + splitPhaseDefaultLoadLossPercentage + splitPhaseLVKV + swerVoltageToLineVoltage + loadPlacement + loadIntervalLengthHours + meterPlacementConfig { + feederHead + distTransformers + switchMeterPlacementConfigs { + meterSwitchClass + namePattern + } + energyConsumerMeterGroup + } + seed + defaultLoadWatts + defaultGenWatts + defaultLoadVar + defaultGenVar + transformerTapSettings + } + solve { + normVMinPu + normVMaxPu + emergVMinPu + emergVMaxPu + baseFrequency + voltageBases + maxIter + maxControlIter + mode + stepSizeMinutes + } + rawResults { + energyMeterVoltagesRaw + energyMetersRaw + resultsPerMeter + overloadsRaw + voltageExceptionsRaw + } + } + } + } + } + } + } + """ + + +def get_paged_opendss_models_request_handler(request): + actual_body = json.loads(request.data.decode()) + query = " ".join(actual_body['query'].split()) + + assert query == " ".join(line.strip() for line in get_paged_opendss_models_query.strip().splitlines()) + assert actual_body['variables'] == { + "limit": 5, + "offset": 0, + "filter": { + "name": "TEST OPENDSS MODEL 1", + "isPublic": True, + "state": ["COMPLETED"], + }, + "sort": { + "state": "ASC", + "createdAt": None, + "name": None, + "isPublic": None + } + } + + return Response(json.dumps({"result": "success"}), status=200, content_type="application/json") + + +def test_get_paged_opendss_models_no_verify_success(httpserver: HTTPServer): + eas_client = EasClient( + LOCALHOST, + httpserver.port, + verify_certificate=False + ) + + httpserver.expect_oneshot_request("/api/graphql").respond_with_handler( + get_paged_opendss_models_request_handler) + res = eas_client.get_paged_opendss_models( + 5, 0, GetOpenDssModelsFilterInput("TEST OPENDSS MODEL 1", True, [OpenDssModelState.COMPLETED]), + GetOpenDssModelsSortCriteriaInput(state=Order.ASC)) + httpserver.check_assertions() + assert res == {"result": "success"} + + +def test_get_paged_opendss_models_invalid_certificate_failure(ca: trustme.CA, httpserver: HTTPServer): + with trustme.Blob(b"invalid ca").tempfile() as ca_filename: + eas_client = EasClient( + LOCALHOST, + httpserver.port, + verify_certificate=True, + ca_filename=ca_filename + ) + + httpserver.expect_oneshot_request("/api/graphql").respond_with_json({"result": "success"}) + with pytest.raises(ssl.SSLError): + eas_client.get_paged_opendss_models() + + +def get_paged_opendss_models_no_param_request_handler(request): + actual_body = json.loads(request.data.decode()) + query = " ".join(actual_body['query'].split()) + + assert query == " ".join(line.strip() for line in get_paged_opendss_models_query.strip().splitlines()) + assert actual_body['variables'] == {} + + return Response(json.dumps({"result": "success"}), status=200, content_type="application/json") + + +def test_get_paged_opendss_models_valid_certificate_success(ca: trustme.CA, httpserver: HTTPServer): + with ca.cert_pem.tempfile() as ca_filename: + eas_client = EasClient( + LOCALHOST, + httpserver.port, + verify_certificate=True, + ca_filename=ca_filename + ) + + httpserver.expect_oneshot_request("/api/graphql").respond_with_handler( + get_paged_opendss_models_no_param_request_handler) + res = eas_client.get_paged_opendss_models() + httpserver.check_assertions() + assert res == {"result": "success"} + + +def test_get_opendss_model_download_url_no_verify_success(httpserver: HTTPServer): + eas_client = EasClient( + LOCALHOST, + httpserver.port, + verify_certificate=False + ) + + httpserver.expect_oneshot_request("/api/opendss-model/1", method="GET").respond_with_response(Response( + status=HTTPStatus.FOUND, + headers={"Location": "https://example.com/download/1"} + )) + res = eas_client.get_opendss_model_download_url(1) + httpserver.check_assertions() + assert res == "https://example.com/download/1" + + +def test_get_opendss_model_download_url_invalid_certificate_failure(ca: trustme.CA, httpserver: HTTPServer): + with trustme.Blob(b"invalid ca").tempfile() as ca_filename: + eas_client = EasClient( + LOCALHOST, + httpserver.port, + verify_certificate=True, + ca_filename=ca_filename + ) + + httpserver.expect_oneshot_request("/api/opendss-model/1", method="GET").respond_with_response(Response( + status=HTTPStatus.FOUND, + headers={"Location": "https://example.com/download/1"} + )) + with pytest.raises(ssl.SSLError): + eas_client.get_opendss_model_download_url(1) + + +def test_get_opendss_model_download_url_valid_certificate_success(ca: trustme.CA, httpserver: HTTPServer): + with ca.cert_pem.tempfile() as ca_filename: + eas_client = EasClient( + LOCALHOST, + httpserver.port, + verify_certificate=True, + ca_filename=ca_filename + ) + + httpserver.expect_oneshot_request("/api/opendss-model/1", method="GET").respond_with_response(Response( + status=HTTPStatus.FOUND, + headers={"Location": "https://example.com/download/1"} + )) + res = eas_client.get_opendss_model_download_url(1) + httpserver.check_assertions() + assert res == "https://example.com/download/1"