ai4cps 0.1.42__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.
ai4cps/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AI4CPS framework for cyber-physical systems."""
2
+
3
+ from .version import __version__
File without changes
@@ -0,0 +1,19 @@
1
+ import os
2
+
3
+ broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
4
+ result_backend = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
5
+
6
+ task_serializer = "json"
7
+ result_serializer = "json"
8
+ accept_content = ["json"]
9
+
10
+ timezone = "UTC"
11
+ enable_utc = True
12
+
13
+ task_track_started = True
14
+ task_ignore_result = False
15
+
16
+ task_always_eager = True
17
+ task_eager_propagates = True
18
+
19
+ imports = ("ai4cps.tasks.feature_tasks",)
@@ -0,0 +1,493 @@
1
+ """
2
+ selfx.utils.time_utils
3
+
4
+ Datetime/timestamp helpers used across SelfX.
5
+
6
+ Design goals
7
+ - Be explicit about timezone handling (naive vs aware).
8
+ - Keep parsing/formatting functions predictable.
9
+ - Prefer pandas vectorized operations for Series / DataFrames.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import datetime as dt
15
+ import math
16
+ from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union
17
+
18
+ import pandas as pd
19
+ import pytz
20
+
21
+ # Types
22
+ Interval = Tuple[dt.datetime, dt.datetime]
23
+ TranslateFn = Callable[[str], str]
24
+
25
+
26
+ # -----------------------------
27
+ # Interval helpers
28
+ # -----------------------------
29
+ def interval_difference(new_interval: Interval, intervals: Sequence[Interval]) -> List[Interval]:
30
+ """
31
+ Subtract a list of intervals from a single interval.
32
+
33
+ Given `new_interval = (start, end)` and a list `intervals` (the "blocked" parts),
34
+ returns a list of remaining (non-overlapping) intervals.
35
+
36
+ Notes
37
+ - Intervals are treated as half-open-ish in practice: [start, end).
38
+ The overlap checks follow that convention.
39
+ - Assumes each interval is a tuple of (start, end) with start < end.
40
+ - `intervals` need not be sorted; order only affects intermediate splitting, not correctness.
41
+ """
42
+ result: List[Interval] = [new_interval]
43
+
44
+ for block in intervals:
45
+ new_result: List[Interval] = []
46
+ b0, b1 = block
47
+
48
+ for cur0, cur1 in result:
49
+ # No overlap
50
+ if b1 <= cur0 or b0 >= cur1:
51
+ new_result.append((cur0, cur1))
52
+ continue
53
+
54
+ # Block overlaps left part of current interval
55
+ if b0 <= cur0 and b1 < cur1:
56
+ new_result.append((b1, cur1))
57
+ continue
58
+
59
+ # Block overlaps right part of current interval
60
+ if b0 > cur0 and b1 >= cur1:
61
+ new_result.append((cur0, b0))
62
+ continue
63
+
64
+ # Block splits current into two pieces
65
+ if b0 > cur0 and b1 < cur1:
66
+ new_result.append((cur0, b0))
67
+ new_result.append((b1, cur1))
68
+ continue
69
+
70
+ # Block fully covers current -> add nothing
71
+
72
+ result = new_result
73
+
74
+ return result
75
+
76
+
77
+ # -----------------------------
78
+ # Parsing / validation
79
+ # -----------------------------
80
+ def str_to_datetime(
81
+ s: Optional[str],
82
+ offset: Optional[dt.timedelta] = None,
83
+ convert_to_utc: bool = False,
84
+ tz: Optional[pytz.BaseTzInfo] = None,
85
+ str_till_us: bool = False,
86
+ ) -> Optional[dt.datetime]:
87
+ """
88
+ Parse a datetime string into a `datetime.datetime`.
89
+
90
+ Supported inputs
91
+ - Default: ISO 8601-like strings parseable by `datetime.fromisoformat`,
92
+ e.g. "2024-01-01 12:34:56", "2024-01-01T12:34:56", with or without tz offset.
93
+ - If `str_till_us=True`: parses european format
94
+ "%d.%m.%y %H:%M:%S.%f" or "%d.%m.%y %H:%M:%S"
95
+
96
+ Timezone behavior
97
+ - If `tz` is provided and parsing produces a naive datetime, it is localized with `tz.localize(...)`.
98
+ (If the parsed datetime is already aware, it is left as-is.)
99
+ - If `convert_to_utc=True`, the result is converted to UTC (requires an aware datetime).
100
+
101
+ Parameters
102
+ ----------
103
+ s:
104
+ Input string or None.
105
+ offset:
106
+ Optional timedelta added after parsing (and after UTC conversion if enabled).
107
+ convert_to_utc:
108
+ Whether to convert the result to UTC.
109
+ tz:
110
+ pytz timezone used to localize naive datetimes.
111
+ str_till_us:
112
+ Whether to parse using the european "%d.%m.%y %H:%M:%S(.%f)" format.
113
+
114
+ Returns
115
+ -------
116
+ datetime or None
117
+ """
118
+ if s is None:
119
+ return None
120
+
121
+ if str_till_us:
122
+ try:
123
+ d = dt.datetime.strptime(s, "%d.%m.%y %H:%M:%S.%f")
124
+ except ValueError:
125
+ d = dt.datetime.strptime(s, "%d.%m.%y %H:%M:%S")
126
+ else:
127
+ d = dt.datetime.fromisoformat(s)
128
+
129
+ # Localize if needed (only for naive)
130
+ if tz is not None and d.tzinfo is None:
131
+ d = tz.localize(d)
132
+
133
+ if convert_to_utc:
134
+ if d.tzinfo is None:
135
+ raise ValueError("convert_to_utc=True requires an aware datetime (provide tz or parse an aware string).")
136
+ d = d.astimezone(pytz.utc)
137
+
138
+ if offset:
139
+ d = d + offset
140
+
141
+ return d
142
+
143
+
144
+ def is_valid_date(string: str, formats: Optional[Sequence[str]] = None) -> bool:
145
+ """
146
+ Check whether `string` matches any date format in `formats`.
147
+ """
148
+ if formats is None:
149
+ formats = (
150
+ "%Y-%m-%d",
151
+ "%d.%m.%Y",
152
+ "%d/%m/%Y",
153
+ "%m-%d-%Y",
154
+ "%Y/%m/%d",
155
+ )
156
+
157
+ for fmt in formats:
158
+ try:
159
+ dt.datetime.strptime(string, fmt)
160
+ return True
161
+ except ValueError:
162
+ continue
163
+ return False
164
+
165
+
166
+ def robust_to_datetime(series: pd.Series) -> pd.Series:
167
+ """
168
+ Robustly parse a pandas Series to datetime.
169
+
170
+ Strategy:
171
+ - First try pandas' inference (`errors="coerce"`)
172
+ - Then try a few common ISO variants with timezone info.
173
+ """
174
+ date1 = pd.to_datetime(series, errors="coerce")
175
+ date2 = pd.to_datetime(series, errors="coerce", format="%Y-%m-%dT%H:%M:%S%z")
176
+ date3 = pd.to_datetime(series, errors="coerce", format="%Y-%m-%dT%H:%M:%S.%f%z")
177
+ date4 = pd.to_datetime(series, errors="coerce", format="%Y-%m-%dT%H:%M:%S.%fZ")
178
+ return date1.fillna(date2).fillna(date3).fillna(date4)
179
+
180
+
181
+ # -----------------------------
182
+ # Human-readable "time ago"
183
+ # -----------------------------
184
+ def time_ago(
185
+ target_time: Union[dt.datetime, pd.Timestamp, None],
186
+ time_now: Optional[dt.datetime] = None,
187
+ translate: Optional[TranslateFn] = None,
188
+ till_hour: bool = False,
189
+ ) -> Optional[str]:
190
+ """
191
+ Return a human-friendly relative time string like "3 minutes ago".
192
+
193
+ Notes
194
+ - Assumes `time_now` and `target_time` are comparable (both naive or both aware).
195
+ """
196
+ if target_time is None or pd.isnull(target_time):
197
+ return None
198
+
199
+ if translate is None:
200
+ translate = lambda s: s # type: ignore[assignment]
201
+
202
+ if time_now is None:
203
+ time_now = now_tz_naive()
204
+
205
+ if isinstance(target_time, pd.Timestamp):
206
+ target_time = target_time.to_pydatetime()
207
+
208
+ diff = time_now - target_time
209
+
210
+ if till_hour and diff.total_seconds() > 3600:
211
+ return dt_to_str_till_sec_europe(
212
+ target_time,
213
+ omit_date=diff.total_seconds() < 86400,
214
+ omit_seconds=True,
215
+ )
216
+
217
+ if diff.days > 0:
218
+ return translate("{} day ago").format(1) if diff.days == 1 else translate("{} days ago").format(diff.days)
219
+
220
+ seconds = int(diff.total_seconds())
221
+ if seconds >= 3600:
222
+ hours = seconds // 3600
223
+ return translate("{} hour ago").format(1) if hours == 1 else translate("{} hours ago").format(hours)
224
+ if seconds >= 60:
225
+ minutes = seconds // 60
226
+ return translate("{} minute ago").format(1) if minutes == 1 else translate("{} minutes ago").format(minutes)
227
+
228
+ return translate("{} second ago").format(1) if seconds == 1 else translate("{} seconds ago").format(seconds)
229
+
230
+
231
+ # -----------------------------
232
+ # Formatting helpers
233
+ # -----------------------------
234
+ def dt_to_str_till_sec(d: dt.datetime, short_year: bool = False) -> str:
235
+ """Format as 'YYYY-MM-DD HH:MM:SS' (or 'YY-...' if short_year)."""
236
+ return d.strftime("%y-%m-%d %H:%M:%S") if short_year else d.strftime("%Y-%m-%d %H:%M:%S")
237
+
238
+
239
+ def dt_to_str_till_sec_europe(
240
+ d: dt.datetime,
241
+ omit_date: bool = False,
242
+ short_year: bool = False,
243
+ omit_seconds: bool = False,
244
+ ) -> str:
245
+ """
246
+ European formatting:
247
+ - With date: 'DD.MM.YYYY HH:MM(:SS)'
248
+ - Without date: 'HH:MM(:SS)'
249
+ - With short_year: DD.MM.YY ...
250
+ """
251
+ if omit_date:
252
+ return d.strftime("%H:%M") if omit_seconds else d.strftime("%H:%M:%S")
253
+
254
+ if short_year:
255
+ return d.strftime("%d.%m.%y %H:%M") if omit_seconds else d.strftime("%d.%m.%y %H:%M:%S")
256
+
257
+ return d.strftime("%d.%m.%Y %H:%M") if omit_seconds else d.strftime("%d.%m.%Y %H:%M:%S")
258
+
259
+
260
+ # -----------------------------
261
+ # Simple date helpers (naive local time)
262
+ # -----------------------------
263
+ def istoday(ts: Optional[dt.datetime]) -> Optional[bool]:
264
+ """Return True if `ts` is today (local), False if not, None if ts is None."""
265
+ if ts is None:
266
+ return None
267
+ return dt.datetime.today().date() == ts.date()
268
+
269
+
270
+ def today_tz_naive() -> dt.datetime:
271
+ """Local naive 'today 00:00:00'."""
272
+ return dt.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
273
+
274
+
275
+ def tomorrow_tz_naive() -> dt.datetime:
276
+ """
277
+ Local naive 'tomorrow 00:00:00'.
278
+
279
+ Uses timedelta, avoids invalid dates at month boundaries.
280
+ """
281
+ return today_tz_naive() + dt.timedelta(days=1)
282
+
283
+
284
+ def now_tz_naive() -> dt.datetime:
285
+ """Local naive current time."""
286
+ return dt.datetime.now()
287
+
288
+
289
+ # -----------------------------
290
+ # Pandas helpers
291
+ # -----------------------------
292
+ def sort_timestamps(data: Dict) -> Dict:
293
+ """
294
+ Sort `data[k]` DataFrames by ['timestamp', 'name'] if timestamps are not monotonic increasing.
295
+
296
+ Expects each DataFrame to have columns: 'timestamp' and 'name'.
297
+ """
298
+ for k in list(data.keys()):
299
+ df = data[k]
300
+ df["timestamp"] = pd.to_datetime(df["timestamp"])
301
+
302
+ if not df["timestamp"].is_monotonic_increasing:
303
+ df = df.sort_values(by=["timestamp", "name"]).reset_index(drop=True)
304
+ data[k] = df
305
+
306
+ return data
307
+
308
+
309
+ def convert_pandas_dt_to_str(series: pd.Series, nano_sec: bool = False) -> pd.Series:
310
+ """
311
+ Convert a datetime-like pandas Series to strings in format '%d.%m.%y %H:%M:%S.%f'.
312
+
313
+ If `nano_sec=True`, append 3 extra digits for nanoseconds (so you effectively get 9 digits).
314
+ """
315
+ fmt = "%d.%m.%y %H:%M:%S.%f"
316
+
317
+ if not pd.api.types.is_datetime64_any_dtype(series):
318
+ return series
319
+
320
+ base = series.dt.strftime(fmt)
321
+
322
+ if not nano_sec:
323
+ return base
324
+
325
+ nanos = series.dt.nanosecond.map(
326
+ lambda x: f"{int(x):03d}" if not (isinstance(x, float) and math.isnan(x)) else "000"
327
+ )
328
+ return base + nanos
329
+
330
+
331
+ def convert_pandas_str_to_dt(series: pd.Series) -> pd.Series:
332
+ """Parse strings formatted as '%d.%m.%y %H:%M:%S.%f' into pandas datetimes."""
333
+ return pd.to_datetime(series, format="%d.%m.%y %H:%M:%S.%f")
334
+
335
+
336
+ # -----------------------------
337
+ # Timezone conversions
338
+ # -----------------------------
339
+ def datetime_to_utc(t: dt.datetime) -> dt.datetime:
340
+ """
341
+ Convert an aware datetime to UTC.
342
+
343
+ Raises
344
+ ------
345
+ ValueError if `t` is naive.
346
+ """
347
+ if t.tzinfo is None:
348
+ raise ValueError("datetime_to_utc() requires an aware datetime.")
349
+ return t.astimezone(pytz.utc)
350
+
351
+
352
+ def to_aware(d: dt.datetime, tz: pytz.BaseTzInfo, *, assume_local_if_naive: bool = True) -> dt.datetime:
353
+ """
354
+ Ensure `d` is timezone-aware.
355
+
356
+ If `d` is naive and `assume_local_if_naive=True`, localize it with the provided `tz`.
357
+ If `d` is already aware, it is converted to `tz`.
358
+
359
+ Raises
360
+ ------
361
+ ValueError if `d` is naive and assume_local_if_naive=False.
362
+ """
363
+ if d.tzinfo is None:
364
+ if not assume_local_if_naive:
365
+ raise ValueError("to_aware(): datetime is naive and assume_local_if_naive=False.")
366
+ return tz.localize(d)
367
+ return d.astimezone(tz)
368
+
369
+
370
+ def to_naive_utc(d: dt.datetime, *, assume_tz_if_naive: Optional[pytz.BaseTzInfo] = None) -> dt.datetime:
371
+ """
372
+ Convert datetime to *naive UTC* (tzinfo removed).
373
+
374
+ If `d` is naive, you must supply `assume_tz_if_naive` so we know how to interpret it.
375
+ """
376
+ if d.tzinfo is None:
377
+ if assume_tz_if_naive is None:
378
+ raise ValueError("to_naive_utc(): naive datetime; provide assume_tz_if_naive to interpret it.")
379
+ d = assume_tz_if_naive.localize(d)
380
+
381
+ return d.astimezone(pytz.utc).replace(tzinfo=None)
382
+
383
+
384
+ # -----------------------------
385
+ # InfluxDB/pipeline-friendly helpers
386
+ # -----------------------------
387
+ def ensure_utc_series(
388
+ s: pd.Series,
389
+ *,
390
+ assume_tz_if_naive: Union[str, pytz.BaseTzInfo, None] = "UTC",
391
+ ) -> pd.Series:
392
+ """
393
+ Ensure a timestamp Series is timezone-aware in UTC.
394
+
395
+ Behavior
396
+ - Parses to datetime (`pd.to_datetime`).
397
+ - If naive:
398
+ - if assume_tz_if_naive is None -> raise
399
+ - else localize to that tz, then convert to UTC
400
+ - If already tz-aware -> convert to UTC
401
+
402
+ Parameters
403
+ ----------
404
+ s:
405
+ Input Series (strings/datetime/ints are accepted by pandas).
406
+ assume_tz_if_naive:
407
+ Timezone to assume for naive timestamps. Common choices:
408
+ - "UTC" (often safest for machine timestamps)
409
+ - "Europe/Berlin" (if your sources are local time)
410
+ - None (force callers to be explicit; raises on naive)
411
+
412
+ Returns
413
+ -------
414
+ Series with dtype datetime64[ns, UTC]
415
+ """
416
+ out = pd.to_datetime(s, errors="raise")
417
+
418
+ if out.dt.tz is None:
419
+ if assume_tz_if_naive is None:
420
+ raise ValueError("ensure_utc_series(): naive timestamps; set assume_tz_if_naive.")
421
+ tzinfo = pytz.timezone(assume_tz_if_naive) if isinstance(assume_tz_if_naive, str) else assume_tz_if_naive
422
+ out = out.dt.tz_localize(tzinfo)
423
+
424
+ return out.dt.tz_convert(pytz.utc)
425
+
426
+
427
+ def ensure_utc_index(
428
+ df: pd.DataFrame,
429
+ *,
430
+ col: Optional[str] = "timestamp",
431
+ assume_tz_if_naive: Union[str, pytz.BaseTzInfo, None] = "UTC",
432
+ sort: bool = True,
433
+ set_as_index: bool = False,
434
+ drop: bool = False,
435
+ ) -> pd.DataFrame:
436
+ """
437
+ Ensure a DataFrame has UTC-aware timestamps, optionally set as index, optionally sorted.
438
+
439
+ Common use cases
440
+ - normalize data before writing to InfluxDB
441
+ - normalize data right after reading from InfluxDB
442
+ - enforce consistent comparisons across sources
443
+
444
+ Parameters
445
+ ----------
446
+ df:
447
+ Input DataFrame.
448
+ col:
449
+ Timestamp column name. If None, uses the current index as the timestamp source.
450
+ assume_tz_if_naive:
451
+ Timezone assumed for naive timestamps (see `ensure_utc_series`).
452
+ sort:
453
+ If True, sort by timestamp (and preserve stable ordering for ties).
454
+ set_as_index:
455
+ If True, set the normalized timestamp as the DataFrame index.
456
+ drop:
457
+ If set_as_index=True, drop the timestamp column.
458
+
459
+ Returns
460
+ -------
461
+ DataFrame with normalized UTC timestamps (column or index).
462
+ """
463
+ out = df.copy()
464
+
465
+ if col is None:
466
+ # Normalize index
467
+ idx = pd.to_datetime(out.index, errors="raise")
468
+ if idx.tz is None:
469
+ if assume_tz_if_naive is None:
470
+ raise ValueError("ensure_utc_index(): naive index; set assume_tz_if_naive.")
471
+ tzinfo = pytz.timezone(assume_tz_if_naive) if isinstance(assume_tz_if_naive, str) else assume_tz_if_naive
472
+ idx = idx.tz_localize(tzinfo)
473
+ out.index = idx.tz_convert(pytz.utc)
474
+ if sort:
475
+ out = out.sort_index()
476
+ return out
477
+
478
+ # Normalize column
479
+ if col not in out.columns:
480
+ raise KeyError(f"ensure_utc_index(): column '{col}' not found in DataFrame.")
481
+
482
+ out[col] = ensure_utc_series(out[col], assume_tz_if_naive=assume_tz_if_naive)
483
+
484
+ if sort:
485
+ # stable sort to keep deterministic ordering with ties
486
+ out = out.sort_values(by=[col], kind="mergesort").reset_index(drop=True)
487
+
488
+ if set_as_index:
489
+ out = out.set_index(col, drop=drop)
490
+ if sort:
491
+ out = out.sort_index()
492
+
493
+ return out