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.
- fameio/cli/convert_results.py +10 -10
- fameio/cli/make_config.py +9 -9
- fameio/cli/options.py +6 -4
- fameio/cli/parser.py +87 -51
- fameio/cli/reformat.py +58 -0
- fameio/input/__init__.py +4 -4
- fameio/input/loader/__init__.py +13 -13
- fameio/input/loader/controller.py +64 -18
- fameio/input/loader/loader.py +25 -16
- fameio/input/metadata.py +57 -38
- fameio/input/resolver.py +9 -10
- fameio/input/scenario/agent.py +62 -26
- fameio/input/scenario/attribute.py +93 -40
- fameio/input/scenario/contract.py +160 -56
- fameio/input/scenario/exception.py +41 -18
- fameio/input/scenario/fameiofactory.py +57 -6
- fameio/input/scenario/generalproperties.py +22 -12
- fameio/input/scenario/scenario.py +117 -38
- fameio/input/scenario/stringset.py +29 -11
- fameio/input/schema/agenttype.py +27 -10
- fameio/input/schema/attribute.py +108 -45
- fameio/input/schema/java_packages.py +14 -12
- fameio/input/schema/schema.py +39 -15
- fameio/input/validator.py +198 -54
- fameio/input/writer.py +137 -46
- fameio/logs.py +28 -47
- fameio/output/__init__.py +5 -1
- fameio/output/agent_type.py +89 -28
- fameio/output/conversion.py +52 -37
- fameio/output/csv_writer.py +107 -27
- fameio/output/data_transformer.py +17 -24
- fameio/output/execution_dao.py +170 -0
- fameio/output/input_dao.py +71 -33
- fameio/output/output_dao.py +33 -11
- fameio/output/reader.py +64 -21
- fameio/output/yaml_writer.py +16 -8
- fameio/scripts/__init__.py +22 -4
- fameio/scripts/convert_results.py +126 -52
- fameio/scripts/convert_results.py.license +1 -1
- fameio/scripts/exception.py +7 -0
- fameio/scripts/make_config.py +34 -13
- fameio/scripts/make_config.py.license +1 -1
- fameio/scripts/reformat.py +71 -0
- fameio/scripts/reformat.py.license +3 -0
- fameio/series.py +174 -59
- fameio/time.py +79 -25
- fameio/tools.py +48 -8
- {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/METADATA +50 -34
- fameio-3.3.0.dist-info/RECORD +60 -0
- {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/WHEEL +1 -1
- {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/entry_points.txt +1 -0
- CHANGELOG.md +0 -288
- fameio-3.1.1.dist-info/RECORD +0 -56
- {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSE.txt +0 -0
- {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSES/Apache-2.0.txt +0 -0
- {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSES/CC-BY-4.0.txt +0 -0
- {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
|
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
|
-
|
24
|
+
FILE_LENGTH_WARN_LIMIT = int(50e3)
|
21
25
|
|
22
26
|
|
23
|
-
class TimeSeriesError(
|
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
|
-
|
44
|
-
|
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[
|
64
|
+
self._series_by_id: dict[str | int | float, dict[Entry, Any]] = {}
|
55
65
|
|
56
|
-
def register_and_validate(self, identifier:
|
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
|
-
|
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:
|
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:
|
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:
|
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 =
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
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]
|
108
|
-
|
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
|
112
|
-
"""
|
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:
|
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:
|
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
|
-
|
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
|
-
|
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.
|
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
|
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(
|
19
|
-
"""Indicates that
|
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
|
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
|
-
|
98
|
-
|
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
|
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:
|
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:
|
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:
|
146
|
-
"""
|
147
|
-
|
148
|
-
|
149
|
-
|
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
|
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:
|
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
|
27
|
-
|
28
|
-
return name.translate(
|
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)
|