shepherd-core 2025.8.1__py3-none-any.whl → 2026.2.1__py3-none-any.whl

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.
Files changed (82) hide show
  1. shepherd_core/config.py +1 -1
  2. shepherd_core/data_models/__init__.py +8 -4
  3. shepherd_core/data_models/base/cal_measurement.py +7 -2
  4. shepherd_core/data_models/base/calibration.py +23 -12
  5. shepherd_core/data_models/base/content.py +12 -2
  6. shepherd_core/data_models/base/shepherd.py +13 -4
  7. shepherd_core/data_models/base/wrapper.py +2 -0
  8. shepherd_core/data_models/content/__init__.py +8 -4
  9. shepherd_core/data_models/content/_external_fixtures.yaml +104 -96
  10. shepherd_core/data_models/content/_metadata_eenvs_bonito.yaml +436 -0
  11. shepherd_core/data_models/content/_metadata_eenvs_synthetic_multivariate_random_walk.yaml +164 -0
  12. shepherd_core/data_models/content/_metadata_eenvs_synthetic_on_off_markov.yaml +3280 -0
  13. shepherd_core/data_models/content/_metadata_eenvs_synthetic_on_off_windows.yaml +3260 -0
  14. shepherd_core/data_models/content/_metadata_eenvs_synthetic_static.yaml +450 -0
  15. shepherd_core/data_models/content/energy_environment.py +341 -23
  16. shepherd_core/data_models/content/energy_environment_fixture.yaml +21 -18
  17. shepherd_core/data_models/content/enum_datatypes.py +109 -0
  18. shepherd_core/data_models/content/firmware.py +44 -16
  19. shepherd_core/data_models/content/{virtual_harvester.py → virtual_harvester_config.py} +13 -96
  20. shepherd_core/data_models/content/{virtual_source.py → virtual_source_config.py} +103 -60
  21. shepherd_core/data_models/content/virtual_source_fixture.yaml +24 -24
  22. shepherd_core/data_models/content/virtual_storage_config.py +429 -0
  23. shepherd_core/data_models/content/virtual_storage_fixture_creator.py +267 -0
  24. shepherd_core/data_models/content/virtual_storage_fixture_ideal.yaml +637 -0
  25. shepherd_core/data_models/content/virtual_storage_fixture_lead.yaml +49 -0
  26. shepherd_core/data_models/content/virtual_storage_fixture_lipo.yaml +735 -0
  27. shepherd_core/data_models/content/virtual_storage_fixture_mlcc.yaml +200 -0
  28. shepherd_core/data_models/content/virtual_storage_fixture_param_experiments.py +151 -0
  29. shepherd_core/data_models/content/virtual_storage_fixture_super.yaml +150 -0
  30. shepherd_core/data_models/content/virtual_storage_fixture_tantal.yaml +550 -0
  31. shepherd_core/data_models/experiment/experiment.py +38 -13
  32. shepherd_core/data_models/experiment/observer_features.py +17 -4
  33. shepherd_core/data_models/experiment/target_config.py +56 -8
  34. shepherd_core/data_models/task/__init__.py +13 -2
  35. shepherd_core/data_models/task/emulation.py +10 -6
  36. shepherd_core/data_models/task/firmware_mod.py +3 -1
  37. shepherd_core/data_models/task/harvest.py +3 -1
  38. shepherd_core/data_models/task/helper_paths.py +2 -2
  39. shepherd_core/data_models/task/observer_tasks.py +8 -6
  40. shepherd_core/data_models/task/programming.py +4 -2
  41. shepherd_core/data_models/task/testbed_tasks.py +8 -2
  42. shepherd_core/data_models/testbed/cape.py +2 -0
  43. shepherd_core/data_models/testbed/gpio.py +2 -0
  44. shepherd_core/data_models/testbed/mcu.py +2 -0
  45. shepherd_core/data_models/testbed/observer.py +2 -0
  46. shepherd_core/data_models/testbed/target.py +7 -5
  47. shepherd_core/data_models/testbed/target_fixture.old1 +1 -1
  48. shepherd_core/data_models/testbed/target_fixture.yaml +1 -1
  49. shepherd_core/data_models/testbed/testbed.py +17 -15
  50. shepherd_core/decoder_waveform/uart.py +1 -1
  51. shepherd_core/exit_handler.py +22 -0
  52. shepherd_core/fw_tools/converter.py +2 -2
  53. shepherd_core/fw_tools/validation.py +1 -1
  54. shepherd_core/inventory/__init__.py +23 -21
  55. shepherd_core/inventory/system.py +3 -3
  56. shepherd_core/logger.py +0 -1
  57. shepherd_core/reader.py +32 -27
  58. shepherd_core/testbed_client/cache_path.py +3 -3
  59. shepherd_core/testbed_client/client_abc_fix.py +14 -3
  60. shepherd_core/testbed_client/client_web.py +7 -5
  61. shepherd_core/testbed_client/fixtures.py +7 -7
  62. shepherd_core/version.py +1 -1
  63. shepherd_core/vsource/__init__.py +4 -0
  64. shepherd_core/vsource/virtual_converter_model.py +29 -28
  65. shepherd_core/vsource/virtual_harvester_model.py +29 -21
  66. shepherd_core/vsource/virtual_harvester_simulation.py +38 -39
  67. shepherd_core/vsource/virtual_source_model.py +18 -14
  68. shepherd_core/vsource/virtual_source_simulation.py +71 -73
  69. shepherd_core/vsource/virtual_storage_model.py +164 -0
  70. shepherd_core/vsource/virtual_storage_model_fixed_point_math.py +58 -0
  71. shepherd_core/vsource/virtual_storage_models_kibam.py +449 -0
  72. shepherd_core/vsource/virtual_storage_simulator.py +104 -0
  73. shepherd_core/writer.py +16 -9
  74. {shepherd_core-2025.8.1.dist-info → shepherd_core-2026.2.1.dist-info}/METADATA +6 -3
  75. shepherd_core-2026.2.1.dist-info/RECORD +102 -0
  76. {shepherd_core-2025.8.1.dist-info → shepherd_core-2026.2.1.dist-info}/WHEEL +1 -1
  77. shepherd_core-2026.2.1.dist-info/licenses/LICENSE +21 -0
  78. shepherd_core/data_models/content/firmware_datatype.py +0 -15
  79. shepherd_core/data_models/virtual_source_doc.txt +0 -207
  80. shepherd_core-2025.8.1.dist-info/RECORD +0 -83
  81. {shepherd_core-2025.8.1.dist-info → shepherd_core-2026.2.1.dist-info}/top_level.txt +0 -0
  82. {shepherd_core-2025.8.1.dist-info → shepherd_core-2026.2.1.dist-info}/zip-safe +0 -0
@@ -0,0 +1,22 @@
1
+ """Generalized exit handler for Shepherd."""
2
+
3
+ import signal
4
+ import sys
5
+ from collections.abc import Callable
6
+ from types import FrameType
7
+
8
+ from .logger import log
9
+
10
+
11
+ def exit_gracefully(_signum: int, _frame: FrameType | None) -> None:
12
+ """Usual exit handler for single-processing applications."""
13
+ log.warning("Exiting!")
14
+ sys.exit(0)
15
+
16
+
17
+ def activate_exit_handler(custom: Callable = exit_gracefully) -> None:
18
+ """Register the provided exit handler or use the default one."""
19
+ signal.signal(signal.SIGTERM, custom)
20
+ signal.signal(signal.SIGINT, custom)
21
+ if hasattr(signal, "SIGALRM"):
22
+ signal.signal(signal.SIGALRM, custom)
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
  import zstandard as zstd
9
9
  from pydantic import validate_call
10
10
 
11
- from shepherd_core.data_models.content.firmware_datatype import FirmwareDType
11
+ from shepherd_core.data_models.content.enum_datatypes import FirmwareDType
12
12
 
13
13
  from .converter_elf import elf_to_hex
14
14
  from .validation import is_elf
@@ -98,7 +98,7 @@ def extract_firmware(data: str | Path, data_type: FirmwareDType, file_path: Path
98
98
  elif data_type == FirmwareDType.path_hex:
99
99
  file = file_path.with_suffix(".hex")
100
100
  else:
101
- msg = "FW-Extraction failed due to unknown datatype '{data_type}'"
101
+ msg = f"FW-Extraction failed due to unknown datatype '{data_type}'"
102
102
  raise ValueError(msg)
103
103
  if not file.parent.exists():
104
104
  file.parent.mkdir(parents=True)
@@ -13,7 +13,7 @@ from intelhex import IntelHex
13
13
  from intelhex import IntelHexError
14
14
  from pydantic import validate_call
15
15
 
16
- from shepherd_core.data_models.content.firmware_datatype import FirmwareDType
16
+ from shepherd_core.data_models.content.enum_datatypes import FirmwareDType
17
17
  from shepherd_core.logger import log
18
18
 
19
19
  from .converter_elf import elf_to_hex
@@ -10,6 +10,7 @@ from datetime import datetime
10
10
  from datetime import timedelta
11
11
  from pathlib import Path
12
12
  from typing import Annotated
13
+ from typing import final
13
14
 
14
15
  from pydantic import Field
15
16
  from typing_extensions import Self
@@ -29,6 +30,7 @@ __all__ = [
29
30
  ]
30
31
 
31
32
 
33
+ @final
32
34
  class Inventory(PythonInventory, SystemInventory, TargetInventory):
33
35
  """Complete inventory for one device.
34
36
 
@@ -65,7 +67,7 @@ class InventoryList(ShpModel):
65
67
  np.concatenate(content).reshape((len(content), len(content[0]))).
66
68
  """
67
69
  if path.is_dir():
68
- path = path / "inventory.yaml"
70
+ path /= "inventory.yaml"
69
71
  with path.resolve().open("w") as fd:
70
72
  fd.write(", ".join(self.elements[0].model_dump().keys()) + "\r\n")
71
73
  for item in self.elements:
@@ -75,28 +77,28 @@ class InventoryList(ShpModel):
75
77
 
76
78
  def warn(self) -> dict:
77
79
  warnings = {}
78
- ts_earl = min([_e.created.timestamp() for _e in self.elements])
79
- for _e in self.elements:
80
- if _e.uptime > timedelta(hours=30).total_seconds():
81
- warnings["uptime"] = f"[{self.hostname}] restart is recommended"
82
- if (_e.created.timestamp() - ts_earl) > 10:
83
- warnings["time_delta"] = f"[{self.hostname}] time-sync has failed"
80
+ ts_earl = min(e_.created.timestamp() for e_ in self.elements)
81
+ for e_ in self.elements:
82
+ if e_.uptime > timedelta(hours=30).total_seconds():
83
+ warnings["uptime"] = f"[{e_.hostname}] restart is recommended"
84
+ if (e_.created.timestamp() - ts_earl) > 10:
85
+ warnings["time_delta"] = f"[{e_.hostname}] time-sync has failed"
84
86
 
85
87
  # turn dict[hostname][type] = val
86
88
  # to dict[type][val] = list[hostnames]
87
- _inp = {
88
- _e.hostname: _e.model_dump(exclude_unset=True, exclude_defaults=True)
89
- for _e in self.elements
89
+ inp_ = {
90
+ e_.hostname: e_.model_dump(exclude_unset=True, exclude_defaults=True)
91
+ for e_ in self.elements
90
92
  }
91
93
  result = {}
92
- for _host, _types in _inp.items():
93
- for _type, _val in _types.items():
94
- if _type not in result:
95
- result[_type] = {}
96
- if _val not in result[_type]:
97
- result[_type][_val] = []
98
- result[_type][_val].append(_host)
99
- rescnt = {_key: len(_val) for _key, _val in result.items()}
94
+ for host_, types_ in inp_.items():
95
+ for type_, val_ in types_.items():
96
+ if type_ not in result:
97
+ result[type_] = {}
98
+ if val_ not in result[type_]:
99
+ result[type_][val_] = []
100
+ result[type_][val_].append(host_)
101
+ rescnt = {key_: len(val_) for key_, val_ in result.items()}
100
102
  t_unique = [
101
103
  "h5py",
102
104
  "numpy",
@@ -107,9 +109,9 @@ class InventoryList(ShpModel):
107
109
  "yaml",
108
110
  "zstandard",
109
111
  ]
110
- for _key in t_unique:
111
- if rescnt[_key] > 1:
112
- warnings[_key] = f"[{_key}] VersionMismatch - {result[_key]}"
112
+ for key_ in t_unique:
113
+ if rescnt[key_] > 1:
114
+ warnings[key_] = f"[{key_}] VersionMismatch - {result[key_]}"
113
115
 
114
116
  # TODO: finish with more potential warnings
115
117
  return warnings
@@ -21,7 +21,7 @@ except ImportError:
21
21
  psutil = None
22
22
 
23
23
  from pydantic import ConfigDict
24
- from pydantic.types import PositiveInt
24
+ from pydantic import PositiveInt
25
25
 
26
26
  from shepherd_core.data_models import ShpModel
27
27
 
@@ -63,12 +63,12 @@ class SystemInventory(ShpModel):
63
63
  uptime = 0
64
64
  log.warning(
65
65
  "Inventory-Parameters will be missing. "
66
- "Please install functionality with "
66
+ "Please install functionality with i.e."
67
67
  "'pip install shepherd_core[inventory] -U' first"
68
68
  )
69
69
  else:
70
70
  ifs1 = psutil.net_if_addrs().items()
71
- ifs2 = {name: (_if[1].address, _if[0].address) for name, _if in ifs1 if len(_if) > 1}
71
+ ifs2 = {name: (if_[1].address, if_[0].address) for name, if_ in ifs1 if len(if_) > 1}
72
72
  uptime = time.time() - psutil.boot_time()
73
73
 
74
74
  fs_cmd = ["/usr/bin/df", "-h", "/"]
shepherd_core/logger.py CHANGED
@@ -1,7 +1,6 @@
1
1
  """Log handler of shepherd."""
2
2
 
3
3
  import logging
4
- import logging.handlers
5
4
 
6
5
  import chromalog
7
6
 
shepherd_core/reader.py CHANGED
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
  from types import MappingProxyType
14
14
  from typing import TYPE_CHECKING
15
15
  from typing import Any
16
+ from typing import final
16
17
 
17
18
  import h5py
18
19
  import numpy as np
@@ -26,7 +27,7 @@ from .config import config
26
27
  from .data_models.base.calibration import CalibrationPair
27
28
  from .data_models.base.calibration import CalibrationSeries
28
29
  from .data_models.base.timezone import local_tz
29
- from .data_models.content.energy_environment import EnergyDType
30
+ from .data_models.content.enum_datatypes import EnergyDType
30
31
  from .decoder_waveform import Uart
31
32
 
32
33
  if TYPE_CHECKING:
@@ -46,7 +47,7 @@ class Reader:
46
47
 
47
48
  """
48
49
 
49
- CHUNK_SAMPLES_N: int = 10_000
50
+ CHUNK_SAMPLES_N: final[int] = 10_000
50
51
 
51
52
  MODE_TO_DTYPE: Mapping[str, Sequence[EnergyDType]] = MappingProxyType(
52
53
  {
@@ -101,9 +102,9 @@ class Reader:
101
102
  try:
102
103
  self.h5file = h5py.File(self.file_path, "r") # = readonly
103
104
  self._reader_opened = True
104
- except OSError as _xcp:
105
+ except OSError as xcp:
105
106
  msg = f"Unable to open HDF5-File '{self.file_path.name}'"
106
- raise TypeError(msg) from _xcp
107
+ raise TypeError(msg) from xcp
107
108
 
108
109
  if self.is_valid():
109
110
  self._logger.debug("File is available now")
@@ -209,13 +210,14 @@ class Reader:
209
210
  Generator - can be configured on first call
210
211
 
211
212
  Args:
212
- ----
213
- :param start_n: (int) Index of first chunk to be read
214
- :param end_n: (int) Index of last chunk to be read
215
- :param n_samples_per_chunk: (int) allows changing
216
- :param is_raw: (bool) output original data, not transformed to SI-Units
217
- :param omit_timestamps: (bool) optimize reading if timestamp is never used
218
- Yields: chunks between start and end (tuple with time, voltage, current)
213
+ start_n: (int) Index of first chunk to be read
214
+ end_n: (int) Index of last chunk to be read
215
+ n_samples_per_chunk: (int) allows changing
216
+ is_raw: (bool) output original data, not transformed to SI-Units
217
+ omit_timestamps: (bool) optimize reading if timestamp is never used
218
+
219
+ Yields:
220
+ chunks between start and end (tuple with time, voltage, current)
219
221
 
220
222
  """
221
223
  if n_samples_per_chunk is None:
@@ -223,21 +225,21 @@ class Reader:
223
225
  end_max = int(self.samples_n // n_samples_per_chunk)
224
226
  end_n = end_max if end_n is None else min(end_n, end_max)
225
227
  self._logger.debug("Reading chunk %d to %d from source-file", start_n, end_n)
226
- _raw = is_raw
227
- _wts = not omit_timestamps
228
+ raw_ = is_raw
229
+ wts_ = not omit_timestamps
228
230
 
229
231
  for i in range(start_n, end_n):
230
232
  idx_start = i * n_samples_per_chunk
231
233
  idx_end = idx_start + n_samples_per_chunk
232
- if _raw:
234
+ if raw_:
233
235
  yield (
234
- self.ds_time[idx_start:idx_end] if _wts else None,
236
+ self.ds_time[idx_start:idx_end] if wts_ else None,
235
237
  self.ds_voltage[idx_start:idx_end],
236
238
  self.ds_current[idx_start:idx_end],
237
239
  )
238
240
  else:
239
241
  yield (
240
- self._cal.time.raw_to_si(self.ds_time[idx_start:idx_end]) if _wts else None,
242
+ self._cal.time.raw_to_si(self.ds_time[idx_start:idx_end]) if wts_ else None,
241
243
  self._cal.voltage.raw_to_si(self.ds_voltage[idx_start:idx_end]),
242
244
  self._cal.current.raw_to_si(self.ds_current[idx_start:idx_end]),
243
245
  )
@@ -314,16 +316,17 @@ class Reader:
314
316
  voltage_step: float | None = (
315
317
  self.get_config().get("virtual_harvester", {}).get("voltage_step_mV", None)
316
318
  )
319
+ if voltage_step is not None: # convert mV to V
320
+ voltage_step *= 1e-3
317
321
  if voltage_step is None:
318
- dsv = self.ds_voltage[0:2000]
322
+ dsv = self._cal.voltage.raw_to_si(self.ds_voltage[0:2000])
319
323
  diffs_np = np.unique(dsv[1:] - dsv[0:-1], return_counts=False)
320
- diffs_ls = [_e for _e in list(np.array(diffs_np)) if _e > 0]
324
+ diffs_ls = [e_ for e_ in list(np.array(diffs_np)) if e_ > 0]
321
325
  # static voltages have 0 steps, so
322
326
  if len(diffs_ls) == 0:
327
+ self._logger.warning("Voltage-Step could not be determined from source-material")
323
328
  return None # or is 0 better? that may provoke div0
324
329
  voltage_step = min(diffs_ls)
325
- if voltage_step is not None:
326
- voltage_step = 1e-3 * voltage_step
327
330
  return voltage_step
328
331
 
329
332
  def get_hrv_config(self) -> dict:
@@ -456,11 +459,11 @@ class Reader:
456
459
  "[FileValidation] Hostname was not set in '%s'", self.file_path.name
457
460
  )
458
461
  # errors during execution
459
- _err = self.count_errors_in_log()
460
- if _err > 0:
462
+ err_ = self.count_errors_in_log()
463
+ if err_ > 0:
461
464
  self._logger.warning(
462
465
  "[FileValidation] Sheep reported %d errors during execution -> check logs in '%s'",
463
- _err,
466
+ err_,
464
467
  self.file_path.name,
465
468
  )
466
469
  return True
@@ -483,6 +486,8 @@ class Reader:
483
486
  # multiprocessing: https://stackoverflow.com/a/71898911
484
487
  # -> failed with multiprocessing.pool and pathos.multiprocessing.ProcessPool.
485
488
 
489
+ TODO: add optional duration argument to allow calculating mean energy of a spatial EEnv
490
+
486
491
  :return: sampled energy in Ws (watt-seconds)
487
492
  """
488
493
  iterations = math.ceil(self.samples_n / self.max_elements)
@@ -602,11 +607,11 @@ class Reader:
602
607
  return 0
603
608
  if "level" not in self.h5file[group_name]:
604
609
  return 0
605
- _lvl = self.h5file[group_name]["level"]
606
- if _lvl.shape[0] < 1:
610
+ lvl_ = self.h5file[group_name]["level"]
611
+ if lvl_.shape[0] < 1:
607
612
  return 0
608
- _items = [1 for _x in _lvl[:] if _x >= min_level]
609
- return len(_items)
613
+ items_ = [1 for x_ in lvl_[:] if x_ >= min_level]
614
+ return len(items_)
610
615
 
611
616
  def get_metadata(
612
617
  self,
@@ -5,10 +5,10 @@ from pathlib import Path
5
5
 
6
6
 
7
7
  def _get_xdg_path(variable_name: str, default_path: Path) -> Path:
8
- _value = os.environ.get(variable_name)
9
- if _value is None or _value == "":
8
+ value_ = os.environ.get(variable_name)
9
+ if not value_: # catches None and ""
10
10
  return default_path
11
- return Path(_value)
11
+ return Path(value_)
12
12
 
13
13
 
14
14
  user_path = Path("~").expanduser()
@@ -18,9 +18,11 @@ TODO: Comfort functions missing
18
18
  from abc import ABC
19
19
  from abc import abstractmethod
20
20
  from typing import Any
21
+ from typing import final
21
22
 
22
23
  from shepherd_core.data_models.base.shepherd import ShpModel
23
24
  from shepherd_core.data_models.base.wrapper import Wrapper
25
+ from shepherd_core.logger import log
24
26
 
25
27
  from .fixtures import Fixtures
26
28
 
@@ -58,6 +60,7 @@ class AbcClient(ABC):
58
60
  # TODO: maybe internal? yes
59
61
  pass
60
62
 
63
+ @final
61
64
  def try_completing_model(
62
65
  self, model_type: str, values: dict[str, Any]
63
66
  ) -> tuple[dict[str, Any], list[str]]:
@@ -71,14 +74,18 @@ class AbcClient(ABC):
71
74
  except ValueError as err:
72
75
  msg = f"Query {model_type} by name / ID failed - {values} is unknown!"
73
76
  raise ValueError(msg) from err
77
+ except KeyError:
78
+ log.error(f"Query failed - model-type {model_type} is unknown")
79
+ return values, []
74
80
  return self.try_inheritance(model_type, values)
75
81
 
76
82
  @abstractmethod
77
83
  def fill_in_user_data(self, values: dict[str, Any]) -> dict[str, Any]:
78
- # TODO: is it really helpful and needed?
84
+ # TODO: is it really needed and helpful?
79
85
  pass
80
86
 
81
87
 
88
+ @final
82
89
  class FixturesClient(AbcClient):
83
90
  """Client-Class to access the file based fixtures."""
84
91
 
@@ -110,12 +117,16 @@ class FixturesClient(AbcClient):
110
117
  def try_inheritance(
111
118
  self, model_type: str, values: dict[str, Any]
112
119
  ) -> tuple[dict[str, Any], list[str]]:
113
- return self._fixtures[model_type].inheritance(values)
120
+ try:
121
+ return self._fixtures[model_type].inheritance(values)
122
+ except KeyError:
123
+ log.error(f"Query failed - model-type {model_type} is unknown")
124
+ return values, []
114
125
 
115
126
  def fill_in_user_data(self, values: dict[str, Any]) -> dict[str, Any]:
116
127
  """Add fake user-data when offline-client is used.
117
128
 
118
- Hotfix until WebClient is working.
129
+ Workaround until WebClient is working.
119
130
  """
120
131
  if values.get("owner") is None:
121
132
  values["owner"] = "unknown"
@@ -3,6 +3,7 @@
3
3
  from importlib import import_module
4
4
  from pathlib import Path
5
5
  from typing import Any
6
+ from typing import final
6
7
 
7
8
  from pydantic import validate_call
8
9
 
@@ -14,6 +15,7 @@ from .client_abc_fix import AbcClient
14
15
  from .user_model import User
15
16
 
16
17
 
18
+ @final
17
19
  class WebClient(AbcClient):
18
20
  """Client-Class to access a testbed instance over the web.
19
21
 
@@ -125,26 +127,26 @@ class WebClient(AbcClient):
125
127
  return False
126
128
 
127
129
  def submit_experiment(self, xp: ShpModel) -> str:
128
- """Transmit XP to server to validate its feasibility.
130
+ """Transmit experiment to server to validate its feasibility.
129
131
 
130
132
  - Experiment will be added to DB (if not present)
131
- - if the same experiment is resubmitted it will just return the ID of that XP
133
+ - if the same experiment is resubmitted it will just return the ID of that experiment
132
134
  - Experiment will be validated by converting it into a task-set (additional validation)
133
135
  - optional: the scheduler should validate there are no time-collisions
134
136
 
135
137
  Will return an ID if valid, otherwise an empty string.
136
138
  TODO: maybe its better to throw specific errors if validation fails
137
- TODO: is it better to include these experiment-related FNs in Xp-Class?
139
+ TODO: is it better to include these experiment-related FNs in experiment-Class?
138
140
  TODO: Experiment-typehint for argument triggers circular import
139
141
  """
140
142
  raise NotImplementedError("TODO")
141
143
 
142
144
  def schedule_experiment(self, id_xp: str) -> bool:
143
- """Enqueue XP on testbed."""
145
+ """Enqueue experiment on testbed."""
144
146
  raise NotImplementedError("TODO")
145
147
 
146
148
  def get_experiment_status(self, id_xp: str) -> str:
147
- """Ask server about current state of XP.
149
+ """Ask server about current state of experiment.
148
150
 
149
151
  - after valid submission: disabled / deactivated
150
152
  - after scheduling: scheduled
@@ -48,10 +48,10 @@ class Fixture:
48
48
  if "name" not in data.parameters:
49
49
  return
50
50
  name = str(data.parameters["name"]).lower()
51
- _id = data.parameters["id"]
51
+ id_ = data.parameters["id"]
52
52
  data_model = data.parameters
53
53
  self.elements_by_name[name] = data_model
54
- self.elements_by_id[_id] = data_model
54
+ self.elements_by_id[id_] = data_model
55
55
  # update iterator
56
56
  self._iter_list: list[dict[str, Any]] = list(self.elements_by_name.values())
57
57
 
@@ -84,7 +84,7 @@ class Fixture:
84
84
  return self.elements_by_name.keys()
85
85
 
86
86
  def refs(self) -> dict:
87
- return {_i["id"]: _i["name"] for _i in self.elements_by_id.values()}
87
+ return {i_["id"]: i_["name"] for i_ in self.elements_by_id.values()}
88
88
 
89
89
  def inheritance(
90
90
  self, values: dict[str, Any], chain: list[str] | None = None
@@ -123,8 +123,8 @@ class Fixture:
123
123
  post_process = True
124
124
 
125
125
  elif values.get("id") in self.elements_by_id:
126
- _id = values["id"]
127
- fixture_base = copy.copy(self.elements_by_id[_id])
126
+ id_ = values["id"]
127
+ fixture_base = copy.copy(self.elements_by_id[id_])
128
128
  post_process = True
129
129
 
130
130
  if post_process:
@@ -218,7 +218,7 @@ class Fixtures:
218
218
 
219
219
  @validate_call
220
220
  def insert_file(self, file: Path) -> None:
221
- with file.open() as fd:
221
+ with file.open(encoding="utf-8-sig") as fd:
222
222
  fixtures = yaml.safe_load(fd)
223
223
  for fixture in fixtures:
224
224
  if not isinstance(fixture, dict):
@@ -237,7 +237,7 @@ class Fixtures:
237
237
  if key in self.components:
238
238
  return self.components[key]
239
239
  msg = f"Component '{key}' not found!"
240
- raise ValueError(msg)
240
+ raise KeyError(msg)
241
241
 
242
242
  def keys(self) -> Iterable[str]:
243
243
  return self.components.keys()
shepherd_core/version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Separated string avoids circular imports."""
2
2
 
3
- version: str = "2025.08.1"
3
+ version: str = "2026.02.1"
@@ -9,15 +9,19 @@ from .virtual_harvester_model import VirtualHarvesterModel
9
9
  from .virtual_harvester_simulation import simulate_harvester
10
10
  from .virtual_source_model import VirtualSourceModel
11
11
  from .virtual_source_simulation import simulate_source
12
+ from .virtual_storage_model import VirtualStorageModel
13
+ from .virtual_storage_simulator import StorageSimulator
12
14
 
13
15
  __all__ = [
14
16
  "ConstantCurrentTarget",
15
17
  "ConstantPowerTarget",
16
18
  "PruCalibration",
17
19
  "ResistiveTarget",
20
+ "StorageSimulator",
18
21
  "VirtualConverterModel",
19
22
  "VirtualHarvesterModel",
20
23
  "VirtualSourceModel",
24
+ "VirtualStorageModel",
21
25
  "simulate_harvester",
22
26
  "simulate_source",
23
27
  ]
@@ -17,8 +17,11 @@ Compromises:
17
17
  import math
18
18
 
19
19
  from shepherd_core.data_models import CalibrationEmulator
20
- from shepherd_core.data_models.content.virtual_source import LUT_SIZE
21
- from shepherd_core.data_models.content.virtual_source import ConverterPRUConfig
20
+ from shepherd_core.data_models.content.virtual_source_config import LUT_SIZE
21
+ from shepherd_core.data_models.content.virtual_source_config import ConverterPRUConfig
22
+ from shepherd_core.data_models.content.virtual_storage_config import StoragePRUConfig
23
+
24
+ from .virtual_storage_model import VirtualStorageModelPRU
22
25
 
23
26
 
24
27
  class PruCalibration:
@@ -32,7 +35,7 @@ class PruCalibration:
32
35
  negative_residue_nA = 0
33
36
 
34
37
  def __init__(self, cal_emu: CalibrationEmulator | None = None) -> None:
35
- self.cal = cal_emu if cal_emu else CalibrationEmulator()
38
+ self.cal = cal_emu or CalibrationEmulator()
36
39
 
37
40
  def conv_adc_raw_to_nA(self, current_raw: int) -> float:
38
41
  I_nA = self.cal.adc_C_A.raw_to_si(current_raw) * (10**9)
@@ -41,7 +44,7 @@ class PruCalibration:
41
44
  I_nA -= self.negative_residue_nA
42
45
  self.negative_residue_nA = 0
43
46
  else:
44
- self.negative_residue_nA = self.negative_residue_nA - I_nA
47
+ self.negative_residue_nA -= I_nA
45
48
  self.negative_residue_nA = min(self.negative_residue_nA, self.RESIDUE_MAX_nA)
46
49
  I_nA = 0
47
50
  return I_nA
@@ -59,13 +62,16 @@ class PruCalibration:
59
62
  class VirtualConverterModel:
60
63
  """Ported python version of the pru vCnv."""
61
64
 
62
- def __init__(self, cfg: ConverterPRUConfig, cal: PruCalibration) -> None:
65
+ def __init__(
66
+ self, cfg: ConverterPRUConfig, cal: PruCalibration, storage_cfg: StoragePRUConfig
67
+ ) -> None:
63
68
  self._cal: PruCalibration = cal
64
69
  self._cfg: ConverterPRUConfig = cfg
65
70
 
71
+ self.storage = VirtualStorageModelPRU(storage_cfg)
72
+
66
73
  # simplifications for python
67
74
  self.R_input_kOhm = float(self._cfg.R_input_kOhm_n22) / 2**22
68
- self.Constant_us_per_nF = float(self._cfg.Constant_us_per_nF_n28) / 2**28
69
75
 
70
76
  # boost internal state
71
77
  self.V_input_uV: float = 0.0
@@ -74,7 +80,7 @@ class VirtualConverterModel:
74
80
  self.interval_startup_disabled_drain_n: int = self._cfg.interval_startup_delay_drain_n
75
81
 
76
82
  # container for the stored energy
77
- self.V_mid_uV: float = self._cfg.V_intermediate_init_uV
83
+ self.V_mid_uV: float = self.storage.calc_V_OC_uV()
78
84
 
79
85
  # buck internal state
80
86
  self.enable_storage: bool = (int(self._cfg.converter_mode) & 0b0001) > 0
@@ -83,19 +89,19 @@ class VirtualConverterModel:
83
89
  self.enable_log_mid: bool = (int(self._cfg.converter_mode) & 0b1000) > 0
84
90
  # back-channel to hrv
85
91
  self.feedback_to_hrv: bool = (int(self._cfg.converter_mode) & 0b1_0000) > 0
86
- self.V_input_request_uV: int = self._cfg.V_intermediate_init_uV
92
+ self.V_input_request_uV: int = int(self.V_mid_uV)
87
93
 
88
94
  self.V_out_dac_uV: float = self._cfg.V_output_uV
89
95
  self.V_out_dac_raw: int = self._cal.conv_uV_to_dac_raw(self._cfg.V_output_uV)
90
96
  self.power_good: bool = True
91
97
 
92
98
  # prepare hysteresis-thresholds
93
- self.dV_enable_output_uV: float = self._cfg.dV_enable_output_uV
94
- self.V_enable_output_threshold_uV: float = self._cfg.V_enable_output_threshold_uV
95
- self.V_disable_output_threshold_uV: float = self._cfg.V_disable_output_threshold_uV
99
+ self.dV_mid_enable_output_uV: float = self._cfg.dV_mid_enable_output_uV
100
+ self.V_mid_enable_output_threshold_uV: float = self._cfg.V_mid_enable_output_threshold_uV
101
+ self.V_mid_disable_output_threshold_uV: float = self._cfg.V_mid_disable_output_threshold_uV
96
102
 
97
- self.V_enable_output_threshold_uV = max(
98
- self.dV_enable_output_uV, self.V_enable_output_threshold_uV
103
+ self.V_mid_enable_output_threshold_uV = max(
104
+ self.dV_mid_enable_output_uV, self.V_mid_enable_output_threshold_uV
99
105
  )
100
106
 
101
107
  # pulled from update_states_and_output() due to easier static init
@@ -125,7 +131,7 @@ class VirtualConverterModel:
125
131
  input_voltage_uV = 0.0
126
132
  # TODO: vdrop in case of v_input > v_storage (non-boost)
127
133
  elif self.enable_storage:
128
- # no boost, but cap, for ie. diode+cap (+resistor)
134
+ # no boost, but cap, for i.e. diode+cap (+resistor)
129
135
  V_diff_uV = (
130
136
  (input_voltage_uV - self.V_mid_uV) if (input_voltage_uV >= self.V_mid_uV) else 0
131
137
  )
@@ -162,14 +168,14 @@ class VirtualConverterModel:
162
168
  current_adc_raw = max(0, current_adc_raw)
163
169
  current_adc_raw = min((2**18) - 1, current_adc_raw)
164
170
 
165
- P_leak_fW = self.V_mid_uV * self._cfg.I_intermediate_leak_nA
171
+ # TODO: remove P_leak in C-Code
166
172
  I_out_nA = self._cal.conv_adc_raw_to_nA(current_adc_raw)
167
173
  if self.enable_buck: # noqa: SIM108
168
174
  eta_inv_out = self.get_output_inv_efficiency(I_out_nA)
169
175
  else:
170
176
  eta_inv_out = 1.0
171
177
 
172
- self.P_out_fW = eta_inv_out * self.V_out_dac_uV * I_out_nA + P_leak_fW
178
+ self.P_out_fW = eta_inv_out * self.V_out_dac_uV * I_out_nA
173
179
 
174
180
  if self.interval_startup_disabled_drain_n > 0:
175
181
  self.interval_startup_disabled_drain_n -= 1
@@ -177,17 +183,12 @@ class VirtualConverterModel:
177
183
 
178
184
  return round(self.P_out_fW) # Python-specific, added for easier testing
179
185
 
180
- # TODO: add range-checks for add, sub Ops
181
186
  def update_cap_storage(self) -> int:
182
- # TODO: this calculation is wrong for everything beside boost-cnv
183
187
  if self.enable_storage:
184
- V_mid_prot_uV = max(1.0, self.V_mid_uV)
185
- P_sum_fW = self.P_inp_fW - self.P_out_fW
186
- I_mid_nA = P_sum_fW / V_mid_prot_uV
187
- dV_mid_uV = I_mid_nA * self.Constant_us_per_nF
188
- self.V_mid_uV += dV_mid_uV
189
-
190
- self.V_mid_uV = min(self.V_mid_uV, self._cfg.V_intermediate_max_uV)
188
+ I_delta_nA = abs((self.P_inp_fW - self.P_out_fW) / self.V_mid_uV)
189
+ is_charging: bool = self.P_inp_fW >= self.P_out_fW
190
+ self.V_mid_uV = self.storage.step(2**4 * I_delta_nA, is_charging=is_charging)
191
+ self.V_mid_uV = min(self.V_mid_uV, self._cfg.V_mid_max_uV)
191
192
  self.V_mid_uV = max(self.V_mid_uV, 1)
192
193
  return round(self.V_mid_uV) # Python-specific, added for easier testing
193
194
 
@@ -200,11 +201,11 @@ class VirtualConverterModel:
200
201
  if check_thresholds:
201
202
  self.sample_count = 0
202
203
  if self.is_outputting:
203
- if V_mid_uV_now < self.V_disable_output_threshold_uV:
204
+ if V_mid_uV_now < self.V_mid_disable_output_threshold_uV:
204
205
  self.is_outputting = False
205
- elif V_mid_uV_now >= self.V_enable_output_threshold_uV:
206
+ elif V_mid_uV_now >= self.V_mid_enable_output_threshold_uV:
206
207
  self.is_outputting = True
207
- self.V_mid_uV -= self.dV_enable_output_uV
208
+ self.V_mid_uV -= self.dV_mid_enable_output_uV
208
209
 
209
210
  if check_thresholds or self._cfg.immediate_pwr_good_signal:
210
211
  # generate power-good-signal