From 2d59d802cca15e7c77ff71164b908be3e77cc9f7 Mon Sep 17 00:00:00 2001 From: HaleyRoss Date: Sun, 12 Jul 2026 18:18:37 -0600 Subject: [PATCH] add batch controllers --- src/pydss/dssInstance.py | 54 +- src/pydss/helics_interface.py | 32 + .../pyControllers/Controllers/MotorStall.py | 264 ++- .../Controllers/MotorStallBatch.py | 324 ++++ .../Controllers/PvVoltageRideThru.py | 206 ++- .../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 ++ 12 files changed, 4243 insertions(+), 87 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/src/pydss/dssInstance.py b/src/pydss/dssInstance.py index 3a08d6c5..bed5db77 100644 --- a/src/pydss/dssInstance.py +++ b/src/pydss/dssInstance.py @@ -167,24 +167,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 @@ -239,6 +262,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()}') @@ -279,7 +303,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') @@ -426,6 +449,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 5a5faaa1..c6c893cf 100644 --- a/src/pydss/helics_interface.py +++ b/src/pydss/helics_interface.py @@ -313,6 +313,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): @@ -333,6 +345,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 ba8c77db..b6a42951 100644 --- a/src/pydss/pyControllers/Controllers/MotorStall.py +++ b/src/pydss/pyControllers/Controllers/MotorStall.py @@ -3,11 +3,15 @@ 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 -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) @@ -60,75 +64,215 @@ def debugInfo(self): return 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 + 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}") + 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.debug(f"voltage_pu: {self.voltage_pu}") + logger.debug(f"self.kw_rated: {self.kw_rated}") + logger.debug(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 = 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 = 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 = t_now - self.rstrt_time_start + if self.rstrt_time > self._settings.t_restart: + self.rstrt = True + else: + self.rstrt_time_start = t_now + 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 = 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 = t_now + 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 + logger.debug(f"self.voltage_pu: {self.voltage_pu}") + + # 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) + + if self.rstrt: + 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 + else: + 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) + + else: + 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 + 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.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.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 + 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) + + # thermal protection + if self.stall: + if self.trip_rstr: + logger.debug(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 - self._controlled_element.SetParameter('kw', self.kw_rated * p ) - self._controlled_element.SetParameter('kvar', self.kvar_rated * q) - - 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) + 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: - if i2r_calc < self._settings.t_th1t: - Kth = 1 - elif i2r_calc > self._settings.t_th2t: - Kth = 0 - else: - m = 1 / (self._settings.t_th1t - self._settings.t_th2t) - c = - m * self._settings.t_th2t - Kth = m * i2r_calc + c + Kth_nonrstr = 1 + else: + Kth_rstr = 1 + Kth_nonrstr = 1 + + # logger.info(f"Kth_rstr: {Kth_rstr}") + # logger.info(f"Kth_nonrstr: {Kth_nonrstr}") - 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 - + 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/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 ca285a6d..56807a85 100644 --- a/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py +++ b/src/pydss/pyControllers/Controllers/PvVoltageRideThru.py @@ -8,6 +8,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. @@ -24,6 +51,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) @@ -52,6 +80,31 @@ def __init__(self, pv_object, settings, dss_instance, elm_object_list, dss_solve #pv_object.SetParameter('kvar', 0) #pv_object.SetParameter('kva', self.model.kva) 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.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 + 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 @@ -289,9 +342,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): @@ -299,11 +355,15 @@ def Update(self, priority, time, update_results): error = 0 self.time_change = self.time != (priority, time) self.time = time + logger.debug(f"self._name: {self._name}") + logger.debug(f"priority: {priority}") if priority == 0: self._is_connected = self._connect() + logger.debug(f"self._is_connected: {self._is_connected}") if priority == 2: u_in = self._update_violaton_timers() + 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: @@ -332,37 +392,50 @@ def voltage_ride_through(self, u_in): """ self._fault_counter_clearing_time_sec = 1 - Pm = Point(self._u_violation_time, u_in) - 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 - 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.4, False) - elif Pm.within(self.trip_region): + self._trip(self.__dss_solver.GetStepSizeSec(), 0.5, False) # rrpt = 2.0 + 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.debug(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.debug(f"self.region else {self.region}") + self._trip(self._trip_deadtime_sec, self._time_to_p_max_sec, True) + logger.debug(f"trip_region") else: is_in_contioeous_region = True region = 3 - - self.region = self.region[1:] + self.region[:1] + logger.debug(f"is_in_contioeous_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.debug(f"new self.region: {self.region}") if is_in_contioeous_region and not self._is_in_contioeous_region: + 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.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.debug(f"multiple_disturdances trip") self._trip(self._trip_deadtime_sec, self._time_to_p_max_sec, True) self._fault_counter = 0 else: @@ -371,7 +444,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.debug(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.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 + 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.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 + + 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 +552,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.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.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: self._controlled_element.SetParameter('enabled', True) @@ -392,6 +568,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.debug(f"self._p_limit: {self._p_limit}") + # self.DERA_control(1.0) + # 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 @@ -416,12 +596,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.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.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")