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 +3 -0
- ai4cps/backend/__init__.py +0 -0
- ai4cps/backend/celery_config.py +19 -0
- ai4cps/backend/datetime_utils.py +493 -0
- ai4cps/backend/features.py +695 -0
- ai4cps/backend/perform.py +191 -0
- ai4cps/backend/results.py +274 -0
- ai4cps/backend/utils.py +137 -0
- ai4cps/dash/__init__.py +0 -0
- ai4cps/dash/assets/Logo dark.svg +39 -0
- ai4cps/dash/assets/Logo light.svg +39 -0
- ai4cps/dash/assets/favicon.ico +0 -0
- ai4cps/dash/assets/selfx_style.css +950 -0
- ai4cps/dash/colors.py +163 -0
- ai4cps/dash/dashboard.py +899 -0
- ai4cps/dash/layouts.py +215 -0
- ai4cps/dash/routing_utils.py +41 -0
- ai4cps/version.py +1 -0
- ai4cps-0.1.42.dist-info/METADATA +160 -0
- ai4cps-0.1.42.dist-info/RECORD +23 -0
- ai4cps-0.1.42.dist-info/WHEEL +5 -0
- ai4cps-0.1.42.dist-info/licenses/LICENSE +21 -0
- ai4cps-0.1.42.dist-info/top_level.txt +1 -0
ai4cps/__init__.py
ADDED
|
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
|