pqopen-lib 0.7.8__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pqopen-lib
3
- Version: 0.7.8
3
+ Version: 0.8.0
4
4
  Summary: A power quality processing library for calculating parameters from waveform data
5
5
  Project-URL: Homepage, https://github.com/DaqOpen/pqopen-lib
6
6
  Project-URL: Issues, https://github.com/DaqOpen/pqopen-lib/issues
@@ -88,7 +88,9 @@ class PowerSystem(object):
88
88
  "mains_signaling_tracer": {},
89
89
  "debug_channels": False,
90
90
  "energy_channels": {},
91
- "one_period_fundamental": False}
91
+ "one_period_fundamental": 0,
92
+ "rms_trapz_rule": False,
93
+ "pmu_calculation": False}
92
94
  self._prepare_calc_channels()
93
95
  self.output_channels: Dict[str, DataChannelBuffer] = {}
94
96
  self._last_processed_sidx = 0
@@ -100,6 +102,7 @@ class PowerSystem(object):
100
102
  self._last_zc_frac = 0.0
101
103
  self._calculation_mode = "NORMAL"
102
104
  self._last_known_freq = self.nominal_frequency
105
+ self._fund_freq_list = np.zeros(1)
103
106
  self._channel_update_needed = False
104
107
 
105
108
 
@@ -107,7 +110,8 @@ class PowerSystem(object):
107
110
  self._calc_channels = {"half_period": {"voltage": {}, "current": {}, "power": {}, "_debug": {}},
108
111
  "one_period": {"voltage": {}, "current": {}, "power": {}, "_debug": {}},
109
112
  "one_period_ovlp": {"voltage": {}, "current": {}, "power": {}, "_debug": {}},
110
- "multi_period": {"voltage": {}, "current": {}, "power": {}, "energy": {}, "_debug": {}}}
113
+ "multi_period": {"voltage": {}, "current": {}, "power": {}, "energy": {}, "_debug": {}},
114
+ "pmu": {"voltage": {}, "current": {}, "power": {}, "_debug": {}}}
111
115
 
112
116
  def add_phase(self, u_channel: AcqBuffer, i_channel: AcqBuffer = None, name: str = ""):
113
117
  """
@@ -214,13 +218,33 @@ class PowerSystem(object):
214
218
  self._features["energy_channels"] = {"persist_file": persist_file, "energy_counters": energy_counters}
215
219
  self._channel_update_needed = True
216
220
 
217
- def enable_one_period_fundamental(self):
221
+ def enable_one_period_fundamental(self, freq_agg_cycles: int = 50):
218
222
  """
219
223
  Enables the calculation of one (single) period fundamental values
220
224
  """
221
- self._features["one_period_fundamental"] = True
225
+ self._features["one_period_fundamental"] = freq_agg_cycles
226
+ self._fund_freq_list = np.zeros(freq_agg_cycles)
222
227
  self._channel_update_needed = True
223
228
 
229
+ def enable_rms_trapz_rule(self):
230
+ """
231
+ Enables the trapezoidal integration rule for rms calculation
232
+ """
233
+ self._features["rms_trapz_rule"] = True
234
+
235
+ def enable_pmu_calculation(self):
236
+ """
237
+ Enables the calculation of equidistant PMU values
238
+ """
239
+ if not self._features["nper_abs_time_sync"]:
240
+ logger.warning("To enable pmu_calculation, nper_abs_time_sync must be enabled first.")
241
+ return False
242
+ self._features["pmu_calculation"] = True
243
+ self._channel_update_needed = True
244
+ self._pmu_last_processed_sidx = 0
245
+ self._pmu_last_processed_ts_us = 0
246
+ self._pmu_time_increment_us = int(1_000_000 / self.nominal_frequency)
247
+
224
248
  def _resync_nper_abs_time(self, zc_idx: int):
225
249
  if not self._features["nper_abs_time_sync"]:
226
250
  return None
@@ -269,6 +293,8 @@ class PowerSystem(object):
269
293
  self._calc_channels["multi_period"]["energy"]["w_pos"].last_sample_value = self._features["energy_channels"]["energy_counters"].get("W_pos", 0.0)
270
294
  self._calc_channels["multi_period"]["energy"]["w_neg"] = DataChannelBuffer('W_neg', agg_type='max', unit="Wh", dtype=np.float64)
271
295
  self._calc_channels["multi_period"]["energy"]["w_neg"].last_sample_value = self._features["energy_channels"]["energy_counters"].get("W_neg", 0.0)
296
+ if self._features["pmu_calculation"]:
297
+ self._calc_channels["pmu"]["power"]["freq"] = DataChannelBuffer('Freq_pmu', agg_type='mean', unit="Hz")
272
298
 
273
299
  for agg_interval, phys_types in self._calc_channels.items():
274
300
  for phys_type, calc_type in phys_types.items():
@@ -320,30 +346,34 @@ class PowerSystem(object):
320
346
  self._zero_crossings.append(actual_zc)
321
347
  if self._zero_cross_counter <= 1:
322
348
  continue
323
- # Calculate Frequency
324
- frequency = self._samplerate/(self._zero_crossings[-1] + actual_zc_frac - self._zero_crossings[-2] - self._last_zc_frac)
325
- self._last_zc_frac = actual_zc_frac
326
349
  # Add actual zero cross counter to debug channel if enabled
327
350
  if "pidx" in self._calc_channels["one_period"]['_debug']:
328
351
  self._calc_channels["one_period"]['_debug']['pidx'].put_data_single(self._zero_crossings[-1], self._zero_cross_counter)
329
352
  # Process one period calculation, start with second zc
330
- self._process_one_period(self._zero_crossings[-2], self._zero_crossings[-1], frequency)
353
+ self._process_one_period(self._zero_crossings[-2], self._zero_crossings[-1], actual_zc_frac)
354
+ self._last_zc_frac = actual_zc_frac
331
355
  if ((self._zero_cross_counter-1) % self.nper) == 0 and (self._zero_cross_counter > self.nper):
332
356
  # Process multi-period
333
357
  self._process_multi_period(self._zero_crossings[-self.nper - 1], self._zero_crossings[-1])
334
358
  self._resync_nper_abs_time(-1)
335
359
  self._process_fluctuation_calc(self._zero_crossings[-self.nper - 1], self._zero_crossings[-1])
360
+
361
+ # Process fixed-time (PMU) channels
362
+ self._process_pmu_calc(self._zero_crossings[-1])
336
363
 
337
364
  self._last_processed_sidx = stop_acq_sidx
338
365
 
339
- def _process_one_period(self, period_start_sidx: int, period_stop_sidx: int, frequency: float):
366
+ def _process_one_period(self, period_start_sidx: int, period_stop_sidx: int, actual_zc_frac: float= 0):
340
367
  """
341
368
  Processes data for a single period, calculating voltage, current, and power.
342
369
 
343
370
  Parameters:
344
371
  period_start_sidx: Start sample index of the period.
345
372
  period_stop_sidx: Stop sample index of the period.
373
+ frequency: Fundamental frequency
346
374
  """
375
+ # Calculate Frequency
376
+ frequency = self._samplerate/(period_stop_sidx + actual_zc_frac - period_start_sidx - self._last_zc_frac)
347
377
  self._calc_channels["one_period"]['power']['freq'].put_data_single(period_stop_sidx, frequency)
348
378
  if "sidx" in self._calc_channels["one_period"]['_debug']:
349
379
  self._calc_channels["one_period"]['_debug']['sidx'].put_data_single(period_stop_sidx, period_stop_sidx)
@@ -363,9 +393,30 @@ class PowerSystem(object):
363
393
  u_values = phase._u_channel.read_data_by_index(phase_period_start_sidx, phase_period_stop_sidx)
364
394
  if self._features["mains_signaling_tracer"]:
365
395
  msv_edge, msv_value = phase._mains_signaling_tracer.process(u_values[::10])
396
+ if self._features["one_period_fundamental"]:
397
+ # Use sample-discrete frequency, not the exact one for full cycle
398
+ u_values_sync = phase._u_channel.read_data_by_index(period_start_sidx, period_stop_sidx)
399
+ self._fund_freq_list = np.roll(self._fund_freq_list,-1)
400
+ self._fund_freq_list[-1] = self._samplerate/len(u_values_sync)
401
+ mean_freq = self._fund_freq_list[self._fund_freq_list>0].mean()
402
+ fund_amp, fund_phase = calc_single_freq(u_values_sync, mean_freq, self._samplerate)
366
403
  for phys_type, output_channel in phase._calc_channels["one_period"]["voltage"].items():
367
404
  if phys_type == "trms":
368
- u_rms = np.sqrt(np.mean(np.power(u_values, 2)))
405
+ if self._features["rms_trapz_rule"]:
406
+ add_start_idx_sample = 1 if self._last_zc_frac < 0 else 0
407
+ add_stop_idx_sample = 1 if actual_zc_frac > 0 else 0
408
+ u_trapz_values = phase._u_channel.read_data_by_index(phase_period_start_sidx-add_start_idx_sample, phase_period_stop_sidx+add_stop_idx_sample)
409
+ sample_points = np.arange(len(u_values), dtype=np.float64)
410
+ if add_start_idx_sample:
411
+ u_trapz_values[0] = (u_trapz_values[1] - u_trapz_values[0])*(1+self._last_zc_frac) + u_trapz_values[0]
412
+ sample_points = np.insert(sample_points,0,self._last_zc_frac)
413
+ if add_stop_idx_sample:
414
+ u_trapz_values[-1] = (u_trapz_values[-1] - u_trapz_values[-2])*self._last_zc_frac + u_trapz_values[-2]
415
+ sample_points = np.insert(sample_points,len(sample_points),sample_points[-1]+actual_zc_frac)
416
+ integral = np.trapezoid(np.power(u_trapz_values,2), sample_points)
417
+ u_rms = np.sqrt(integral * frequency / self._samplerate)
418
+ else:
419
+ u_rms = np.sqrt(np.mean(np.power(u_values, 2)))
369
420
  output_channel.put_data_single(phase_period_stop_sidx, u_rms)
370
421
  if phys_type == "msv_bit":
371
422
  if msv_edge is not None:
@@ -375,9 +426,10 @@ class PowerSystem(object):
375
426
  if phys_type == "slope":
376
427
  output_channel.put_data_single(phase_period_stop_sidx, np.abs(np.diff(u_values)).max())
377
428
  if phys_type == "fund_rms":
378
- # Use sample-discrete frequency, not the exact one for full cycle
379
- fund_amp, fund_phase = calc_single_freq(u_values, self._samplerate/len(u_values), self._samplerate)
380
- output_channel.put_data_single(phase_period_stop_sidx, fund_amp)
429
+ output_channel.put_data_single(period_stop_sidx, fund_amp)
430
+ if phys_type == "fund_phi":
431
+ output_channel.put_data_single(period_stop_sidx, pq.normalize_phi(np.rad2deg(fund_phase+np.pi/2)))
432
+
381
433
  for phys_type, output_channel in phase._calc_channels["half_period"]["voltage"].items():
382
434
  if phys_type == "trms":
383
435
  # First half period
@@ -544,6 +596,52 @@ class PowerSystem(object):
544
596
  self._pst_last_calc_sidx = stop_sidx
545
597
  self._pst_next_round_ts = floor_timestamp(stop_ts, self._pst_interval_sec, ts_resolution="us")+self._pst_interval_sec*1_000_000
546
598
 
599
+ def _process_pmu_calc(self, stop_sidx: int):
600
+ """
601
+ Process data to calculate PMU (Phasor Measurement Unit) parameters.
602
+
603
+ Parameters:
604
+ stop_sidx: Stop sample index for calculation
605
+
606
+ Returns:
607
+ None
608
+ """
609
+ if not self._features["pmu_calculation"]:
610
+ return None
611
+ # Read timestamps
612
+ start_sidx = int(self._pmu_last_processed_sidx - 1.5*self._samplerate / self.nominal_frequency)
613
+ start_sidx = max(0, start_sidx)
614
+ ts_us = self._time_channel.read_data_by_index(start_sidx, stop_sidx)
615
+ first_pmu_ts = (self._pmu_last_processed_ts_us + self._pmu_time_increment_us) if self._pmu_last_processed_ts_us > 0 else ts_us[0] - ts_us[0] % self._pmu_time_increment_us + self._pmu_time_increment_us
616
+ last_pmu_ts = ts_us[-1] - (ts_us[-1] % self._pmu_time_increment_us) - self._pmu_time_increment_us # convert until n-1
617
+ logger.debug(f"first_pmu_ts: {first_pmu_ts:d}, last_pmu_ts: {last_pmu_ts:d}, self._pmu_time_increment_us: {self._pmu_time_increment_us:d}")
618
+ wanted_pmu_ts = np.arange(first_pmu_ts, last_pmu_ts+1, self._pmu_time_increment_us, dtype=np.int64)
619
+ wanted_pmu_sidx = [int(np.searchsorted(ts_us, pmu_ts)+start_sidx) for pmu_ts in wanted_pmu_ts]
620
+ wanted_pmu_ts_map = dict(zip(wanted_pmu_sidx, wanted_pmu_ts))
621
+ real_pmu_ts_map = {pmu_sidx: int(ts_us[pmu_sidx - start_sidx]) for pmu_sidx in wanted_pmu_sidx}
622
+ ovlp_window_start = max(0, int(wanted_pmu_sidx[0] - 1.5*self._samplerate / self.nominal_frequency))
623
+ freq, sample_indices = self._calc_channels["one_period"]["power"]["freq"].read_data_by_acq_sidx(ovlp_window_start, stop_sidx)
624
+
625
+ if len(sample_indices) == 0:
626
+ return None
627
+ for phase in self._phases:
628
+ u_raw = phase._u_channel.read_data_by_index(ovlp_window_start, stop_sidx)
629
+ for pmu_sidx in wanted_pmu_sidx:
630
+ # Search for previous zc
631
+ zc_idx = np.searchsorted(sample_indices, pmu_sidx,side="left")
632
+ if zc_idx == len(sample_indices):
633
+ zc_idx -= 1
634
+ local_stop_idx = max(0, pmu_sidx - ovlp_window_start)
635
+ local_start_idx = max(0, local_stop_idx - int(np.round(self._samplerate/freq[zc_idx])))
636
+ fund_amp, fund_phase = calc_single_freq(u_raw[local_start_idx:local_stop_idx], freq[zc_idx], self._samplerate)
637
+ frac_sidx_phi_offset = (wanted_pmu_ts_map[pmu_sidx] - real_pmu_ts_map[pmu_sidx])/1e6*2*np.pi*freq[zc_idx]
638
+ phase._calc_channels["pmu"]["voltage"]["rms"].put_data_single(pmu_sidx, fund_amp)
639
+ phase._calc_channels["pmu"]["voltage"]["phi"].put_data_single(pmu_sidx, pq.normalize_phi(np.rad2deg(fund_phase+np.pi/2+frac_sidx_phi_offset)))
640
+ # TODO: Add Frequency PMU Channel as well??
641
+ # TODO: Add current channels?
642
+ self._pmu_last_processed_sidx = stop_sidx
643
+ self._pmu_last_processed_ts_us = last_pmu_ts
644
+
547
645
  def _detect_zero_crossings(self, start_acq_sidx: int, stop_acq_sidx: int) -> List[float]:
548
646
  """
549
647
  Detects zero crossings in the signal.
@@ -644,12 +742,13 @@ class PowerPhase(object):
644
742
  Parameters:
645
743
  features: Dict of features
646
744
  """
647
- self._calc_channels = {"half_period": {}, "one_period": {}, "one_period_ovlp": {}, "multi_period": {}}
745
+ self._calc_channels = {"half_period": {}, "one_period": {}, "one_period_ovlp": {}, "multi_period": {}, "pmu": {}}
648
746
  # Create Voltage Channels
649
747
  self._calc_channels["half_period"]["voltage"] = {}
650
748
  self._calc_channels["one_period"]["voltage"] = {}
651
749
  self._calc_channels["one_period_ovlp"]["voltage"] = {}
652
750
  self._calc_channels["multi_period"]["voltage"] = {}
751
+ self._calc_channels["pmu"]["voltage"] = {}
653
752
  self._calc_channels["half_period"]["voltage"]["trms"] = DataChannelBuffer('U{:s}_hp_rms'.format(self.name), agg_type='rms', unit="V")
654
753
  self._calc_channels["one_period"]["voltage"]["trms"] = DataChannelBuffer('U{:s}_1p_rms'.format(self.name), agg_type='rms', unit="V")
655
754
  self._calc_channels["one_period"]["voltage"]["slope"] = DataChannelBuffer('U{:s}_1p_slope'.format(self.name), agg_type='max', unit="V/s")
@@ -677,8 +776,13 @@ class PowerPhase(object):
677
776
  self._calc_channels["one_period"]["voltage"]["msv_mag"] = DataChannelBuffer('U{:s}_1p_msv'.format(self.name), agg_type='max', unit="V")
678
777
  self._calc_channels["one_period"]["voltage"]["msv_bit"] = DataChannelBuffer('U{:s}_msv_bit'.format(self.name), unit="")
679
778
 
680
- if "one_period_fundamental" in features and features["one_period_fundamental"]:
779
+ if "one_period_fundamental" in features and features["one_period_fundamental"] > 0:
681
780
  self._calc_channels["one_period"]["voltage"]["fund_rms"] = DataChannelBuffer('U{:s}_1p_H1_rms'.format(self.name), agg_type='rms', unit="V")
781
+ self._calc_channels["one_period"]["voltage"]["fund_phi"] = DataChannelBuffer('U{:s}_1p_H1_phi'.format(self.name), agg_type='phi', unit="°")
782
+
783
+ if "pmu_calculation" in features and features["pmu_calculation"]:
784
+ self._calc_channels["pmu"]["voltage"]["rms"] = DataChannelBuffer('U{:s}_pmu_rms'.format(self.name), agg_type='rms', unit="V")
785
+ self._calc_channels["pmu"]["voltage"]["phi"] = DataChannelBuffer('U{:s}_pmu_phi'.format(self.name), agg_type='phi', unit="°")
682
786
 
683
787
  # Create Current Channels
684
788
  if self._i_channel:
@@ -686,6 +790,7 @@ class PowerPhase(object):
686
790
  self._calc_channels["multi_period"]["current"] = {}
687
791
  self._calc_channels["one_period"]["current"]["trms"] = DataChannelBuffer('I{:s}_1p_rms'.format(self.name), agg_type='rms', unit="A")
688
792
  self._calc_channels["multi_period"]["current"]["trms"] = DataChannelBuffer('I{:s}_rms'.format(self.name), agg_type='rms', unit="A")
793
+ self._calc_channels["pmu"]["current"] = {}
689
794
  self._calc_channels["one_period"]["power"] = {}
690
795
  self._calc_channels["multi_period"]["power"] = {}
691
796
 
@@ -698,6 +803,10 @@ class PowerPhase(object):
698
803
  self._calc_channels["multi_period"]["power"]['p_fund_mag'] = DataChannelBuffer('P{:s}_H1'.format(self.name), agg_type='mean', unit="W")
699
804
  self._calc_channels["multi_period"]["power"]['q_fund_mag'] = DataChannelBuffer('Q{:s}_H1'.format(self.name), agg_type='mean', unit="var")
700
805
 
806
+ if "pmu_calculation" in features and features["pmu_calculation"]:
807
+ self._calc_channels["pmu"]["current"]["rms"] = DataChannelBuffer('I{:s}_pmu_rms'.format(self.name), agg_type='rms', unit="A")
808
+ self._calc_channels["pmu"]["current"]["phi"] = DataChannelBuffer('I{:s}_pmu_phi'.format(self.name), agg_type='phi', unit="°")
809
+
701
810
  # Create Power Channels
702
811
  self._calc_channels["one_period"]["power"]['p_avg'] = DataChannelBuffer('P{:s}_1p'.format(self.name), agg_type='mean', unit="W")
703
812
  self._calc_channels["multi_period"]["power"]['p_avg'] = DataChannelBuffer('P{:s}'.format(self.name), agg_type='mean', unit="W")
@@ -40,8 +40,7 @@ class ZeroCrossDetector:
40
40
  self.samplerate = samplerate
41
41
 
42
42
  # Design a Butterworth low-pass filter
43
- normal_cutoff = self.f_cutoff / (0.5 * self.samplerate)
44
- self._filter_coeff = signal.iirfilter(2, normal_cutoff, btype='lowpass', ftype='butter')
43
+ self._filter_coeff = signal.iirfilter(2, self.f_cutoff, btype='lowpass', ftype='butter', fs=self.samplerate)
45
44
  self._filter_zi = np.zeros(len(self._filter_coeff[0]) - 1)
46
45
 
47
46
  self._last_filtered_sample = 0
@@ -51,9 +50,8 @@ class ZeroCrossDetector:
51
50
  self._last_zc_n_val = None
52
51
 
53
52
  # Calculate the filter delay in samples
54
- w, h = signal.freqz(self._filter_coeff[0], self._filter_coeff[1],
55
- [2 * np.pi * self.f_cutoff / self.samplerate])
56
- self.filter_delay_samples = np.angle(h)[0] / (2 * np.pi) * self.samplerate / self.f_cutoff
53
+ w, h = signal.freqz(self._filter_coeff[0], self._filter_coeff[1], worN=[self.f_cutoff], fs=self.samplerate)
54
+ self.filter_delay_samples = np.angle(h)[0] / (2 * np.pi) * self.samplerate / self.f_cutoff - 1 # due to adding sample in front for continuity
57
55
  self.filtered_data = []
58
56
 
59
57
  def process(self, data: np.ndarray)-> list:
@@ -12,7 +12,7 @@ packages = ["pqopen"]
12
12
 
13
13
  [project]
14
14
  name = "pqopen-lib"
15
- version = "0.7.8"
15
+ version = "0.8.0"
16
16
  dependencies = [
17
17
  "numpy",
18
18
  "daqopen-lib",
@@ -229,7 +229,25 @@ class TestPowerSystemCalculation(unittest.TestCase):
229
229
  self.assertIsNone(np.testing.assert_allclose(u_h_rms[:,3], expected_u_h3_rms, rtol=0.01))
230
230
  u_msv_rms, _ = self.power_system.output_channels["U1_msv_rms"].read_data_by_acq_sidx(0, u_values.size)
231
231
  self.assertIsNone(np.testing.assert_allclose(u_msv_rms, expected_u_msv_rms, rtol=0.01))
232
+
233
+ def test_one_period_calc_trapz_rule(self):
234
+ t = np.linspace(0, 1, int(self.power_system._samplerate), endpoint=False)
235
+ u_values = np.sqrt(2)*np.sin(2*np.pi*50.02*t)
232
236
 
237
+ expected_u_rms = np.array(np.zeros(47)) + 1.0
238
+ expected_freq = np.array(np.zeros(47)) + 50.02
239
+
240
+ self.power_system.enable_rms_trapz_rule()
241
+ self.u_channel.put_data(u_values)
242
+ self.i_channel.put_data(u_values)
243
+ self.power_system.process()
244
+
245
+ # Check Voltage
246
+ u_rms, _ = self.power_system.output_channels["U1_1p_rms"].read_data_by_acq_sidx(0, u_values.size)
247
+ self.assertIsNone(np.testing.assert_array_almost_equal(u_rms[1:], expected_u_rms, 4))
248
+ # Check Frequency
249
+ freq, _ = self.power_system.output_channels["Freq"].read_data_by_acq_sidx(0, u_values.size)
250
+ self.assertIsNone(np.testing.assert_array_almost_equal(freq[1:], expected_freq, 3))
233
251
 
234
252
  class TestPowerSystemCalculationThreePhase(unittest.TestCase):
235
253
  def setUp(self):
@@ -509,7 +527,7 @@ class TestPowerSystemNperSync(unittest.TestCase):
509
527
  self.power_system.process()
510
528
 
511
529
  u_rms, sidx = self.power_system.output_channels["U1_rms"].read_data_by_acq_sidx(0, u_values.size)
512
- self.assertAlmostEqual(sidx[5*5],5.2*self.power_system._samplerate, places=-1)
530
+ self.assertAlmostEqual(sidx[5*5],5.22*self.power_system._samplerate, places=-1)
513
531
 
514
532
  # def test_fractional_freq(self):
515
533
  # abs_ts_start = datetime.datetime(2024,1,1,0,0,5, tzinfo=datetime.UTC).timestamp()
@@ -552,7 +570,7 @@ class TestPowerSystemFluctuation(unittest.TestCase):
552
570
  self.time_channel.put_data((t[blk_idx*blocksize:(blk_idx+1)*blocksize]+abs_ts_start)*1e6)
553
571
  self.power_system.process()
554
572
  self.assertAlmostEqual(self.power_system.output_channels["U1_pst"].last_sample_value, 0, places=1)
555
- self.assertEqual(self.power_system.output_channels["U1_pst"].last_sample_acq_sidx, np.round(self.power_system._samplerate*(61+0.02)))
573
+ self.assertEqual(self.power_system.output_channels["U1_pst"].last_sample_acq_sidx, int(self.power_system._samplerate*(61+0.02)))
556
574
 
557
575
  def test_steady_state_600s(self):
558
576
  self.power_system.enable_fluctuation_calculation(nominal_voltage=230, pst_interval_sec=600)
@@ -568,6 +586,37 @@ def test_steady_state_600s(self):
568
586
  self.assertAlmostEqual(self.power_system.output_channels["U1_pst"].last_sample_value, 0, places=1)
569
587
  self.assertEqual(self.power_system.output_channels["U1_pst"].last_sample_acq_sidx, np.round(self.power_system._samplerate*621))
570
588
 
589
+ class TestPowerSystemPMU(unittest.TestCase):
590
+ def setUp(self):
591
+ self.u_channel = AcqBuffer(name="U1")
592
+ self.time_channel = AcqBuffer(dtype=np.int64)
593
+
594
+ # Create PowerSystem instance
595
+ self.power_system = PowerSystem(
596
+ zcd_channel=self.u_channel,
597
+ input_samplerate=5555.555,
598
+ zcd_threshold=1
599
+ )
600
+ # Add Phase
601
+ self.power_system.add_phase(u_channel=self.u_channel)
602
+ self.power_system.enable_nper_abs_time_sync(self.time_channel)
603
+ self.power_system.enable_one_period_fundamental(1)
604
+ self.power_system.enable_pmu_calculation()
605
+
606
+ def test_simple(self):
607
+ abs_ts_start = datetime.datetime(2025,1,1,0,0,0, tzinfo=datetime.UTC).timestamp()
608
+ t = np.arange(0, 1, 1/self.power_system._samplerate)
609
+ u_values = 230*np.sqrt(2)*np.sin(2*np.pi*50*t)
610
+
611
+ blocksize = 1000
612
+ for blk_idx in range(t.size // blocksize):
613
+ self.u_channel.put_data(u_values[blk_idx*blocksize:(blk_idx+1)*blocksize])
614
+ self.time_channel.put_data((t[blk_idx*blocksize:(blk_idx+1)*blocksize]+abs_ts_start)*1e6)
615
+ self.power_system.process()
616
+ self.assertAlmostEqual(self.power_system.output_channels["U1_pmu_rms"].last_sample_value, 230, places=0)
617
+ self.assertAlmostEqual(self.power_system.output_channels["U1_pmu_phi"].last_sample_value, 0, places=0)
618
+ #self.assertEqual(self.power_system.output_channels["U1_pmu_rms"].last_sample_acq_sidx, np.round(self.power_system._samplerate*(61+0.02)))
619
+
571
620
 
572
621
  if __name__ == "__main__":
573
622
  unittest.main()
@@ -44,7 +44,7 @@ class TestZeroCrossDetector(unittest.TestCase):
44
44
  sine_wave = self.generate_sine_wave(freq=freq, duration=duration)
45
45
 
46
46
  zero_crossings = self.detector.process(sine_wave)
47
- self.assertAlmostEqual(self.detector.filter_delay_samples, -5)
47
+ self.assertAlmostEqual(self.detector.filter_delay_samples, -6)
48
48
 
49
49
  # Expected zero-crossings: 5 Hz * 1 positive crossing per cycle * 1 second
50
50
  expected_crossings = np.arange(start=1, stop=6)*self.samplerate/freq
File without changes
File without changes
File without changes
File without changes