pqopen-lib 0.7.9__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.9
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
@@ -89,7 +89,8 @@ class PowerSystem(object):
89
89
  "debug_channels": False,
90
90
  "energy_channels": {},
91
91
  "one_period_fundamental": 0,
92
- "rms_trapz_rule": False}
92
+ "rms_trapz_rule": False,
93
+ "pmu_calculation": False}
93
94
  self._prepare_calc_channels()
94
95
  self.output_channels: Dict[str, DataChannelBuffer] = {}
95
96
  self._last_processed_sidx = 0
@@ -109,7 +110,8 @@ class PowerSystem(object):
109
110
  self._calc_channels = {"half_period": {"voltage": {}, "current": {}, "power": {}, "_debug": {}},
110
111
  "one_period": {"voltage": {}, "current": {}, "power": {}, "_debug": {}},
111
112
  "one_period_ovlp": {"voltage": {}, "current": {}, "power": {}, "_debug": {}},
112
- "multi_period": {"voltage": {}, "current": {}, "power": {}, "energy": {}, "_debug": {}}}
113
+ "multi_period": {"voltage": {}, "current": {}, "power": {}, "energy": {}, "_debug": {}},
114
+ "pmu": {"voltage": {}, "current": {}, "power": {}, "_debug": {}}}
113
115
 
114
116
  def add_phase(self, u_channel: AcqBuffer, i_channel: AcqBuffer = None, name: str = ""):
115
117
  """
@@ -230,6 +232,19 @@ class PowerSystem(object):
230
232
  """
231
233
  self._features["rms_trapz_rule"] = True
232
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
+
233
248
  def _resync_nper_abs_time(self, zc_idx: int):
234
249
  if not self._features["nper_abs_time_sync"]:
235
250
  return None
@@ -278,6 +293,8 @@ class PowerSystem(object):
278
293
  self._calc_channels["multi_period"]["energy"]["w_pos"].last_sample_value = self._features["energy_channels"]["energy_counters"].get("W_pos", 0.0)
279
294
  self._calc_channels["multi_period"]["energy"]["w_neg"] = DataChannelBuffer('W_neg', agg_type='max', unit="Wh", dtype=np.float64)
280
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")
281
298
 
282
299
  for agg_interval, phys_types in self._calc_channels.items():
283
300
  for phys_type, calc_type in phys_types.items():
@@ -340,6 +357,9 @@ class PowerSystem(object):
340
357
  self._process_multi_period(self._zero_crossings[-self.nper - 1], self._zero_crossings[-1])
341
358
  self._resync_nper_abs_time(-1)
342
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])
343
363
 
344
364
  self._last_processed_sidx = stop_acq_sidx
345
365
 
@@ -373,6 +393,13 @@ class PowerSystem(object):
373
393
  u_values = phase._u_channel.read_data_by_index(phase_period_start_sidx, phase_period_stop_sidx)
374
394
  if self._features["mains_signaling_tracer"]:
375
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)
376
403
  for phys_type, output_channel in phase._calc_channels["one_period"]["voltage"].items():
377
404
  if phys_type == "trms":
378
405
  if self._features["rms_trapz_rule"]:
@@ -399,12 +426,10 @@ class PowerSystem(object):
399
426
  if phys_type == "slope":
400
427
  output_channel.put_data_single(phase_period_stop_sidx, np.abs(np.diff(u_values)).max())
401
428
  if phys_type == "fund_rms":
402
- # Use sample-discrete frequency, not the exact one for full cycle
403
- self._fund_freq_list = np.roll(self._fund_freq_list,-1)
404
- self._fund_freq_list[-1] = self._samplerate/len(u_values)
405
- mean_freq = self._fund_freq_list[self._fund_freq_list>0].mean()
406
- fund_amp, fund_phase = calc_single_freq(u_values, mean_freq, self._samplerate)
407
- 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
+
408
433
  for phys_type, output_channel in phase._calc_channels["half_period"]["voltage"].items():
409
434
  if phys_type == "trms":
410
435
  # First half period
@@ -571,6 +596,52 @@ class PowerSystem(object):
571
596
  self._pst_last_calc_sidx = stop_sidx
572
597
  self._pst_next_round_ts = floor_timestamp(stop_ts, self._pst_interval_sec, ts_resolution="us")+self._pst_interval_sec*1_000_000
573
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
+
574
645
  def _detect_zero_crossings(self, start_acq_sidx: int, stop_acq_sidx: int) -> List[float]:
575
646
  """
576
647
  Detects zero crossings in the signal.
@@ -671,12 +742,13 @@ class PowerPhase(object):
671
742
  Parameters:
672
743
  features: Dict of features
673
744
  """
674
- 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": {}}
675
746
  # Create Voltage Channels
676
747
  self._calc_channels["half_period"]["voltage"] = {}
677
748
  self._calc_channels["one_period"]["voltage"] = {}
678
749
  self._calc_channels["one_period_ovlp"]["voltage"] = {}
679
750
  self._calc_channels["multi_period"]["voltage"] = {}
751
+ self._calc_channels["pmu"]["voltage"] = {}
680
752
  self._calc_channels["half_period"]["voltage"]["trms"] = DataChannelBuffer('U{:s}_hp_rms'.format(self.name), agg_type='rms', unit="V")
681
753
  self._calc_channels["one_period"]["voltage"]["trms"] = DataChannelBuffer('U{:s}_1p_rms'.format(self.name), agg_type='rms', unit="V")
682
754
  self._calc_channels["one_period"]["voltage"]["slope"] = DataChannelBuffer('U{:s}_1p_slope'.format(self.name), agg_type='max', unit="V/s")
@@ -706,6 +778,11 @@ class PowerPhase(object):
706
778
 
707
779
  if "one_period_fundamental" in features and features["one_period_fundamental"] > 0:
708
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="°")
709
786
 
710
787
  # Create Current Channels
711
788
  if self._i_channel:
@@ -713,6 +790,7 @@ class PowerPhase(object):
713
790
  self._calc_channels["multi_period"]["current"] = {}
714
791
  self._calc_channels["one_period"]["current"]["trms"] = DataChannelBuffer('I{:s}_1p_rms'.format(self.name), agg_type='rms', unit="A")
715
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"] = {}
716
794
  self._calc_channels["one_period"]["power"] = {}
717
795
  self._calc_channels["multi_period"]["power"] = {}
718
796
 
@@ -725,6 +803,10 @@ class PowerPhase(object):
725
803
  self._calc_channels["multi_period"]["power"]['p_fund_mag'] = DataChannelBuffer('P{:s}_H1'.format(self.name), agg_type='mean', unit="W")
726
804
  self._calc_channels["multi_period"]["power"]['q_fund_mag'] = DataChannelBuffer('Q{:s}_H1'.format(self.name), agg_type='mean', unit="var")
727
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
+
728
810
  # Create Power Channels
729
811
  self._calc_channels["one_period"]["power"]['p_avg'] = DataChannelBuffer('P{:s}_1p'.format(self.name), agg_type='mean', unit="W")
730
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.9"
15
+ version = "0.8.0"
16
16
  dependencies = [
17
17
  "numpy",
18
18
  "daqopen-lib",
@@ -527,7 +527,7 @@ class TestPowerSystemNperSync(unittest.TestCase):
527
527
  self.power_system.process()
528
528
 
529
529
  u_rms, sidx = self.power_system.output_channels["U1_rms"].read_data_by_acq_sidx(0, u_values.size)
530
- 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)
531
531
 
532
532
  # def test_fractional_freq(self):
533
533
  # abs_ts_start = datetime.datetime(2024,1,1,0,0,5, tzinfo=datetime.UTC).timestamp()
@@ -570,7 +570,7 @@ class TestPowerSystemFluctuation(unittest.TestCase):
570
570
  self.time_channel.put_data((t[blk_idx*blocksize:(blk_idx+1)*blocksize]+abs_ts_start)*1e6)
571
571
  self.power_system.process()
572
572
  self.assertAlmostEqual(self.power_system.output_channels["U1_pst"].last_sample_value, 0, places=1)
573
- 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)))
574
574
 
575
575
  def test_steady_state_600s(self):
576
576
  self.power_system.enable_fluctuation_calculation(nominal_voltage=230, pst_interval_sec=600)
@@ -586,6 +586,37 @@ def test_steady_state_600s(self):
586
586
  self.assertAlmostEqual(self.power_system.output_channels["U1_pst"].last_sample_value, 0, places=1)
587
587
  self.assertEqual(self.power_system.output_channels["U1_pst"].last_sample_acq_sidx, np.round(self.power_system._samplerate*621))
588
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
+
589
620
 
590
621
  if __name__ == "__main__":
591
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