fameio 3.1.1__py3-none-any.whl → 3.3.0__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 (57) hide show
  1. fameio/cli/convert_results.py +10 -10
  2. fameio/cli/make_config.py +9 -9
  3. fameio/cli/options.py +6 -4
  4. fameio/cli/parser.py +87 -51
  5. fameio/cli/reformat.py +58 -0
  6. fameio/input/__init__.py +4 -4
  7. fameio/input/loader/__init__.py +13 -13
  8. fameio/input/loader/controller.py +64 -18
  9. fameio/input/loader/loader.py +25 -16
  10. fameio/input/metadata.py +57 -38
  11. fameio/input/resolver.py +9 -10
  12. fameio/input/scenario/agent.py +62 -26
  13. fameio/input/scenario/attribute.py +93 -40
  14. fameio/input/scenario/contract.py +160 -56
  15. fameio/input/scenario/exception.py +41 -18
  16. fameio/input/scenario/fameiofactory.py +57 -6
  17. fameio/input/scenario/generalproperties.py +22 -12
  18. fameio/input/scenario/scenario.py +117 -38
  19. fameio/input/scenario/stringset.py +29 -11
  20. fameio/input/schema/agenttype.py +27 -10
  21. fameio/input/schema/attribute.py +108 -45
  22. fameio/input/schema/java_packages.py +14 -12
  23. fameio/input/schema/schema.py +39 -15
  24. fameio/input/validator.py +198 -54
  25. fameio/input/writer.py +137 -46
  26. fameio/logs.py +28 -47
  27. fameio/output/__init__.py +5 -1
  28. fameio/output/agent_type.py +89 -28
  29. fameio/output/conversion.py +52 -37
  30. fameio/output/csv_writer.py +107 -27
  31. fameio/output/data_transformer.py +17 -24
  32. fameio/output/execution_dao.py +170 -0
  33. fameio/output/input_dao.py +71 -33
  34. fameio/output/output_dao.py +33 -11
  35. fameio/output/reader.py +64 -21
  36. fameio/output/yaml_writer.py +16 -8
  37. fameio/scripts/__init__.py +22 -4
  38. fameio/scripts/convert_results.py +126 -52
  39. fameio/scripts/convert_results.py.license +1 -1
  40. fameio/scripts/exception.py +7 -0
  41. fameio/scripts/make_config.py +34 -13
  42. fameio/scripts/make_config.py.license +1 -1
  43. fameio/scripts/reformat.py +71 -0
  44. fameio/scripts/reformat.py.license +3 -0
  45. fameio/series.py +174 -59
  46. fameio/time.py +79 -25
  47. fameio/tools.py +48 -8
  48. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/METADATA +50 -34
  49. fameio-3.3.0.dist-info/RECORD +60 -0
  50. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/WHEEL +1 -1
  51. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/entry_points.txt +1 -0
  52. CHANGELOG.md +0 -288
  53. fameio-3.1.1.dist-info/RECORD +0 -56
  54. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSE.txt +0 -0
  55. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSES/Apache-2.0.txt +0 -0
  56. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSES/CC-BY-4.0.txt +0 -0
  57. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSES/CC0-1.0.txt +0 -0
fameio/series.py CHANGED
@@ -1,26 +1,30 @@
1
1
  # SPDX-FileCopyrightText: 2025 German Aerospace Center <fame@dlr.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
+ from __future__ import annotations
5
+
4
6
  import math
5
7
  import os
6
8
  from enum import Enum, auto
7
9
  from pathlib import Path
8
- from typing import Union, Any
10
+ from typing import Any
9
11
 
10
12
  import pandas as pd
11
13
  from fameprotobuf.input_file_pb2 import InputData
12
14
  from google.protobuf.internal.wire_format import INT64_MIN, INT64_MAX
13
15
 
16
+ from fameio.input import InputError
14
17
  from fameio.input.resolver import PathResolver
15
18
  from fameio.logs import log, log_error
19
+ from fameio.output import OutputError
16
20
  from fameio.time import ConversionError, FameTime
17
- from fameio.tools import clean_up_file_name
21
+ from fameio.tools import clean_up_file_name, CSV_FILE_SUFFIX
18
22
 
19
23
 
20
- CSV_FILE_SUFFIX = ".csv"
24
+ FILE_LENGTH_WARN_LIMIT = int(50e3)
21
25
 
22
26
 
23
- class TimeSeriesError(Exception):
27
+ class TimeSeriesError(InputError, OutputError):
24
28
  """Indicates that an error occurred during management of time series"""
25
29
 
26
30
 
@@ -31,7 +35,7 @@ class Entry(Enum):
31
35
 
32
36
 
33
37
  class TimeSeriesManager:
34
- """Manages matching of files to time series ids and their protobuf representation"""
38
+ """Manages matching of files to time series ids and their protobuf representation."""
35
39
 
36
40
  _TIMESERIES_RECONSTRUCTION_PATH = "./timeseries/"
37
41
  _CONSTANT_IDENTIFIER = "Constant value: {}"
@@ -40,87 +44,177 @@ class TimeSeriesManager:
40
44
 
41
45
  _ERR_FILE_NOT_FOUND = "Cannot find Timeseries file '{}'."
42
46
  _ERR_NUMERIC_STRING = " Remove quotes to use a constant numeric value instead of a timeseries file."
43
- _ERR_CORRUPT_TIME_SERIES_KEY = "TimeSeries file '{}' corrupt: At least one entry in first column isn't a timestamp."
44
- _ERR_CORRUPT_TIME_SERIES_VALUE = "TimeSeries file '{}' corrupt: At least one entry in value column isn't numeric."
47
+ _ERR_CORRUPT_KEYS = "TimeSeries file '{}' corrupt: At least one entry in first column isn't a timestamp."
48
+ _ERR_CORRUPT_VALUES = "TimeSeries file '{}' corrupt: At least one entry in second column isn't numeric."
45
49
  _ERR_NON_NUMERIC = "Values in TimeSeries must be numeric but was: '{}'"
46
50
  _ERR_NAN_VALUE = "Values in TimeSeries must not be missing or NaN."
47
51
  _ERR_UNREGISTERED_SERIES = "No timeseries registered with identifier '{}' - was the Scenario validated?"
52
+ _ERR_UNREGISTERED_SERIES_RE = "No timeseries registered with identifier '{}' - were the timeseries reconstructed?"
48
53
  _WARN_NO_DATA = "No timeseries stored in timeseries manager. Double check if you expected timeseries."
49
- _WARN_DATA_IGNORED = "Timeseries contains additional columns with data which will be ignored."
54
+ _WARN_DATA_IGNORED = "Timeseries '{}' contains additional columns with data which will be ignored."
55
+ _WARN_LARGE_CONVERSION = (
56
+ "Timeseries file '{}' is large and needs conversion of time stamps. If performance "
57
+ "issues occur and the file is reused, convert the time stamp column once with "
58
+ "`fameio.time.FameTime.convert_datetime_to_fame_time_step(datetime_string)`."
59
+ )
50
60
 
51
61
  def __init__(self, path_resolver: PathResolver = PathResolver()) -> None:
52
62
  self._path_resolver = path_resolver
53
63
  self._id_count = -1
54
- self._series_by_id: dict[Union[str, int, float], dict[Entry, Any]] = {}
64
+ self._series_by_id: dict[str | int | float, dict[Entry, Any]] = {}
55
65
 
56
- def register_and_validate(self, identifier: Union[str, int, float]) -> None:
57
- """
58
- Registers given timeseries `identifier` and validates associated timeseries
66
+ def register_and_validate(self, identifier: str | int | float) -> None:
67
+ """Registers given timeseries `identifier` and validates associated timeseries.
59
68
 
60
69
  Args:
61
70
  identifier: to be registered - either a single numeric value or a string pointing to a timeseries file
62
71
 
63
72
  Raises:
64
- TimeSeriesException: if file was not found, ill-formatted, or value was invalid
73
+ TimeSeriesError: if the file could not be found or contains improper data, or if identifier is NaN,
74
+ logged with level "ERROR"
65
75
  """
66
76
  if not self._time_series_is_registered(identifier):
67
77
  self._register_time_series(identifier)
68
78
 
69
- def _time_series_is_registered(self, identifier: Union[str, int, float]) -> bool:
70
- """Returns True if the value was already registered"""
79
+ def _time_series_is_registered(self, identifier: str | int | float) -> bool:
80
+ """Returns True if the value was already registered."""
71
81
  return identifier in self._series_by_id
72
82
 
73
- def _register_time_series(self, identifier: Union[str, int, float]) -> None:
74
- """Assigns an id to the given `identifier` and loads the time series into a dataframe"""
83
+ def _register_time_series(self, identifier: str | int | float) -> None:
84
+ """Assigns an id to the given `identifier` and loads the time series into a dataframe.
85
+
86
+ Args:
87
+ identifier: to be registered - either a single numeric value or a string pointing to a timeseries file
88
+
89
+ Raises:
90
+ TimeSeriesError: if the file could not be found or contains improper data, or if identifier is NaN,
91
+ logged with level "ERROR"
92
+ """
75
93
  self._id_count += 1
76
94
  name, series = self._get_name_and_dataframe(identifier)
77
95
  self._series_by_id[identifier] = {Entry.ID: self._id_count, Entry.NAME: name, Entry.DATA: series}
78
96
 
79
- def _get_name_and_dataframe(self, identifier: Union[str, int, float]) -> tuple[str, pd.DataFrame]:
80
- """Returns name and DataFrame containing the series obtained from the given `identifier`"""
97
+ def _get_name_and_dataframe(self, identifier: str | int | float) -> tuple[str, pd.DataFrame]:
98
+ """Returns name and DataFrame containing the series obtained from the given `identifier`.
99
+
100
+ Args:
101
+ identifier: to be registered - either a single numeric value or a string pointing to a timeseries file
102
+
103
+ Returns:
104
+ tuple of name & dataframe
105
+
106
+ Raises:
107
+ TimeSeriesError: if the file could not be found or contains improper data, or if identifier is NaN,
108
+ logged with level "ERROR"
109
+ """
81
110
  if isinstance(identifier, str):
82
111
  series_path = self._path_resolver.resolve_series_file_path(Path(identifier).as_posix())
83
112
  if series_path and os.path.exists(series_path):
84
- data = pd.read_csv(series_path, sep=";", header=None, comment="#")
85
- try:
86
- return identifier, self._check_and_convert_series(data)
87
- except TypeError as e:
88
- raise log_error(TimeSeriesError(self._ERR_CORRUPT_TIME_SERIES_VALUE.format(identifier), e)) from e
89
- except ConversionError as e:
90
- raise log_error(TimeSeriesError(self._ERR_CORRUPT_TIME_SERIES_KEY.format(identifier), e)) from e
91
- else:
92
- message = self._ERR_FILE_NOT_FOUND.format(identifier)
93
- if self._is_number_string(identifier):
94
- message += self._ERR_NUMERIC_STRING
95
- raise log_error(TimeSeriesError(message))
96
- else:
97
- return self._create_timeseries_from_value(identifier)
98
-
99
- def _check_and_convert_series(self, data: pd.DataFrame) -> pd.DataFrame:
100
- """Ensures validity of time series and convert to required format for writing to disk"""
101
- additional_columns = data.loc[:, 2:]
102
- is_empty = additional_columns.dropna(how="all").empty
103
- if not is_empty:
104
- log().warning(self._WARN_DATA_IGNORED)
113
+ data = self.read_timeseries_file(series_path)
114
+ return identifier, self.check_and_convert_series(data, identifier)
115
+ message = self._ERR_FILE_NOT_FOUND.format(identifier)
116
+ if self._is_number_string(identifier):
117
+ message += self._ERR_NUMERIC_STRING
118
+ raise log_error(TimeSeriesError(message))
119
+ return self._create_timeseries_from_value(identifier)
120
+
121
+ @staticmethod
122
+ def read_timeseries_file(file: Path | str) -> pd.DataFrame:
123
+ """Loads a timeseries from file.
124
+
125
+ Args:
126
+ file: to be read
127
+
128
+ Returns:
129
+ data frame obtained from file
130
+
131
+ Raises:
132
+ TimeSeriesError: if file could not be read, logged with level "ERROR"
133
+ """
134
+ try:
135
+ return pd.read_csv(file, sep=";", header=None, comment="#")
136
+ except OSError as e:
137
+ raise log_error(TimeSeriesError(e)) from e
138
+
139
+ @staticmethod
140
+ def check_and_convert_series(data: pd.DataFrame, file_name: str, warn: bool = True) -> pd.DataFrame:
141
+ """Ensures validity of time series and convert to required format for writing to disk.
142
+
143
+ Args:
144
+ data: dataframe to be converted to expected format
145
+ file_name: used in warnings and errors
146
+ warn: if True, a warning is raised if large files require conversion (default: True)
147
+
148
+ Returns:
149
+ 2-column dataframe, first column: integers, second column: floats (no NaN)
150
+
151
+ Raises:
152
+ TimeSeriesError: if the data do not correspond to a valid time series, logged with level "ERROR"
153
+ """
154
+ try:
155
+ converted, large_conversion = TimeSeriesManager._check_and_convert_series(data, file_name)
156
+ if large_conversion and warn:
157
+ log().warning(TimeSeriesManager._WARN_LARGE_CONVERSION.format(file_name))
158
+ return converted
159
+ except TypeError as e:
160
+ raise log_error(TimeSeriesError(TimeSeriesManager._ERR_CORRUPT_VALUES.format(file_name), e)) from e
161
+ except ConversionError as e:
162
+ raise log_error(TimeSeriesError(TimeSeriesManager._ERR_CORRUPT_KEYS.format(file_name), e)) from e
163
+
164
+ @staticmethod
165
+ def _check_and_convert_series(data: pd.DataFrame, file_name: str) -> tuple[pd.DataFrame, bool]:
166
+ """Ensures validity of time series and convert to required format for writing to disk.
167
+
168
+ Args:
169
+ data: dataframe to be converted to expected format
170
+ file_name: used in warnings
171
+
172
+ Returns:
173
+ tuple of 1) dataframe and 2) large conversion indicator:
174
+ 2-column dataframe first column: integers, second column: floats (no NaN)
175
+ large conversion indicator: if true, the timeseries was large and required conversion
176
+
177
+ Raises:
178
+ ConversionError: if first data column could not be converted to integer, logged with level "ERROR"
179
+ TypeError: if second data column in given data could not be converted to float or contained NaN,
180
+ logged with level "ERROR"
181
+ """
182
+ large_file_indicator = False
183
+ data, additional_columns = data.loc[:, :2], data.loc[:, 2:]
184
+ if not additional_columns.dropna(how="all").empty:
185
+ log().warning(TimeSeriesManager._WARN_DATA_IGNORED.format(file_name))
105
186
  if data.dtypes[0] != "int64":
187
+ if len(data[0]) > FILE_LENGTH_WARN_LIMIT:
188
+ large_file_indicator = True
106
189
  data[0] = [FameTime.convert_string_if_is_datetime(time) for time in data[0]]
107
- data[1] = [TimeSeriesManager._assert_valid(value) for value in data[1]]
108
- return data
190
+ if data.dtypes[1] != "float64":
191
+ data[1] = [TimeSeriesManager._assert_float(value) for value in data[1]]
192
+ if data[1].isna().any():
193
+ raise log_error(TypeError(TimeSeriesManager._ERR_NAN_VALUE))
194
+ return data, large_file_indicator
109
195
 
110
196
  @staticmethod
111
- def _assert_valid(value: Any) -> float:
112
- """Returns the given `value` if it is a numeric value other than NaN"""
197
+ def _assert_float(value: Any) -> float:
198
+ """Converts any given value to a float or raise an Exception.
199
+
200
+ Args:
201
+ value: to be converted to float
202
+
203
+ Returns:
204
+ float representation of value
205
+
206
+ Raises:
207
+ TypeError: if given value cannot be converted to float, logged with level "ERROR"
208
+ """
113
209
  try:
114
210
  value = float(value)
115
211
  except ValueError as e:
116
212
  raise log_error(TypeError(TimeSeriesManager._ERR_NON_NUMERIC.format(value))) from e
117
- if math.isnan(value):
118
- raise log_error(TypeError(TimeSeriesManager._ERR_NAN_VALUE))
119
213
  return value
120
214
 
121
215
  @staticmethod
122
216
  def _is_number_string(identifier: str) -> bool:
123
- """Returns True if given identifier can be cast to float"""
217
+ """Returns True if given identifier can be cast to float."""
124
218
  try:
125
219
  float(identifier)
126
220
  return True
@@ -128,16 +222,25 @@ class TimeSeriesManager:
128
222
  return False
129
223
 
130
224
  @staticmethod
131
- def _create_timeseries_from_value(value: Union[int, float]) -> tuple[str, pd.DataFrame]:
132
- """Returns name and dataframe for a new static timeseries created from the given `value`"""
225
+ def _create_timeseries_from_value(value: int | float) -> tuple[str, pd.DataFrame]:
226
+ """Returns name and dataframe for a new static timeseries created from the given `value`.
227
+
228
+ Args:
229
+ value: the static value of the timeseries to be created
230
+
231
+ Returns:
232
+ tuple of name & dataframe
233
+
234
+ Raises:
235
+ TimeSeriesError: if given value is NaN, logged with level "ERROR"
236
+ """
133
237
  if math.isnan(value):
134
238
  raise log_error(TimeSeriesError(TimeSeriesManager._ERR_NAN_VALUE))
135
239
  data = pd.DataFrame({0: [INT64_MIN, INT64_MAX], 1: [value, value]})
136
240
  return TimeSeriesManager._CONSTANT_IDENTIFIER.format(value), data
137
241
 
138
- def get_series_id_by_identifier(self, identifier: Union[str, int, float]) -> int:
139
- """
140
- Returns id for a previously stored time series by given `identifier`
242
+ def get_series_id_by_identifier(self, identifier: str | int | float) -> int:
243
+ """Returns id for a previously stored time series by given `identifier`.
141
244
 
142
245
  Args:
143
246
  identifier: to get the unique ID for
@@ -146,20 +249,20 @@ class TimeSeriesManager:
146
249
  unique ID for the given identifier
147
250
 
148
251
  Raises:
149
- TimeSeriesException: if identifier was not yet registered
252
+ TimeSeriesError: if identifier was not yet registered, logged with level "ERROR"
150
253
  """
151
254
  if not self._time_series_is_registered(identifier):
152
255
  raise log_error(TimeSeriesError(self._ERR_UNREGISTERED_SERIES.format(identifier)))
153
- return self._series_by_id.get(identifier)[Entry.ID]
256
+ return self._series_by_id.get(identifier)[Entry.ID] # type: ignore[index]
154
257
 
155
258
  def get_all_series(self) -> list[tuple[int, str, pd.DataFrame]]:
156
- """Returns iterator over id, name and dataframe of all stored series"""
259
+ """Returns iterator over id, name and dataframe of all stored series."""
157
260
  if len(self._series_by_id) == 0:
158
261
  log().warning(self._WARN_NO_DATA)
159
262
  return [(v[Entry.ID], v[Entry.NAME], v[Entry.DATA]) for v in self._series_by_id.values()]
160
263
 
161
264
  def reconstruct_time_series(self, timeseries: list[InputData.TimeSeriesDao]) -> None:
162
- """Reconstructs and stores time series from given list of `timeseries_dao`"""
265
+ """Reconstructs and stores time series from given list of `timeseries_dao`."""
163
266
  for one_series in timeseries:
164
267
  self._id_count += 1
165
268
  reconstructed = {Entry.ID: one_series.series_id}
@@ -175,7 +278,8 @@ class TimeSeriesManager:
175
278
  )
176
279
  self._series_by_id[one_series.series_id] = reconstructed
177
280
 
178
- def _get_cleaned_file_name(self, timeseries_name: str):
281
+ def _get_cleaned_file_name(self, timeseries_name: str) -> str:
282
+ """Ensure given file name has CSV file ending."""
179
283
  if Path(timeseries_name).suffix.lower() == CSV_FILE_SUFFIX:
180
284
  filename = Path(timeseries_name).name
181
285
  else:
@@ -184,7 +288,18 @@ class TimeSeriesManager:
184
288
 
185
289
  def get_reconstructed_series_by_id(self, series_id: int) -> str:
186
290
  """Return name or path for given `series_id` if series these are identified by their number.
187
- Use this only if series were added via `reconstruct_time_series`"""
291
+
292
+ Use this only if series were added via `reconstruct_time_series`
293
+
294
+ Args:
295
+ series_id: number of series
296
+
297
+ Returns:
298
+ name or path of time series
299
+
300
+ Raises:
301
+ TimeSeriesError: if series was not registered during `reconstruct_time_series`, logged with level "ERROR"
302
+ """
188
303
  if series_id < 0 or series_id > self._id_count:
189
- raise log_error(TimeSeriesError(self._ERR_UNREGISTERED_SERIES.format(series_id)))
304
+ raise log_error(TimeSeriesError(self._ERR_UNREGISTERED_SERIES_RE.format(series_id)))
190
305
  return self._series_by_id[series_id][Entry.NAME]
fameio/time.py CHANGED
@@ -1,13 +1,17 @@
1
1
  # SPDX-FileCopyrightText: 2025 German Aerospace Center <fame@dlr.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
+ from __future__ import annotations
5
+
4
6
  import datetime as dt
5
7
  import math
6
8
  import re
7
9
  from enum import Enum, auto
8
- from typing import Union
10
+ from typing import Any
9
11
 
12
+ from fameio.input import InputError
10
13
  from fameio.logs import log_error
14
+ from fameio.output import OutputError
11
15
 
12
16
  START_IN_REAL_TIME = "2000-01-01_00:00:00"
13
17
  DATE_FORMAT = "%Y-%m-%d_%H:%M:%S"
@@ -15,12 +19,12 @@ DATE_REGEX = re.compile("[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}:[0-9]{2}:[0-9]{2}")
15
19
  FAME_FIRST_DATETIME = dt.datetime.strptime(START_IN_REAL_TIME, DATE_FORMAT)
16
20
 
17
21
 
18
- class ConversionError(Exception):
19
- """Indicates that something went wrong during time stamp conversion"""
22
+ class ConversionError(InputError, OutputError):
23
+ """Indicates that time stamp conversion failed."""
20
24
 
21
25
 
22
26
  class TimeUnit(Enum):
23
- """Time units defined in FAME"""
27
+ """Time units defined in FAME."""
24
28
 
25
29
  SECONDS = auto()
26
30
  MINUTES = auto()
@@ -32,7 +36,7 @@ class TimeUnit(Enum):
32
36
 
33
37
 
34
38
  class Constants:
35
- """Time steps in FAME simulations associated with corresponding TimeUnits"""
39
+ """Time steps in FAME simulations associated with corresponding TimeUnits."""
36
40
 
37
41
  FIRST_YEAR = 2000
38
42
  STEPS_PER_SECOND = 1
@@ -45,9 +49,9 @@ class Constants:
45
49
  STEPS_PER_DAY = STEPS_PER_HOUR * HOURS_PER_DAY
46
50
  STEPS_PER_YEAR = STEPS_PER_DAY * DAYS_PER_YEAR
47
51
  STEPS_PER_WEEK = STEPS_PER_DAY * 7
48
- STEPS_PER_MONTH = STEPS_PER_YEAR / 12
52
+ STEPS_PER_MONTH = int(STEPS_PER_YEAR / 12)
49
53
 
50
- steps_per_unit = {
54
+ steps_per_unit: dict[TimeUnit, int] = {
51
55
  TimeUnit.SECONDS: STEPS_PER_SECOND,
52
56
  TimeUnit.MINUTES: STEPS_PER_MINUTE,
53
57
  TimeUnit.HOURS: STEPS_PER_HOUR,
@@ -59,7 +63,7 @@ class Constants:
59
63
 
60
64
 
61
65
  class FameTime:
62
- """Handles conversion of TimeSteps and TimeDurations into TimeStamps and vice versa"""
66
+ """Handles conversion of TimeSteps and TimeDurations into TimeStamps and vice versa."""
63
67
 
64
68
  _TIME_UNIT_UNKNOWN = "TimeUnit conversion of '{}' not implemented."
65
69
  _FORMAT_INVALID = "'{}' is not recognised as time stamp string - check its format."
@@ -70,7 +74,18 @@ class FameTime:
70
74
 
71
75
  @staticmethod
72
76
  def convert_datetime_to_fame_time_step(datetime_string: str) -> int:
73
- """Converts real Datetime string to FAME time step"""
77
+ """Converts Datetime string to FAME time step.
78
+
79
+ Args:
80
+ datetime_string: a datetime in FAME formatting
81
+
82
+ Returns:
83
+ corresponding FAME time step
84
+
85
+ Raises:
86
+ ConversionError: if string could not be interpreted or does not represent a valid FAME time step,
87
+ logged with level "ERROR"
88
+ """
74
89
  if not FameTime.is_datetime(datetime_string):
75
90
  raise log_error(ConversionError(FameTime._FORMAT_INVALID.format(datetime_string)))
76
91
  datetime = FameTime._convert_to_datetime(datetime_string)
@@ -85,7 +100,17 @@ class FameTime:
85
100
 
86
101
  @staticmethod
87
102
  def _convert_to_datetime(datetime_string: str) -> dt.datetime:
88
- """Converts given `datetime_string` to real-world datetime"""
103
+ """Converts given `datetime_string` in FAME formatting to real-world datetime.
104
+
105
+ Args:
106
+ datetime_string: to be converted to datetime
107
+
108
+ Returns:
109
+ datetime representation of provided string
110
+
111
+ Raises:
112
+ ConversionError: if provided string could not be converted, logged with level "ERROR"
113
+ """
89
114
  try:
90
115
  return dt.datetime.strptime(datetime_string, DATE_FORMAT)
91
116
  except ValueError as e:
@@ -93,9 +118,17 @@ class FameTime:
93
118
 
94
119
  @staticmethod
95
120
  def convert_fame_time_step_to_datetime(fame_time_steps: int, date_format: str = DATE_FORMAT) -> str:
96
- """
97
- Converts given `fame_time_steps` to corresponding real-world datetime string in `date_format`,
98
- raises ConversionException if invalid `date_format` received.
121
+ """Converts given `fame_time_steps` to corresponding real-world datetime string in `date_format`.
122
+
123
+ Args:
124
+ fame_time_steps: an integer representing time in FAME's internal format
125
+ date_format: to be used for datetime representation
126
+
127
+ Returns:
128
+ string representing the real-world datetime of the provided time steps
129
+
130
+ Raises:
131
+ ConversionError: if `date_format` is invalid, logged with level "ERROR"
99
132
  """
100
133
  years_since_start_time = math.floor(fame_time_steps / Constants.STEPS_PER_YEAR)
101
134
  current_year = years_since_start_time + Constants.FIRST_YEAR
@@ -110,22 +143,33 @@ class FameTime:
110
143
 
111
144
  @staticmethod
112
145
  def convert_time_span_to_fame_time_steps(value: int, unit: TimeUnit) -> int:
113
- """Converts value of `TimeUnit.UNIT` to fame time steps"""
146
+ """Converts value of `TimeUnit.UNIT` to FAME time steps.
147
+
148
+ Args:
149
+ value: amount of the units to be converted
150
+ unit: base time unit
151
+
152
+ Returns:
153
+ FAME time steps equivalent of `value x unit`
154
+
155
+ Raises:
156
+ ConversionError: if an unknown time unit is used, logged with level "ERROR"
157
+ """
114
158
  steps = Constants.steps_per_unit.get(unit)
115
159
  if steps:
116
160
  return steps * value
117
161
  raise log_error(ConversionError(FameTime._TIME_UNIT_UNKNOWN.format(unit)))
118
162
 
119
163
  @staticmethod
120
- def is_datetime(string: str) -> bool:
121
- """Returns `True` if given `string` matches Datetime string format and can be converted to FAME time step"""
164
+ def is_datetime(string: Any) -> bool:
165
+ """Returns `True` if given `string` matches Datetime string format and can be converted to FAME time step."""
122
166
  if isinstance(string, str):
123
167
  return DATE_REGEX.fullmatch(string.strip()) is not None
124
168
  return False
125
169
 
126
170
  @staticmethod
127
- def is_fame_time_compatible(value: Union[int, str]) -> bool:
128
- """Returns `True` if given int or string `value` can be converted to a FAME time step"""
171
+ def is_fame_time_compatible(value: int | str) -> bool:
172
+ """Returns `True` if given int or string `value` can be converted to a FAME time step."""
129
173
  if isinstance(value, int):
130
174
  return True
131
175
  if isinstance(value, str):
@@ -134,7 +178,7 @@ class FameTime:
134
178
 
135
179
  @staticmethod
136
180
  def _is_integer(string: str) -> bool:
137
- """Returns `True` if given string can be interpreted as integer"""
181
+ """Returns `True` if given string can be interpreted as integer."""
138
182
  try:
139
183
  int(string)
140
184
  except ValueError:
@@ -142,14 +186,24 @@ class FameTime:
142
186
  return True
143
187
 
144
188
  @staticmethod
145
- def convert_string_if_is_datetime(value: Union[int, str]) -> int:
146
- """
147
- Returns FAME time steps If given `value` is a valid FAME datetime string it is converted to FAME time steps;
148
- Or, if given `value` is an integer, it is returned without modification.
149
- Raises an Exception if given `value` is neither a valid FAME datetime string nor an integer value
189
+ def convert_string_if_is_datetime(value: int | str) -> int:
190
+ """Returns FAME time steps of given `value`.
191
+
192
+ If it is a valid FAME datetime string it is converted to FAME time steps.
193
+ Else if given `value` is an integer, it is returned without modification.
194
+
195
+ Args:
196
+ value: to be converted
197
+
198
+ Returns:
199
+ FAME time steps equivalent of provided value
200
+
201
+ Raises:
202
+ ConversionError: if given `value` is neither a FAME datetime string nor an integer value,
203
+ logged with level "ERROR"
150
204
  """
151
205
  if FameTime.is_datetime(value):
152
- return int(FameTime.convert_datetime_to_fame_time_step(value))
206
+ return int(FameTime.convert_datetime_to_fame_time_step(value)) # type: ignore[arg-type]
153
207
  try:
154
208
  return int(value)
155
209
  except ValueError as e:
fameio/tools.py CHANGED
@@ -1,28 +1,68 @@
1
1
  # SPDX-FileCopyrightText: 2025 German Aerospace Center <fame@dlr.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
+ from __future__ import annotations
5
+
4
6
  from pathlib import Path
5
- from typing import Any, Union
7
+ from typing import Any
8
+
9
+ from fameio.logs import log_error
10
+
11
+ CSV_FILE_SUFFIX = ".csv"
12
+
13
+ _ERR_INVALID_PATTERN = "Pattern '{}' cannot be used here due to: '{}'"
6
14
 
7
15
 
8
16
  def keys_to_lower(dictionary: dict[str, Any]) -> dict[str, Any]:
9
- """Returns new dictionary content of given `dictionary` but its top-level `keys` in lower case"""
17
+ """Returns new dictionary content of given `dictionary` but its top-level `keys` in lower case."""
10
18
  return {keys.lower(): value for keys, value in dictionary.items()}
11
19
 
12
20
 
13
21
  def ensure_is_list(value: Any) -> list:
14
- """Returns a list: Either the provided `value` if it is a list, or a new list containing the provided value"""
22
+ """Returns a list: Either the provided `value` if it is a list, or a new list containing the provided value."""
15
23
  if isinstance(value, list):
16
24
  return value
17
25
  return [value]
18
26
 
19
27
 
20
- def ensure_path_exists(path: Union[Path, str]):
21
- """Creates a specified path if not already existent"""
28
+ def ensure_path_exists(path: Path | str):
29
+ """Creates a specified path if not already existent."""
22
30
  Path(path).mkdir(parents=True, exist_ok=True)
23
31
 
24
32
 
25
33
  def clean_up_file_name(name: str) -> str:
26
- """Returns given `name` with replacements defined in `replace_map`"""
27
- replace_map = {" ": "_", ":": "_", "/": "-"}
28
- return name.translate(str.maketrans(replace_map))
34
+ """Returns given `name` replacing spaces and colons with underscore, and slashed with a dash."""
35
+ translation_table = str.maketrans({" ": "_", ":": "_", "/": "-"})
36
+ return name.translate(translation_table)
37
+
38
+
39
+ def get_csv_files_with_pattern(base_path: Path, pattern: str) -> list[Path]:
40
+ """Find all csv files matching the given `pattern` based on the given `base_path`.
41
+
42
+ Args:
43
+ base_path: to start the search from
44
+ pattern: to match the files against that are to be found
45
+
46
+ Returns:
47
+ Full file paths for files ending with ".csv" and matching the given pattern
48
+
49
+ Raises:
50
+ ValueError: if pattern cannot be used to search path, logged with level "ERROR"
51
+ """
52
+ try:
53
+ return [file for file in base_path.glob(pattern) if file.suffix.lower() == CSV_FILE_SUFFIX]
54
+ except NotImplementedError as e:
55
+ raise log_error(ValueError(_ERR_INVALID_PATTERN.format(pattern, e))) from e
56
+
57
+
58
+ def extend_file_name(original_file: Path, appendix: str) -> Path:
59
+ """Return original file path, but appending `FILE_NAME_APPENDIX` before the suffix.
60
+
61
+ Args:
62
+ original_file: from which to derive the new path
63
+ appendix: to be added to the end of the file name before the suffix
64
+
65
+ Returns:
66
+ new file path including the appendix in the file name
67
+ """
68
+ return Path(original_file.parent, original_file.stem + appendix + original_file.suffix)