From c665a553adf10d47e0bce4186963c07c2aa1efe8 Mon Sep 17 00:00:00 2001 From: Fuhong Xie Date: Wed, 3 Dec 2025 11:51:17 -0700 Subject: [PATCH 01/14] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d8c26f9b..7a3df070 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Welcome to the pydss Repository! + **PyDSS** is a high level python interface for **OpenDSS** and provides the following functionalities Documentation on installation, setup and examples can be found here https://nrel.github.io/PyDSS/index.html From 5c386642b8e02f1c520dedab352e983bbbc2ca01 Mon Sep 17 00:00:00 2001 From: Fuhong Xie Date: Wed, 3 Dec 2025 11:53:22 -0700 Subject: [PATCH 02/14] update the motor load and PV ridethrough --- src/pydss/SolveMode.py | 3 +- src/pydss/cli/run.py | 9 +- src/pydss/common.py | 1 - src/pydss/dataset_buffer.py | 1 + src/pydss/dssElement.py | 9 +- src/pydss/dssInstance.py | 51 ++- src/pydss/helics_interface.py | 44 ++- src/pydss/modes/solver_base.py | 4 +- .../pyControllers/Controllers/MotorStall.py | 336 ++++++++++++++---- .../Controllers/PvVoltageRideThru.py | 197 ++++++++-- src/pydss/pyControllers/models.py | 92 +++-- src/pydss/pyControllers/pyController.py | 2 +- src/pydss/pyDSS.py | 2 +- src/pydss/pydss_project.py | 1 + src/pydss/registry.py | 39 +- src/pydss/simulation_input_models.py | 33 ++ src/pydss/value_storage.py | 4 +- 17 files changed, 638 insertions(+), 190 deletions(-) diff --git a/src/pydss/SolveMode.py b/src/pydss/SolveMode.py index 55c3b069..4ae08157 100644 --- a/src/pydss/SolveMode.py +++ b/src/pydss/SolveMode.py @@ -10,7 +10,8 @@ def GetSolver(settings: SimulationSettingsModel, dssInstance): - logger.info('Setting solver to %s mode.', settings.project.simulation_type.value) + # logger.info('Setting solver to %s mode.', settings.project.simulation_type.value) + logger.info(f'Setting solver to {settings.project.simulation_type.value} mode.') return get_solver_from_simulation_type(settings.project) diff --git a/src/pydss/cli/run.py b/src/pydss/cli/run.py index 4df5b9e5..7096a048 100644 --- a/src/pydss/cli/run.py +++ b/src/pydss/cli/run.py @@ -5,6 +5,7 @@ from pathlib import Path import ast import sys +import os from loguru import logger import click @@ -80,6 +81,11 @@ def run(project_path, options=None, tar_project=False, zip_project=False, verbos logger.error("Logs path %s does not exist", logs_path) sys.exit(1) filename = logs_path / "pydss.log" + if settings.logging.clear_old_log_file: + logs_path = project_path / "Logs" + filename = logs_path / "pydss.log" + if os.path.exists(filename): + os.remove(filename) logger.level(console_level) if filename: @@ -92,8 +98,9 @@ def run(project_path, options=None, tar_project=False, zip_project=False, verbos if not isinstance(options, dict): logger.error("options are invalid: %s", options) sys.exit(1) - + # logger.info(f"indicator 1") project = PyDssProject.load_project(project_path, options=options, simulation_file=simulations_file) + # logger.info(f"indicator 2") project.run(tar_project=tar_project, zip_project=zip_project, dry_run=dry_run) if dry_run: diff --git a/src/pydss/common.py b/src/pydss/common.py index fd1082c6..5d0aecd4 100644 --- a/src/pydss/common.py +++ b/src/pydss/common.py @@ -37,7 +37,6 @@ class ControllerType(enum.Enum): STORAGE_CONTROLLER = "StorageController" THERMOSTATIC_LOAD_CONTROLLER = "ThermostaticLoad" XMFR_CONTROLLER = "xmfrController" - DYNAMIC_VOLTAGE_SUPPORT = "DynamicVoltageSupport" CONTROLLER_TYPES = tuple(x.value for x in ControllerType) CONFIG_EXT = ".toml" diff --git a/src/pydss/dataset_buffer.py b/src/pydss/dataset_buffer.py index 0b60602c..be2f991b 100644 --- a/src/pydss/dataset_buffer.py +++ b/src/pydss/dataset_buffer.py @@ -37,6 +37,7 @@ def __init__( max_chunk_bytes=None, attributes=None, names=None, column_ranges_per_name=None, data=None ): + if max_chunk_bytes is None: max_chunk_bytes = DEFAULT_MAX_CHUNK_BYTES self._buf_index = 0 diff --git a/src/pydss/dssElement.py b/src/pydss/dssElement.py index 377c3fa0..8396e2b6 100644 --- a/src/pydss/dssElement.py +++ b/src/pydss/dssElement.py @@ -1,5 +1,5 @@ import ast - +from loguru import logger from opendssdirect import DSSException from pydss.dssBus import dssBus @@ -60,9 +60,10 @@ def __init__(self, dssInstance): super(dssElement, self).__init__(dssInstance, name, fullName) self._Enabled = dssInstance.CktElement.Enabled() if not self._Enabled: + logger.debug(f"Element isn't defined: {fullName}") return - self._Parameters = {} + logger.debug(fullName) self._NumTerminals = dssInstance.CktElement.NumTerminals() self._NumConductors = dssInstance.CktElement.NumConductors() @@ -135,8 +136,12 @@ def DataLength(self, VarName): return 0, None def GetValue(self, VarName, convert=False): + if self._dssInstance.Element.Name() != self._FullName: self.SetActiveObject() + fullName = self._dssInstance.Element.Name() + # logger.debug("123456789123456789123456789123456789123456789123456789") + # logger.debug(fullName) if VarName in self._Variables: VarValue = self.GetVariable(VarName, convert=convert) elif VarName in self._Parameters: diff --git a/src/pydss/dssInstance.py b/src/pydss/dssInstance.py index 3a08d6c5..86699dab 100644 --- a/src/pydss/dssInstance.py +++ b/src/pydss/dssInstance.py @@ -156,7 +156,11 @@ def _compile_model(self): def _CreateControllers(self, ControllerDict): self._pyControls = {} self._pyControls_types = {} + # logger.info(f'self._dssObjects -> {self._dssObjects}') + # os.system("PAUSE") for ControllerType, ElementsDict in ControllerDict.items(): + # logger.info(f'ControllerType -> {ControllerType}, ElementsDict -> {ElementsDict}') + # os.system("PAUSE") for ElmName, SettingsDict in ElementsDict.items(): Controller = pyControllers.pyController.Create(ElmName, ControllerType, SettingsDict, self._dssObjects, self._dssInstance, self._dssSolver) @@ -205,7 +209,8 @@ def CreateDssObjects(dssBuses): InvalidSelection = ['Settings', 'ActiveClass', 'dss', 'utils', 'PDElements', 'XYCurves', 'Bus', 'Properties'] # TODO: this causes a segmentation fault. Aadil says it may not be needed. #self._dssObjectsByClass={'LoadShape': self._get_relavent_object_dict('LoadShape')} - + # logger.info(f"dss.Circuit.AllElementNames() -> {dss.Circuit.AllElementNames()}") + # os.system("PAUSE") for ElmName in dss.Circuit.AllElementNames(): Class, Name = ElmName.split('.', 1) ClassName = Class + 's' @@ -246,6 +251,10 @@ def RunStep(self, step, updateObjects=None): if self._settings.profiles.use_profile_manager: self.profileStore.update() + if self._settings.helics.co_simulation_mode: + # self._heilcs_interface.updateHelicsPublications() + self._increment_flag, helics_time = self._heilcs_interface.request_time_increment() + if self._settings.helics.co_simulation_mode: self._heilcs_interface.updateHelicsSubscriptions() else: @@ -253,6 +262,17 @@ def RunStep(self, step, updateObjects=None): for object, params in updateObjects.items(): cl, name = object.split('.') self._Modifier.Edit_Element(cl, name, params) + + # pydss_update_agian = "y" + # while pydss_update_agian == "y": + # if self._settings.helics.co_simulation_mode: + # self._heilcs_interface.updateHelicsSubscriptions() + # else: + # if updateObjects: + # for object, params in updateObjects.items(): + # cl, name = object.split('.') + # self._Modifier.Edit_Element(cl, name, params) + # pydss_update_agian = input('enter your pydss update command: ') # run simulation time step and get results time_step_has_converged = True @@ -263,6 +283,7 @@ def RunStep(self, step, updateObjects=None): for i in range(self._settings.project.max_control_iterations): has_converged, error = self._update_controllers(priority, step, i, UpdateResults=False) logger.debug('Control Loop {} convergence error: {}'.format(priority, error)) + logger.debug('Control Loop {} convergence @step {} '.format(priority, step)) if has_converged: priority_has_converged = True break @@ -297,8 +318,9 @@ def RunStep(self, step, updateObjects=None): if self._settings.helics.co_simulation_mode: self._heilcs_interface.updateHelicsPublications() - self._increment_flag, helics_time = self._heilcs_interface.request_time_increment() - + # if step < 1: + # self._increment_flag, helics_time = self._heilcs_interface.request_time_increment_2() + # os.system("PAUSE") return time_step_has_converged def _HandleConvergenceErrorChecks(self, step, error): @@ -316,7 +338,7 @@ def _HandleOpenDSSConvergenceErrorChecks(self, step): self._convergenceErrorsOpenDSS += 1 if self._maxConvergenceErrorCount is not None and self._convergenceErrorsOpenDSS > self._maxConvergenceErrorCount: - logger.error("Exceeded OpenDSS convergence error count threshold at step %s", step) + logger.error(f"Exceeded OpenDSS convergence error count threshold at step {step}") raise OpenDssConvergenceErrorCountExceeded(f"{self._convergenceErrorsOpenDSS} errors occurred") def DryRunSimulation(self, project, scenario): @@ -357,7 +379,7 @@ def RunSimulation(self, project, scenario, MC_scenario_number=None): dss.Solution.Convergence(self._settings.project.error_tolerance) logger.info('Running simulation from {} till {}.'.format(sTime, eTime)) logger.info('Simulation time step {}.'.format(Steps)) - logger.info("Set OpenDSS convergence to %s", dss.Solution.Convergence()) + logger.info(f"Set OpenDSS convergence to {dss.Solution.Convergence()}") logger.info('Max convergence error count {}.'.format(self._maxConvergenceErrorCount)) logger.info("initializing store") self.ResultContainer.InitializeDataStore(project.hdf_store, Steps, MC_scenario_number) @@ -388,12 +410,21 @@ def RunSimulation(self, project, scenario, MC_scenario_number=None): within_range = self._simulation_range.is_within_range(self._dssSolver.GetDateTime()) if within_range: pydss_has_converged = self.RunStep(step) + # logger.info(f'Finish simulation: step {step} 1') + # pydss_has_converged = self.RunStep(step) + # logger.info(f'Finish simulation: step {step} 2') + # pydss_has_converged = self.RunStep(step) + # logger.info(f'Finish simulation: step {step} 3') opendss_has_converged = dss.Solution.Converged() if not opendss_has_converged: - logger.error("OpenDSS did not converge at step=%s pydss_converged=%s", - step, pydss_has_converged) + logger.error(f"OpenDSS did not converge at step={step} pydss_converged={pydss_has_converged}") self._HandleOpenDSSConvergenceErrorChecks(step) - has_converged = pydss_has_converged and opendss_has_converged + + if pydss_has_converged: + has_converged = True + else: + has_converged = pydss_has_converged and opendss_has_converged + if step == 0 and self.ResultContainer is not None: size = make_human_readable_size(self.ResultContainer.max_num_bytes()) logger.info('Storage requirement estimation: %s, estimated based on first time step run.', size) @@ -401,6 +432,10 @@ def RunSimulation(self, project, scenario, MC_scenario_number=None): step, has_converged = self._RunPostProcessors(step, Steps, postprocessors) if self._increment_flag: step += 1 + # if step < 1: + # step += 1 + # else: + # step += 2 # In the case of a frequency sweep, the code updates results at each frequency. # Doing so again would cause a duplicate result. diff --git a/src/pydss/helics_interface.py b/src/pydss/helics_interface.py index 5a5faaa1..41adedb8 100644 --- a/src/pydss/helics_interface.py +++ b/src/pydss/helics_interface.py @@ -4,7 +4,7 @@ import helics import os import re - +import pandas as pd from loguru import logger from pydss.simulation_input_models import SimulationSettingsModel @@ -208,12 +208,13 @@ def _create_helics_federate(self): IP = self._settings.helics.broker Port = self._settings.helics.broker_port logger.info("Connecting to broker @ {}".format(f"{IP}:{Port}" if Port else IP)) + logger.info(f"Connecting to broker @ {self._settings.helics}") if self._settings.helics.broker: helics.helicsFederateInfoSetBroker(self.fedinfo, str(self._settings.helics.broker)) if self._settings.helics.broker_port: helics.helicsFederateInfoSetBrokerPort(self.fedinfo, self._settings.helics.broker_port) helics.helicsFederateInfoSetTimeProperty(self.fedinfo, helics.helics_property_time_delta, - self._settings.helics.time_delta) + self._settings.helics.time_delta/3) helics.helicsFederateInfoSetIntegerProperty(self.fedinfo, helics.helics_property_int_log_level, self._settings.helics.logging_level) helics.helicsFederateInfoSetIntegerProperty(self.fedinfo, helics.helics_property_int_max_iterations, @@ -261,8 +262,9 @@ def updateHelicsSubscriptions(self): value = helics.helicsInputGetInteger(subscription.sub) if value and value != 0: - if value > 1e6 or value < -1e6: - value = 1.0 + logger.info(f"value is {value}") + if value > 1e6 or value < -1e6 or pd.isna(value): + value = 1.0 value = value * subscription.multiplier subscription.object.SetParameter(subscription.property, value) @@ -337,7 +339,39 @@ def updateHelicsPublications(self): def request_time_increment(self): error = sum([abs(sub.states[0] - sub.states[1]) for sub in self.subscriptions.subscriptions]) - r_seconds = self._dss_solver.GetTotalSeconds() #- self._dss_solver.GetStepResolutionSeconds() + # r_seconds = self._dss_solver.GetTotalSeconds() # parallel + r_seconds = self._dss_solver.GetTotalSeconds() + self._dss_solver.GetStepResolutionSeconds() # transmission priority + # if r_seconds > 0: + # r_seconds = self._dss_solver.GetTotalSeconds() + self._dss_solver.GetStepResolutionSeconds() + # logger.info(f"self._dss_solver.GetTotalSeconds(): {self._dss_solver.GetTotalSeconds()}, self._dss_solver.GetStepResolutionSeconds(): {self._dss_solver.GetStepResolutionSeconds()}") + # os.system('PAUSE') + if not self._settings.helics.iterative_mode: + while self.c_seconds < r_seconds: + self.c_seconds = helics.helicsFederateRequestTime(self._federate, r_seconds) + logger.info('Time requested: {} - time granted: {} '.format(r_seconds, self.c_seconds)) + return True, self.c_seconds + else: + + self.c_seconds, iteration_state = helics.helicsFederateRequestTimeIterative( + self._federate, + r_seconds, + helics.helics_iteration_request_iterate_if_needed + ) + + logger.info('Time requested: {} - time granted: {} error: {} it: {}'.format( + r_seconds, self.c_seconds, error, self.itr)) + if error > -1 and self.itr < self._co_convergance_max_iterations - 1: + self.itr += 1 + return False, self.c_seconds + else: + self.itr = 0 + return True, self.c_seconds + + def request_time_increment_2(self): + error = sum([abs(sub.states[0] - sub.states[1]) for sub in self.subscriptions.subscriptions]) + r_seconds = self._dss_solver.GetTotalSeconds() + self._dss_solver.GetStepResolutionSeconds()/2 + # logger.info('Time requested: {} - self._dss_solver.GetStepResolutionSeconds(): {} '.format(r_seconds, self._dss_solver.GetStepResolutionSeconds())) + # os.system('PAUSE') if not self._settings.helics.iterative_mode: while self.c_seconds < r_seconds: self.c_seconds = helics.helicsFederateRequestTime(self._federate, r_seconds) diff --git a/src/pydss/modes/solver_base.py b/src/pydss/modes/solver_base.py index df8168b1..880451b6 100644 --- a/src/pydss/modes/solver_base.py +++ b/src/pydss/modes/solver_base.py @@ -22,7 +22,7 @@ def __init__(self, dssInstance, settings: ProjectModel): StartDay = time_offset_days StartTimeMin = time_offset_seconds / 60.0 - sStepResolution = settings.step_resolution_sec + sStepResolution = settings.step_resolution_sec/3 self.StartDay = self._StartTime.timetuple().tm_yday self.EndDay = self._EndTime.timetuple().tm_yday @@ -36,7 +36,7 @@ def __init__(self, dssInstance, settings: ProjectModel): #self._dssSolution.DblHour() self.reSolve() - logger.info("%s solver setup complete", settings.simulation_type) + logger.info(f"{settings.simulation_type} solver setup complete") def setFrequency(self, frequency): self._dssSolution.Frequency(frequency) diff --git a/src/pydss/pyControllers/Controllers/MotorStall.py b/src/pydss/pyControllers/Controllers/MotorStall.py index ba8c77db..2b768cec 100644 --- a/src/pydss/pyControllers/Controllers/MotorStall.py +++ b/src/pydss/pyControllers/Controllers/MotorStall.py @@ -3,6 +3,9 @@ import scipy.signal as signal import numpy as np import math +import os +from loguru import logger +import random from pydss.pyControllers.models import MotorStallSettings from pydss.pyControllers.pyControllerAbstract import ControllerAbstract @@ -19,34 +22,61 @@ def __init__(self, motor_obj, settings, dss_instance, elm_object_list, dss_solve self._controlled_element = motor_obj self._settings = MotorStallSettings(**settings) self._dss_solver = dss_solver - self.mode = 3 + self.mode = 1 self.model_mode = self._controlled_element.SetParameter('model', self.mode) # 3 - motor, 1 - Standard constant P+jQ + self.load_mult = dss_instance.Solution.LoadMult() self._controlled_element.SetParameter('vminpu', 0.0) self.kw_rated = self._controlled_element.GetParameter('kw') self.kvar_rated = self._controlled_element.GetParameter('kvar') - self.kva_rated = (self.kw_rated**2 + self.kvar_rated**2)**0.5 + self.rated_pf = self._settings.rated_pf + self.comp_lf = self._settings.comp_lf + # self.kva_rated = self.kw_rated / self.comp_lf + self.kva_rated = math.sqrt(self.kw_rated*self.kw_rated + self.kvar_rated*self.kvar_rated) self.stall_time_start = 0 self.stall = False + self.stall_counting = False + + self.rstrt_time_start = 0 + self.rstrt = True + self.rstrt_counting = False + + self.uv_time_start = 0 + self.uv_trip = False + self.uv_counting = False + self.f_uvr = self._settings.f_uvr + self.uv_tr1 = self._settings.uv_tr1 + self.t_tr1 = self._settings.t_tr1 + + self.vc_1off = self._settings.vc_1off + self.vc_2off = self._settings.vc_2off + self.vc_1on = self._settings.vc_1on + self.vc_2on = self._settings.vc_2on self.r_stall_pu = self._settings.r_stall_pu + self.x_stall_pu = self._settings.x_stall_pu + + self.va_base = 100 * 1e6 self.kvbase = self._controlled_element.sBus[0].GetVariable("kVBase") - self.i_base = 1e3 * self.kw_rated / 1e3 * self.kvbase + self.i_base = self.kva_rated / self.kvbase self.dt = dss_solver.GetStepResolutionSeconds() - self.h = signal.TransferFunction([1], [self._settings.t_th, 1]) - self.r = signal.TransferFunction([1], [self._settings.t_restart, 1]) - - self.u = [0, 0] - self.t_arr = [0, self.dt] - self.x = 0 - - self.i2r = 0 - - self.p_stall = 0 - self.q_stall = 0 + self.t_th = self._settings.t_th + self.i2r_rstr_prev = 1*1*self.r_stall_pu + self.temp_rstr_prev = self.i2r_rstr_prev + self.i2r_nonrstr_prev = 1*1*self.r_stall_pu + self.temp_nonrstr_prev = self.i2r_nonrstr_prev + + self.voltage_prev = 1.0 + self.i2r_rstr = 0 + self.temp_rstr = self.temp_rstr_prev + self.i2r_nonrstr = 0 + self.temp_nonrstr = self.temp_nonrstr_prev + self.trip_rstr = False + self.trip_nonrstr = False + self.thermal_threshold = self._settings.t_th1t + (self._settings.t_th2t-self._settings.t_th1t)*random.random() return @@ -61,74 +91,234 @@ def debugInfo(self): def Update(self, Priority, time, update_results): self.t = self._dss_solver.GetTotalSeconds() - if self.i_base: - self.current_pu = self._controlled_element.GetVariable('CurrentsMagAng')[0] / self.i_base + # logger.info(f"self.t: {self.t}") + # logger.info(f"self._controlled_element: {self._controlled_element}") + # logger.info(f"{self.name} - {self.kw_rated} - {self.kvar_rated} - {self.kvbase} - {self.i_base}") + if self.i_base: + self.p = self._controlled_element.GetVariable('Powers')[0] + self._controlled_element.GetVariable('Powers')[2] + self.q = self._controlled_element.GetVariable('Powers')[1] + self._controlled_element.GetVariable('Powers')[3] self.voltage = self._controlled_element.GetVariable('VoltagesMagAng')[0] - self.p = self._controlled_element.GetVariable('Powers')[0] - self.q = self._controlled_element.GetVariable('Powers')[1] self.voltage_pu = self._controlled_element.sBus[0].GetVariable("puVmagAngle")[0] + # logger.info(f"CurrentsMagAng: {self._controlled_element.GetVariable('CurrentsMagAng')}") + # logger.info(f"voltage_pu: {self.voltage_pu}") + # logger.info(f"Powers: {self._controlled_element.GetVariable('Powers')}") + # logger.info(f"self.kw_rated: {self.kw_rated}") + # logger.info(f"self.kvar_rated: {self.kvar_rated}") - if Priority == 0: - - i2r = self.current_pu ** 2 * self.r_stall_pu - self.i2r = max(self.i2r, i2r) - self.t = np.array([self.t_arr [-1], self.t]) - self.u = np.array([self.u[-1], self.i2r]) - tout, yout, xout = signal.lsim(self.h, self.u, self.t_arr, self.x) - self.x = xout[-1] - i2r_calc = yout[-1] / 50.0 - + comp_lf = self.comp_lf # self.p / self.kw_rated + comp_pf = self.rated_pf # self.p / (self.p**2 + self.q**2)**0.5 - comp_lf = self.p / self.kw_rated - comp_pf = 0.75 #self.p / (self.p**2 + self.q**2)**0.5 - - v_stall_adj = self._settings.v_stall*(1 + self._settings.lf_adj * (comp_lf-1)) - v_break_adj = self._settings.v_break*(1 + self._settings.lf_adj * (comp_lf-1)) + v_stall_adj = self._settings.v_stall*(1 + self._settings.lf_adj * (comp_lf-1)) + v_break_adj = self._settings.v_break*(1 + self._settings.lf_adj * (comp_lf-1)) + # logger.info(f"v_stall_adj: {v_stall_adj}") + # logger.info(f"v_break_adj: {v_break_adj}") + if Priority == 0: + ## stall and restart time clock + if self.voltage_pu < v_stall_adj and not self.stall: + if self.stall_counting: + self.stall_time = self._dss_solver.GetTotalSeconds() - self.stall_time_start + if self.stall_time > self._settings.t_stall and not self.stall: + self.stall = True + self.rstrt = False + else: + self.stall_time_start = self._dss_solver.GetTotalSeconds() + self.stall_counting = True + else: + self.stall_counting = False + if self.voltage_pu > self._settings.v_rstrt and not self.rstrt: + if self.rstrt_counting: + self.rstrt_time = self._dss_solver.GetTotalSeconds() - self.rstrt_time_start + if self.rstrt_time > self._settings.t_restart: + self.rstrt = True + else: + self.rstrt_time_start = self._dss_solver.GetTotalSeconds() + self.rstrt_counting = True + else: + self.rstrt_counting = False + ## uv trip + if self.voltage_pu < self.uv_tr1 and not self.uv_trip: + if self.uv_counting: + self.uv_time = self._dss_solver.GetTotalSeconds() - self.uv_time_start + if self.uv_time > self.t_tr1 and not self.uv_trip: + self.uv_trip = True + self.uv_counting = False + else: + self.uv_time_start = self._dss_solver.GetTotalSeconds() + self.uv_counting = True + if self.uv_trip: + Kthuv = 1.0 - self.f_uvr + else: + Kthuv = 1.0 + # logger.info(f"Kthuv: {Kthuv}") + + ## v contactor trip + if self.voltage_prev <= self.voltage_pu: + ## reconnect + if self.voltage_pu > self.vc_1on: + Kthc = 1.0 + elif self.voltage_pu < self.vc_2on: + Kthc = 0.0 + else: + Kthc = (self.voltage_pu - self.vc_2on)/(self.vc_1on - self.vc_2on) + else: + ## trip + if self.voltage_pu > self.vc_1off: + Kthc = 1.0 + elif self.voltage_pu < self.vc_2off: + Kthc = 0.0 + else: + Kthc = (self.voltage_pu - self.vc_2off)/(self.vc_1off - self.vc_2off) + # logger.info(f"Kthc: {Kthc}") + p0 = 1 - self._settings.k_p1 * (1-v_break_adj)**self._settings.n_p1 q0 = ((1 - comp_pf**2)**0.5 / comp_pf)-self._settings.k_q1*(1-v_break_adj)**self._settings.n_q1 - - p = self.p / self.kw_rated - q = self.q / self.kvar_rated - - if self.voltage_pu > v_break_adj and not self.stall: - p = p0 + self._settings.k_p1*(self.voltage_pu-v_break_adj)**self._settings.n_p1 - q = q0 + self._settings.k_q1*(self.voltage_pu-v_break_adj)**self._settings.n_q1 - self._controlled_element.SetParameter('kw', self.kw_rated * p) - self._controlled_element.SetParameter('kvar', self.kvar_rated * q) - - elif self.voltage_pu <= v_break_adj and not self.stall: - p = p0 + self._settings.k_p2 * (v_break_adj - self.voltage_pu)**self._settings.n_p2 - q = q0 + self._settings.k_q2 * (v_break_adj - self.voltage_pu)**self._settings.n_q2 - - self._controlled_element.SetParameter('kw', self.kw_rated * p ) - self._controlled_element.SetParameter('kvar', self.kvar_rated * q) + # logger.info(f"p0: {p0}") + # logger.info(f"q0: {q0}") + logger.info(f"self.voltage_pu: {self.voltage_pu}") - if self.voltage_pu < v_stall_adj and not self.stall: - self.p_stall = self._controlled_element.GetParameter('kw') - self.q_stall = self._controlled_element.GetParameter('kvar') - self.stall_time_start = self._dss_solver.GetTotalSeconds() - self.stall = True - - if self.voltage_pu > v_stall_adj and self.stall: - self.stall_time = self._dss_solver.GetTotalSeconds() - self.stall_time_start - if self.stall_time < self._settings.t_stall: - self._controlled_element.SetParameter('kw', self.p_stall) - self._controlled_element.SetParameter('kvar', self.q_stall) - else: - if i2r_calc < self._settings.t_th1t: - Kth = 1 - elif i2r_calc > self._settings.t_th2t: - Kth = 0 + # the operation model + if self.stall: + # stage III + p_stall = self.voltage_pu ** 2 * self.r_stall_pu / (self.r_stall_pu ** 2 + self.x_stall_pu ** 2) + q_stall = self.voltage_pu ** 2 * self.x_stall_pu / (self.r_stall_pu ** 2 + self.x_stall_pu ** 2) + # logger.info(f"p_stall: {p_stall}") + # logger.info(f"q_stall: {q_stall}") + if self.rstrt: + logger.info(f"Stage III: Motor stall and {self._settings.f_rst} of load is restarted") + if self.voltage_pu > v_break_adj: + p = p0 + self._settings.k_p1*(self.voltage_pu-v_break_adj)**self._settings.n_p1 + q = q0 + self._settings.k_q1*(self.voltage_pu-v_break_adj)**self._settings.n_q1 else: - m = 1 / (self._settings.t_th1t - self._settings.t_th2t) - c = - m * self._settings.t_th2t - Kth = m * i2r_calc + c + p = p0 + self._settings.k_p2 * (v_break_adj - self.voltage_pu)**self._settings.n_p2 + q = q0 + self._settings.k_q2 * (v_break_adj - self.voltage_pu)**self._settings.n_q2 + # self._controlled_element.SetParameter('kw', Kth * self.kw_rated * p_rstrt * self._settings.f_rst + Kth * p * self.kva_rated * (1-self._settings.f_rst)) + # self._controlled_element.SetParameter('kvar', Kth * self.kvar_rated * q_rstrt * self._settings.f_rst + Kth * q * self.kva_rated * (1-self._settings.f_rst)) + current_pu_nonrstrt = self.voltage_pu / math.sqrt(self.r_stall_pu ** 2 + self.x_stall_pu ** 2) + current_pu_rstrt = p / self.voltage_pu + self.i2r_rstr = current_pu_rstrt * current_pu_rstrt * self.r_stall_pu + self.i2r_nonrstr = current_pu_nonrstrt * current_pu_nonrstrt * self.r_stall_pu + self.temp_rstr = (self.dt*(self.i2r_rstr+self.i2r_rstr_prev)-(self.dt-2*self.t_th)*self.temp_rstr_prev)/(2*self.t_th+self.dt) + self.temp_nonrstr = (self.dt*(self.i2r_nonrstr+self.i2r_nonrstr_prev)-(self.dt-2*self.t_th)*self.temp_nonrstr_prev)/(2*self.t_th+self.dt) + p_rstrt = p * self._settings.f_rst + q_rstrt = q * self._settings.f_rst + p_nonrstrt = p_stall * (1 - self._settings.f_rst) + q_nonrstrt = q_stall * (1 - self._settings.f_rst) + # logger.info(f"p_rstrt: {p_rstrt}") + # logger.info(f"q_rstrt: {q_rstrt}") + # logger.info(f"p_nonrstrt: {p_nonrstrt}") + # logger.info(f"q_nonrstrt: {q_nonrstrt}") + # logger.info(f"current_pu_rstrt: {current_pu_rstrt}") + # logger.info(f"current_pu_nonrstrt: {current_pu_nonrstrt}") + # logger.info(f"self.i2r_nonrstr: {self.i2r_nonrstr}") + # logger.info(f"self.temp_nonrstr: {self.temp_nonrstr}") + else: + logger.info(f"Stage III: Motor stall and not restarted load") + # self._controlled_element.SetParameter('kw', Kth * p_stall * self.kva_rated) + # self._controlled_element.SetParameter('kvar', Kth * q_stall * self.kva_rated) + p_rstrt = p_stall * self._settings.f_rst + q_rstrt = q_stall * self._settings.f_rst + p_nonrstrt = p_stall * (1 - self._settings.f_rst) + q_nonrstrt = q_stall * (1 - self._settings.f_rst) + current_pu = self.voltage_pu / math.sqrt(self.r_stall_pu ** 2 + self.x_stall_pu ** 2) + self.i2r_rstr = current_pu * current_pu * self.r_stall_pu + self.i2r_nonrstr = current_pu * current_pu * self.r_stall_pu + self.temp_rstr = (self.dt*(self.i2r_rstr+self.i2r_rstr_prev)-(self.dt-2*self.t_th)*self.temp_rstr_prev)/(2*self.t_th+self.dt) + self.temp_nonrstr = (self.dt*(self.i2r_nonrstr+self.i2r_nonrstr_prev)-(self.dt-2*self.t_th)*self.temp_nonrstr_prev)/(2*self.t_th+self.dt) + # logger.info(f"p_rstrt: {p_rstrt}") + # logger.info(f"q_rstrt: {q_rstrt}") + # logger.info(f"p_nonrstrt: {p_nonrstrt}") + # logger.info(f"q_nonrstrt: {q_nonrstrt}") + # logger.info(f"current_pu: {current_pu}") + # logger.info(f"self.i2r_rstr: {self.i2r_rstr}") + # logger.info(f"self.temp_rstr: {self.temp_rstr}") + else: + if self.voltage_pu > v_break_adj: + # stage I + logger.info(f"Stage I: normal operation") + p = p0 + self._settings.k_p1*(self.voltage_pu-v_break_adj)**self._settings.n_p1 + q = q0 + self._settings.k_q1*(self.voltage_pu-v_break_adj)**self._settings.n_q1 + # logger.info(f"p1: {p}") + # logger.info(f"q1: {q}") + else: + # stage II or before stall + logger.info(f"Stage II: Motor voltage below the break down voltage") + p = p0 + self._settings.k_p2 * (v_break_adj - self.voltage_pu)**self._settings.n_p2 + q = q0 + self._settings.k_q2 * (v_break_adj - self.voltage_pu)**self._settings.n_q2 + # logger.info(f"p2: {p}") + # logger.info(f"q2: {q}") + current_pu = p / self.voltage_pu + p_rstrt = p * self._settings.f_rst + p_nonrstrt = p * (1 - self._settings.f_rst) + q_rstrt = q * self._settings.f_rst + q_nonrstrt = q * (1 - self._settings.f_rst) + self.i2r_rstr = current_pu * current_pu * self.r_stall_pu + self.i2r_nonrstr = current_pu * current_pu * self.r_stall_pu + self.temp_rstr = (self.dt*(self.i2r_rstr+self.i2r_rstr_prev)-(self.dt-2*self.t_th)*self.temp_rstr_prev)/(2*self.t_th+self.dt) + self.temp_nonrstr = (self.dt*(self.i2r_nonrstr+self.i2r_nonrstr_prev)-(self.dt-2*self.t_th)*self.temp_nonrstr_prev)/(2*self.t_th+self.dt) + # logger.info(f"p_rstrt: {p_rstrt}") + # logger.info(f"q_rstrt: {q_rstrt}") + # logger.info(f"p_nonrstrt: {p_nonrstrt}") + # logger.info(f"q_nonrstrt: {q_nonrstrt}") + # logger.info(f"current_pu: {current_pu}") + # logger.info(f"self.i2r_rstr: {self.i2r_rstr}") + # logger.info(f"self.temp_rstr: {self.temp_rstr}") - self._controlled_element.SetParameter('kw', self.p_stall * Kth ) - self._controlled_element.SetParameter('kvar', self.q_stall * Kth ) - - self.model_mode_old = self.model_mode - + # thermal protection + if self.stall: + if self.trip_rstr: + # logger.info(f"Motor is tripped") + Kth_rstr = 0 + ## for single bus system + elif self.temp_rstr > self._settings.t_th2t: + self.trip_rstr = True + Kth_rstr = 0 + elif self.temp_rstr > self._settings.t_th1t and self.temp_rstr <= self._settings.t_th2t: + Kth_rstr = 1 - (self.temp_rstr - self._settings.t_th1t)/(self._settings.t_th2t - self._settings.t_th1t) + # ## for multiple bus system + # elif self.temp_rstr > self.thermal_threshold: + # self.trip_rstr = True + # Kth_rstr = 0 + else: + Kth_rstr = 1 + + if self.trip_nonrstr: + # logger.info(f"Motor is tripped") + Kth_nonrstr = 0 + ## for single bus system + elif self.temp_nonrstr > self._settings.t_th2t: + self.trip_nonrstr = True + Kth_nonrstr = 0 + elif self.temp_nonrstr > self._settings.t_th1t and self.temp_nonrstr <= self._settings.t_th2t: + Kth_nonrstr = 1 - (self.temp_nonrstr - self._settings.t_th1t)/(self._settings.t_th2t - self._settings.t_th1t) + # # for multiple bus system + # elif self.temp_nonrstr > self.thermal_threshold: + # self.trip_nonrstr = True + # Kth_nonrstr = 0 + else: + Kth_nonrstr = 1 + else: + Kth_rstr = 1 + Kth_nonrstr = 1 + + # logger.info(f"Kth_rstr: {Kth_rstr}") + # logger.info(f"Kth_nonrstr: {Kth_nonrstr}") + + pset = (Kth_rstr*p_rstrt + Kth_nonrstr*p_nonrstrt) * self.kw_rated + qset = (Kth_rstr*q_rstrt + Kth_nonrstr*q_nonrstrt) * self.kw_rated + if self.stall: + qset = (Kth_rstr*q_rstrt + Kth_nonrstr*q_nonrstrt) * self.kw_rated + # logger.info(f"pset: {pset}") + # logger.info(f"qset: {qset}") + + self._controlled_element.SetParameter('kw', Kthc * Kthuv * pset ) + self._controlled_element.SetParameter('kvar', Kthc * Kthuv * qset ) + # os.system("PAUSE") + + self.voltage_prev = self.voltage_pu + self.temp_rstr_prev = self.temp_rstr + self.i2r_rstr_prev = self.i2r_rstr + self.temp_nonrstr_prev = self.temp_nonrstr + self.i2r_nonrstr_prev = self.i2r_nonrstr return 0 diff --git a/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py b/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py index ca285a6d..dcb16b32 100644 --- a/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py +++ b/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py @@ -2,6 +2,9 @@ from shapely.ops import triangulate, unary_union import datetime import math +import random +from loguru import logger +import os from pydss.pyControllers.pyControllerAbstract import ControllerAbstract from pydss.pyControllers.models import PvVoltageRideThruModel @@ -51,13 +54,39 @@ def __init__(self, pv_object, settings, dss_instance, elm_object_list, dss_solve # Initializing the model #pv_object.SetParameter('kvar', 0) #pv_object.SetParameter('kva', self.model.kva) + self.dt = dss_solver.GetStepResolutionSeconds() self._p_rated = float(pv_object.GetParameter('kW')) + self._pf_rated = float(pv_object.GetParameter('pf')) + self._phase_rated = float(pv_object.GetParameter('Phases')) + + logger.info(f"{self._name} -> _p_rated: {self._p_rated }, _pf_rated: {self._pf_rated}, _phase_rated: {self._phase_rated}, dt: {self.dt}") + # os.system("PAUSE") + + self.t_rv = 0.02 + self.t_v = 0.02 + self.t_g = 0.02 + self.rrpwr = 2 + self.Imax = 1.2 + self.ul0 = 0.44 + self.ul1 = 0.49 + self.uh0 = 1.2 + self.uh1 = 1.15 + self.ul = self.ul0 + (self.ul1-self.ul0)*random.random() + self.uh = self.uh1 + (self.uh0-self.uh1)*random.random() + self.u_in_prev = 1.0 + self.ut_filt_prev = 1.0 + self.Vtrip_ctrl_prev = 1.0 + self.Vmult_prev = 1.0 + self.Ip_out_prev = 1.0 + self.Iq_out_prev = 0.0 + self.Ip_out_filt_prev = 1.0 + self.Iq_out_filt_prev = 0.0 # MISC settings self._trip_deadtime_sec = self.model.reconnect_deadtime_sec self._time_to_p_max_sec = self.model.reconnect_pmax_time_sec #self._p_rated = self.model.max_kw - self._voltage_calc_mode = self.model.voltage_calc_mode + self._voltage_calc_mode = self.model.voltage_calc_mode # max # initialize deadtimes and other variables self._initialize_ride_through_settings() if self.model.follow_standard == PvStandard.IEEE_1547_2003: @@ -177,7 +206,7 @@ def _create_operation_regions(self): self._fault_counter_max = 2 self._fault_counter_clearing_time_sec = 10 - elif self.model.ride_through_category == RideThroughCategory.CATEGORY_I: + elif self.model.ride_through_category == RideThroughCategory.CATEGORY_III: ov2pu_eq = 1.20 ov2sec_eq = 0.16 ov1pu_min = 1.1 @@ -200,41 +229,41 @@ def _create_operation_regions(self): #check overvoltage points if self.model.ov_2_pu != ov2pu_eq: - #print("User defined setting outside of IEEE 1547 acceptable range.") + logger.error("User defined setting outside of IEEE 1547 acceptable range.") assert False if self.model.ov_2_ct_sec != ov2sec_eq: - #print("User defined setting outside of IEEE 1547 acceptable range.") + logger.error("User defined setting outside of IEEE 1547 acceptable range.") assert False if self.model.ov_1_pu < ov1pu_min and self.model.ov_1_pu > ov1pu_max: - #print("User defined setting outside of IEEE 1547 acceptable range.") + logger.error("User defined setting outside of IEEE 1547 acceptable range.") assert False if self.model.ov_1_ct_sec < ov1sec_min and self.model.ov_1_ct_sec > ov1sec_max: - #print("User defined setting outside of IEEE 1547 acceptable range.") + logger.error("User defined setting outside of IEEE 1547 acceptable range.") assert False #check undervoltage points if self.model.uv_2_pu < uv2pu_min and self.model.uv_2_pu > uv2pu_max: - #print("User defined setting outside of IEEE 1547 acceptable range.") + logger.error("User defined setting outside of IEEE 1547 acceptable range.") assert False if self.model.uv_2_ct_sec < uv2sec_min and self.model.uv_2_ct_sec > uv2sec_max: - #print("User defined setting outside of IEEE 1547 acceptable range.") + logger.error("User defined setting outside of IEEE 1547 acceptable range.") assert False if self.model.uv_1_pu < uv1pu_min and self.model.uv_1_pu > uv1pu_max: - #print("User defined setting outside of IEEE 1547 acceptable range.") + logger.error("User defined setting outside of IEEE 1547 acceptable range.") assert False if self.model.uv_1_ct_sec uv1sec_max: - #print("User defined setting outside of IEEE 1547 acceptable range.") + logger.error("User defined setting outside of IEEE 1547 acceptable range.") assert False - self._controlled_element.SetParameter('Model', '7') + self._controlled_element.SetParameter('Model', '1') # change to model 1 self._controlled_element.SetParameter('Vmaxpu', V[0]) - self._controlled_element.SetParameter('Vminpu', V[1]) + self._controlled_element.SetParameter('Vminpu', '0.1')# V[1]) contineous_points = [Point(V[0], 0), Point(V[0], t_max), Point(V[1], t_max), Point(V[1], 0)] contineous_region = Polygon([[p.y, p.x] for p in contineous_points]) @@ -260,10 +289,9 @@ def _create_operation_regions(self): may_trip_region = total_region.difference(intersection) if self.model.ride_through_category in [RideThroughCategory.CATEGORY_I, RideThroughCategory.CATEGORY_II]: - if self.model.permissive_operation == PermissiveOperation.CURRENT_LIMITED: + if self.model.permissive_operation == PermissiveOperation.CURRENT_LIMITED: if self.model.may_trip_operation == MayTripOperation.PERMISSIVE_OPERATION: - self.curr_lim_region = unary_union( - [permissive_ov_region, permissive_uv_region, mandatory_region, may_trip_region]) + self.curr_lim_region = unary_union([permissive_ov_region, permissive_uv_region, mandatory_region, may_trip_region]) self.momentary_sucession_region = None self.trip_region = unary_union([ov_trip_region, uv_trip_region]) else: @@ -299,11 +327,15 @@ def Update(self, priority, time, update_results): error = 0 self.time_change = self.time != (priority, time) self.time = time + logger.info(f"self._name: {self._name}") + logger.info(f"priority: {priority}") if priority == 0: self._is_connected = self._connect() + logger.info(f"self._is_connected: {self._is_connected}") if priority == 2: u_in = self._update_violaton_timers() + logger.info(f"Update u_in: {u_in}") if self.model.follow_standard == PvStandard.IEEE_1547_2018: self.voltage_ride_through(u_in) elif self.model.follow_standard == PvStandard.IEEE_1547_2003: @@ -311,7 +343,7 @@ def Update(self, priority, time, update_results): else: raise Exception("Valid standard setting defined. Options are: 1547-2003, 1547-2018") - P = -sum(self._controlled_element.GetVariable('Powers')[::2]) + # P = -sum(self._controlled_element.GetVariable('Powers')[::2]) # self.power_hist.append(P) # self.voltage_hist.append(u_in) # self.timer_hist.append(self._u_violation_time) @@ -330,39 +362,51 @@ def trip(self, u_in): def voltage_ride_through(self, u_in): """ Implementation of the IEEE1587-2018 voltage ride-through requirements for inverter systems """ - self._fault_counter_clearing_time_sec = 1 + # self._fault_counter_clearing_time_sec = 1 Pm = Point(self._u_violation_time, u_in) + logger.info(Pm) if Pm.within(self.curr_lim_region): region = 0 is_in_contioeous_region = False + logger.info(f"curr_lim_region") elif self.momentary_sucession_region and Pm.within(self.momentary_sucession_region): region = 1 is_in_contioeous_region = False - self._trip(self.__dss_solver.GetStepSizeSec(), 0.4, False) + self._trip(self.__dss_solver.GetStepSizeSec(), 0.5, False) # rrpt = 2.0 + logger.info(f"momentary_sucession_region") elif Pm.within(self.trip_region): region = 2 is_in_contioeous_region = False if self.region == [3, 1, 1]: + logger.info(f"self.region 3:1:1 {self.region}") self._trip(self._trip_deadtime_sec, self._time_to_p_max_sec, False, True) - else: - self._trip(self._trip_deadtime_sec, self._time_to_p_max_sec, False) + else: + logger.info(f"self.region else {self.region}") + self._trip(self._trip_deadtime_sec, self._time_to_p_max_sec, True) + logger.info(f"trip_region") else: is_in_contioeous_region = True region = 3 - - self.region = self.region[1:] + self.region[:1] + logger.info(f"is_in_contioeous_region") + + logger.info(f"old self.region: {self.region}") + self.region = self.region[1:] + self.region[:1] # shift [1,2,3]->[2,3,1] self.region[0] = region + logger.info(f"new self.region: {self.region}") if is_in_contioeous_region and not self._is_in_contioeous_region: + logger.info(f"faulr just clear") self._fault_window_clearing_start_time = self.__dss_solver.GetDateTime() clearing_time = (self.__dss_solver.GetDateTime() - self._fault_window_clearing_start_time).total_seconds() + logger.info(f"clearing_time: {clearing_time}") if self._is_in_contioeous_region and not is_in_contioeous_region: if clearing_time <= self._fault_counter_clearing_time_sec: self._fault_counter += 1 if self._fault_counter > self._fault_counter_max: if self.model.multiple_disturdances == MultipleDisturbances.TRIP: + logger.info(f"multiple_disturdances trip") self._trip(self._trip_deadtime_sec, self._time_to_p_max_sec, True) self._fault_counter = 0 else: @@ -371,7 +415,104 @@ def voltage_ride_through(self, u_in): self._fault_counter = 0 self._is_in_contioeous_region = is_in_contioeous_region return + + def DERA_control(self, mode): + u_in = self._controlled_element.GetVariable('VoltagesMagAng')[::2] + u_base = self._controlled_element.sBus[0].GetVariable('kVBase') * 1000 + logger.info(f"{self._name} -> u_in: {u_in}") + Pord = 1.0 + if self._phase_rated == 1: + # single-phase DER + u_in = max(u_in) / u_base + else: + # three-phase DER + u_in = sum(u_in) / u_base / 3.0 + + if self.__dss_solver.GetTotalSeconds() < round(self.dt, 5): + logger.info(f"OpenDSS Initializatin -> time: {self.__dss_solver.GetTotalSeconds()}") + self.u_in_prev = u_in + self.ut_filt_prev = u_in + self.Vtrip_ctrl_prev = 1.0 + self.Vmult_prev = 1.0 + self.Ip_out_prev = 1 / u_in + self.Iq_out_prev = 0.0 + self.Ip_out_filt_prev = 1 / u_in + self.Iq_out_filt_prev = 0.0 + + self.ut_filt = (self.dt*(u_in+self.u_in_prev)-(self.dt-2*self.t_rv)*self.ut_filt_prev)/(2*self.t_rv+self.dt) + Ip = Pord / self.ut_filt + Iq = Pord * math.tan(math.acos(self._pf_rated)) / self.ut_filt + + # Q prority current limit control + if Iq > self.Imax: + Iqcmd = self.Imax + elif Iq < -self.Imax: + Iqcmd = -self.Imax + else: + Iqcmd = Iq + Ipmax = math.sqrt(self.Imax*self.Imax - Iqcmd*Iqcmd) + if Ip > Ipmax: + Ipcmd = Ipmax + elif Ip < 0: + Ipcmd = 0 + else: + Ipcmd = Ip + + # undervoltage multiplier + if self.ut_filt < self.ul0: + v11 = 0 + elif self.ut_filt > self.ul1: + v11 = 1 + else: + v11 = (self.ut_filt - self.ul0)/(self.ul1 - self.ul0) + # overoltage multiplier + if self.ut_filt < self.uh1: + v12 = 1 + elif self.ut_filt > self.uh0: + v12 = 0 + else: + v12 = (self.ut_filt - self.uh1)/(self.uh0 - self.uh1) + Vtrip_ctrl = v11*v12 + self.Vmult = (self.dt*(Vtrip_ctrl+self.Vtrip_ctrl_prev)-(self.dt-2*self.t_v)*self.Vmult_prev)/(2*self.t_v+self.dt) + + Ip_out = self.Vmult*Ipcmd*mode + Iq_out = self.Vmult*Iqcmd*mode + if Ip_out < self.Ip_out_prev: + Ip_out_filt = (self.dt*(Ip_out+self.Ip_out_prev)-(self.dt-2*0.01)*self.Ip_out_filt_prev)/(2*0.01+self.dt) + else: + Ip_out_filt = (self.dt*(Ip_out+self.Ip_out_prev)-(self.dt-2*self.t_g)*self.Ip_out_filt_prev)/(2*self.t_g+self.dt) + Iq_out_filt = (self.dt*(Iq_out+self.Iq_out_prev)-(self.dt-2*self.t_g)*self.Iq_out_filt_prev)/(2*self.t_g+self.dt) + + if self._fault_counter > 0 and (Ip_out_filt > self.Ip_out_filt_prev + self.rrpwr * self.dt): + Ip_out_filt = self.Ip_out_filt_prev + self.rrpwr * self.dt + + logger.info(f"u_in: {u_in}") + logger.info(f"Ipcmd: {Ipcmd}") + logger.info(f"Ip_out: {Ip_out}") + logger.info(f"self.ut_filt: {self.ut_filt}") + logger.info(f"Ip_out_filt: {Ip_out_filt}") + logger.info(f"Ip_out_filt_prev: {self.Ip_out_filt_prev}") + logger.info(f"Iq_out_filt: {Iq_out_filt}") + logger.info(f"self.Vmult: {self.Vmult}") + logger.info(f"mode: {mode}") + + self.DERA_p_limit = math.sqrt(Ip_out_filt*Ip_out_filt + Iq_out_filt*Iq_out_filt) * u_in * self._p_rated + + self.ut_filt_prev = self.ut_filt + self.u_in_prev = u_in + self.Vmult_prev = self.Vmult + self.Vtrip_ctrl_prev = Vtrip_ctrl + self.Ip_out_prev = Ip_out + self.Iq_out_prev = Iq_out + self.Ip_out_filt_prev = Ip_out_filt + self.Iq_out_filt_prev = Iq_out_filt + + + def TODO(self): + #add timer for DERA undervoltage/overvoltage voltage multiplier block + return None + def _connect(self): if not self._is_connected: u_in = self._controlled_element.GetVariable('VoltagesMagAng')[::2] @@ -382,6 +523,12 @@ def _connect(self): self.voltage[0] = u_in u_in = sum(self.voltage) / len(self.voltage) deadtime = (self.__dss_solver.GetDateTime() - self._tripped_start_time).total_seconds() + logger.info(f"_connect u_in: {u_in}") + logger.info(f"deadtime: {deadtime}") + logger.info(f"self._tripped_dead_time: {self._tripped_dead_time}") + # self.DERA_control(0.0) + logger.info(f"self.DERA_p_limit: {self.DERA_p_limit}") + # os.system("PAUSE") if u_in < self._rvs[0] and u_in > self._rvs[1] and deadtime >= self._tripped_dead_time: self._controlled_element.SetParameter('enabled', True) @@ -392,6 +539,10 @@ def _connect(self): conntime = (self.__dss_solver.GetDateTime() - self._reconnect_start_time).total_seconds() self._p_limit = conntime / self._tripped_p_max_delay * self._p_rated if conntime < self._tripped_p_max_delay \ else self._p_rated + logger.info(f"self._p_limit: {self._p_limit}") + # self.DERA_control(1.0) + # logger.info(f"self.DERA_p_limit: {self.DERA_p_limit}") + # os.system("PAUSE") self._controlled_element.SetParameter('kw', self._p_limit) return self._is_connected @@ -416,12 +567,14 @@ def _trip(self, Deadtime, time2Pmax, forceTrip, permissive_to_trip=False): self._tripped_start_time = self.__dss_solver.GetDateTime() self._tripped_p_max_delay = time2Pmax self._tripped_dead_time = Deadtime + logger.info(f"self._tripped_dead_time: {self._tripped_dead_time}") return def _update_violaton_timers(self): u_in = self._controlled_element.GetVariable('VoltagesMagAng')[::2] u_base = self._controlled_element.sBus[0].GetVariable('kVBase') * 1000 u_in = max(u_in) / u_base if self._voltage_calc_mode == VoltageCalcModes.MAX else sum(u_in) / (u_base * len(u_in)) + logger.info(f"{self._name}: voltage {u_in}") if self.use_avg_voltage: self.voltage = self.voltage[1:] + self.voltage[:1] self.voltage[0] = u_in diff --git a/src/pydss/pyControllers/models.py b/src/pydss/pyControllers/models.py index cae7cb20..d8dc3446 100644 --- a/src/pydss/pyControllers/models.py +++ b/src/pydss/pyControllers/models.py @@ -165,6 +165,38 @@ class MotorStallSimpleSettings(BaseControllerModel): class MotorStallSettings(BaseControllerModel): + t_stall: Annotated[ + float, + Field(0.032, description="Stall time (range) based on laboratory testing"), + ] + t_restart: Annotated[ + float, + Field(0.300, description="Induction motor restart time is relatively short"), + ] + comp_lf: Annotated[ + float, + Field(1.000, description="Motor D real power to motor base ratio"), + ] + rated_pf: Annotated[ + float, + Field(0.980, description="Assumed slightly inductive motors load"), + ] + v_stall: Annotated[ + float, + Field(0.45, ge=0.40, le=0.70, description="Stall voltage (range) based on laboratory testing"), + ] + r_stall_pu: Annotated[ + float, + Field(0.120, description="Based on laboratory testing results of residential air-conditioners."), + ] + x_stall_pu: Annotated[ + float, + Field(0.120, description="Based on laboratory testing results of residential air-conditioners."), + ] + lf_adj: Annotated[ + float, + Field(0.0, description="Load factor adjustment to the stall voltage10"), + ] k_p1: Annotated[ float, Field(0 , description="Real power constant for running state 111"), @@ -197,66 +229,60 @@ class MotorStallSettings(BaseControllerModel): float, Field(2.5, description="Reactive power exponent for running state 2."), ] - t_th: Annotated[ + v_break: Annotated[ float, - Field(4.0, description="Varies based on manufacturer and external factors - sensitivity analysis required"), + Field(0.86, description="Compressor motor 'breakdown' voltage (pu)"), ] f_rst: Annotated[ float, Field(0.2, description="Captures diversity in load; also based on testing (fraction of motors capable of restart)."), ] - lf_adj: Annotated[ + v_rstrt: Annotated[ float, - Field(0.0, description="Load factor adjustment to the stall voltage10"), + Field(0.95, description="Reconnect when acceptable voltage met"), ] - t_th1t: Annotated[ + vc_1off: Annotated[ float, - Field(0.7, description="Assumed tripping starting at 70% temperature"), + Field(0.5, description="Motor D voltage at which contactors start opening, pu"), ] - t_th2t: Annotated[ + vc_2off: Annotated[ float, - Field(1.9, description="Assumed all tripped at 190% temperature"), - ] - p_fault: Annotated[ + Field(0.4, description="Motor D voltage at which all contactors are opened, pu"), + ] + vc_1on: Annotated[ float, - Field(3.5, ge=3.0, le=5.0, description="Active power multiplier post fault."), + Field(0.6, description="Motor D voltage at which all contactors are reclosed, pu"), ] - q_fault: Annotated[ + vc_2on: Annotated[ float, - Field(5.0, ge=3.0, le=7.0, description="Reactive power multiplier post fault."), + Field(0.5, description="Motor D voltage at which contactors start reclosing, pu"), ] - v_stall: Annotated[ + t_th: Annotated[ float, - Field(0.55, ge=0.45, le=0.60, description="Stall voltage (range) based on laboratory testing"), + Field(15.0, description="Varies based on manufacturer and external factors - sensitivity analysis required"), ] - v_break: Annotated[ + t_th1t: Annotated[ float, - Field(0.86, description="Compressor motor 'breakdown' voltage (pu)"), + Field(0.7, description="Assumed tripping starting at 70% temperature"), ] - v_rstrt: Annotated[ + t_th2t: Annotated[ float, - Field(0.95, description="Reconnect when acceptable voltage met"), + Field(1.9, description="Assumed all tripped at 190% temperature"), ] - t_stall: Annotated[ + f_uvr: Annotated[ float, - Field(0.032, description="Stall time (range) based on laboratory testing"), + Field(0.0, ge=0.0, le=0.10, description="Motor D heating time constant, s"), ] - t_restart: Annotated[ + uv_tr1: Annotated[ float, - Field(0.300, description="Induction motor restart time is relatively short"), + Field(0.6, ge=0.45, le=0.70, description="Motor D 1st undervoltage pick-up, pu"), ] - rated_pf: Annotated[ + t_tr1: Annotated[ float, - Field(0.939, description="Assumed slightly inductive motors load"), + Field(0.02, ge=0.01, le=0.05, description="Motor D 1st undervoltage trip delay, s"), ] - r_stall_pu: Annotated[ - float, - Field(0.100, description="Based on laboratory testing results of residential air-conditioners."), - ] - x_stall_pu: Annotated[ - float, - Field(0.100, description="Based on laboratory testing results of residential air-conditioners."), - ] + + class PvControllerModel(BaseControllerModel): diff --git a/src/pydss/pyControllers/pyController.py b/src/pydss/pyControllers/pyController.py index 21d22351..b54b8855 100644 --- a/src/pydss/pyControllers/pyController.py +++ b/src/pydss/pyControllers/pyController.py @@ -19,7 +19,7 @@ def Create(ElmName, ControllerType, Settings, ElmObjectList, dssInstance, dssSol "Please define the controller in ~pydss\pyControllers\Controllers".format( ControllerType ) - + assert (ElmName in ElmObjectList), "'{}' does not exist in the pydss master object dictionary.".format(ElmName) relObject = ElmObjectList[ElmName] diff --git a/src/pydss/pyDSS.py b/src/pydss/pyDSS.py index 9108de24..079f7308 100644 --- a/src/pydss/pyDSS.py +++ b/src/pydss/pyDSS.py @@ -43,7 +43,7 @@ def run_scenario(self, project, scenario, settings: SimulationSettingsModel, dry opendss = OpenDSS(settings) self._dump_scenario_simulation_settings(settings) - logger.info('Running scenario: %s', settings.project.active_scenario) + logger.info(f'Running scenario: {settings.project.active_scenario}') if settings.monte_carlo.num_scenarios > 0: opendss.RunMCsimulation(project, scenario, samples=settings.monte_carlo.num_scenarios) else: diff --git a/src/pydss/pydss_project.py b/src/pydss/pydss_project.py index 26d29dda..4ded8cac 100644 --- a/src/pydss/pydss_project.py +++ b/src/pydss/pydss_project.py @@ -342,6 +342,7 @@ def run(self, logging_configured=True, tar_project=False, zip_project=False, dry else: store_filename = os.path.join(self._project_dir, STORE_FILENAME) self._dump_simulation_settings() + logger.info("indicator 3") driver = None if self._settings.exports.export_data_in_memory: diff --git a/src/pydss/registry.py b/src/pydss/registry.py index de00947c..522d35ad 100644 --- a/src/pydss/registry.py +++ b/src/pydss/registry.py @@ -39,50 +39,13 @@ ), }, ], - ControllerType.PV_VOLTAGE_RIDETHROUGH.value: [ - { - "name": "NO_VRT_DVS_test", - "filename": os.path.join( - os.path.dirname(getattr(pydss, "__path__")[0]), - "pydss/pyControllers/Controllers/Settings/VoltageRideThru.toml" - ), - }, - { - "name": "1547_CAT_III_test", - "filename": os.path.join( - os.path.dirname(getattr(pydss, "__path__")[0]), - "pydss/pyControllers/Controllers/Settings/VoltageRideThru.toml" - ), - }, - { - "name": "1547_CAT_III_DVS_test", - "filename": os.path.join( - os.path.dirname(getattr(pydss, "__path__")[0]), - "pydss/pyControllers/Controllers/Settings/VoltageRideThru.toml" - ), - }, - ], + ControllerType.PV_VOLTAGE_RIDETHROUGH.value: [], ControllerType.SOCKET_CONTROLLER.value: [], ControllerType.STORAGE_CONTROLLER.value: [], ControllerType.XMFR_CONTROLLER.value: [], ControllerType.MOTOR_STALL.value: [], ControllerType.MOTOR_STALL_SIMPLE.value: [], ControllerType.FAULT_CONTROLLER.value: [], - ControllerType.DYNAMIC_VOLTAGE_SUPPORT.value:[{ - "name": "DVS_test", - "filename": os.path.join( - os.path.dirname(getattr(pydss, "__path__")[0]), - "PyDSS/pyControllers/Controllers/Settings/DynamicVoltageSupport.toml" - ), - }, - { - "name": "DVS_VRT_test", - "filename": os.path.join( - os.path.dirname(getattr(pydss, "__path__")[0]), - "PyDSS/pyControllers/Controllers/Settings/DynamicVoltageSupport.toml" - ), - }, - ], }, } diff --git a/src/pydss/simulation_input_models.py b/src/pydss/simulation_input_models.py index 8b2911ce..ca10d6f9 100644 --- a/src/pydss/simulation_input_models.py +++ b/src/pydss/simulation_input_models.py @@ -694,6 +694,31 @@ def check_max_co_iterations(cls, val): raise ValueError(f"max_co_iterations must be between 1 and 1000: {val}") return val +class ThreatModel(InputsBaseModel): + threat_federate: Annotated[ + bool, + Field( + title="threat_federate", + description="Set to true to enable the Threat Federate.", + default=False, + alias="Enable Threat Federate", + )] + federate_name: Annotated[ + str, + Field( + title="federate_name", + description="Name of the federate", + default="threat_test", + alias="Federate Name", + )] + threat_mapping: Annotated[ + Optional[Path], + Field( + None, + title="threat_mapping", + description="Path of the mapping file for threat federate.", + alias="Threat Mapping Path", + )] class LoggingModel(InputsBaseModel): """Defines the user inputs for controlling logging.""" @@ -1131,6 +1156,14 @@ class SimulationSettingsModel(InputsBaseModel): default=HelicsModel(), alias="Helics", )] + threat_settings:Annotated[ + ThreatModel, + Field( + title="threat_settings", + description="Threat settings", + default=ThreatModel(), + alias="Threat", + )] logging: Annotated[ LoggingModel, Field( diff --git a/src/pydss/value_storage.py b/src/pydss/value_storage.py index 1a078ae6..4eb5e498 100644 --- a/src/pydss/value_storage.py +++ b/src/pydss/value_storage.py @@ -355,7 +355,7 @@ def set_nan(self): if np.issubdtype(self._value_type, np.int64): self._value = INTEGER_NAN else: - self._value = np.NaN + self._value = np.nan def set_value(self, value): self._value = value @@ -487,7 +487,7 @@ def set_name(self, name): def set_nan(self): for i in range(len(self._value)): - self._value[i] = np.NaN + self._value[i] = np.nan def set_value(self, value): self._value = value From 442bd8707c139824f6ea62efd6a3bdbb5f0fa442 Mon Sep 17 00:00:00 2001 From: HaleyRoss Date: Tue, 30 Dec 2025 13:49:09 -0600 Subject: [PATCH 03/14] Updating pythin version in docker file. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2666c64b..e81c55bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7.0-slim +FROM python:3.11.0-slim RUN apt-get update From 3403d13b649230cad837dd4d9f853407026ba70b Mon Sep 17 00:00:00 2001 From: HaleyRoss Date: Thu, 5 Feb 2026 08:43:26 -0700 Subject: [PATCH 04/14] Correcting ride through bug --- src/pydss/pyControllers/Controllers/PvFrequencyRideThru.py | 2 +- src/pydss/pyControllers/Controllers/PvVoltageRideThru.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pydss/pyControllers/Controllers/PvFrequencyRideThru.py b/src/pydss/pyControllers/Controllers/PvFrequencyRideThru.py index 7ad3a866..db7295f9 100644 --- a/src/pydss/pyControllers/Controllers/PvFrequencyRideThru.py +++ b/src/pydss/pyControllers/Controllers/PvFrequencyRideThru.py @@ -1,7 +1,7 @@ from ipaddress import v4_int_to_packed from pydss.pyControllers.pyControllerAbstract import ControllerAbstract from shapely.geometry import MultiPoint, Polygon, Point, MultiPolygon -from shapely.ops import triangulate, cascaded_union +from shapely.ops import triangulate, unary_union import matplotlib.pyplot as plt import datetime import math diff --git a/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py b/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py index dcb16b32..5294823b 100644 --- a/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py +++ b/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py @@ -527,7 +527,7 @@ def _connect(self): logger.info(f"deadtime: {deadtime}") logger.info(f"self._tripped_dead_time: {self._tripped_dead_time}") # self.DERA_control(0.0) - logger.info(f"self.DERA_p_limit: {self.DERA_p_limit}") + # logger.info(f"self.DERA_p_limit: {self.DERA_p_limit}") # os.system("PAUSE") if u_in < self._rvs[0] and u_in > self._rvs[1] and deadtime >= self._tripped_dead_time: From c58e558da6c330da69a126bd7b759c7bcf62e007 Mon Sep 17 00:00:00 2001 From: Panossian Date: Thu, 5 Feb 2026 09:55:52 -0700 Subject: [PATCH 05/14] update deprecated cascaded_union to unary_union --- .../pyControllers/Controllers/PvFrequencyRideThru.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pydss/pyControllers/Controllers/PvFrequencyRideThru.py b/src/pydss/pyControllers/Controllers/PvFrequencyRideThru.py index db7295f9..abf04ebf 100644 --- a/src/pydss/pyControllers/Controllers/PvFrequencyRideThru.py +++ b/src/pydss/pyControllers/Controllers/PvFrequencyRideThru.py @@ -240,11 +240,11 @@ def __CreateOperationRegions(self): MayTripRegion = TotalRegion.difference(intersection) #everything not in the active region is the white "may trip" if self.__Settings['May trip operation'] == 'Trip': - self.CurrLimRegion = cascaded_union([MandatoryRegion1, MandatoryRegion2]) #Naming probably not appropriate with frequency variation. - self.TripRegion = cascaded_union([OFtripRegion, UFtripRegion, MayTripRegion]) + self.CurrLimRegion = unary_union([MandatoryRegion1, MandatoryRegion2]) #Naming probably not appropriate with frequency variation. + self.TripRegion = unary_union([OFtripRegion, UFtripRegion, MayTripRegion]) elif self.__Settings['May trip operation'] == 'Ride-Through': - self.CurrLimRegion = cascaded_union([MandatoryRegion1, MandatoryRegion2, MayTripRegion]) - self.TripRegion = cascaded_union([OFtripRegion, UFtripRegion]) + self.CurrLimRegion = unary_union([MandatoryRegion1, MandatoryRegion2, MayTripRegion]) + self.TripRegion = unary_union([OFtripRegion, UFtripRegion]) else: assert False self.ContinuousRegion = ContinuousRegion From e305be8e04f0f9835faf8ee363369fd5ad457e73 Mon Sep 17 00:00:00 2001 From: HaleyRoss Date: Thu, 26 Mar 2026 15:02:39 -0600 Subject: [PATCH 06/14] Correct Vbase between model changes --- src/pydss/pyControllers/Controllers/PvVoltageRideThru.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py b/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py index 5294823b..f58e0d7c 100644 --- a/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py +++ b/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py @@ -261,6 +261,13 @@ def _create_operation_regions(self): logger.error("User defined setting outside of IEEE 1547 acceptable range.") assert False + # Re-set kV to force RecalcElementData before switching from Model 7 to 1. + # Model 7 internally uses VBase = kV*1000/sqrt(3) even for 1-phase generators, + # but Model 1 expects VBase = kV*1000 for 1-phase. Changing only the 'Model' + # property does not trigger RecalcElementData, leaving a stale VBase that + # causes Model 1 to operate in constant-impedance mode (inflated output). + kv = self._controlled_element.GetParameter('kV') + self._controlled_element.SetParameter('kV', kv) self._controlled_element.SetParameter('Model', '1') # change to model 1 self._controlled_element.SetParameter('Vmaxpu', V[0]) self._controlled_element.SetParameter('Vminpu', '0.1')# V[1]) From ea42551fe4427355eba5e29e720f166196064818 Mon Sep 17 00:00:00 2001 From: Panossian Date: Wed, 1 Apr 2026 15:52:26 -0600 Subject: [PATCH 07/14] removed divide by 3 which was added to emulate coiteration but is unneccessary here --- src/pydss/helics_interface.py | 2 +- src/pydss/modes/solver_base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pydss/helics_interface.py b/src/pydss/helics_interface.py index 41adedb8..404f0077 100644 --- a/src/pydss/helics_interface.py +++ b/src/pydss/helics_interface.py @@ -214,7 +214,7 @@ def _create_helics_federate(self): if self._settings.helics.broker_port: helics.helicsFederateInfoSetBrokerPort(self.fedinfo, self._settings.helics.broker_port) helics.helicsFederateInfoSetTimeProperty(self.fedinfo, helics.helics_property_time_delta, - self._settings.helics.time_delta/3) + self._settings.helics.time_delta) helics.helicsFederateInfoSetIntegerProperty(self.fedinfo, helics.helics_property_int_log_level, self._settings.helics.logging_level) helics.helicsFederateInfoSetIntegerProperty(self.fedinfo, helics.helics_property_int_max_iterations, diff --git a/src/pydss/modes/solver_base.py b/src/pydss/modes/solver_base.py index 880451b6..95fca8ed 100644 --- a/src/pydss/modes/solver_base.py +++ b/src/pydss/modes/solver_base.py @@ -22,7 +22,7 @@ def __init__(self, dssInstance, settings: ProjectModel): StartDay = time_offset_days StartTimeMin = time_offset_seconds / 60.0 - sStepResolution = settings.step_resolution_sec/3 + sStepResolution = settings.step_resolution_sec self.StartDay = self._StartTime.timetuple().tm_yday self.EndDay = self._EndTime.timetuple().tm_yday From ddc19b7c4565d9b27f00e81609a1e9c623da5889 Mon Sep 17 00:00:00 2001 From: Panossian Date: Tue, 7 Apr 2026 16:53:09 -0600 Subject: [PATCH 08/14] fix Nan to nan --- src/pydss/cli/run.py | 10 ++++------ src/pydss/utils/simulation_utils.py | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/pydss/cli/run.py b/src/pydss/cli/run.py index 7096a048..39aabb8e 100644 --- a/src/pydss/cli/run.py +++ b/src/pydss/cli/run.py @@ -81,15 +81,13 @@ def run(project_path, options=None, tar_project=False, zip_project=False, verbos logger.error("Logs path %s does not exist", logs_path) sys.exit(1) filename = logs_path / "pydss.log" - if settings.logging.clear_old_log_file: - logs_path = project_path / "Logs" - filename = logs_path / "pydss.log" - if os.path.exists(filename): - os.remove(filename) logger.level(console_level) if filename: - logger.add(filename) + if settings.logging.clear_old_log_file: + logger.add(filename, mode="w") + else: + logger.add(filename) logger.info(f"CLI: [{get_cli_string()}]",) diff --git a/src/pydss/utils/simulation_utils.py b/src/pydss/utils/simulation_utils.py index 7797609f..487915fd 100644 --- a/src/pydss/utils/simulation_utils.py +++ b/src/pydss/utils/simulation_utils.py @@ -23,7 +23,7 @@ def append(self, val): def average(self): if len(self._buf) < self._window_size: - return np.NaN + return np.nan return sum(self._buf) / len(self._buf) From a78a9e0ff5be31a8271a1038ebd279450248b11f Mon Sep 17 00:00:00 2001 From: Panossian Date: Wed, 8 Apr 2026 09:00:07 -0600 Subject: [PATCH 09/14] add DynamicVoltageSupport to enums --- src/pydss/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pydss/common.py b/src/pydss/common.py index 5d0aecd4..fd1082c6 100644 --- a/src/pydss/common.py +++ b/src/pydss/common.py @@ -37,6 +37,7 @@ class ControllerType(enum.Enum): STORAGE_CONTROLLER = "StorageController" THERMOSTATIC_LOAD_CONTROLLER = "ThermostaticLoad" XMFR_CONTROLLER = "xmfrController" + DYNAMIC_VOLTAGE_SUPPORT = "DynamicVoltageSupport" CONTROLLER_TYPES = tuple(x.value for x in ControllerType) CONFIG_EXT = ".toml" From c20d5bbeb0852157712b56ba18b4764aad62d224 Mon Sep 17 00:00:00 2001 From: Panossian Date: Wed, 8 Apr 2026 13:18:03 -0600 Subject: [PATCH 10/14] remove extraneous log comments add export for dynamic voltage test --- src/pydss/cli/run.py | 2 - .../pyControllers/Controllers/MotorStall.py | 43 +++++-------------- .../Exports/.null | 0 3 files changed, 11 insertions(+), 34 deletions(-) create mode 100644 tests/data/dynamic_voltage_support_test_project/Exports/.null diff --git a/src/pydss/cli/run.py b/src/pydss/cli/run.py index 39aabb8e..b4f10b6a 100644 --- a/src/pydss/cli/run.py +++ b/src/pydss/cli/run.py @@ -96,9 +96,7 @@ def run(project_path, options=None, tar_project=False, zip_project=False, verbos if not isinstance(options, dict): logger.error("options are invalid: %s", options) sys.exit(1) - # logger.info(f"indicator 1") project = PyDssProject.load_project(project_path, options=options, simulation_file=simulations_file) - # logger.info(f"indicator 2") project.run(tar_project=tar_project, zip_project=zip_project, dry_run=dry_run) if dry_run: diff --git a/src/pydss/pyControllers/Controllers/MotorStall.py b/src/pydss/pyControllers/Controllers/MotorStall.py index 2b768cec..0d4ec338 100644 --- a/src/pydss/pyControllers/Controllers/MotorStall.py +++ b/src/pydss/pyControllers/Controllers/MotorStall.py @@ -91,19 +91,19 @@ def debugInfo(self): def Update(self, Priority, time, update_results): self.t = self._dss_solver.GetTotalSeconds() - # logger.info(f"self.t: {self.t}") - # logger.info(f"self._controlled_element: {self._controlled_element}") - # logger.info(f"{self.name} - {self.kw_rated} - {self.kvar_rated} - {self.kvbase} - {self.i_base}") + logger.debug(f"self.t: {self.t}") + logger.debug(f"self._controlled_element: {self._controlled_element}") + logger.debug(f"{self.name} - {self.kw_rated} - {self.kvar_rated} - {self.kvbase} - {self.i_base}") if self.i_base: self.p = self._controlled_element.GetVariable('Powers')[0] + self._controlled_element.GetVariable('Powers')[2] self.q = self._controlled_element.GetVariable('Powers')[1] + self._controlled_element.GetVariable('Powers')[3] self.voltage = self._controlled_element.GetVariable('VoltagesMagAng')[0] self.voltage_pu = self._controlled_element.sBus[0].GetVariable("puVmagAngle")[0] - # logger.info(f"CurrentsMagAng: {self._controlled_element.GetVariable('CurrentsMagAng')}") - # logger.info(f"voltage_pu: {self.voltage_pu}") - # logger.info(f"Powers: {self._controlled_element.GetVariable('Powers')}") - # logger.info(f"self.kw_rated: {self.kw_rated}") - # logger.info(f"self.kvar_rated: {self.kvar_rated}") + logger.debug(f"CurrentsMagAng: {self._controlled_element.GetVariable('CurrentsMagAng')}") + logger.debug(f"voltage_pu: {self.voltage_pu}") + logger.debug(f"Powers: {self._controlled_element.GetVariable('Powers')}") + logger.debug(f"self.kw_rated: {self.kw_rated}") + logger.debug(f"self.kvar_rated: {self.kvar_rated}") comp_lf = self.comp_lf # self.p / self.kw_rated comp_pf = self.rated_pf # self.p / (self.p**2 + self.q**2)**0.5 @@ -173,8 +173,6 @@ def Update(self, Priority, time, update_results): p0 = 1 - self._settings.k_p1 * (1-v_break_adj)**self._settings.n_p1 q0 = ((1 - comp_pf**2)**0.5 / comp_pf)-self._settings.k_q1*(1-v_break_adj)**self._settings.n_q1 - # logger.info(f"p0: {p0}") - # logger.info(f"q0: {q0}") logger.info(f"self.voltage_pu: {self.voltage_pu}") # the operation model @@ -182,8 +180,7 @@ def Update(self, Priority, time, update_results): # stage III p_stall = self.voltage_pu ** 2 * self.r_stall_pu / (self.r_stall_pu ** 2 + self.x_stall_pu ** 2) q_stall = self.voltage_pu ** 2 * self.x_stall_pu / (self.r_stall_pu ** 2 + self.x_stall_pu ** 2) - # logger.info(f"p_stall: {p_stall}") - # logger.info(f"q_stall: {q_stall}") + if self.rstrt: logger.info(f"Stage III: Motor stall and {self._settings.f_rst} of load is restarted") if self.voltage_pu > v_break_adj: @@ -204,14 +201,7 @@ def Update(self, Priority, time, update_results): q_rstrt = q * self._settings.f_rst p_nonrstrt = p_stall * (1 - self._settings.f_rst) q_nonrstrt = q_stall * (1 - self._settings.f_rst) - # logger.info(f"p_rstrt: {p_rstrt}") - # logger.info(f"q_rstrt: {q_rstrt}") - # logger.info(f"p_nonrstrt: {p_nonrstrt}") - # logger.info(f"q_nonrstrt: {q_nonrstrt}") - # logger.info(f"current_pu_rstrt: {current_pu_rstrt}") - # logger.info(f"current_pu_nonrstrt: {current_pu_nonrstrt}") - # logger.info(f"self.i2r_nonrstr: {self.i2r_nonrstr}") - # logger.info(f"self.temp_nonrstr: {self.temp_nonrstr}") + else: logger.info(f"Stage III: Motor stall and not restarted load") # self._controlled_element.SetParameter('kw', Kth * p_stall * self.kva_rated) @@ -238,15 +228,11 @@ def Update(self, Priority, time, update_results): logger.info(f"Stage I: normal operation") p = p0 + self._settings.k_p1*(self.voltage_pu-v_break_adj)**self._settings.n_p1 q = q0 + self._settings.k_q1*(self.voltage_pu-v_break_adj)**self._settings.n_q1 - # logger.info(f"p1: {p}") - # logger.info(f"q1: {q}") else: # stage II or before stall logger.info(f"Stage II: Motor voltage below the break down voltage") p = p0 + self._settings.k_p2 * (v_break_adj - self.voltage_pu)**self._settings.n_p2 q = q0 + self._settings.k_q2 * (v_break_adj - self.voltage_pu)**self._settings.n_q2 - # logger.info(f"p2: {p}") - # logger.info(f"q2: {q}") current_pu = p / self.voltage_pu p_rstrt = p * self._settings.f_rst p_nonrstrt = p * (1 - self._settings.f_rst) @@ -256,18 +242,11 @@ def Update(self, Priority, time, update_results): self.i2r_nonrstr = current_pu * current_pu * self.r_stall_pu self.temp_rstr = (self.dt*(self.i2r_rstr+self.i2r_rstr_prev)-(self.dt-2*self.t_th)*self.temp_rstr_prev)/(2*self.t_th+self.dt) self.temp_nonrstr = (self.dt*(self.i2r_nonrstr+self.i2r_nonrstr_prev)-(self.dt-2*self.t_th)*self.temp_nonrstr_prev)/(2*self.t_th+self.dt) - # logger.info(f"p_rstrt: {p_rstrt}") - # logger.info(f"q_rstrt: {q_rstrt}") - # logger.info(f"p_nonrstrt: {p_nonrstrt}") - # logger.info(f"q_nonrstrt: {q_nonrstrt}") - # logger.info(f"current_pu: {current_pu}") - # logger.info(f"self.i2r_rstr: {self.i2r_rstr}") - # logger.info(f"self.temp_rstr: {self.temp_rstr}") # thermal protection if self.stall: if self.trip_rstr: - # logger.info(f"Motor is tripped") + logger.debug(f"Motor is tripped") Kth_rstr = 0 ## for single bus system elif self.temp_rstr > self._settings.t_th2t: diff --git a/tests/data/dynamic_voltage_support_test_project/Exports/.null b/tests/data/dynamic_voltage_support_test_project/Exports/.null new file mode 100644 index 00000000..e69de29b From 6b5ed1577b6dc779995972ba82249020ce4443da Mon Sep 17 00:00:00 2001 From: Panossian Date: Wed, 8 Apr 2026 13:23:26 -0600 Subject: [PATCH 11/14] add report and log dirs for dynamic voltage test --- tests/data/dynamic_voltage_support_test_project/Logs/.null | 0 tests/data/dynamic_voltage_support_test_project/Reports/null.txt | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/data/dynamic_voltage_support_test_project/Logs/.null create mode 100644 tests/data/dynamic_voltage_support_test_project/Reports/null.txt diff --git a/tests/data/dynamic_voltage_support_test_project/Logs/.null b/tests/data/dynamic_voltage_support_test_project/Logs/.null new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/dynamic_voltage_support_test_project/Reports/null.txt b/tests/data/dynamic_voltage_support_test_project/Reports/null.txt new file mode 100644 index 00000000..e69de29b From aa94b238dc20cd5d34505a60b2f18364e11342d4 Mon Sep 17 00:00:00 2001 From: Panossian Date: Tue, 14 Apr 2026 18:36:11 -0600 Subject: [PATCH 12/14] remove extra logs update from deprecated lsim2 to lsim --- .../Controllers/DynamicVoltageSupport.py | 6 +++--- src/pydss/pyControllers/Controllers/MotorStall.py | 13 ++++--------- src/pydss/registry.py | 1 + 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/pydss/pyControllers/Controllers/DynamicVoltageSupport.py b/src/pydss/pyControllers/Controllers/DynamicVoltageSupport.py index f22104ae..91b81375 100644 --- a/src/pydss/pyControllers/Controllers/DynamicVoltageSupport.py +++ b/src/pydss/pyControllers/Controllers/DynamicVoltageSupport.py @@ -116,7 +116,7 @@ def debugInfo(self): def Voltage_Transducer(self,ave_v_pu): self.V=np.array([self.V[-1],ave_v_pu]) #update input array with the current time-step voltage - _,self.V_filt,vout=signal.lsim2(self.V_filter_transfer_function,self.V,self.T,self.v_out_prev) #run it through our voltage transducer to get response + _,self.V_filt,vout=signal.lsim(self.V_filter_transfer_function,self.V,self.T,self.v_out_prev) #run it through our voltage transducer to get response self.v_out_prev=vout[-1] #record evolution of state vector for next step initial condition #CALCULATE V_ERROR @@ -126,11 +126,11 @@ def Voltage_Transducer(self,ave_v_pu): def Inverter_Controller(self,kw_kvar:str,new_setting): if kw_kvar == 'kw': #if we are controlling kw self.inv_control_kw=np.array([self.kw_setting_prev,new_setting]) # set to last step kvar and the desired next step - _,self.inv_control_filt_kw,inv_out_kw=signal.lsim2(self.inv_control_transfer_function,self.inv_control_kw,self.T,self.inv_out_kw_prev) #run through our transfer function to get response + _,self.inv_control_filt_kw,inv_out_kw=signal.lsim(self.inv_control_transfer_function,self.inv_control_kw,self.T,self.inv_out_kw_prev) #run through our transfer function to get response self.inv_out_kw_prev=inv_out_kw[-1] #record evolution of state vector for next step initial conditioN else: #if we are controlling kvar self.inv_control_kvar=np.array([self.kvar_setting_prev,new_setting]) # set to last step kvar and the desired next step - _,self.inv_control_filt_kvar,inv_out_kvar=signal.lsim2(self.inv_control_transfer_function,self.inv_control_kvar,self.T,self.inv_out_kvar_prev) #run through our transfer function to get response + _,self.inv_control_filt_kvar,inv_out_kvar=signal.lsim(self.inv_control_transfer_function,self.inv_control_kvar,self.T,self.inv_out_kvar_prev) #run through our transfer function to get response self.inv_out_kvar_prev=inv_out_kvar[-1] #record evolution of state vector for next step initial condition return diff --git a/src/pydss/pyControllers/Controllers/MotorStall.py b/src/pydss/pyControllers/Controllers/MotorStall.py index 0d4ec338..185d5fde 100644 --- a/src/pydss/pyControllers/Controllers/MotorStall.py +++ b/src/pydss/pyControllers/Controllers/MotorStall.py @@ -110,8 +110,7 @@ def Update(self, Priority, time, update_results): v_stall_adj = self._settings.v_stall*(1 + self._settings.lf_adj * (comp_lf-1)) v_break_adj = self._settings.v_break*(1 + self._settings.lf_adj * (comp_lf-1)) - # logger.info(f"v_stall_adj: {v_stall_adj}") - # logger.info(f"v_break_adj: {v_break_adj}") + if Priority == 0: ## stall and restart time clock if self.voltage_pu < v_stall_adj and not self.stall: @@ -262,7 +261,7 @@ def Update(self, Priority, time, update_results): Kth_rstr = 1 if self.trip_nonrstr: - # logger.info(f"Motor is tripped") + logger.info(f"Motor is tripped") Kth_nonrstr = 0 ## for single bus system elif self.temp_nonrstr > self._settings.t_th2t: @@ -279,20 +278,16 @@ def Update(self, Priority, time, update_results): else: Kth_rstr = 1 Kth_nonrstr = 1 - - # logger.info(f"Kth_rstr: {Kth_rstr}") - # logger.info(f"Kth_nonrstr: {Kth_nonrstr}") pset = (Kth_rstr*p_rstrt + Kth_nonrstr*p_nonrstrt) * self.kw_rated qset = (Kth_rstr*q_rstrt + Kth_nonrstr*q_nonrstrt) * self.kw_rated if self.stall: qset = (Kth_rstr*q_rstrt + Kth_nonrstr*q_nonrstrt) * self.kw_rated - # logger.info(f"pset: {pset}") - # logger.info(f"qset: {qset}") + logger.debug(f"pset: {pset}") + logger.debug(f"qset: {qset}") self._controlled_element.SetParameter('kw', Kthc * Kthuv * pset ) self._controlled_element.SetParameter('kvar', Kthc * Kthuv * qset ) - # os.system("PAUSE") self.voltage_prev = self.voltage_pu self.temp_rstr_prev = self.temp_rstr diff --git a/src/pydss/registry.py b/src/pydss/registry.py index 522d35ad..be5fc9b2 100644 --- a/src/pydss/registry.py +++ b/src/pydss/registry.py @@ -46,6 +46,7 @@ ControllerType.MOTOR_STALL.value: [], ControllerType.MOTOR_STALL_SIMPLE.value: [], ControllerType.FAULT_CONTROLLER.value: [], + ControllerType.DYNAMIC_VOLTAGE_SUPPORT.value: [], }, } From 88888bfb3ee7023affd4b63f8f515eef784e11f0 Mon Sep 17 00:00:00 2001 From: Panossian Date: Tue, 14 Apr 2026 18:40:10 -0600 Subject: [PATCH 13/14] make sure new control types are registered before testing --- tests/test_dynamic_voltage_support.py | 33 ++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/test_dynamic_voltage_support.py b/tests/test_dynamic_voltage_support.py index 978d675b..db3ef926 100644 --- a/tests/test_dynamic_voltage_support.py +++ b/tests/test_dynamic_voltage_support.py @@ -1,21 +1,48 @@ import re import os +from pydss import __path__ as pydsspath from pydss.pydss_project import PyDssProject from pydss.pydss_results import PyDssResults from tests.common import ( DYNAMIC_VOLTAGE_SUPPORT_PATH, - cleanup_project + cleanup_project, ) from pydss.common import (SIMULATION_SETTINGS_FILENAME, RUN_SIMULATION_FILENAME, + ControllerType ) from pydss.pydss_fs_interface import STORE_FILENAME +from pydss.registry import Registry def test_dynamic_voltage_support(cleanup_project): + # register the controllers if not in registry + registry = Registry() + dynamic_voltage_support_toml = pydsspath[0] + "/pyControllers/Controllers/Settings/DynamicVoltageSupport.toml" + if not registry.is_controller_registered(ControllerType.DYNAMIC_VOLTAGE_SUPPORT.value, "DVS_test"): + registry.register_controller( + ControllerType.DYNAMIC_VOLTAGE_SUPPORT.value, + {"name": "DVS_test", + "filename": dynamic_voltage_support_toml}) + if not registry.is_controller_registered(ControllerType.DYNAMIC_VOLTAGE_SUPPORT.value, "DVS_VRT_test"): + registry.register_controller( + ControllerType.DYNAMIC_VOLTAGE_SUPPORT.value, + {"name": "DVS_VRT_test", + "filename": dynamic_voltage_support_toml}) + if not registry.is_controller_registered(ControllerType.PV_VOLTAGE_RIDETHROUGH.value, "NO_VRT_DVS_test"): + registry.register_controller( + ControllerType.PV_VOLTAGE_RIDETHROUGH.value, + {"name": "NO_VRT_DVS_test", + "filename": dynamic_voltage_support_toml.replace('DynamicVoltageSupport', 'VoltageRideThru')}) + registry.unregister_controller(ControllerType.PV_VOLTAGE_RIDETHROUGH.value, "1547_CAT_III_DVS_test") + if not registry.is_controller_registered(ControllerType.PV_VOLTAGE_RIDETHROUGH.value, "1547_CAT_III_DVS_test"): + registry.register_controller( + ControllerType.PV_VOLTAGE_RIDETHROUGH.value, + {"name": "1547_CAT_III_DVS_test", + "filename": dynamic_voltage_support_toml.replace('DynamicVoltageSupport','VoltageRideThru')}) + # then run the project PyDssProject.run_project( path=DYNAMIC_VOLTAGE_SUPPORT_PATH, - simulation_file=SIMULATION_SETTINGS_FILENAME - ) + simulation_file=SIMULATION_SETTINGS_FILENAME) results=PyDssResults(DYNAMIC_VOLTAGE_SUPPORT_PATH) scenario_DVS_only=results.scenarios[0] #DVS Only scenario_no_vrt=results.scenarios[1] #DVS and VRT with instantaneous trip From 6450e8425c93b0913330e50499b6286efdcf825f Mon Sep 17 00:00:00 2001 From: HaleyRoss Date: Sun, 12 Jul 2026 18:18:37 -0600 Subject: [PATCH 14/14] add batch controllers --- pyproject.toml | 1 + src/pydss/dssInstance.py | 77 +- src/pydss/helics_interface.py | 32 + .../pyControllers/Controllers/MotorStall.py | 34 +- .../Controllers/MotorStallBatch.py | 324 ++++ .../Controllers/PvVoltageRideThru.py | 118 +- .../Controllers/PvVoltageRideThruBatch.py | 327 ++++ .../pyControllers/pyControllerAbstract.py | 4 + .../simulation_pv_ride_through.toml | 60 + tests/data/motor_stall_baseline.json | 1288 ++++++++++++++ tests/data/pv_ride_through_baseline.json | 1532 +++++++++++++++++ tests/test_motor_stall_validation.py | 122 ++ tests/test_pv_ride_through_validation.py | 117 ++ 13 files changed, 3940 insertions(+), 96 deletions(-) create mode 100644 src/pydss/pyControllers/Controllers/MotorStallBatch.py create mode 100644 src/pydss/pyControllers/Controllers/PvVoltageRideThruBatch.py create mode 100644 tests/data/controllers/simulation_pv_ride_through.toml create mode 100644 tests/data/motor_stall_baseline.json create mode 100644 tests/data/pv_ride_through_baseline.json create mode 100644 tests/test_motor_stall_validation.py create mode 100644 tests/test_pv_ride_through_validation.py diff --git a/pyproject.toml b/pyproject.toml index 7e9c3dc7..346fe56f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "numpy", "OpenDSSDirect.py==0.8.4", "pandas", + "pssepath", "pvder", "pydantic~=2.5.2", "pymongo", diff --git a/src/pydss/dssInstance.py b/src/pydss/dssInstance.py index 86699dab..bebd9a8b 100644 --- a/src/pydss/dssInstance.py +++ b/src/pydss/dssInstance.py @@ -171,24 +171,47 @@ def _CreateControllers(self, ControllerDict): if controller_name not in self._pyControls_types: self._pyControls_types[controller_name] = class_name logger.info('Created pyController -> Controller.' + ElmName) + + # --- Batch MotorStall controllers --- + from pydss.pyControllers.Controllers.MotorStall import MotorStall + from pydss.pyControllers.Controllers.MotorStallBatch import MotorStallBatch + motor_stall_keys = [k for k, v in self._pyControls.items() + if isinstance(v, MotorStall)] + if len(motor_stall_keys) > 0: + motor_stall_ctrls = [self._pyControls[k] for k in motor_stall_keys] + batch = MotorStallBatch(motor_stall_ctrls) + # Remove individual controllers, add the batch + for k in motor_stall_keys: + del self._pyControls[k] + self._pyControls['Controller.MotorStallBatch'] = batch + logger.info(f"Batched {len(motor_stall_keys)} MotorStall controllers into MotorStallBatch") + + # --- Batch PvVoltageRideThru controllers --- + from pydss.pyControllers.Controllers.PvVoltageRideThru import PvVoltageRideThru + from pydss.pyControllers.Controllers.PvVoltageRideThruBatch import PvVoltageRideThruBatch + pv_rt_keys = [k for k, v in self._pyControls.items() + if isinstance(v, PvVoltageRideThru)] + if len(pv_rt_keys) > 0: + pv_rt_ctrls = [self._pyControls[k] for k in pv_rt_keys] + pv_batch = PvVoltageRideThruBatch(pv_rt_ctrls) + for k in pv_rt_keys: + del self._pyControls[k] + self._pyControls['Controller.PvVoltageRideThruBatch'] = pv_batch + logger.info(f"Batched {len(pv_rt_keys)} PvVoltageRideThru controllers into PvVoltageRideThruBatch") + + self._controller_list = list(self._pyControls.values()) + self._controllers_by_priority = {p: [] for p in range(CONTROLLER_PRIORITIES)} + for controller in self._controller_list: + for p in getattr(controller, 'ACTIVE_PRIORITIES', range(CONTROLLER_PRIORITIES)): + self._controllers_by_priority[p].append(controller) return def _update_controllers(self, Priority, Time, Iteration, UpdateResults): - errors = [] maxError = 0 - _pyControls_types = set(self._pyControls_types.values()) - - for class_name in _pyControls_types: - self._dssInstance.Basic.SetActiveClass(class_name) - elm = self._dssInstance.ActiveClass.First() - while elm: - element_name = self._dssInstance.CktElement.Name() - controller_name = 'Controller.' + element_name - if controller_name in self._pyControls: - controller = self._pyControls[controller_name] - error = controller.Update(Priority, Time, UpdateResults) - maxError = error if error > maxError else maxError - elm = self._dssInstance.ActiveClass.Next() + for controller in self._controllers_by_priority[Priority]: + error = controller.Update(Priority, Time, UpdateResults) + if error > maxError: + maxError = error return maxError < self._settings.project.error_tolerance, maxError @staticmethod @@ -244,6 +267,7 @@ def _get_relavent_object_dict(self, key): @track_timing(timer_stats_collector) def RunStep(self, step, updateObjects=None): + # updating parameters before simulation run if self._settings.logging.log_time_step_updates: logger.info(f'Pydss datetime - {self._dssSolver.GetDateTime()}') @@ -262,17 +286,6 @@ def RunStep(self, step, updateObjects=None): for object, params in updateObjects.items(): cl, name = object.split('.') self._Modifier.Edit_Element(cl, name, params) - - # pydss_update_agian = "y" - # while pydss_update_agian == "y": - # if self._settings.helics.co_simulation_mode: - # self._heilcs_interface.updateHelicsSubscriptions() - # else: - # if updateObjects: - # for object, params in updateObjects.items(): - # cl, name = object.split('.') - # self._Modifier.Edit_Element(cl, name, params) - # pydss_update_agian = input('enter your pydss update command: ') # run simulation time step and get results time_step_has_converged = True @@ -300,7 +313,6 @@ def RunStep(self, step, updateObjects=None): logger.warning('Control Loop {} no convergence @ {} '.format(priority, step)) self._HandleConvergenceErrorChecks(step, error) - if self._settings.frequency.enable_frequency_sweep and \ self._settings.project.simulation_type != SimulationType.DYNAMIC: self._dssSolver.setMode('Harmonic') @@ -320,6 +332,7 @@ def RunStep(self, step, updateObjects=None): self._heilcs_interface.updateHelicsPublications() # if step < 1: # self._increment_flag, helics_time = self._heilcs_interface.request_time_increment_2() + # os.system("PAUSE") return time_step_has_converged @@ -410,16 +423,11 @@ def RunSimulation(self, project, scenario, MC_scenario_number=None): within_range = self._simulation_range.is_within_range(self._dssSolver.GetDateTime()) if within_range: pydss_has_converged = self.RunStep(step) - # logger.info(f'Finish simulation: step {step} 1') - # pydss_has_converged = self.RunStep(step) - # logger.info(f'Finish simulation: step {step} 2') - # pydss_has_converged = self.RunStep(step) - # logger.info(f'Finish simulation: step {step} 3') opendss_has_converged = dss.Solution.Converged() if not opendss_has_converged: logger.error(f"OpenDSS did not converge at step={step} pydss_converged={pydss_has_converged}") self._HandleOpenDSSConvergenceErrorChecks(step) - + if pydss_has_converged: has_converged = True else: @@ -432,10 +440,6 @@ def RunSimulation(self, project, scenario, MC_scenario_number=None): step, has_converged = self._RunPostProcessors(step, Steps, postprocessors) if self._increment_flag: step += 1 - # if step < 1: - # step += 1 - # else: - # step += 2 # In the case of a frequency sweep, the code updates results at each frequency. # Doing so again would cause a duplicate result. @@ -461,6 +465,7 @@ def RunSimulation(self, project, scenario, MC_scenario_number=None): if self._settings.exports.export_results: current_results = self.ResultContainer.CurrentResults + yield False, step, has_converged, current_results finally: diff --git a/src/pydss/helics_interface.py b/src/pydss/helics_interface.py index 404f0077..fd10ba6b 100644 --- a/src/pydss/helics_interface.py +++ b/src/pydss/helics_interface.py @@ -315,6 +315,18 @@ def _registerFederatePublications(self, publications:Publications = None): logger.info(str(self.publications.publications)) for publication in self.publications.publications: logger.info(f"pubscription created: {publication}") + + # Register an aggregate generator total-power publication so that + # the co-simulation launcher can subscribe PSSE machines to it. + # Published as [P_kW, Q_kvar] with POSITIVE values for generation. + self._gen_total_pub = None + gen_class = self._objects_by_class.get("Generators", {}) + if gen_class: + gen_pub_name = f"{self._settings.helics.federate_name}.Generators.Total.TotalPower" + self._gen_total_pub = helics.helicsFederateRegisterGlobalTypePublication( + self._federate, gen_pub_name, "vector", "" + ) + logger.info(f"Registered generator aggregate publication: {gen_pub_name}") return def updateHelicsPublications(self): @@ -335,6 +347,26 @@ def updateHelicsPublications(self): else: raise ValueError("Unsupported data type forr teh HELICS interface") logger.info(f"{publication} - {value}") + + # Publish aggregate generator total power [P_kW, Q_kvar]. + # Sign convention: POSITIVE = generation (negated from OpenDSS + # CktElement.Powers which uses load convention, i.e. negative for + # power injected by generators). + if self._gen_total_pub is not None: + total_gen_p = 0.0 + total_gen_q = 0.0 + for gen_name, gen_obj in self._objects_by_class.get("Generators", {}).items(): + powers = gen_obj.GetValue("Powers") + if powers is not None and isinstance(powers, list): + for i in range(0, len(powers), 2): + total_gen_p += powers[i] + if i + 1 < len(powers): + total_gen_q += powers[i + 1] + # Negate: OpenDSS Powers are negative for gen injection + total_gen_p = -total_gen_p + total_gen_q = -total_gen_q + helics.helicsPublicationPublishVector(self._gen_total_pub, [total_gen_p, total_gen_q]) + logger.debug(f"Published generator total power: [{total_gen_p:.4f}, {total_gen_q:.4f}]") return def request_time_increment(self): diff --git a/src/pydss/pyControllers/Controllers/MotorStall.py b/src/pydss/pyControllers/Controllers/MotorStall.py index 0d4ec338..1f74f730 100644 --- a/src/pydss/pyControllers/Controllers/MotorStall.py +++ b/src/pydss/pyControllers/Controllers/MotorStall.py @@ -6,11 +6,12 @@ import os from loguru import logger import random - from pydss.pyControllers.models import MotorStallSettings from pydss.pyControllers.pyControllerAbstract import ControllerAbstract -class MotorStall(ControllerAbstract): +class MotorStall(ControllerAbstract): + ACTIVE_PRIORITIES = (0,) + def __init__(self, motor_obj, settings, dss_instance, elm_object_list, dss_solver): super(MotorStall, self).__init__(motor_obj, settings, dss_instance, elm_object_list, dss_solver) @@ -90,7 +91,8 @@ def debugInfo(self): return def Update(self, Priority, time, update_results): - self.t = self._dss_solver.GetTotalSeconds() + t_now = self._dss_solver.GetTotalSeconds() + self.t = t_now logger.debug(f"self.t: {self.t}") logger.debug(f"self._controlled_element: {self._controlled_element}") logger.debug(f"{self.name} - {self.kw_rated} - {self.kvar_rated} - {self.kvbase} - {self.i_base}") @@ -99,9 +101,7 @@ def Update(self, Priority, time, update_results): self.q = self._controlled_element.GetVariable('Powers')[1] + self._controlled_element.GetVariable('Powers')[3] self.voltage = self._controlled_element.GetVariable('VoltagesMagAng')[0] self.voltage_pu = self._controlled_element.sBus[0].GetVariable("puVmagAngle")[0] - logger.debug(f"CurrentsMagAng: {self._controlled_element.GetVariable('CurrentsMagAng')}") logger.debug(f"voltage_pu: {self.voltage_pu}") - logger.debug(f"Powers: {self._controlled_element.GetVariable('Powers')}") logger.debug(f"self.kw_rated: {self.kw_rated}") logger.debug(f"self.kvar_rated: {self.kvar_rated}") @@ -116,22 +116,22 @@ def Update(self, Priority, time, update_results): ## stall and restart time clock if self.voltage_pu < v_stall_adj and not self.stall: if self.stall_counting: - self.stall_time = self._dss_solver.GetTotalSeconds() - self.stall_time_start + self.stall_time = t_now - self.stall_time_start if self.stall_time > self._settings.t_stall and not self.stall: self.stall = True self.rstrt = False else: - self.stall_time_start = self._dss_solver.GetTotalSeconds() + self.stall_time_start = t_now self.stall_counting = True else: self.stall_counting = False if self.voltage_pu > self._settings.v_rstrt and not self.rstrt: if self.rstrt_counting: - self.rstrt_time = self._dss_solver.GetTotalSeconds() - self.rstrt_time_start + self.rstrt_time = t_now - self.rstrt_time_start if self.rstrt_time > self._settings.t_restart: self.rstrt = True else: - self.rstrt_time_start = self._dss_solver.GetTotalSeconds() + self.rstrt_time_start = t_now self.rstrt_counting = True else: self.rstrt_counting = False @@ -139,12 +139,12 @@ def Update(self, Priority, time, update_results): ## uv trip if self.voltage_pu < self.uv_tr1 and not self.uv_trip: if self.uv_counting: - self.uv_time = self._dss_solver.GetTotalSeconds() - self.uv_time_start + self.uv_time = t_now - self.uv_time_start if self.uv_time > self.t_tr1 and not self.uv_trip: self.uv_trip = True self.uv_counting = False else: - self.uv_time_start = self._dss_solver.GetTotalSeconds() + self.uv_time_start = t_now self.uv_counting = True if self.uv_trip: Kthuv = 1.0 - self.f_uvr @@ -173,7 +173,7 @@ def Update(self, Priority, time, update_results): p0 = 1 - self._settings.k_p1 * (1-v_break_adj)**self._settings.n_p1 q0 = ((1 - comp_pf**2)**0.5 / comp_pf)-self._settings.k_q1*(1-v_break_adj)**self._settings.n_q1 - logger.info(f"self.voltage_pu: {self.voltage_pu}") + logger.debug(f"self.voltage_pu: {self.voltage_pu}") # the operation model if self.stall: @@ -182,7 +182,7 @@ def Update(self, Priority, time, update_results): q_stall = self.voltage_pu ** 2 * self.x_stall_pu / (self.r_stall_pu ** 2 + self.x_stall_pu ** 2) if self.rstrt: - logger.info(f"Stage III: Motor stall and {self._settings.f_rst} of load is restarted") + logger.debug(f"Stage III: Motor stall and {self._settings.f_rst} of load is restarted") if self.voltage_pu > v_break_adj: p = p0 + self._settings.k_p1*(self.voltage_pu-v_break_adj)**self._settings.n_p1 q = q0 + self._settings.k_q1*(self.voltage_pu-v_break_adj)**self._settings.n_q1 @@ -203,7 +203,7 @@ def Update(self, Priority, time, update_results): q_nonrstrt = q_stall * (1 - self._settings.f_rst) else: - logger.info(f"Stage III: Motor stall and not restarted load") + logger.debug(f"Stage III: Motor stall and not restarted load") # self._controlled_element.SetParameter('kw', Kth * p_stall * self.kva_rated) # self._controlled_element.SetParameter('kvar', Kth * q_stall * self.kva_rated) p_rstrt = p_stall * self._settings.f_rst @@ -225,12 +225,12 @@ def Update(self, Priority, time, update_results): else: if self.voltage_pu > v_break_adj: # stage I - logger.info(f"Stage I: normal operation") + logger.debug(f"Stage I: normal operation") p = p0 + self._settings.k_p1*(self.voltage_pu-v_break_adj)**self._settings.n_p1 q = q0 + self._settings.k_q1*(self.voltage_pu-v_break_adj)**self._settings.n_q1 else: # stage II or before stall - logger.info(f"Stage II: Motor voltage below the break down voltage") + logger.debug(f"Stage II: Motor voltage below the break down voltage") p = p0 + self._settings.k_p2 * (v_break_adj - self.voltage_pu)**self._settings.n_p2 q = q0 + self._settings.k_q2 * (v_break_adj - self.voltage_pu)**self._settings.n_q2 current_pu = p / self.voltage_pu @@ -293,7 +293,7 @@ def Update(self, Priority, time, update_results): self._controlled_element.SetParameter('kw', Kthc * Kthuv * pset ) self._controlled_element.SetParameter('kvar', Kthc * Kthuv * qset ) # os.system("PAUSE") - + self.voltage_prev = self.voltage_pu self.temp_rstr_prev = self.temp_rstr self.i2r_rstr_prev = self.i2r_rstr diff --git a/src/pydss/pyControllers/Controllers/MotorStallBatch.py b/src/pydss/pyControllers/Controllers/MotorStallBatch.py new file mode 100644 index 00000000..ef7b86d0 --- /dev/null +++ b/src/pydss/pyControllers/Controllers/MotorStallBatch.py @@ -0,0 +1,324 @@ +# Vectorized batch implementation of MotorStall controller +# Holds all motor state as numpy arrays; computes all 200+ motors in one +# vectorized pass, then writes results back individually. + +import math +import random + +import numpy as np +from loguru import logger + +from pydss.pyControllers.models import MotorStallSettings + + +class MotorStallBatch: + """Drop-in replacement that processes ALL MotorStall controllers in one + vectorized numpy pass per timestep. + + Expected by dssInstance: .Update(Priority, time, update_results) -> 0 + .Name() -> str + .ControlledElement() -> str (first element) + .ACTIVE_PRIORITIES = (0,) + """ + + ACTIVE_PRIORITIES = (0,) + + def __init__(self, controllers): + """Build from a list of already-constructed MotorStall instances. + + Parameters + ---------- + controllers : list[MotorStall] + The individual controller instances (we steal their DSS element + refs and settings, then discard the Python-level Update loop). + """ + n = len(controllers) + if n == 0: + raise ValueError("MotorStallBatch requires at least one controller") + self._n = n + self._names = [c.name for c in controllers] + self._elements = [c._controlled_element for c in controllers] + self._bus_objects = [c._controlled_element.sBus[0] for c in controllers] + self._dss_solver = controllers[0]._dss_solver + self._dss = controllers[0]._controlled_element._dssInstance + + # ---- per-motor scalar settings (turned into arrays) ---- + self._kw_rated = np.array([c.kw_rated for c in controllers]) + self._kvar_rated = np.array([c.kvar_rated for c in controllers]) + self._kva_rated = np.array([c.kva_rated for c in controllers]) + self._kvbase = np.array([c.kvbase for c in controllers]) + self._i_base = np.array([c.i_base for c in controllers]) + + self._comp_lf = np.array([c.comp_lf for c in controllers]) + self._rated_pf = np.array([c.rated_pf for c in controllers]) + self._r_stall_pu = np.array([c.r_stall_pu for c in controllers]) + self._x_stall_pu = np.array([c.x_stall_pu for c in controllers]) + self._z2 = self._r_stall_pu ** 2 + self._x_stall_pu ** 2 # precompute + + self._v_stall = np.array([c._settings.v_stall for c in controllers]) + self._v_break = np.array([c._settings.v_break for c in controllers]) + self._lf_adj = np.array([c._settings.lf_adj for c in controllers]) + self._t_stall = np.array([c._settings.t_stall for c in controllers]) + self._v_rstrt = np.array([c._settings.v_rstrt for c in controllers]) + self._t_restart = np.array([c._settings.t_restart for c in controllers]) + self._f_rst = np.array([c._settings.f_rst for c in controllers]) + + self._k_p1 = np.array([c._settings.k_p1 for c in controllers]) + self._n_p1 = np.array([c._settings.n_p1 for c in controllers]) + self._k_p2 = np.array([c._settings.k_p2 for c in controllers]) + self._n_p2 = np.array([c._settings.n_p2 for c in controllers]) + self._k_q1 = np.array([c._settings.k_q1 for c in controllers]) + self._n_q1 = np.array([c._settings.n_q1 for c in controllers]) + self._k_q2 = np.array([c._settings.k_q2 for c in controllers]) + self._n_q2 = np.array([c._settings.n_q2 for c in controllers]) + + self._f_uvr = np.array([c.f_uvr for c in controllers]) + self._uv_tr1 = np.array([c.uv_tr1 for c in controllers]) + self._t_tr1 = np.array([c.t_tr1 for c in controllers]) + + self._vc_1off = np.array([c.vc_1off for c in controllers]) + self._vc_2off = np.array([c.vc_2off for c in controllers]) + self._vc_1on = np.array([c.vc_1on for c in controllers]) + self._vc_2on = np.array([c.vc_2on for c in controllers]) + + self._t_th = np.array([c.t_th for c in controllers]) + self._t_th1t = np.array([c._settings.t_th1t for c in controllers]) + self._t_th2t = np.array([c._settings.t_th2t for c in controllers]) + + self._dt = np.array([c.dt for c in controllers]) + + # ---- per-motor state ---- + self._stall = np.zeros(n, dtype=bool) + self._stall_counting = np.zeros(n, dtype=bool) + self._stall_time_start = np.zeros(n) + + self._rstrt = np.ones(n, dtype=bool) + self._rstrt_counting = np.zeros(n, dtype=bool) + self._rstrt_time_start = np.zeros(n) + + self._uv_trip = np.zeros(n, dtype=bool) + self._uv_counting = np.zeros(n, dtype=bool) + self._uv_time_start = np.zeros(n) + + self._voltage_prev = np.ones(n) + + init_i2r = 1.0 * 1.0 * self._r_stall_pu + self._i2r_rstr_prev = init_i2r.copy() + self._temp_rstr_prev = init_i2r.copy() + self._i2r_nonrstr_prev = init_i2r.copy() + self._temp_nonrstr_prev = init_i2r.copy() + + self._trip_rstr = np.zeros(n, dtype=bool) + self._trip_nonrstr = np.zeros(n, dtype=bool) + + # Store full names for bulk command writes + self._full_names = [elem._FullName for elem in self._elements] + # valid mask: i_base != 0 + self._valid = self._i_base != 0.0 + + # ---- Interface expected by dssInstance ---- + + def Name(self): + return "MotorStallBatch" + + def ControlledElement(self): + return self._elements[0].GetInfo()[0] + "." + self._elements[0].GetInfo()[1] + + def debugInfo(self): + return + + def Update(self, Priority, time_val, update_results): + if Priority != 0: + return 0 + + n = self._n + t_now = self._dss_solver.GetTotalSeconds() + + # ---- READ phase: per-element API calls ---- + voltage_pu = np.empty(n) + for i in range(n): + if not self._valid[i]: + voltage_pu[i] = 1.0 + continue + bus = self._bus_objects[i] + bus.SetActiveObject() + voltage_pu[i] = self._dss.Bus.puVmagAngle()[0] + + # ---- COMPUTE phase: fully vectorized ---- + v = voltage_pu + v_prev = self._voltage_prev + + v_stall_adj = self._v_stall * (1.0 + self._lf_adj * (self._comp_lf - 1.0)) + v_break_adj = self._v_break * (1.0 + self._lf_adj * (self._comp_lf - 1.0)) + + # -- stall timing -- + below_stall = (v < v_stall_adj) & (~self._stall) + # motors that were already counting and still below stall + counting_and_below = below_stall & self._stall_counting + stall_time = t_now - self._stall_time_start + newly_stalled = counting_and_below & (stall_time > self._t_stall) & (~self._stall) + self._stall[newly_stalled] = True + self._rstrt[newly_stalled] = False + # motors that just started counting + start_counting = below_stall & (~self._stall_counting) + self._stall_time_start[start_counting] = t_now + self._stall_counting[below_stall] = True + # motors no longer below stall: reset counting + self._stall_counting[~below_stall] = False + + # -- restart timing -- + above_rstrt = (v > self._v_rstrt) & (~self._rstrt) + counting_and_above = above_rstrt & self._rstrt_counting + rstrt_time = t_now - self._rstrt_time_start + newly_restarted = counting_and_above & (rstrt_time > self._t_restart) + self._rstrt[newly_restarted] = True + start_rstrt_counting = above_rstrt & (~self._rstrt_counting) + self._rstrt_time_start[start_rstrt_counting] = t_now + self._rstrt_counting[above_rstrt] = True + self._rstrt_counting[~above_rstrt] = False + + # -- UV trip -- + below_uv = (v < self._uv_tr1) & (~self._uv_trip) + uv_counting_below = below_uv & self._uv_counting + uv_time = t_now - self._uv_time_start + newly_uv_tripped = uv_counting_below & (uv_time > self._t_tr1) & (~self._uv_trip) + self._uv_trip[newly_uv_tripped] = True + self._uv_counting[newly_uv_tripped] = False + start_uv_counting = below_uv & (~self._uv_counting) & (~self._uv_trip) + self._uv_time_start[start_uv_counting] = t_now + self._uv_counting[start_uv_counting] = True + + Kthuv = np.where(self._uv_trip, 1.0 - self._f_uvr, 1.0) + + # -- contactor -- + rising = v_prev <= v + # reconnect path + Kthc_recon = np.where(v > self._vc_1on, 1.0, + np.where(v < self._vc_2on, 0.0, + (v - self._vc_2on) / (self._vc_1on - self._vc_2on))) + # trip path + Kthc_trip = np.where(v > self._vc_1off, 1.0, + np.where(v < self._vc_2off, 0.0, + (v - self._vc_2off) / (self._vc_1off - self._vc_2off))) + Kthc = np.where(rising, Kthc_recon, Kthc_trip) + + # -- p0, q0 -- + p0 = 1.0 - self._k_p1 * (1.0 - v_break_adj) ** self._n_p1 + q0 = (np.sqrt(1.0 - self._rated_pf ** 2) / self._rated_pf + - self._k_q1 * (1.0 - v_break_adj) ** self._n_q1) + + # -- stall power -- + p_stall = v ** 2 * self._r_stall_pu / self._z2 + q_stall = v ** 2 * self._x_stall_pu / self._z2 + + # -- running power (stage I or II) -- + above_break = v > v_break_adj + p_run = np.where(above_break, + p0 + self._k_p1 * (v - v_break_adj) ** self._n_p1, + p0 + self._k_p2 * (v_break_adj - v) ** self._n_p2) + q_run = np.where(above_break, + q0 + self._k_q1 * (v - v_break_adj) ** self._n_q1, + q0 + self._k_q2 * (v_break_adj - v) ** self._n_q2) + + # Decide p_rstrt, q_rstrt, p_nonrstrt, q_nonrstrt and thermal inputs + # Case 1: stall=True, rstrt=True (stage III restarted) + # Case 2: stall=True, rstrt=False (stage III not restarted) + # Case 3: stall=False (stage I/II) + + case1 = self._stall & self._rstrt + case2 = self._stall & (~self._rstrt) + case3 = ~self._stall + + # Initialize output arrays + p_rstrt = np.empty(n) + q_rstrt = np.empty(n) + p_nonrstrt = np.empty(n) + q_nonrstrt = np.empty(n) + i2r_rstr = np.empty(n) + i2r_nonrstr = np.empty(n) + + # Case 1: stall + restart + if case1.any(): + cur_nonrstrt_1 = v[case1] / np.sqrt(self._z2[case1]) + cur_rstrt_1 = p_run[case1] / v[case1] + i2r_rstr[case1] = cur_rstrt_1 ** 2 * self._r_stall_pu[case1] + i2r_nonrstr[case1] = cur_nonrstrt_1 ** 2 * self._r_stall_pu[case1] + p_rstrt[case1] = p_run[case1] * self._f_rst[case1] + q_rstrt[case1] = q_run[case1] * self._f_rst[case1] + p_nonrstrt[case1] = p_stall[case1] * (1.0 - self._f_rst[case1]) + q_nonrstrt[case1] = q_stall[case1] * (1.0 - self._f_rst[case1]) + + # Case 2: stall + not restart + if case2.any(): + cur_2 = v[case2] / np.sqrt(self._z2[case2]) + i2r_rstr[case2] = cur_2 ** 2 * self._r_stall_pu[case2] + i2r_nonrstr[case2] = cur_2 ** 2 * self._r_stall_pu[case2] + p_rstrt[case2] = p_stall[case2] * self._f_rst[case2] + q_rstrt[case2] = q_stall[case2] * self._f_rst[case2] + p_nonrstrt[case2] = p_stall[case2] * (1.0 - self._f_rst[case2]) + q_nonrstrt[case2] = q_stall[case2] * (1.0 - self._f_rst[case2]) + + # Case 3: not stalled + if case3.any(): + cur_3 = p_run[case3] / v[case3] + i2r_rstr[case3] = cur_3 ** 2 * self._r_stall_pu[case3] + i2r_nonrstr[case3] = cur_3 ** 2 * self._r_stall_pu[case3] + p_rstrt[case3] = p_run[case3] * self._f_rst[case3] + q_rstrt[case3] = q_run[case3] * self._f_rst[case3] + p_nonrstrt[case3] = p_run[case3] * (1.0 - self._f_rst[case3]) + q_nonrstrt[case3] = q_run[case3] * (1.0 - self._f_rst[case3]) + + # -- thermal update (bilinear transform) -- + temp_rstr = (self._dt * (i2r_rstr + self._i2r_rstr_prev) - + (self._dt - 2.0 * self._t_th) * self._temp_rstr_prev) / (2.0 * self._t_th + self._dt) + temp_nonrstr = (self._dt * (i2r_nonrstr + self._i2r_nonrstr_prev) - + (self._dt - 2.0 * self._t_th) * self._temp_nonrstr_prev) / (2.0 * self._t_th + self._dt) + + # -- thermal protection -- + # Only applies when stalled + stalled = self._stall + + # restartable fraction thermal + Kth_rstr = np.ones(n) + tripped_rstr = self._trip_rstr & stalled + Kth_rstr[tripped_rstr] = 0.0 + newly_trip_rstr = (~self._trip_rstr) & stalled & (temp_rstr > self._t_th2t) + self._trip_rstr[newly_trip_rstr] = True + Kth_rstr[newly_trip_rstr] = 0.0 + partial_rstr = (~self._trip_rstr) & stalled & (temp_rstr > self._t_th1t) & (temp_rstr <= self._t_th2t) + Kth_rstr[partial_rstr] = 1.0 - (temp_rstr[partial_rstr] - self._t_th1t[partial_rstr]) / (self._t_th2t[partial_rstr] - self._t_th1t[partial_rstr]) + + # non-restartable fraction thermal + Kth_nonrstr = np.ones(n) + tripped_nonrstr = self._trip_nonrstr & stalled + Kth_nonrstr[tripped_nonrstr] = 0.0 + newly_trip_nonrstr = (~self._trip_nonrstr) & stalled & (temp_nonrstr > self._t_th2t) + self._trip_nonrstr[newly_trip_nonrstr] = True + Kth_nonrstr[newly_trip_nonrstr] = 0.0 + partial_nonrstr = (~self._trip_nonrstr) & stalled & (temp_nonrstr > self._t_th1t) & (temp_nonrstr <= self._t_th2t) + Kth_nonrstr[partial_nonrstr] = 1.0 - (temp_nonrstr[partial_nonrstr] - self._t_th1t[partial_nonrstr]) / (self._t_th2t[partial_nonrstr] - self._t_th1t[partial_nonrstr]) + + # -- final power setpoints -- + pset = (Kth_rstr * p_rstrt + Kth_nonrstr * p_nonrstrt) * self._kw_rated + qset = (Kth_rstr * q_rstrt + Kth_nonrstr * q_nonrstrt) * self._kw_rated + pset_final = Kthc * Kthuv * pset + qset_final = Kthc * Kthuv * qset + + # ---- WRITE phase: per-element API calls ---- + dss = self._dss + run_cmd = dss.utils.run_command + for i in range(n): + if not self._valid[i]: + continue + fname = self._full_names[i] + run_cmd(f"{fname}.kw={pset_final[i]}") + run_cmd(f"{fname}.kvar={qset_final[i]}") + + # ---- update state for next step ---- + self._voltage_prev[:] = v + self._temp_rstr_prev[:] = temp_rstr + self._i2r_rstr_prev[:] = i2r_rstr + self._temp_nonrstr_prev[:] = temp_nonrstr + self._i2r_nonrstr_prev[:] = i2r_nonrstr + + return 0 diff --git a/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py b/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py index f58e0d7c..7956b66e 100644 --- a/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py +++ b/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py @@ -11,6 +11,33 @@ from pydss.pyControllers.enumerations import PvStandard, VoltageCalcModes, RideThroughCategory, PermissiveOperation, MayTripOperation, MultipleDisturbances +def _extract_poly_coords(region): + """Extract exterior coordinate lists from a Polygon or MultiPolygon.""" + if region is None: + return [] + if hasattr(region, 'geoms'): + return [list(g.exterior.coords) for g in region.geoms] + else: + return [list(region.exterior.coords)] + + +def _point_in_any_polygon(x, y, poly_list): + """Ray-casting point-in-polygon test across a list of coordinate rings.""" + for coords in poly_list: + n = len(coords) + inside = False + j = n - 1 + for i in range(n): + xi, yi = coords[i] + xj, yj = coords[j] + if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi): + inside = not inside + j = i + if inside: + return True + return False + + class PvVoltageRideThru(ControllerAbstract): """Implementation of IEEE1547-2003 and IEEE1547-2018 voltage ride-through standards using the OpenDSS Generator model. Subclass of the :class:`pydss.pyControllers.pyControllerAbstract.ControllerAbstract` abstract class. @@ -27,6 +54,7 @@ class PvVoltageRideThru(ControllerAbstract): :raises: Assertionerror if 'pv_object' is not a wrapped OpenDSS Generator element """ + ACTIVE_PRIORITIES = (0, 2) def __init__(self, pv_object, settings, dss_instance, elm_object_list, dss_solver): super(PvVoltageRideThru, self).__init__(pv_object, settings, dss_instance, elm_object_list, dss_solver) @@ -59,7 +87,7 @@ def __init__(self, pv_object, settings, dss_instance, elm_object_list, dss_solve self._pf_rated = float(pv_object.GetParameter('pf')) self._phase_rated = float(pv_object.GetParameter('Phases')) - logger.info(f"{self._name} -> _p_rated: {self._p_rated }, _pf_rated: {self._pf_rated}, _phase_rated: {self._phase_rated}, dt: {self.dt}") + logger.debug(f"{self._name} -> _p_rated: {self._p_rated }, _pf_rated: {self._pf_rated}, _phase_rated: {self._phase_rated}, dt: {self.dt}") # os.system("PAUSE") self.t_rv = 0.02 @@ -324,9 +352,12 @@ def _create_operation_regions(self): self.momentary_sucession_region = unary_union([permissive_ov_region, permissive_uv_region]) self.trip_region = unary_union([ov_trip_region, uv_trip_region, may_trip_region]) self.normal_region = contineous_region - - - + + # Pre-extract polygon coordinates for fast point-in-polygon checks + self._curr_lim_polys = _extract_poly_coords(self.curr_lim_region) + self._momentary_polys = _extract_poly_coords(self.momentary_sucession_region) + self._trip_polys = _extract_poly_coords(self.trip_region) + return V, T def Update(self, priority, time, update_results): @@ -334,15 +365,15 @@ def Update(self, priority, time, update_results): error = 0 self.time_change = self.time != (priority, time) self.time = time - logger.info(f"self._name: {self._name}") - logger.info(f"priority: {priority}") + logger.debug(f"self._name: {self._name}") + logger.debug(f"priority: {priority}") if priority == 0: self._is_connected = self._connect() - logger.info(f"self._is_connected: {self._is_connected}") + logger.debug(f"self._is_connected: {self._is_connected}") if priority == 2: u_in = self._update_violaton_timers() - logger.info(f"Update u_in: {u_in}") + logger.debug(f"Update u_in: {u_in}") if self.model.follow_standard == PvStandard.IEEE_1547_2018: self.voltage_ride_through(u_in) elif self.model.follow_standard == PvStandard.IEEE_1547_2003: @@ -371,49 +402,50 @@ def voltage_ride_through(self, u_in): """ # self._fault_counter_clearing_time_sec = 1 - Pm = Point(self._u_violation_time, u_in) - logger.info(Pm) - if Pm.within(self.curr_lim_region): + pt_t = self._u_violation_time + pt_v = u_in + logger.debug(f"Point({pt_t}, {pt_v})") + if _point_in_any_polygon(pt_t, pt_v, self._curr_lim_polys): region = 0 is_in_contioeous_region = False - logger.info(f"curr_lim_region") - elif self.momentary_sucession_region and Pm.within(self.momentary_sucession_region): + logger.debug(f"curr_lim_region") + elif self._momentary_polys and _point_in_any_polygon(pt_t, pt_v, self._momentary_polys): region = 1 is_in_contioeous_region = False self._trip(self.__dss_solver.GetStepSizeSec(), 0.5, False) # rrpt = 2.0 - logger.info(f"momentary_sucession_region") - elif Pm.within(self.trip_region): + logger.debug(f"momentary_sucession_region") + elif _point_in_any_polygon(pt_t, pt_v, self._trip_polys): region = 2 is_in_contioeous_region = False if self.region == [3, 1, 1]: - logger.info(f"self.region 3:1:1 {self.region}") + logger.debug(f"self.region 3:1:1 {self.region}") self._trip(self._trip_deadtime_sec, self._time_to_p_max_sec, False, True) else: - logger.info(f"self.region else {self.region}") + logger.debug(f"self.region else {self.region}") self._trip(self._trip_deadtime_sec, self._time_to_p_max_sec, True) - logger.info(f"trip_region") + logger.debug(f"trip_region") else: is_in_contioeous_region = True region = 3 - logger.info(f"is_in_contioeous_region") + logger.debug(f"is_in_contioeous_region") - logger.info(f"old self.region: {self.region}") + logger.debug(f"old self.region: {self.region}") self.region = self.region[1:] + self.region[:1] # shift [1,2,3]->[2,3,1] self.region[0] = region - logger.info(f"new self.region: {self.region}") + logger.debug(f"new self.region: {self.region}") if is_in_contioeous_region and not self._is_in_contioeous_region: - logger.info(f"faulr just clear") + logger.debug(f"faulr just clear") self._fault_window_clearing_start_time = self.__dss_solver.GetDateTime() clearing_time = (self.__dss_solver.GetDateTime() - self._fault_window_clearing_start_time).total_seconds() - logger.info(f"clearing_time: {clearing_time}") + logger.debug(f"clearing_time: {clearing_time}") if self._is_in_contioeous_region and not is_in_contioeous_region: if clearing_time <= self._fault_counter_clearing_time_sec: self._fault_counter += 1 if self._fault_counter > self._fault_counter_max: if self.model.multiple_disturdances == MultipleDisturbances.TRIP: - logger.info(f"multiple_disturdances trip") + logger.debug(f"multiple_disturdances trip") self._trip(self._trip_deadtime_sec, self._time_to_p_max_sec, True) self._fault_counter = 0 else: @@ -426,7 +458,7 @@ def voltage_ride_through(self, u_in): def DERA_control(self, mode): u_in = self._controlled_element.GetVariable('VoltagesMagAng')[::2] u_base = self._controlled_element.sBus[0].GetVariable('kVBase') * 1000 - logger.info(f"{self._name} -> u_in: {u_in}") + logger.debug(f"{self._name} -> u_in: {u_in}") Pord = 1.0 if self._phase_rated == 1: @@ -437,7 +469,7 @@ def DERA_control(self, mode): u_in = sum(u_in) / u_base / 3.0 if self.__dss_solver.GetTotalSeconds() < round(self.dt, 5): - logger.info(f"OpenDSS Initializatin -> time: {self.__dss_solver.GetTotalSeconds()}") + logger.debug(f"OpenDSS Initializatin -> time: {self.__dss_solver.GetTotalSeconds()}") self.u_in_prev = u_in self.ut_filt_prev = u_in self.Vtrip_ctrl_prev = 1.0 @@ -494,15 +526,15 @@ def DERA_control(self, mode): if self._fault_counter > 0 and (Ip_out_filt > self.Ip_out_filt_prev + self.rrpwr * self.dt): Ip_out_filt = self.Ip_out_filt_prev + self.rrpwr * self.dt - logger.info(f"u_in: {u_in}") - logger.info(f"Ipcmd: {Ipcmd}") - logger.info(f"Ip_out: {Ip_out}") - logger.info(f"self.ut_filt: {self.ut_filt}") - logger.info(f"Ip_out_filt: {Ip_out_filt}") - logger.info(f"Ip_out_filt_prev: {self.Ip_out_filt_prev}") - logger.info(f"Iq_out_filt: {Iq_out_filt}") - logger.info(f"self.Vmult: {self.Vmult}") - logger.info(f"mode: {mode}") + logger.debug(f"u_in: {u_in}") + logger.debug(f"Ipcmd: {Ipcmd}") + logger.debug(f"Ip_out: {Ip_out}") + logger.debug(f"self.ut_filt: {self.ut_filt}") + logger.debug(f"Ip_out_filt: {Ip_out_filt}") + logger.debug(f"Ip_out_filt_prev: {self.Ip_out_filt_prev}") + logger.debug(f"Iq_out_filt: {Iq_out_filt}") + logger.debug(f"self.Vmult: {self.Vmult}") + logger.debug(f"mode: {mode}") self.DERA_p_limit = math.sqrt(Ip_out_filt*Ip_out_filt + Iq_out_filt*Iq_out_filt) * u_in * self._p_rated @@ -530,11 +562,11 @@ def _connect(self): self.voltage[0] = u_in u_in = sum(self.voltage) / len(self.voltage) deadtime = (self.__dss_solver.GetDateTime() - self._tripped_start_time).total_seconds() - logger.info(f"_connect u_in: {u_in}") - logger.info(f"deadtime: {deadtime}") - logger.info(f"self._tripped_dead_time: {self._tripped_dead_time}") + logger.debug(f"_connect u_in: {u_in}") + logger.debug(f"deadtime: {deadtime}") + logger.debug(f"self._tripped_dead_time: {self._tripped_dead_time}") # self.DERA_control(0.0) - # logger.info(f"self.DERA_p_limit: {self.DERA_p_limit}") + # logger.debug(f"self.DERA_p_limit: {self.DERA_p_limit}") # os.system("PAUSE") if u_in < self._rvs[0] and u_in > self._rvs[1] and deadtime >= self._tripped_dead_time: @@ -546,9 +578,9 @@ def _connect(self): conntime = (self.__dss_solver.GetDateTime() - self._reconnect_start_time).total_seconds() self._p_limit = conntime / self._tripped_p_max_delay * self._p_rated if conntime < self._tripped_p_max_delay \ else self._p_rated - logger.info(f"self._p_limit: {self._p_limit}") + logger.debug(f"self._p_limit: {self._p_limit}") # self.DERA_control(1.0) - # logger.info(f"self.DERA_p_limit: {self.DERA_p_limit}") + # logger.debug(f"self.DERA_p_limit: {self.DERA_p_limit}") # os.system("PAUSE") self._controlled_element.SetParameter('kw', self._p_limit) return self._is_connected @@ -574,14 +606,14 @@ def _trip(self, Deadtime, time2Pmax, forceTrip, permissive_to_trip=False): self._tripped_start_time = self.__dss_solver.GetDateTime() self._tripped_p_max_delay = time2Pmax self._tripped_dead_time = Deadtime - logger.info(f"self._tripped_dead_time: {self._tripped_dead_time}") + logger.debug(f"self._tripped_dead_time: {self._tripped_dead_time}") return def _update_violaton_timers(self): u_in = self._controlled_element.GetVariable('VoltagesMagAng')[::2] u_base = self._controlled_element.sBus[0].GetVariable('kVBase') * 1000 u_in = max(u_in) / u_base if self._voltage_calc_mode == VoltageCalcModes.MAX else sum(u_in) / (u_base * len(u_in)) - logger.info(f"{self._name}: voltage {u_in}") + logger.debug(f"{self._name}: voltage {u_in}") if self.use_avg_voltage: self.voltage = self.voltage[1:] + self.voltage[:1] self.voltage[0] = u_in diff --git a/src/pydss/pyControllers/Controllers/PvVoltageRideThruBatch.py b/src/pydss/pyControllers/Controllers/PvVoltageRideThruBatch.py new file mode 100644 index 00000000..cb541e48 --- /dev/null +++ b/src/pydss/pyControllers/Controllers/PvVoltageRideThruBatch.py @@ -0,0 +1,327 @@ +# Vectorized batch implementation of PvVoltageRideThru controller +# +# Processes all PV ride-through controllers in a single pass per priority, +# reading voltage once per priority call to eliminate redundant API calls. +# Pre-caches kVBase (constant during simulation) to halve voltage reads. + +import numpy as np +from loguru import logger + +from pydss.pyControllers.Controllers.PvVoltageRideThru import _point_in_any_polygon +from pydss.pyControllers.enumerations import ( + PvStandard, + VoltageCalcModes, + MultipleDisturbances, +) + + +class PvVoltageRideThruBatch: + """Drop-in replacement that processes ALL PvVoltageRideThru controllers + in a single pass per priority, minimizing redundant OpenDSS API calls. + + Compared to running N individual PvVoltageRideThru controllers: + - Reads voltage once per priority instead of 2-3 times (saves ~N API calls) + - Pre-caches kVBase so no per-step kVBase reads (saves 2*N API calls) + - Eliminates redundant voltage read inside _trip() (saves N API calls) + - Uses run_command for writes (same as individual) + - Vectorizes timer updates and reconnect ramp with numpy + + Expected by dssInstance: .Update(Priority, time, update_results) -> 0 + .Name() -> str + .ControlledElement() -> str + .ACTIVE_PRIORITIES = (0, 2) + """ + + ACTIVE_PRIORITIES = (0, 2) + + def __init__(self, controllers): + """Build from a list of already-constructed PvVoltageRideThru instances. + + Parameters + ---------- + controllers : list[PvVoltageRideThru] + The individual controller instances whose state and settings + are transferred into vectorized arrays. + """ + n = len(controllers) + if n == 0: + raise ValueError("PvVoltageRideThruBatch requires at least one controller") + self._n = n + + self._elements = [c._controlled_element for c in controllers] + # Access name-mangled __dss_solver via Python name mangling + self._dss_solver = controllers[0]._PvVoltageRideThru__dss_solver + self._dss = controllers[0]._controlled_element._dssInstance + self._full_names = [elem._FullName for elem in self._elements] + + # Pre-cache kVBase in volts (constant during simulation) + self._u_base = np.array([ + c._controlled_element.sBus[0].GetVariable('kVBase') * 1000 + for c in controllers + ]) + + # Voltage calc mode: True=max, False=avg + self._use_max_voltage = np.array([ + c._voltage_calc_mode == VoltageCalcModes.MAX for c in controllers + ]) + + # ---- Per-controller settings → arrays ---- + self._p_rated = np.array([c._p_rated for c in controllers]) + self._rvs_upper = np.array([c._rvs[0] for c in controllers]) + self._rvs_lower = np.array([c._rvs[1] for c in controllers]) + self._trip_deadtime_sec = np.array([c._trip_deadtime_sec for c in controllers]) + self._time_to_p_max_sec = np.array([c._time_to_p_max_sec for c in controllers]) + + self._step_size_sec = self._dss_solver.GetStepSizeSec() + + # Per-controller polygon data (lists; empty for 2003 controllers) + self._curr_lim_polys_list = [ + getattr(c, '_curr_lim_polys', []) for c in controllers + ] + self._momentary_polys_list = [ + getattr(c, '_momentary_polys', []) for c in controllers + ] + self._trip_polys_list = [ + getattr(c, '_trip_polys', []) for c in controllers + ] + + # Standard flags + self._is_1547_2018 = [ + c.model.follow_standard == PvStandard.IEEE_1547_2018 + for c in controllers + ] + self._is_1547_2003 = [ + c.model.follow_standard == PvStandard.IEEE_1547_2003 + for c in controllers + ] + self._multiple_dist_trip = [ + getattr(c.model, 'multiple_disturdances', None) == MultipleDisturbances.TRIP + for c in controllers + ] + + # Fault counter limits + self._fault_counter_max = np.array([ + getattr(c, '_fault_counter_max', 0) for c in controllers + ]) + self._fault_counter_clearing_time_sec = np.array([ + getattr(c, '_fault_counter_clearing_time_sec', 0) + for c in controllers + ]) + + # ---- State arrays (float seconds instead of datetime) ---- + t_now = self._dss_solver.GetTotalSeconds() + self._is_connected = np.ones(n, dtype=bool) + self._p_limit = self._p_rated.copy() + self._reconnect_start_time = t_now - self._time_to_p_max_sec + self._tripped_p_max_delay = np.zeros(n) + self._tripped_dead_time = np.zeros(n) + self._tripped_start_time = np.full(n, t_now) + self._normal_operation = np.ones(n, dtype=bool) + self._normal_operation_start_time = np.full(n, t_now) + self._u_violation_time = np.full(n, 99999.0) + self._voltage_violation_m = np.zeros(n, dtype=bool) + self._fault_counter = np.zeros(n, dtype=int) + self._is_in_continuous_region = np.ones(n, dtype=bool) + self._fault_window_clearing_start_time = np.full(n, t_now) + self._uViolation_start_time = np.full(n, t_now) + + # Region history: (n, 3) - rotation matches original's list rotation + self._region = np.full((n, 3), 3, dtype=int) + + logger.info( + f"PvVoltageRideThruBatch created with {n} controllers, " + f"2018: {sum(self._is_1547_2018)}, 2003: {sum(self._is_1547_2003)}" + ) + + # ---- Interface expected by dssInstance ---- + + def Name(self): + return "PvVoltageRideThruBatch" + + def ControlledElement(self): + info = self._elements[0].GetInfo() + return f"{info[0]}.{info[1]}" + + def debugInfo(self): + return [] + + def _read_voltages(self): + """Read per-unit voltage for each controller. + + Uses the element's VoltagesMagAng (same as original) but with + pre-cached kVBase to avoid per-step bus reads. + """ + n = self._n + v = np.empty(n) + for i in range(n): + v_mag = self._elements[i].GetVariable('VoltagesMagAng')[::2] + if self._use_max_voltage[i]: + v[i] = max(v_mag) / self._u_base[i] + else: + v[i] = sum(v_mag) / (self._u_base[i] * len(v_mag)) + return v + + def Update(self, priority, time_val, update_results): + if priority == 0: + self._update_connect() + elif priority == 2: + self._update_ride_through() + return 0 + + def _update_connect(self): + """Priority 0: reconnect and power ramp logic.""" + t_now = self._dss_solver.GetTotalSeconds() + u_in = self._read_voltages() + run_cmd = self._dss.utils.run_command + + # Snapshot disconnected state before modifications + was_disconnected = ~self._is_connected.copy() + + # --- Disconnected controllers: check reconnect conditions --- + deadtime = t_now - self._tripped_start_time + in_range = (u_in < self._rvs_upper) & (u_in > self._rvs_lower) + can_reconnect = was_disconnected & in_range & (deadtime >= self._tripped_dead_time) + + if can_reconnect.any(): + for i in np.where(can_reconnect)[0]: + run_cmd(f"{self._full_names[i]}.enabled=yes") + run_cmd(f"{self._full_names[i]}.kw=0") + self._is_connected[can_reconnect] = True + self._reconnect_start_time[can_reconnect] = t_now + + # --- Already connected controllers: compute ramp and set kw --- + already_connected = self._is_connected & ~can_reconnect + if already_connected.any(): + idx = np.where(already_connected)[0] + conntime = t_now - self._reconnect_start_time[idx] + delay = self._tripped_p_max_delay[idx] + rated = self._p_rated[idx] + ramping = conntime < delay + p_lim = np.where( + ramping, + conntime / np.maximum(delay, 1e-30) * rated, + rated, + ) + self._p_limit[idx] = p_lim + for i in idx: + run_cmd(f"{self._full_names[i]}.kw={self._p_limit[i]}") + + def _update_ride_through(self): + """Priority 2: violation timers and voltage ride-through logic.""" + n = self._n + t_now = self._dss_solver.GetTotalSeconds() + u_in = self._read_voltages() + run_cmd = self._dss.utils.run_command + + # ---- Update violation timers (vectorized) ---- + in_normal = (u_in < self._rvs_upper) & (u_in > self._rvs_lower) + + # Transition to normal voltage + just_normal = in_normal & ~self._normal_operation + self._normal_operation[in_normal] = True + self._normal_operation_start_time[just_normal] = t_now + self._voltage_violation_m[in_normal] = False + + # Transition to abnormal voltage + abnormal = ~in_normal + just_abnormal = abnormal & ~self._voltage_violation_m + self._voltage_violation_m[abnormal] = True + self._uViolation_start_time[just_abnormal] = t_now + self._u_violation_time[just_abnormal] = 0.0 + already_abnormal = abnormal & ~just_abnormal + self._u_violation_time[already_abnormal] = ( + t_now - self._uViolation_start_time[already_abnormal] + ) + + # ---- Ride-through logic (per-controller for polygon checks) ---- + new_region = np.full(n, 3, dtype=int) + new_is_continuous = np.ones(n, dtype=bool) + + for i in range(n): + # IEEE 1547-2003: simple undervoltage trip + if self._is_1547_2003[i]: + if u_in[i] < 0.88 and self._is_connected[i]: + run_cmd(f"{self._full_names[i]}.kw=0") + self._is_connected[i] = False + self._tripped_start_time[i] = t_now + self._tripped_p_max_delay[i] = 0.4 + self._tripped_dead_time[i] = 30.0 + continue + + if not self._is_1547_2018[i]: + continue + + pt_t = self._u_violation_time[i] + pt_v = u_in[i] + + if _point_in_any_polygon(pt_t, pt_v, self._curr_lim_polys_list[i]): + new_region[i] = 0 + new_is_continuous[i] = False + + elif (self._momentary_polys_list[i] and + _point_in_any_polygon(pt_t, pt_v, self._momentary_polys_list[i])): + new_region[i] = 1 + new_is_continuous[i] = False + # Momentary: only trip if currently connected + if self._is_connected[i]: + run_cmd(f"{self._full_names[i]}.kw=0") + self._is_connected[i] = False + self._tripped_start_time[i] = t_now + self._tripped_p_max_delay[i] = 0.5 + self._tripped_dead_time[i] = self._step_size_sec + + elif _point_in_any_polygon(pt_t, pt_v, self._trip_polys_list[i]): + new_region[i] = 2 + new_is_continuous[i] = False + # Trip region: always trip (forceTrip or permissive_to_trip + # both result in tripping regardless of connected state) + run_cmd(f"{self._full_names[i]}.kw=0") + self._is_connected[i] = False + self._tripped_start_time[i] = t_now + self._tripped_p_max_delay[i] = self._time_to_p_max_sec[i] + self._tripped_dead_time[i] = self._trip_deadtime_sec[i] + # else: continuous region (region=3), no action + + # ---- Update region history ---- + # Original rotation: [a,b,c] → [b,c,a] then [0]=new + # Result: [new, old[2], old[0]] + old_0 = self._region[:, 0].copy() + old_2 = self._region[:, 2].copy() + self._region[:, 0] = new_region + self._region[:, 1] = old_2 + self._region[:, 2] = old_0 + + # ---- Fault counter logic (vectorized where possible) ---- + # Transition: non-continuous → continuous (fault window clearing starts) + to_continuous = new_is_continuous & ~self._is_in_continuous_region + self._fault_window_clearing_start_time[to_continuous] = t_now + + clearing_time = t_now - self._fault_window_clearing_start_time + + # Transition: continuous → non-continuous (new fault event) + from_continuous = self._is_in_continuous_region & ~new_is_continuous + within_window = from_continuous & ( + clearing_time <= self._fault_counter_clearing_time_sec + ) + self._fault_counter[within_window] += 1 + + # Fault counter exceeded max → trip if configured + exceeded = within_window & (self._fault_counter > self._fault_counter_max) + if exceeded.any(): + for i in np.where(exceeded)[0]: + if self._multiple_dist_trip[i]: + run_cmd(f"{self._full_names[i]}.kw=0") + self._is_connected[i] = False + self._tripped_start_time[i] = t_now + self._tripped_p_max_delay[i] = self._time_to_p_max_sec[i] + self._tripped_dead_time[i] = self._trip_deadtime_sec[i] + self._fault_counter[i] = 0 + + # Clear counter if clearing time exceeded + clear_counter = ( + (clearing_time > self._fault_counter_clearing_time_sec) & + (self._fault_counter > 0) + ) + self._fault_counter[clear_counter] = 0 + + self._is_in_continuous_region[:] = new_is_continuous diff --git a/src/pydss/pyControllers/pyControllerAbstract.py b/src/pydss/pyControllers/pyControllerAbstract.py index dbb79dd8..345d6f58 100644 --- a/src/pydss/pyControllers/pyControllerAbstract.py +++ b/src/pydss/pyControllers/pyControllerAbstract.py @@ -3,6 +3,10 @@ class ControllerAbstract(abc.ABC): + # Subclasses can override to declare which priorities they handle. + # Defaults to all priorities for backward compatibility. + ACTIVE_PRIORITIES = (0, 1, 2) + def __init__(self, controlledObj, Settings, dssInstance, ElmObjectList, dssSolver): """Abstract class CONSTRUCTOR.""" pass diff --git a/tests/data/controllers/simulation_pv_ride_through.toml b/tests/data/controllers/simulation_pv_ride_through.toml new file mode 100644 index 00000000..9927b214 --- /dev/null +++ b/tests/data/controllers/simulation_pv_ride_through.toml @@ -0,0 +1,60 @@ +[Project] +"Start time" = "2019-06-20 00:00:00.0" +"Simulation duration (min)" = 0.05 +"Loadshape start time" = "2019-06-20 00:00:00.0" +"Step resolution (sec)" = 0.05 +"Max Control Iterations" = 50 +"Error tolerance" = 0.001 +"Control mode" = "Static" +"Disable pydss controllers" = false +"Simulation Type" = "QSTS" +"Project Path" = "./tests/data" +"Active Project" = "controllers" +"Active Scenario" = "base_case" +"DSS File" = "Master_Spohn_existing_VV.dss" +"DSS File Absolute Path" = false +"Return Results" = false + +[[Project.Scenarios]] +name = "voltage_ride_through" +post_process_infos = [] + +[Exports] +"Export Mode" = "byClass" +"Export Style" = "Single file" +"Export Format" = "csv" +"Export Compression" = false +"Export Elements" = false +"Export Data Tables" = false +"Export Data In Memory" = false +"HDF Max Chunk Bytes" = 32768 +"Export Event Log" = true +"Log Results" = true + +[Frequency] +"Enable frequency sweep" = false +"Fundamental frequency" = 60 +"Start frequency" = 1.0 +"End frequency" = 15.0 +"frequency increment" = 2.0 +"Neglect shunt admittance" = false +"Percentage load in series" = 50.0 + +[Helics] +"Co-simulation Mode" = false +"Federate name" = "pydss" +"Time delta" = 0.01 +"Core type" = "zmq" +Uninterruptible = true +"Helics logging level" = 5 + +[Logging] +"Logging Level" = "INFO" +"Log to external file" = true +"Display on screen" = true +"Clear old log file" = false + +[MonteCarlo] +"Number of Monte Carlo scenarios" = -1 + +[Reports] diff --git a/tests/data/motor_stall_baseline.json b/tests/data/motor_stall_baseline.json new file mode 100644 index 00000000..4547b8ab --- /dev/null +++ b/tests/data/motor_stall_baseline.json @@ -0,0 +1,1288 @@ +{ + "kw_columns": [ + "Load.mpx000635970__A1 [kVA]", + "Load.mpx000635970__N1 [kVA]", + "Load.mpx000460267__A1 [kVA]", + "Load.mpx000460267__N1 [kVA]", + "Load.mpx000637601__A1 [kVA]", + "Load.mpx000637601__N1 [kVA]", + "Load.mpx000594341__A1 [kVA]", + "Load.mpx000594341__N1 [kVA]" + ], + "kw_values": [ + [ + 1.7448250686753124, + 0.0, + 1.7192608107747853, + 0.0, + 0.35498922812932193, + 0.0, + 2.023341926587915, + 0.0 + ], + [ + 1.7558561456770863, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0370532645301513, + 0.0 + ], + [ + 1.754620154517619, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0360013410467177, + 0.0 + ], + [ + 1.7545701020288038, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0359605286187517, + 0.0 + ], + [ + 1.7545681397653845, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.035958933162884, + 0.0 + ], + [ + 1.7545680631209577, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.035958870851587, + 0.0 + ], + [ + 1.7545680601276958, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0359588684180925, + 0.0 + ], + [ + 1.7545680600107891, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0359588683230507, + 0.0 + ], + [ + 1.7545680600062161, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.035958868319334, + 0.0 + ], + [ + 1.6331700925553987, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4682607656686599, + 0.0 + ], + [ + 2.269576846441895, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46855744281178874, + 0.0 + ], + [ + 0.506230028078825, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.47062491395253253, + 0.0 + ], + [ + 0.27646184756716186, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4807195968836502, + 0.0 + ], + [ + 0.29624967904014604, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4805026403501998, + 0.0 + ], + [ + 2.0695007643233447, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46839373748929486, + 0.0 + ], + [ + 0.5085892629457479, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618579021534, + 0.0 + ], + [ + 0.27612545467941335, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48072401495342204, + 0.0 + ], + [ + 0.29630622039506305, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4805018448315003, + 0.0 + ], + [ + 2.0694975251210055, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4683937625167042, + 0.0 + ], + [ + 0.5085894740874674, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618522581111, + 0.0 + ], + [ + 0.2761254245710704, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.480724015348862, + 0.0 + ], + [ + 0.29630622545569435, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48050184476029456, + 0.0 + ], + [ + 2.0694975248310685, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46839376251894427, + 0.0 + ], + [ + 0.5085894741063623, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618522576052, + 0.0 + ], + [ + 0.2761254245683737, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48072401534890186, + 0.0 + ], + [ + 0.2963062254562111, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48050184476028557, + 0.0 + ], + [ + 2.0694975248310308, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46839376251894455, + 0.0 + ], + [ + 0.508589474106364, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618522576052, + 0.0 + ], + [ + 0.2761254245683737, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48072401534890186, + 0.0 + ], + [ + 0.2963062254562111, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48050184476028557, + 0.0 + ], + [ + 2.0694975248310308, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46839376251894455, + 0.0 + ], + [ + 0.508589474106364, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618522576052, + 0.0 + ], + [ + 0.2761254245683737, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48072401534890186, + 0.0 + ], + [ + 0.2963062254562111, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48050184476028557, + 0.0 + ], + [ + 2.0694975248310308, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46839376251894455, + 0.0 + ], + [ + 0.508589474106364, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618522576052, + 0.0 + ], + [ + 0.2761254245683737, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48072401534890186, + 0.0 + ], + [ + 0.2963062254562111, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48050184476028557, + 0.0 + ], + [ + 2.0694975248310308, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46839376251894455, + 0.0 + ], + [ + 0.508589474106364, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618522576052, + 0.0 + ], + [ + 0.2761254245683737, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48072401534890186, + 0.0 + ], + [ + 0.2963062254562111, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48050184476028557, + 0.0 + ], + [ + 2.0694975248310308, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46839376251894455, + 0.0 + ], + [ + 0.508589474106364, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618522576052, + 0.0 + ], + [ + 0.2761254245683737, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48072401534890186, + 0.0 + ], + [ + 0.2963062254562111, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48050184476028557, + 0.0 + ], + [ + 2.0694975248310308, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46839376251894455, + 0.0 + ], + [ + 0.508589474106364, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618522576052, + 0.0 + ], + [ + 0.2761254245683737, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48072401534890186, + 0.0 + ], + [ + 0.2963062254562111, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.48050184476028557, + 0.0 + ], + [ + 2.0694975248310308, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.46839376251894455, + 0.0 + ], + [ + 0.508589474106364, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.4705618522576052, + 0.0 + ], + [ + 0.29841247927590564, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0460415142963715, + 0.0 + ], + [ + 9.937579271252337, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.9584010017299485, + 0.0 + ], + [ + 9.370258325622824, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.9646497713894975, + 0.0 + ], + [ + 9.015475391027666, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.967793732145951, + 0.0 + ], + [ + 8.584543039219442, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.9718868367212963, + 0.0 + ], + [ + 8.15611886413042, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.9758789314802552, + 0.0 + ], + [ + 7.72655962955163, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.9798733271360571, + 0.0 + ], + [ + 7.2959348470184615, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.9838682302785478, + 0.0 + ] + ], + "kvar_columns": [ + "Load.mpx000635970__A1 [kVA]", + "Load.mpx000635970__N1 [kVA]", + "Load.mpx000460267__A1 [kVA]", + "Load.mpx000460267__N1 [kVA]", + "Load.mpx000637601__A1 [kVA]", + "Load.mpx000637601__N1 [kVA]", + "Load.mpx000594341__A1 [kVA]", + "Load.mpx000594341__N1 [kVA]" + ], + "kvar_values": [ + [ + 0.28097349934714666, + 0.0, + 0.12378680748235765, + 0.0, + -0.016578808887942866, + 0.0, + 0.31515940965719585, + 0.0 + ], + [ + 0.9777995527333886, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.31666351486126915, + 0.0 + ], + [ + 0.9918793834254129, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.3171320705633456, + 0.0 + ], + [ + 0.9925231507257206, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.3171498459450554, + 0.0 + ], + [ + 0.9925484088648507, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.31715054893222305, + 0.0 + ], + [ + 0.9925493954204216, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.3171505764001849, + 0.0 + ], + [ + 0.9925494339493138, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.3171505774729293, + 0.0 + ], + [ + 0.992549435454018, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.3171505775148243, + 0.0 + ], + [ + 0.9925494355127665, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.3171505775164594, + 0.0 + ], + [ + 0.9241925819487792, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07300884745704725, + 0.0 + ], + [ + 1.7259221759711196, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07484188115393019, + 0.0 + ], + [ + 0.4964192705046305, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07042680968814806, + 0.0 + ], + [ + 0.27615744279364085, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464590608204408, + 0.0 + ], + [ + 0.29626213173094257, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485906801747641, + 0.0 + ], + [ + 2.1145006968331144, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014502891261, + 0.0 + ], + [ + 0.4986848683712658, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07040347675712434, + 0.0 + ], + [ + 0.2758184924238183, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464430101924166, + 0.0 + ], + [ + 0.2963189867890641, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485916982432435, + 0.0 + ], + [ + 2.1144973111360055, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014391480613, + 0.0 + ], + [ + 0.4986850711120863, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07040347466877125, + 0.0 + ], + [ + 0.2758184620868729, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464430087556673, + 0.0 + ], + [ + 0.29631899187777744, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485916983343524, + 0.0 + ], + [ + 2.1144973108329546, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014391470644, + 0.0 + ], + [ + 0.49868507113022953, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0704034746685842, + 0.0 + ], + [ + 0.27581846208415556, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464430087555439, + 0.0 + ], + [ + 0.2963189918782973, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485916983343603, + 0.0 + ], + [ + 2.114497310832915, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014391470638, + 0.0 + ], + [ + 0.49868507113023103, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0704034746685842, + 0.0 + ], + [ + 0.27581846208415556, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464430087555439, + 0.0 + ], + [ + 0.2963189918782973, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485916983343603, + 0.0 + ], + [ + 2.114497310832915, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014391470638, + 0.0 + ], + [ + 0.49868507113023103, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0704034746685842, + 0.0 + ], + [ + 0.27581846208415556, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464430087555439, + 0.0 + ], + [ + 0.2963189918782973, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485916983343603, + 0.0 + ], + [ + 2.114497310832915, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014391470638, + 0.0 + ], + [ + 0.49868507113023103, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0704034746685842, + 0.0 + ], + [ + 0.27581846208415556, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464430087555439, + 0.0 + ], + [ + 0.2963189918782973, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485916983343603, + 0.0 + ], + [ + 2.114497310832915, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014391470638, + 0.0 + ], + [ + 0.49868507113023103, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0704034746685842, + 0.0 + ], + [ + 0.27581846208415556, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464430087555439, + 0.0 + ], + [ + 0.2963189918782973, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485916983343603, + 0.0 + ], + [ + 2.114497310832915, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014391470638, + 0.0 + ], + [ + 0.49868507113023103, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0704034746685842, + 0.0 + ], + [ + 0.27581846208415556, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464430087555439, + 0.0 + ], + [ + 0.2963189918782973, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485916983343603, + 0.0 + ], + [ + 2.114497310832915, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014391470638, + 0.0 + ], + [ + 0.49868507113023103, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0704034746685842, + 0.0 + ], + [ + 0.27581846208415556, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07464430087555439, + 0.0 + ], + [ + 0.2963189918782973, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07485916983343603, + 0.0 + ], + [ + 2.114497310832915, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.07612014391470638, + 0.0 + ], + [ + 0.49868507113023103, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0704034746685842, + 0.0 + ], + [ + 0.29861537909286917, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.31935566249741215, + 0.0 + ], + [ + 9.9399289575787, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.30527994095366195, + 0.0 + ], + [ + 9.367327937252306, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.30576593603283914, + 0.0 + ], + [ + 9.013578196678276, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.30634550372294295, + 0.0 + ], + [ + 8.582424061247806, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.30695028183896145, + 0.0 + ], + [ + 8.15412370292993, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.3075735110227046, + 0.0 + ], + [ + 7.724682808545379, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.308196679013851, + 0.0 + ], + [ + 7.294175208505134, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.3088199397452631, + 0.0 + ] + ], + "kw_index": [ + "2019-06-20 00:00:00", + "2019-06-20 00:00:00.049999952", + "2019-06-20 00:00:00.099999905", + "2019-06-20 00:00:00.150000095", + "2019-06-20 00:00:00.200000048", + "2019-06-20 00:00:00.250000", + "2019-06-20 00:00:00.299999952", + "2019-06-20 00:00:00.349999905", + "2019-06-20 00:00:00.400000095", + "2019-06-20 00:00:00.450000048", + "2019-06-20 00:00:00.500000", + "2019-06-20 00:00:00.549999952", + "2019-06-20 00:00:00.599999905", + "2019-06-20 00:00:00.650000095", + "2019-06-20 00:00:00.700000048", + "2019-06-20 00:00:00.750000", + "2019-06-20 00:00:00.799999952", + "2019-06-20 00:00:00.849999905", + "2019-06-20 00:00:00.900000095", + "2019-06-20 00:00:00.950000048", + "2019-06-20 00:00:01", + "2019-06-20 00:00:01.049999952", + "2019-06-20 00:00:01.099999905", + "2019-06-20 00:00:01.150000095", + "2019-06-20 00:00:01.200000048", + "2019-06-20 00:00:01.250000", + "2019-06-20 00:00:01.299999952", + "2019-06-20 00:00:01.349999905", + "2019-06-20 00:00:01.400000095", + "2019-06-20 00:00:01.450000048", + "2019-06-20 00:00:01.500000", + "2019-06-20 00:00:01.549999952", + "2019-06-20 00:00:01.599999905", + "2019-06-20 00:00:01.650000095", + "2019-06-20 00:00:01.700000048", + "2019-06-20 00:00:01.750000", + "2019-06-20 00:00:01.799999952", + "2019-06-20 00:00:01.849999905", + "2019-06-20 00:00:01.900000095", + "2019-06-20 00:00:01.950000048", + "2019-06-20 00:00:02", + "2019-06-20 00:00:02.049999952", + "2019-06-20 00:00:02.099999905", + "2019-06-20 00:00:02.150000095", + "2019-06-20 00:00:02.200000048", + "2019-06-20 00:00:02.250000", + "2019-06-20 00:00:02.299999952", + "2019-06-20 00:00:02.349999905", + "2019-06-20 00:00:02.400000095", + "2019-06-20 00:00:02.450000048", + "2019-06-20 00:00:02.500000", + "2019-06-20 00:00:02.549999952", + "2019-06-20 00:00:02.599999905", + "2019-06-20 00:00:02.650000095", + "2019-06-20 00:00:02.700000048", + "2019-06-20 00:00:02.750000", + "2019-06-20 00:00:02.799999952", + "2019-06-20 00:00:02.849999905", + "2019-06-20 00:00:02.900000095", + "2019-06-20 00:00:02.950000048" + ] +} \ No newline at end of file diff --git a/tests/data/pv_ride_through_baseline.json b/tests/data/pv_ride_through_baseline.json new file mode 100644 index 00000000..1724d5ef --- /dev/null +++ b/tests/data/pv_ride_through_baseline.json @@ -0,0 +1,1532 @@ +{ + "kw_columns": [ + "Generator.pvgnem_mpx000635970__A1 [kVA]", + "Generator.pvgnem_mpx000635970__N1 [kVA]", + "Generator.pvgnem_mpx000460267__A1 [kVA]", + "Generator.pvgnem_mpx000460267__N1 [kVA]", + "Generator.pvgnem_mpx000594341__A1 [kVA]", + "Generator.pvgnem_mpx000594341__N1 [kVA]", + "Generator.pvgui_mpx000637601__A1 [kVA]", + "Generator.pvgui_mpx000637601__N1 [kVA]", + "Generator.pvgui_mpx000460267__A1 [kVA]", + "Generator.pvgui_mpx000460267__N1 [kVA]" + ], + "kw_values": [ + [ + -19.999252772438012, + 0.0, + -8.4997253828769, + 0.0, + -4.599856860148204, + 0.0, + -5.499829773341424, + 0.0, + -3.8998739992023412, + 0.0 + ], + [ + -19.999967755813433, + 0.0, + -8.499988149792255, + 0.0, + -4.599993823273964, + 0.0, + -5.499992654434874, + 0.0, + -3.8999945628458583, + 0.0 + ], + [ + -19.99999860862669, + 0.0, + -8.49999948865011, + 0.0, + -4.599999733467284, + 0.0, + -5.499999683030558, + 0.0, + -3.8999997653806386, + 0.0 + ], + [ + -19.999999939960695, + 0.0, + -8.499999977934689, + 0.0, + -4.599999988498818, + 0.0, + -5.499999986322419, + 0.0, + -3.8999999898759166, + 0.0 + ], + [ + -19.999999997409233, + 0.0, + -8.499999999047864, + 0.0, + -4.599999999503711, + 0.0, + -5.4999999994097974, + 0.0, + -3.8999999995631383, + 0.0 + ], + [ + -19.999999999888193, + 0.0, + -8.49999999995891, + 0.0, + -4.599999999978581, + 0.0, + -5.499999999974529, + 0.0, + -3.8999999999811465, + 0.0 + ], + [ + -19.99999999999516, + 0.0, + -8.499999999998224, + 0.0, + -4.599999999999073, + 0.0, + -5.4999999999989, + 0.0, + -3.8999999999991846, + 0.0 + ], + [ + -19.999999999999776, + 0.0, + -8.49999999999992, + 0.0, + -4.599999999999959, + 0.0, + -5.499999999999952, + 0.0, + -3.899999999999964, + 0.0 + ], + [ + -19.999999999999993, + 0.0, + -8.500000000000002, + 0.0, + -4.599999999999999, + 0.0, + -5.5, + 0.0, + -3.9000000000000004, + 0.0 + ], + [ + -19.976350794886148, + 0.0, + -8.491380654633938, + 0.0, + -4.595395277141466, + 0.0, + -3.3019699063907972, + 0.0, + -2.3808457300474575, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.601539531806113, + 0.0, + -2.71164401969468, + 0.0, + -1.9124684574902167, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600019139837478, + 0.0, + -2.7115785182003544, + 0.0, + -1.9124229958825794, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000238231013, + 0.0, + -2.7115776971271393, + 0.0, + -1.912422426001985, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000002967446, + 0.0, + -2.7115776868517063, + 0.0, + -1.9124224188700862, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000036979, + 0.0, + -2.711577686723252, + 0.0, + -1.912422418780929, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000461, + 0.0, + -2.711577686721648, + 0.0, + -1.9124224187798153, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000006, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.600000000000003, + 0.0, + -2.7115776867216295, + 0.0, + -1.912422418779803, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -2.581329383548329, + 0.0, + -1.8201167923105797, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -2.581243847578493, + 0.0, + -1.8200617053117676, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -5.502734251163694, + 0.0, + -3.901570780209129, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -5.5000024892562, + 0.0, + -3.900001423860796, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -5.500000002220422, + 0.0, + -3.9000000012707834, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -5.500000000001978, + 0.0, + -3.900000000001131, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -5.500000000000002, + 0.0, + -3.9000000000000017, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -5.500000000000001, + 0.0, + -3.9000000000000004, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -5.500000000000001, + 0.0, + -3.9000000000000004, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -5.500000000000001, + 0.0, + -3.9000000000000004, + 0.0 + ] + ], + "kvar_columns": [ + "Generator.pvgnem_mpx000635970__A1 [kVA]", + "Generator.pvgnem_mpx000635970__N1 [kVA]", + "Generator.pvgnem_mpx000460267__A1 [kVA]", + "Generator.pvgnem_mpx000460267__N1 [kVA]", + "Generator.pvgnem_mpx000594341__A1 [kVA]", + "Generator.pvgnem_mpx000594341__N1 [kVA]", + "Generator.pvgui_mpx000637601__A1 [kVA]", + "Generator.pvgui_mpx000637601__N1 [kVA]", + "Generator.pvgui_mpx000460267__A1 [kVA]", + "Generator.pvgui_mpx000460267__N1 [kVA]" + ], + "kvar_values": [ + [ + 0.00028252040589268293, + 0.0, + 0.00011020430770432199, + 0.0, + 5.733051505455933e-05, + 0.0, + 6.867388109466787e-05, + 0.0, + 5.0564329417269964e-05, + 0.0 + ], + [ + 1.2192711802526901e-05, + 0.0, + 4.756030792862021e-06, + 0.0, + 2.4741785689172957e-06, + 0.0, + 2.963714318411803e-06, + 0.0, + 2.1821788343601158e-06, + 0.0 + ], + [ + 5.261320656018142e-07, + 0.0, + 2.052290868732598e-07, + 0.0, + 1.0676410487064913e-07, + 0.0, + 1.2788821939579974e-07, + 0.0, + 9.416393396577405e-08, + 0.0 + ], + [ + 2.2703186800754337e-08, + 0.0, + 8.855865502255255e-09, + 0.0, + 4.6069902879253275e-09, + 0.0, + 5.518518918279369e-09, + 0.0, + 4.0632794480188754e-09, + 0.0 + ], + [ + 9.79666538114543e-10, + 0.0, + 3.821409677584597e-10, + 0.0, + 1.9879689716617576e-10, + 0.0, + 2.3813086613699854e-10, + 0.0, + 1.75335287622147e-10, + 0.0 + ], + [ + 4.227501904097153e-11, + 0.0, + 1.64890536780149e-11, + 0.0, + 8.578837196182576e-12, + 0.0, + 1.0274959549860797e-11, + 0.0, + 7.565560622424528e-12, + 0.0 + ], + [ + 1.8274022295372562e-12, + 0.0, + 7.104006272129482e-13, + 0.0, + 3.703490847328794e-13, + 0.0, + 4.4343551053316333e-13, + 0.0, + 3.2596858545730354e-13, + 0.0 + ], + [ + 8.265033102361485e-14, + 0.0, + 3.1462832339457236e-14, + 0.0, + 1.5361933947133365e-14, + 0.0, + 1.9582557797548363e-14, + 0.0, + 1.4424017535930036e-14, + 0.0 + ], + [ + -2.2737367544323206e-16, + 0.0, + 1.1937117960769684e-15, + 0.0, + 6.679101716144942e-16, + 0.0, + 0.0, + 0.0, + 5.684341886080802e-16, + 0.0 + ], + [ + 0.004855183838392805, + 0.0, + 0.0020063656989907485, + 0.0, + 0.0010742982793826741, + 0.0, + 0.0007677278332964192, + 0.0, + 0.0005625524754621551, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -0.00013084313873497423, + 0.0, + -6.44243392604693e-05, + 0.0, + -4.7626879102438124e-05, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -2.125873088004937e-06, + 0.0, + -1.046605175190507e-06, + 0.0, + -7.600146710160516e-07, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -3.059337251443139e-08, + 0.0, + -1.5051117259190505e-08, + 0.0, + -1.0844297122503122e-08, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -4.1522281435391054e-10, + 0.0, + -2.0420060309334076e-10, + 0.0, + -1.4651585900082865e-10, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -5.456428198158392e-12, + 0.0, + -2.683066213648999e-12, + 0.0, + -1.920327008519962e-12, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -7.09690084477188e-14, + 0.0, + -3.467448550509289e-14, + 0.0, + -2.4940050025179518e-14, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -9.663381206337363e-16, + 0.0, + -3.552713678800501e-16, + 0.0, + -2.7000623958883806e-16, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 8.526512829121202e-17, + 0.0, + 5.684341886080802e-17, + 0.0, + 4.263256414560601e-17, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0007353262632335032, + 0.0, + 0.0004604815619532659, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.7373769565480758e-06, + 0.0, + 1.1104872219718233e-06, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.0011180577768459266, + 0.0, + -0.0007033632003684609, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -9.512164956326786e-07, + 0.0, + -5.927124701230469e-07, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -8.4470927674829e-10, + 0.0, + -5.261314868221234e-10, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -7.507630073178006e-13, + 0.0, + -4.674696185702487e-13, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -8.348877145181178e-16, + 0.0, + -5.897504706808832e-16, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + "kw_index": [ + "2019-06-20 00:00:00", + "2019-06-20 00:00:00.049999952", + "2019-06-20 00:00:00.099999905", + "2019-06-20 00:00:00.150000095", + "2019-06-20 00:00:00.200000048", + "2019-06-20 00:00:00.250000", + "2019-06-20 00:00:00.299999952", + "2019-06-20 00:00:00.349999905", + "2019-06-20 00:00:00.400000095", + "2019-06-20 00:00:00.450000048", + "2019-06-20 00:00:00.500000", + "2019-06-20 00:00:00.549999952", + "2019-06-20 00:00:00.599999905", + "2019-06-20 00:00:00.650000095", + "2019-06-20 00:00:00.700000048", + "2019-06-20 00:00:00.750000", + "2019-06-20 00:00:00.799999952", + "2019-06-20 00:00:00.849999905", + "2019-06-20 00:00:00.900000095", + "2019-06-20 00:00:00.950000048", + "2019-06-20 00:00:01", + "2019-06-20 00:00:01.049999952", + "2019-06-20 00:00:01.099999905", + "2019-06-20 00:00:01.150000095", + "2019-06-20 00:00:01.200000048", + "2019-06-20 00:00:01.250000", + "2019-06-20 00:00:01.299999952", + "2019-06-20 00:00:01.349999905", + "2019-06-20 00:00:01.400000095", + "2019-06-20 00:00:01.450000048", + "2019-06-20 00:00:01.500000", + "2019-06-20 00:00:01.549999952", + "2019-06-20 00:00:01.599999905", + "2019-06-20 00:00:01.650000095", + "2019-06-20 00:00:01.700000048", + "2019-06-20 00:00:01.750000", + "2019-06-20 00:00:01.799999952", + "2019-06-20 00:00:01.849999905", + "2019-06-20 00:00:01.900000095", + "2019-06-20 00:00:01.950000048", + "2019-06-20 00:00:02", + "2019-06-20 00:00:02.049999952", + "2019-06-20 00:00:02.099999905", + "2019-06-20 00:00:02.150000095", + "2019-06-20 00:00:02.200000048", + "2019-06-20 00:00:02.250000", + "2019-06-20 00:00:02.299999952", + "2019-06-20 00:00:02.349999905", + "2019-06-20 00:00:02.400000095", + "2019-06-20 00:00:02.450000048", + "2019-06-20 00:00:02.500000", + "2019-06-20 00:00:02.549999952", + "2019-06-20 00:00:02.599999905", + "2019-06-20 00:00:02.650000095", + "2019-06-20 00:00:02.700000048", + "2019-06-20 00:00:02.750000", + "2019-06-20 00:00:02.799999952", + "2019-06-20 00:00:02.849999905", + "2019-06-20 00:00:02.900000095", + "2019-06-20 00:00:02.950000048" + ] +} \ No newline at end of file diff --git a/tests/test_motor_stall_validation.py b/tests/test_motor_stall_validation.py new file mode 100644 index 00000000..559cdef6 --- /dev/null +++ b/tests/test_motor_stall_validation.py @@ -0,0 +1,122 @@ +"""Validation test for MotorStall controller. + +Captures load kW and kvar time-series from a motor stall simulation and +compares against a saved baseline. Use this to validate that refactored +controller implementations produce identical results. + +Usage: + # Step 1: Generate baseline (run once with the original controller) + pytest tests/test_motor_stall_validation.py::test_motor_stall_save_baseline -s + + # Step 2: After refactoring, validate against baseline + pytest tests/test_motor_stall_validation.py::test_motor_stall_validate -s +""" + +import json +from pathlib import Path + +import numpy as np +import pytest + +from pydss.pydss_project import PyDssProject +from pydss.pydss_results import PyDssResults + +BASE_PATH = Path(__file__).parent.absolute() +PROJECT_PATH = BASE_PATH / "data" / "controllers" +BASELINE_FILE = BASE_PATH / "data" / "motor_stall_baseline.json" + + +def _run_and_get_results(): + """Run motor stall simulation and return load kW/kvar dataframes. + + The export stores 'Powers' as complex (real=kW, imag=kvar). + """ + project = PyDssProject.load_project( + PROJECT_PATH, + simulation_file="simulation_motor_stall.toml", + ) + project.run() + + results = PyDssResults(PROJECT_PATH) + scenario = results.scenarios[0] + + powers_df = scenario.get_full_dataframe("Loads", "Powers") + kw_df = powers_df.apply(lambda c: c.map(lambda v: v.real) if c.dtype == complex else c) + kvar_df = powers_df.apply(lambda c: c.map(lambda v: v.imag) if c.dtype == complex else c) + + return kw_df, kvar_df + + +def test_motor_stall_save_baseline(): + """Run simulation and save results as the reference baseline.""" + kw_df, kvar_df = _run_and_get_results() + + baseline = { + "kw_columns": list(kw_df.columns), + "kw_values": kw_df.values.tolist(), + "kvar_columns": list(kvar_df.columns), + "kvar_values": kvar_df.values.tolist(), + "kw_index": [str(t) for t in kw_df.index], + } + + with open(BASELINE_FILE, "w") as f: + json.dump(baseline, f, indent=2) + + print(f"\nBaseline saved to {BASELINE_FILE}") + print(f" Loads: {len(kw_df.columns)}") + print(f" Timesteps: {len(kw_df)}") + print(f" kW range: [{kw_df.values.min():.4f}, {kw_df.values.max():.4f}]") + print(f" kvar range: [{kvar_df.values.min():.4f}, {kvar_df.values.max():.4f}]") + + +def test_motor_stall_validate(): + """Run simulation and compare against saved baseline.""" + if not BASELINE_FILE.exists(): + pytest.skip(f"No baseline file found at {BASELINE_FILE}. Run test_motor_stall_save_baseline first.") + + with open(BASELINE_FILE) as f: + baseline = json.load(f) + + kw_df, kvar_df = _run_and_get_results() + + # Check columns match + assert list(kw_df.columns) == baseline["kw_columns"], "Load names changed" + assert list(kvar_df.columns) == baseline["kvar_columns"], "Load names changed" + + # Check timestep count matches + baseline_kw = np.array(baseline["kw_values"]) + baseline_kvar = np.array(baseline["kvar_values"]) + assert kw_df.shape == baseline_kw.shape, ( + f"Shape mismatch: got {kw_df.shape}, expected {baseline_kw.shape}" + ) + + # Compare values + kw_diff = np.abs(kw_df.values - baseline_kw) + kvar_diff = np.abs(kvar_df.values - baseline_kvar) + + kw_max_diff = kw_diff.max() + kvar_max_diff = kvar_diff.max() + + print(f"\n Max kW difference: {kw_max_diff:.2e}") + print(f" Max kvar difference: {kvar_max_diff:.2e}") + + # Allow small floating-point tolerance + atol = 1e-6 + if kw_max_diff > atol: + # Find the worst offender + idx = np.unravel_index(kw_diff.argmax(), kw_diff.shape) + col_name = kw_df.columns[idx[1]] + print(f" Worst kW diff at step {idx[0]}, load '{col_name}': " + f"got {kw_df.values[idx]:.6f}, expected {baseline_kw[idx]:.6f}") + + if kvar_max_diff > atol: + idx = np.unravel_index(kvar_diff.argmax(), kvar_diff.shape) + col_name = kvar_df.columns[idx[1]] + print(f" Worst kvar diff at step {idx[0]}, load '{col_name}': " + f"got {kvar_df.values[idx]:.6f}, expected {baseline_kvar[idx]:.6f}") + + np.testing.assert_allclose(kw_df.values, baseline_kw, atol=atol, + err_msg="kW values differ from baseline") + np.testing.assert_allclose(kvar_df.values, baseline_kvar, atol=atol, + err_msg="kvar values differ from baseline") + print(" PASSED: Results match baseline within tolerance") diff --git a/tests/test_pv_ride_through_validation.py b/tests/test_pv_ride_through_validation.py new file mode 100644 index 00000000..8e7f4fe4 --- /dev/null +++ b/tests/test_pv_ride_through_validation.py @@ -0,0 +1,117 @@ +"""Validation test for PvVoltageRideThru controller. + +Captures Generator kW and kvar time-series from a voltage ride-through +simulation and compares against a saved baseline. Use this to validate +that refactored (batch) controller implementations produce identical results. + +Usage: + # Step 1: Generate baseline (run once with the original controller) + pytest tests/test_pv_ride_through_validation.py::test_pv_ride_through_save_baseline -s + + # Step 2: After refactoring, validate against baseline + pytest tests/test_pv_ride_through_validation.py::test_pv_ride_through_validate -s +""" + +import json +from pathlib import Path + +import numpy as np +import pytest + +from pydss.pydss_project import PyDssProject +from pydss.pydss_results import PyDssResults + +BASE_PATH = Path(__file__).parent.absolute() +PROJECT_PATH = BASE_PATH / "data" / "controllers" +BASELINE_FILE = BASE_PATH / "data" / "pv_ride_through_baseline.json" + + +def _run_and_get_results(): + """Run voltage ride-through simulation and return Generator kW/kvar dataframes.""" + project = PyDssProject.load_project( + PROJECT_PATH, + simulation_file="simulation_pv_ride_through.toml", + ) + project.run() + + results = PyDssResults(PROJECT_PATH) + scenario = results.scenarios[0] + + powers_df = scenario.get_full_dataframe("Generators", "Powers") + kw_df = powers_df.apply(lambda c: c.map(lambda v: v.real) if c.dtype == complex else c) + kvar_df = powers_df.apply(lambda c: c.map(lambda v: v.imag) if c.dtype == complex else c) + + return kw_df, kvar_df + + +def test_pv_ride_through_save_baseline(): + """Run simulation and save results as the reference baseline.""" + kw_df, kvar_df = _run_and_get_results() + + baseline = { + "kw_columns": list(kw_df.columns), + "kw_values": kw_df.values.tolist(), + "kvar_columns": list(kvar_df.columns), + "kvar_values": kvar_df.values.tolist(), + "kw_index": [str(t) for t in kw_df.index], + } + + with open(BASELINE_FILE, "w") as f: + json.dump(baseline, f, indent=2) + + print(f"\nBaseline saved to {BASELINE_FILE}") + print(f" Generators: {len(kw_df.columns)}") + print(f" Timesteps: {len(kw_df)}") + print(f" kW range: [{kw_df.values.min():.4f}, {kw_df.values.max():.4f}]") + print(f" kvar range: [{kvar_df.values.min():.4f}, {kvar_df.values.max():.4f}]") + + +def test_pv_ride_through_validate(): + """Run simulation and compare against saved baseline.""" + if not BASELINE_FILE.exists(): + pytest.skip(f"No baseline file found at {BASELINE_FILE}. Run test_pv_ride_through_save_baseline first.") + + with open(BASELINE_FILE) as f: + baseline = json.load(f) + + kw_df, kvar_df = _run_and_get_results() + + # Check columns match + assert list(kw_df.columns) == baseline["kw_columns"], "Generator names changed" + assert list(kvar_df.columns) == baseline["kvar_columns"], "Generator names changed" + + # Check shape + baseline_kw = np.array(baseline["kw_values"]) + baseline_kvar = np.array(baseline["kvar_values"]) + assert kw_df.shape == baseline_kw.shape, ( + f"Shape mismatch: got {kw_df.shape}, expected {baseline_kw.shape}" + ) + + # Compare values + kw_diff = np.abs(kw_df.values - baseline_kw) + kvar_diff = np.abs(kvar_df.values - baseline_kvar) + + kw_max_diff = kw_diff.max() + kvar_max_diff = kvar_diff.max() + + print(f"\n Max kW difference: {kw_max_diff:.2e}") + print(f" Max kvar difference: {kvar_max_diff:.2e}") + + atol = 1e-6 + if kw_max_diff > atol: + idx = np.unravel_index(kw_diff.argmax(), kw_diff.shape) + col_name = kw_df.columns[idx[1]] + print(f" Worst kW diff at step {idx[0]}, generator '{col_name}': " + f"got {kw_df.values[idx]:.6f}, expected {baseline_kw[idx]:.6f}") + + if kvar_max_diff > atol: + idx = np.unravel_index(kvar_diff.argmax(), kvar_diff.shape) + col_name = kvar_df.columns[idx[1]] + print(f" Worst kvar diff at step {idx[0]}, generator '{col_name}': " + f"got {kvar_df.values[idx]:.6f}, expected {baseline_kvar[idx]:.6f}") + + np.testing.assert_allclose(kw_df.values, baseline_kw, atol=atol, + err_msg="kW values differ from baseline") + np.testing.assert_allclose(kvar_df.values, baseline_kvar, atol=atol, + err_msg="kvar values differ from baseline") + print(" PASSED: Results match baseline within tolerance")