pqopen-lib 0.8.3__tar.gz → 0.8.5__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.3
3
+ Version: 0.8.5
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
@@ -659,7 +659,7 @@ class PowerSystem(object):
659
659
  List[int]: Detected zero-crossing indices.
660
660
  """
661
661
  zcd_data = self._zcd_channel.read_data_by_index(start_idx=start_acq_sidx, stop_idx=stop_acq_sidx)
662
- zero_crossings = self._zero_cross_detector.process(zcd_data)
662
+ zero_crossings = self._zero_cross_detector.process(zcd_data, self._zero_crossings[-1] - start_acq_sidx)
663
663
  if not zero_crossings:
664
664
  if (self._zero_crossings[-1] + self._samplerate/self._zcd_minimum_frequency - self._zero_cross_detector.filter_delay_samples) < stop_acq_sidx:
665
665
  zero_crossings.append(self._zero_crossings[-1] + self._samplerate/self._last_known_freq - start_acq_sidx)
@@ -675,9 +675,18 @@ class PowerSystem(object):
675
675
  if self._last_known_freq < self._zcd_minimum_frequency:
676
676
  self._last_known_freq = self.nominal_frequency
677
677
  self._calculation_mode = "FALLBACK"
678
+ elif self._calculation_mode == "FALLBACK":
679
+ remaining_virtual_zc = []
680
+ last_rel_zc = self._zero_crossings[-1] - start_acq_sidx
681
+ zc_gap = zero_crossings[0] - last_rel_zc
682
+ num_zc_to_fill = int(zc_gap / (self._samplerate/self._last_known_freq)) + 1
683
+ virtual_zc_interval = int(zc_gap / num_zc_to_fill)
684
+ remaining_virtual_zc = [last_rel_zc + i*virtual_zc_interval for i in range(1,num_zc_to_fill)]
685
+ logger.debug("Finishing Fallback: Added virtual zero crossing: idx=" + ",".join([f"{virtual_zc:.1f}" for virtual_zc in remaining_virtual_zc]))
686
+ zero_crossings = remaining_virtual_zc + zero_crossings
687
+ self._calculation_mode = "NORMAL"
678
688
  else:
679
689
  self._calculation_mode = "NORMAL"
680
-
681
690
  return zero_crossings
682
691
 
683
692
  def get_aggregated_data(self, start_acq_sidx: int, stop_acq_sidx: int) -> dict:
@@ -697,6 +706,21 @@ class PowerSystem(object):
697
706
  output_values[ch_name] = ch_data
698
707
  return output_values
699
708
 
709
+ def get_channel_info(self) -> dict:
710
+ channel_info = {}
711
+ for phase in self._phases:
712
+ for calc_interval, interval_group in phase._calc_channels.items():
713
+ for derived_type, phys_group in interval_group.items():
714
+ for phys_type, channel in phys_group.items():
715
+ channel_info[channel.name] = {
716
+ "unit": channel.unit,
717
+ "phys_type": phys_type,
718
+ "derived_type": derived_type,
719
+ "calc_interval": calc_interval,
720
+ "phase": phase.name
721
+ }
722
+ return channel_info
723
+
700
724
  def __del__(self):
701
725
  if self._features["energy_channels"]:
702
726
  w_pos_value = float(self._calc_channels["multi_period"]["energy"]["w_pos"].last_sample_value)
@@ -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,
@@ -55,7 +55,7 @@ class ZeroCrossDetector:
55
55
  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
56
56
  self.filtered_data = []
57
57
 
58
- def process(self, data: np.ndarray)-> list:
58
+ def process(self, data: np.ndarray, abs_last_zc: int = None)-> list:
59
59
  """
60
60
  Processes a block of input data and detect zero-crossings.
61
61
 
@@ -117,6 +117,8 @@ class ZeroCrossDetector:
117
117
  else:
118
118
  if self._last_zc and (real_zc <= self._last_zc):
119
119
  logger.warning("Detected ZC before last one, ignoring")
120
+ elif (abs_last_zc is not None) and (self._last_zc and (real_zc <= abs_last_zc)):
121
+ logger.warning("Detected ZC before last one, ignoring")
120
122
  else:
121
123
  zero_crossings.append(real_zc + self.filter_delay_samples)
122
124
  last_used_p_idx = p_idx
@@ -12,7 +12,7 @@ packages = ["pqopen"]
12
12
 
13
13
  [project]
14
14
  name = "pqopen-lib"
15
- version = "0.8.3"
15
+ version = "0.8.5"
16
16
  dependencies = [
17
17
  "numpy",
18
18
  "daqopen-lib >= 0.7.4",
@@ -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
@@ -143,6 +149,22 @@ class TestPowerSystemZcd(unittest.TestCase):
143
149
  u_rms, _ = self.power_system.output_channels["U1_1p_rms"].read_data_by_acq_sidx(0, values.size)
144
150
  self.assertIsNone(np.testing.assert_array_almost_equal(u_rms[-10:], expected_u_rms[-10:], 3))
145
151
 
152
+ def test_one_period_calc_temp_fallback(self):
153
+ t = np.linspace(0, 10, int(self.power_system._samplerate)*10, endpoint=False)
154
+ values = np.sqrt(2)*np.sin(2*np.pi*50*t)
155
+
156
+ values[int(self.power_system._samplerate*3):int(self.power_system._samplerate*7)] *= 0
157
+
158
+ expected_u_rms = np.array(np.zeros(48)) + 1.0
159
+
160
+ calc_blocksize = 50
161
+ for i in range(values.size//calc_blocksize):
162
+ self.u_channel.put_data(values[i*calc_blocksize:(i+1)*calc_blocksize])
163
+ self.power_system.process()
164
+ # Check Voltage
165
+ u_rms, _ = self.power_system.output_channels["U1_1p_rms"].read_data_by_acq_sidx(values.size-self.power_system._samplerate, values.size)
166
+ self.assertIsNone(np.testing.assert_array_almost_equal(u_rms[-10:], expected_u_rms[-10:], 3))
167
+
146
168
  class TestPowerSystemCalculation(unittest.TestCase):
147
169
  def setUp(self):
148
170
  self.u_channel = AcqBuffer()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes