pqopen-lib 0.8.4__tar.gz → 0.9.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.8.4
3
+ Version: 0.9.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
@@ -63,6 +63,19 @@ def calc_thd(harm_rms: np.ndarray, max_harmonic: int = 40, min_harmonic: int = 2
63
63
  thd = np.sqrt(np.sum(np.power(harm_rms[min_harmonic:max_harmonic+1]/fund_rms, 2)))*100
64
64
  return thd
65
65
 
66
+ def calc_hf_1khz_band(fft_data: np.ndarray, samplerate: float):
67
+ fft_data_abs = np.abs(fft_data)
68
+ freq_resolution = 0.5*samplerate / fft_data_abs.shape[0]
69
+ freq_steps = np.arange(-500, (samplerate/2) + 1500 - (samplerate/2) % 1000, 1000)
70
+ freq_steps[0] = 0
71
+ freq_steps[-1] = samplerate/2
72
+ freq_band_idx = (freq_steps / freq_resolution).astype(int)
73
+ hf_1khz_band_rms = []
74
+ for band_idx in range(freq_band_idx.shape[0] - 1):
75
+ hf_1khz_band_rms.append(np.sqrt(np.sum(fft_data_abs[freq_band_idx[band_idx]:freq_band_idx[band_idx+1]])))
76
+
77
+ return np.array(hf_1khz_band_rms)
78
+
66
79
  def resample_and_fft(data: np.ndarray, resample_size: int = None) -> np.ndarray:
67
80
  """
68
81
  Resample the input data to a specified size and compute its FFT.
@@ -24,6 +24,7 @@ from typing import List, Dict
24
24
  import logging
25
25
  from pathlib import Path
26
26
  import json
27
+ from scipy.signal import get_window
27
28
 
28
29
  from daqopen.channelbuffer import AcqBuffer, DataChannelBuffer
29
30
  from pqopen.zcd import ZeroCrossDetector
@@ -91,7 +92,8 @@ class PowerSystem(object):
91
92
  "energy_channels": {},
92
93
  "one_period_fundamental": 0,
93
94
  "rms_trapz_rule": False,
94
- "pmu_calculation": False}
95
+ "pmu_calculation": False,
96
+ "hf_1khz_band_calculation": False}
95
97
  self._prepare_calc_channels()
96
98
  self.output_channels: Dict[str, DataChannelBuffer] = {}
97
99
  self._last_processed_sidx = 0
@@ -246,6 +248,15 @@ class PowerSystem(object):
246
248
  self._pmu_last_processed_ts_us = 0
247
249
  self._pmu_time_increment_us = int(1_000_000 / self.nominal_frequency)
248
250
 
251
+ def enable_hf_1khz_band_calculation(self):
252
+ """
253
+ Enables the calculation of wide band analysis with 1 kHz bands (non standard)
254
+ """
255
+ num_bands = int(self._samplerate*0.5 / 1000) + 1
256
+ self._features["hf_1khz_band_calculation"] = num_bands
257
+ self._hf_1khz_window = get_window("hann", self._harm_fft_resample_size)
258
+ self._channel_update_needed = True
259
+
249
260
  def _resync_nper_abs_time(self, zc_idx: int):
250
261
  if not self._features["nper_abs_time_sync"]:
251
262
  return None
@@ -305,11 +316,11 @@ class PowerSystem(object):
305
316
  if self._features["harmonics"]:
306
317
  for phase in self._phases:
307
318
  if phase._u_channel.freq_response:
308
- phase._u_fft_corr_array = create_fft_corr_array(self._harm_fft_resample_size,
319
+ phase._u_fft_corr_array = create_fft_corr_array(int((self._samplerate / self.nominal_frequency) * self.nper / 2),
309
320
  self._samplerate/2,
310
321
  phase._u_channel.freq_response)
311
322
  if phase._i_channel is not None and phase._i_channel.freq_response:
312
- phase._i_fft_corr_array = create_fft_corr_array(self._harm_fft_resample_size,
323
+ phase._i_fft_corr_array = create_fft_corr_array(int((self._samplerate / self.nominal_frequency) * self.nper / 2),
313
324
  self._samplerate/2,
314
325
  phase._i_channel.freq_response)
315
326
 
@@ -482,7 +493,8 @@ class PowerSystem(object):
482
493
  data_fft_U = pq.resample_and_fft(u_values, self._harm_fft_resample_size)
483
494
  if phase._u_fft_corr_array is not None:
484
495
  resample_factor = min(self._harm_fft_resample_size / u_values.size, 1)
485
- data_fft_U *= phase._u_fft_corr_array[np.linspace(0, self._harm_fft_resample_size*resample_factor, self._harm_fft_resample_size//2+1).astype(np.int32)]
496
+ corr_freq_idx = np.linspace(0, self._samplerate*0.5*resample_factor*self.nper/self.nominal_frequency, self._harm_fft_resample_size//2+1).astype(np.int32)
497
+ data_fft_U *= phase._u_fft_corr_array[corr_freq_idx]
486
498
  u_h_mag, u_h_phi = pq.calc_harmonics(data_fft_U, self.nper, self._features["harmonics"])
487
499
  u_ih_mag = pq.calc_interharmonics(data_fft_U, self.nper, self._features["harmonics"])
488
500
  if phase._number == 1: # use phase 1 angle as reference
@@ -511,6 +523,17 @@ class PowerSystem(object):
511
523
  output_channel.put_data_single(stop_sidx, u_rms)
512
524
  if phys_type == "over":
513
525
  output_channel.put_data_single(stop_sidx, u_rms)
526
+ if phys_type == "hf_1khz_rms":
527
+ u_values_windowed = u_values[:self._harm_fft_resample_size]*self._hf_1khz_window
528
+ u_real_fft = np.fft.rfft(u_values_windowed)
529
+ #u_real_fft = np.fft.rfft(u_values_windowed)
530
+ if phase._u_fft_corr_array is not None:
531
+ corr_freq_idx = np.linspace(0, self._samplerate*0.5, u_real_fft.shape[0]).astype(np.int32)
532
+ u_real_fft *= phase._u_fft_corr_array[corr_freq_idx]
533
+ u_Pxx = (np.abs(u_real_fft) ** 2) / np.sum(self._hf_1khz_window ** 2)
534
+ u_Pxx /= self._harm_fft_resample_size * 0.5
535
+ u_hf_1khz = pq.calc_hf_1khz_band(u_Pxx, self._samplerate)
536
+ output_channel.put_data_single(stop_sidx, u_hf_1khz)
514
537
 
515
538
  if phase._i_channel:
516
539
  i_values = phase._i_channel.read_data_by_index(start_sidx, stop_sidx)
@@ -676,6 +699,14 @@ class PowerSystem(object):
676
699
  self._last_known_freq = self.nominal_frequency
677
700
  self._calculation_mode = "FALLBACK"
678
701
  elif self._calculation_mode == "FALLBACK":
702
+ remaining_virtual_zc = []
703
+ last_rel_zc = self._zero_crossings[-1] - start_acq_sidx
704
+ zc_gap = zero_crossings[0] - last_rel_zc
705
+ num_zc_to_fill = int(zc_gap / (self._samplerate/self._last_known_freq)) + 1
706
+ virtual_zc_interval = int(zc_gap / num_zc_to_fill)
707
+ remaining_virtual_zc = [last_rel_zc + i*virtual_zc_interval for i in range(1,num_zc_to_fill)]
708
+ logger.debug("Finishing Fallback: Added virtual zero crossing: idx=" + ",".join([f"{virtual_zc:.1f}" for virtual_zc in remaining_virtual_zc]))
709
+ zero_crossings = remaining_virtual_zc + zero_crossings
679
710
  self._calculation_mode = "NORMAL"
680
711
  else:
681
712
  self._calculation_mode = "NORMAL"
@@ -698,6 +729,21 @@ class PowerSystem(object):
698
729
  output_values[ch_name] = ch_data
699
730
  return output_values
700
731
 
732
+ def get_channel_info(self) -> dict:
733
+ channel_info = {}
734
+ for phase in self._phases:
735
+ for calc_interval, interval_group in phase._calc_channels.items():
736
+ for derived_type, phys_group in interval_group.items():
737
+ for phys_type, channel in phys_group.items():
738
+ channel_info[channel.name] = {
739
+ "unit": channel.unit,
740
+ "phys_type": phys_type,
741
+ "derived_type": derived_type,
742
+ "calc_interval": calc_interval,
743
+ "phase": phase.name
744
+ }
745
+ return channel_info
746
+
701
747
  def __del__(self):
702
748
  if self._features["energy_channels"]:
703
749
  w_pos_value = float(self._calc_channels["multi_period"]["energy"]["w_pos"].last_sample_value)
@@ -787,6 +833,10 @@ class PowerPhase(object):
787
833
  self._calc_channels["pmu"]["voltage"]["rms"] = DataChannelBuffer('U{:s}_pmu_rms'.format(self.name), agg_type='rms', unit="V")
788
834
  self._calc_channels["pmu"]["voltage"]["phi"] = DataChannelBuffer('U{:s}_pmu_phi'.format(self.name), agg_type='phi', unit="°")
789
835
 
836
+ if "hf_1khz_band_calculation" in features and features["hf_1khz_band_calculation"]:
837
+ num_bands = features["hf_1khz_band_calculation"]
838
+ self._calc_channels["multi_period"]["voltage"]["hf_1khz_rms"] = DataChannelBuffer('U{:s}_HF_1kHz_rms'.format(self.name), sample_dimension=num_bands, agg_type='rms', unit="V")
839
+
790
840
  # Create Current Channels
791
841
  if self._i_channel:
792
842
  self._calc_channels["one_period"]["current"] = {}
@@ -57,11 +57,13 @@ import numpy as np
57
57
  from pqopen.helper import floor_timestamp, JsonDecimalLimiter
58
58
  from pqopen.eventdetector import Event
59
59
  from daqopen.channelbuffer import DataChannelBuffer, AcqBuffer
60
+ from daqopen.daqinfo import DaqInfo
60
61
  from pathlib import Path
61
62
  from typing import List, Dict
62
63
  import logging
63
64
  import json
64
65
  import gzip
66
+ import time
65
67
  import paho.mqtt.client as mqtt
66
68
 
67
69
 
@@ -91,7 +93,7 @@ class StorageEndpoint(object):
91
93
  """
92
94
  Writes aggregated data to the storage endpoint.
93
95
 
94
- Args:
96
+ Parameters:
95
97
  data: The aggregated data to store.
96
98
  timestamp_us: The timestamp in microseconds for the aggregated data.
97
99
  interval_seconds: The aggregation interval in seconds.
@@ -101,6 +103,17 @@ class StorageEndpoint(object):
101
103
  def write_event(self, event: Event):
102
104
  pass
103
105
 
106
+ def write_measurement_config(self, m_config: dict, daq_info: DaqInfo, channe_info: dict, **kwargs):
107
+ """
108
+ Writes measurement config to the storage endpoint.
109
+
110
+ Parameters:
111
+ m_config: The measurement config to store.
112
+ daq_info: The daq info to store.
113
+ channel_info: The channel's specific infos
114
+ """
115
+ pass
116
+
104
117
  class StoragePlan(object):
105
118
  """Defines a plan for storing data with specified intervals and channels."""
106
119
 
@@ -190,6 +203,9 @@ class StoragePlan(object):
190
203
  if self._store_events_enabled:
191
204
  self.storage_endpoint.write_event(event, **self._additional_config)
192
205
 
206
+ def store_measurement_config(self, m_config: dict, daq_info: DaqInfo, channel_info: dict):
207
+ self.storage_endpoint.write_measurement_config(m_config, daq_info, channel_info, **self._additional_config)
208
+
193
209
  class StorageController(object):
194
210
  """Manages multiple storage plans and processes data for storage."""
195
211
 
@@ -304,7 +320,10 @@ class StorageController(object):
304
320
  available_channels: dict,
305
321
  measurement_id: str,
306
322
  device_id: str,
307
- start_timestamp_us: int):
323
+ start_timestamp_us: int,
324
+ m_config: dict | None = None,
325
+ daq_info: DaqInfo | None = None,
326
+ channel_info: dict| None = None):
308
327
  """
309
328
  Setup endpoints and storage plans from config
310
329
 
@@ -319,7 +338,7 @@ class StorageController(object):
319
338
  Raises:
320
339
  NotImplementedError: If a not implemented endpoint will be configured
321
340
  """
322
- self._configured_eps = {}
341
+ self._configured_eps: dict[str, StorageEndpoint] = {}
323
342
  for ep_type, ep_config in endpoints.items():
324
343
  if ep_type == "csv":
325
344
  csv_storage_endpoint = CsvStorageEndpoint("csv", measurement_id, ep_config.get("data_dir", "/tmp/"))
@@ -375,8 +394,11 @@ class StorageController(object):
375
394
  else:
376
395
  logger.warning(f"Channel {channel} not available for storing")
377
396
  self.add_storage_plan(storage_plan)
378
-
379
-
397
+ # Initially store the measurement config if configured
398
+ if sp_config.get("store_config", False) and m_config and daq_info and channel_info:
399
+ storage_plan.store_measurement_config(m_config=m_config,
400
+ daq_info=daq_info,
401
+ channel_info=channel_info)
380
402
 
381
403
  class TestStorageEndpoint(StorageEndpoint):
382
404
  """A implementation of StorageEndpoint for testing purposes."""
@@ -499,6 +521,27 @@ class MqttStorageEndpoint(StorageEndpoint):
499
521
  self._client.publish(topic_prefix + f"/{self._device_id:s}/event/json",
500
522
  json_item.encode('utf-8'), qos=2)
501
523
 
524
+ def write_measurement_config(self, m_config: dict, daq_info: DaqInfo, channel_info: dict, **kwargs):
525
+ """
526
+ Write measurement config
527
+ """
528
+ m_config_object = {
529
+ "type": "m_config",
530
+ "measurement_uuid": self.measurement_id,
531
+ "timestamp": time.time(),
532
+ "m_config": m_config,
533
+ "daq_info": daq_info.to_dict(),
534
+ "channel_info": channel_info
535
+ }
536
+ json_item = json.dumps(m_config_object)
537
+ topic_prefix = kwargs.get("mqtt_topic_prefix", self._topic_prefix)
538
+ if self._compression:
539
+ self._client.publish(topic_prefix + f"/{self._device_id:s}/m_config/gjson",
540
+ gzip.compress(json_item.encode('utf-8')), qos=2)
541
+ else:
542
+ self._client.publish(topic_prefix + f"/{self._device_id:s}/m_config/json",
543
+ json_item.encode('utf-8'), qos=2)
544
+
502
545
  class HomeAssistantStorageEndpoint(StorageEndpoint):
503
546
  """Represents a MQTT endpoint (MQTT) for HomeAssistant."""
504
547
  def __init__(self,
@@ -12,7 +12,7 @@ packages = ["pqopen"]
12
12
 
13
13
  [project]
14
14
  name = "pqopen-lib"
15
- version = "0.8.4"
15
+ version = "0.9.0"
16
16
  dependencies = [
17
17
  "numpy",
18
18
  "daqopen-lib >= 0.7.4",
@@ -8,16 +8,17 @@ sys.path.append(os.path.dirname(SCRIPT_DIR))
8
8
 
9
9
  import pqopen.powerquality as pq
10
10
  from daqopen.channelbuffer import DataChannelBuffer
11
+ from pqopen.helper import create_fft_corr_array
11
12
 
12
13
  class TestPowerPowerQualityHarmonic(unittest.TestCase):
13
14
  def setUp(self):
14
15
  ...
15
16
 
16
17
  def test_simple(self):
17
- samplerate = 1000
18
+ samplerate = 5000
18
19
  f_fund = 50.0
19
20
  num_periods = 10
20
- t = np.linspace(0, 0.2, samplerate, endpoint=False)
21
+ t = np.linspace(0, 0.2, int(samplerate*0.2), endpoint=False)
21
22
  values = np.sqrt(2)*np.sin(2*np.pi*f_fund*t) + 0.1*np.sqrt(2)*np.sin(2*np.pi*2*f_fund*t + 45*np.pi/2)
22
23
  expected_v_h_rms = [0, 1, 0.1, 0, 0, 0, 0, 0, 0, 0, 0]
23
24
 
@@ -29,10 +30,10 @@ class TestPowerPowerQualityHarmonic(unittest.TestCase):
29
30
  self.assertAlmostEqual(v_h_phi[2], -90+45*2, places=2)
30
31
 
31
32
  def test_advanced(self):
32
- samplerate = 1000
33
+ samplerate = 5000
33
34
  f_fund = 53.0
34
35
  num_periods = 10
35
- t = np.linspace(0, num_periods/f_fund, samplerate, endpoint=False)
36
+ t = np.linspace(0, num_periods/f_fund, int(samplerate*num_periods/f_fund), endpoint=False)
36
37
  values = (1.0*np.sqrt(2)*np.sin(2*np.pi*1.0*f_fund*t) +
37
38
  0.1*np.sqrt(2)*np.sin(2*np.pi*1.5*f_fund*t + 45*np.pi/2)+
38
39
  0.1*np.sqrt(2)*np.sin(2*np.pi*2.0*f_fund*t + 45*np.pi/2))
@@ -53,6 +54,33 @@ class TestPowerPowerQualityHarmonic(unittest.TestCase):
53
54
  self.assertAlmostEqual(v_h_phi[2], -90+45*2, places=2)
54
55
  self.assertAlmostEqual(v_thd, expected_v_thd, places=1)
55
56
 
57
+ def test_freq_response_correction(self):
58
+ samplerate = 5000
59
+ nominal_frequency = 50
60
+ f_fund = 45
61
+ num_periods = 10
62
+ harm_fft_resample_size = 512
63
+ t = np.linspace(0, num_periods/f_fund, int(samplerate*num_periods/f_fund), endpoint=False)
64
+ values = (1.0*np.sqrt(2)*np.sin(2*np.pi*1.0*f_fund*t) +
65
+ 0.1*np.sqrt(2)*np.sin(2*np.pi*1.5*f_fund*t + 45*np.pi/2)+
66
+ 0.1*np.sqrt(2)*np.sin(2*np.pi*2.0*f_fund*t + 45*np.pi/2)+
67
+ 0.1*np.sqrt(2)*np.sin(2*np.pi*5.0*f_fund*t + 45*np.pi/2))
68
+ freq_response = ((50, 1.0), (200, 1.0), (225, 0.5))
69
+ expected_v_h_rms = [0, 1, 0.1, 0, 0, 0.2, 0, 0, 0, 0, 0]
70
+
71
+ v_fft = pq.resample_and_fft(values, resample_size=harm_fft_resample_size)
72
+ resample_factor = v_fft.shape[0]*2 / values.shape[0]
73
+
74
+ v_fft_corr_array = create_fft_corr_array(int((samplerate / nominal_frequency) * num_periods / 2),
75
+ samplerate*0.5,
76
+ freq_response)
77
+
78
+ corr_array_to_apply = v_fft_corr_array[np.linspace(0, samplerate*0.5*resample_factor*num_periods/nominal_frequency, harm_fft_resample_size//2+1).astype(np.int32)]
79
+ v_fft *= corr_array_to_apply
80
+ v_h_rms, v_h_phi = pq.calc_harmonics(v_fft, 10, 10)
81
+
82
+ self.assertIsNone(np.testing.assert_allclose(v_h_rms, expected_v_h_rms, atol=0.01))
83
+
56
84
 
57
85
  class TestPowerPowerQualityFlicker(unittest.TestCase):
58
86
  def setUp(self):
@@ -52,6 +52,12 @@ class TestPowerSystemChannelConfig(unittest.TestCase):
52
52
  # Assuming process is to be implemented, we just check its existence
53
53
  self.assertTrue(callable(self.power_system.process))
54
54
 
55
+ def test_get_channel_info(self):
56
+ self.power_system.add_phase(u_channel=self.mock_acq_buffer)
57
+ self.power_system._update_calc_channels()
58
+ channel_info = self.power_system.get_channel_info()
59
+ self.assertTrue(isinstance(channel_info, dict))
60
+
55
61
  class TestPowerPhaseChannelConfig(unittest.TestCase):
56
62
  def setUp(self):
57
63
  # Mock AcqBuffer
@@ -267,7 +273,7 @@ class TestPowerSystemCalculation(unittest.TestCase):
267
273
 
268
274
  class TestPowerSystemCalculationFreqResponse(unittest.TestCase):
269
275
  def setUp(self):
270
- self.u_channel = AcqBuffer(freq_response=((50,1.0), (100,0.9)))
276
+ self.u_channel = AcqBuffer(freq_response=((50,1.0), (100,0.9), (1000, 0.9), (2000, 0.7)))
271
277
  self.i_channel = AcqBuffer()
272
278
 
273
279
  # Create PowerSystem instance
@@ -299,6 +305,24 @@ class TestPowerSystemCalculationFreqResponse(unittest.TestCase):
299
305
  u_msv_rms, _ = self.power_system.output_channels["U1_msv_rms"].read_data_by_acq_sidx(0, u_values.size)
300
306
  self.assertIsNone(np.testing.assert_allclose(u_msv_rms, expected_u_msv_rms, rtol=0.01))
301
307
 
308
+ def test_hf_1khz_band_calc(self):
309
+ t = np.linspace(0, 1, int(self.power_system._samplerate), endpoint=False)
310
+ u_values = (np.sqrt(2)*np.sin(2*np.pi*50*t) +
311
+ 0.2*np.sqrt(2)*np.sin(2*np.pi*1000*t) +
312
+ 0.1*np.sqrt(2)*np.sin(2*np.pi*2000*t))
313
+ i_values = 2*np.sqrt(2)*np.sin(2*np.pi*50*t+60*np.pi/180) # cos_phi = 0.5
314
+
315
+ expected_u_hf_rms = np.array([1, 0.2, 0.1, 0, 0, 0])
316
+
317
+ self.power_system.enable_hf_1khz_band_calculation()
318
+ self.u_channel.put_data(u_values)
319
+ self.i_channel.put_data(i_values)
320
+ self.power_system.process()
321
+
322
+ # Check Voltage
323
+ u_hf_rms, _ = self.power_system.output_channels["U1_HF_1kHz_rms"].read_data_by_acq_sidx(0, u_values.size)
324
+ self.assertIsNone(np.testing.assert_allclose(u_hf_rms[3,:], expected_u_hf_rms, atol=0.01))
325
+
302
326
  class TestPowerSystemCalculationThreePhase(unittest.TestCase):
303
327
  def setUp(self):
304
328
  self.u1_channel = AcqBuffer()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes