ee3943b5b1
CI / test (deps-minimum, windows-latest, unit) (push) Blocked by required conditions
CI / test_py314 (deps-latest, ubuntu-latest, unit) (push) Blocked by required conditions
CI / test_py314 (deps-latest, windows-latest, unit) (push) Blocked by required conditions
CI / test_py314_future (deps-latest, ubuntu-latest, unit) (push) Blocked by required conditions
CI / test_py314_future (deps-latest, windows-latest, unit) (push) Blocked by required conditions
Secret Leaks / trufflehog (push) Waiting to run
CI / check_code_quality (push) Waiting to run
CI / test (deps-latest, ubuntu-latest, integration) (push) Blocked by required conditions
CI / test (deps-latest, ubuntu-latest, unit) (push) Blocked by required conditions
CI / test (deps-latest, windows-latest, integration) (push) Blocked by required conditions
CI / test (deps-latest, windows-latest, unit) (push) Blocked by required conditions
CI / test (deps-minimum, ubuntu-latest, integration) (push) Blocked by required conditions
CI / test (deps-minimum, ubuntu-latest, unit) (push) Blocked by required conditions
CI / test (deps-minimum, windows-latest, integration) (push) Blocked by required conditions
Build documentation / build (push) Failing after 0s
749 lines
29 KiB
Python
749 lines
29 KiB
Python
"""Tests for the per-device wide-format TsFile builder."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from typing import Any, Sequence
|
||
|
||
import pyarrow as pa
|
||
import pytest
|
||
|
||
|
||
# `tsfile` requires pyarrow<20 for python<3.14, which conflicts with datasets'
|
||
# pyarrow>=21.0.0. It is therefore only installed in the py3.14 CI. Skip this
|
||
# whole module (at collection time) when tsfile is not importable.
|
||
pytest.importorskip("tsfile")
|
||
|
||
from tsfile import ColumnCategory, ColumnSchema, TableSchema, Tablet, TsFileWriter # noqa: E402
|
||
from tsfile.constants import TSDataType # noqa: E402
|
||
|
||
from datasets import IterableDataset, load_dataset # noqa: E402
|
||
from datasets.builder import InvalidConfigName # noqa: E402
|
||
from datasets.data_files import DataFilesList # noqa: E402
|
||
from datasets.packaged_modules.tsfile.tsfile import TsFileConfig, _to_epoch # noqa: E402
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Time-base constants
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Every fixture's timestamps live in a disjoint epoch-ms slice off ``T0`` so
|
||
# that, when two files of the same device are merged, the resulting
|
||
# time-sorted order is fully determined by writer-side timestamps. This lets
|
||
# the assertions below check both *content* and *order* unambiguously.
|
||
T0 = 1_700_000_000_000 # base: single_device, multi_device, all_types
|
||
T_EVOLVED = T0 + 500_000 # evolved file (after single_device's 5 points)
|
||
T_INT32 = T0 + 1_000_000
|
||
T_INT64 = T0 + 2_000_000
|
||
T_FLOAT = T0 + 3_000_000
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Generic writer
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
# A row maps column name -> Python value, plus a special "time" -> int (epoch).
|
||
Row = dict[str, Any]
|
||
ColumnSpec = tuple[str, TSDataType, ColumnCategory]
|
||
|
||
|
||
def _write_tsfile(path: str, tables: Sequence[tuple[str, Sequence[ColumnSpec], Sequence[Sequence[Row]]]]) -> None:
|
||
"""Write one or more tables, each as one or more tablets, to ``path``.
|
||
|
||
Each ``tables`` entry is ``(table_name, columns, tablets)`` where:
|
||
|
||
- ``columns`` is the table schema as ``[(name, TSDataType, ColumnCategory), ...]``;
|
||
it must include exactly one TIME column called ``"time"``.
|
||
- ``tablets`` is a list of tablets; each tablet is a list of row dicts. A
|
||
row dict must carry ``"time"`` plus every TAG/FIELD column in the table.
|
||
"""
|
||
writer = TsFileWriter(path)
|
||
try:
|
||
# Register schemas first so multiple-table files validate up-front.
|
||
for table_name, columns, _ in tables:
|
||
writer.register_table(TableSchema(table_name, [ColumnSchema(*c) for c in columns]))
|
||
|
||
for table_name, columns, tablets in tables:
|
||
non_time = [(n, t) for (n, t, c) in columns if c != ColumnCategory.TIME]
|
||
col_names = [n for n, _ in non_time]
|
||
col_types = [t for _, t in non_time]
|
||
for rows in tablets:
|
||
tablet = Tablet(col_names, col_types, len(rows))
|
||
tablet.set_table_name(table_name)
|
||
for i, row in enumerate(rows):
|
||
tablet.add_timestamp(i, row["time"])
|
||
for name in col_names:
|
||
tablet.add_value_by_name(name, i, row[name])
|
||
writer.write_table(tablet)
|
||
finally:
|
||
writer.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-fixture writers (each declarative + tiny)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _write_single_device(path: str) -> None:
|
||
"""One device 'd1', two DOUBLE fields, 5 points starting at T0."""
|
||
cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("device", TSDataType.STRING, ColumnCategory.TAG),
|
||
("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
]
|
||
rows = [{"time": T0 + i * 1000, "device": "d1", "temperature": 20.0 + i, "humidity": 50.0 + i} for i in range(5)]
|
||
_write_tsfile(path, [("mytable", cols, [rows])])
|
||
|
||
|
||
def _write_multi_device(path: str) -> None:
|
||
"""Three devices, 3 points each, all sharing the same field schema."""
|
||
cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("device", TSDataType.STRING, ColumnCategory.TAG),
|
||
("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
]
|
||
tablets = [
|
||
[{"time": T0 + i * 1000, "device": dev, "temperature": 10.0 + i, "humidity": 50.0 + i} for i in range(3)]
|
||
for dev in ("d1", "d2", "d3")
|
||
]
|
||
_write_tsfile(path, [("plant", cols, tablets)])
|
||
|
||
|
||
def _write_evolved(path: str) -> None:
|
||
"""Same table+device as single_device, plus a new ``voltage`` field."""
|
||
cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("device", TSDataType.STRING, ColumnCategory.TAG),
|
||
("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
("voltage", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
]
|
||
rows = [
|
||
{
|
||
"time": T_EVOLVED + i * 1000,
|
||
"device": "d1",
|
||
"temperature": 30.0 + i,
|
||
"humidity": 60.0 + i,
|
||
"voltage": 220.0 + i,
|
||
}
|
||
for i in range(3)
|
||
]
|
||
_write_tsfile(path, [("mytable", cols, [rows])])
|
||
|
||
|
||
def _write_numeric_field(path: str, ts_type: TSDataType, base_ts: int, value_fn) -> None:
|
||
"""One device 'd1' with a single numeric ``temperature`` field."""
|
||
cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("device", TSDataType.STRING, ColumnCategory.TAG),
|
||
("temperature", ts_type, ColumnCategory.FIELD),
|
||
]
|
||
rows = [{"time": base_ts + i * 1000, "device": "d1", "temperature": value_fn(i)} for i in range(3)]
|
||
_write_tsfile(path, [("mytable", cols, [rows])])
|
||
|
||
|
||
def _write_two_tables(path: str) -> None:
|
||
"""Two distinct tables in one file: ``table_a`` (registered first) and ``table_b``."""
|
||
a_cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("device", TSDataType.STRING, ColumnCategory.TAG),
|
||
("a", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
]
|
||
b_cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("device", TSDataType.STRING, ColumnCategory.TAG),
|
||
("b", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
]
|
||
a_rows = [{"time": 1_000 + i, "device": "d1", "a": float(i)} for i in range(2)]
|
||
b_rows = [{"time": 2_000 + i, "device": "d1", "b": 100.0 + i} for i in range(2)]
|
||
_write_tsfile(path, [("table_a", a_cols, [a_rows]), ("table_b", b_cols, [b_rows])])
|
||
|
||
|
||
def _write_all_types(path: str) -> None:
|
||
"""Every supported TSDataType represented as a FIELD."""
|
||
cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("tag", TSDataType.STRING, ColumnCategory.TAG),
|
||
("col_boolean", TSDataType.BOOLEAN, ColumnCategory.FIELD),
|
||
("col_int32", TSDataType.INT32, ColumnCategory.FIELD),
|
||
("col_int64", TSDataType.INT64, ColumnCategory.FIELD),
|
||
("col_float", TSDataType.FLOAT, ColumnCategory.FIELD),
|
||
("col_double", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
("col_text", TSDataType.TEXT, ColumnCategory.FIELD),
|
||
("col_string", TSDataType.STRING, ColumnCategory.FIELD),
|
||
("col_timestamp", TSDataType.TIMESTAMP, ColumnCategory.FIELD),
|
||
("col_date", TSDataType.DATE, ColumnCategory.FIELD),
|
||
("col_blob", TSDataType.BLOB, ColumnCategory.FIELD),
|
||
]
|
||
rows = [
|
||
{
|
||
"time": T0 + i * 1000,
|
||
"tag": "d1",
|
||
"col_boolean": i % 2 == 0,
|
||
"col_int32": 100 + i,
|
||
"col_int64": 1_000_000 + i,
|
||
"col_float": 1.5 + i,
|
||
"col_double": 100.5 + i,
|
||
"col_text": f"text_{i}",
|
||
"col_string": f"str_{i}",
|
||
"col_timestamp": 1_600_000_000_000 + i * 1000,
|
||
"col_date": date(2024, 1, 1 + i),
|
||
"col_blob": f"blob{i}".encode(),
|
||
}
|
||
for i in range(3)
|
||
]
|
||
_write_tsfile(path, [("alltypes", cols, [rows])])
|
||
|
||
|
||
def _write_large_device(path: str, n_points: int = 200) -> None:
|
||
"""Single device with many points, used to exercise multi-batch concat."""
|
||
cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("device", TSDataType.STRING, ColumnCategory.TAG),
|
||
("v", TSDataType.INT64, ColumnCategory.FIELD),
|
||
]
|
||
rows = [{"time": T0 + i, "device": "d1", "v": i} for i in range(n_points)]
|
||
_write_tsfile(path, [("mytable", cols, [rows])])
|
||
|
||
|
||
def _write_two_devices_subset(path: str, devices: Sequence[str], base_ts: int) -> None:
|
||
"""A multi-device fixture used to assemble cross-file device sets."""
|
||
cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("device", TSDataType.STRING, ColumnCategory.TAG),
|
||
("v", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
]
|
||
tablets = [[{"time": base_ts + i * 1000, "device": dev, "v": float(i)} for i in range(3)] for dev in devices]
|
||
_write_tsfile(path, [("mytable", cols, tablets)])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def make_tsfile(tmp_path):
|
||
"""Factory fixture: ``make_tsfile("name", writer_fn, *args, **kwargs)``."""
|
||
|
||
def _make(name: str, writer_fn, *args, **kwargs) -> str:
|
||
p = str(tmp_path / f"{name}.tsfile")
|
||
writer_fn(p, *args, **kwargs)
|
||
return p
|
||
|
||
return _make
|
||
|
||
|
||
@pytest.fixture
|
||
def tsfile_path(make_tsfile):
|
||
return make_tsfile("sample", _write_single_device)
|
||
|
||
|
||
@pytest.fixture
|
||
def multi_device_tsfile_path(make_tsfile):
|
||
return make_tsfile("multi", _write_multi_device)
|
||
|
||
|
||
@pytest.fixture
|
||
def evolved_tsfile_path(make_tsfile):
|
||
return make_tsfile("evolved", _write_evolved)
|
||
|
||
|
||
@pytest.fixture
|
||
def two_tables_tsfile_path(make_tsfile):
|
||
return make_tsfile("two_tables", _write_two_tables)
|
||
|
||
|
||
@pytest.fixture
|
||
def all_types_tsfile_path(make_tsfile):
|
||
return make_tsfile("alltypes", _write_all_types)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Config-level
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_config_raises_when_invalid_name():
|
||
with pytest.raises(InvalidConfigName, match="Bad characters"):
|
||
TsFileConfig(name="name-with-*-invalid-character")
|
||
|
||
|
||
@pytest.mark.parametrize("data_files", ["str_path", ["str_path"], DataFilesList(["str_path"], [()])])
|
||
def test_config_raises_when_invalid_data_files(data_files):
|
||
with pytest.raises(ValueError, match="Expected a DataFilesDict"):
|
||
TsFileConfig(name="name", data_files=data_files)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"kwargs, match",
|
||
[
|
||
({"input_batch_size": 0}, "input_batch_size"),
|
||
({"output_batch_size": 0}, "output_batch_size"),
|
||
({"columns": []}, "non-empty"),
|
||
({"timestamp_unit": "minute"}, "timestamp_unit"),
|
||
({"on_bad_files": "boom"}, "on_bad_files"),
|
||
],
|
||
)
|
||
def test_config_rejects_invalid_values(kwargs, match):
|
||
with pytest.raises(ValueError, match=match):
|
||
TsFileConfig(name="x", **kwargs)
|
||
|
||
|
||
def test_config_normalizes_time_bounds():
|
||
cfg = TsFileConfig(
|
||
name="x",
|
||
start_time=pa.scalar(1500, type=pa.timestamp("ms")),
|
||
end_time=2000,
|
||
)
|
||
assert cfg.start_time == 1500
|
||
assert cfg.end_time == 2000
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _to_epoch unit tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_to_epoch_int_passthrough():
|
||
assert _to_epoch(1234, "ms") == 1234
|
||
|
||
|
||
def test_to_epoch_naive_datetime():
|
||
assert _to_epoch(datetime(1970, 1, 1, 0, 0, 1), "ms") == 1000
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"aware",
|
||
[
|
||
datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone(timedelta(hours=8))),
|
||
"2024-01-01T00:00:00+08:00",
|
||
],
|
||
ids=["datetime", "iso_string"],
|
||
)
|
||
def test_to_epoch_aware_inputs_normalized_to_utc(aware):
|
||
# 2024-01-01T00:00:00 in UTC+8 == 2023-12-31T16:00:00 UTC.
|
||
naive_utc = datetime(2023, 12, 31, 16, 0, 0)
|
||
assert _to_epoch(aware, "ms") == _to_epoch(naive_utc, "ms")
|
||
|
||
|
||
def test_to_epoch_date():
|
||
assert _to_epoch(date(1970, 1, 2), "ms") == 86_400_000
|
||
|
||
|
||
def test_to_epoch_iso_string():
|
||
assert _to_epoch("1970-01-01T00:00:01", "ms") == 1000
|
||
|
||
|
||
def test_to_epoch_pa_scalar():
|
||
assert _to_epoch(pa.scalar(1500, type=pa.timestamp("ms")), "ms") == 1500
|
||
|
||
|
||
def test_to_epoch_rejects_bool():
|
||
with pytest.raises(TypeError, match="bool"):
|
||
_to_epoch(True, "ms")
|
||
|
||
|
||
@pytest.mark.parametrize("value", [object(), b"bytes", "not-a-date"])
|
||
def test_to_epoch_rejects_garbage(value):
|
||
with pytest.raises(TypeError, match="must be a"):
|
||
_to_epoch(value, "ms")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# End-to-end: single device, full table
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_load_full_table(tsfile_path):
|
||
ds = load_dataset("tsfile", data_files=tsfile_path)["train"]
|
||
|
||
# One row per device. TAG = scalar string; time + fields = lists.
|
||
assert ds.column_names == ["device", "time", "temperature", "humidity"]
|
||
assert len(ds) == 1
|
||
row = ds[0]
|
||
assert row["device"] == "d1"
|
||
assert len(row["time"]) == 5
|
||
assert row["time"][0] == datetime(2023, 11, 14, 22, 13, 20)
|
||
assert row["time"][-1] == datetime(2023, 11, 14, 22, 13, 24)
|
||
assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
|
||
assert row["humidity"] == [50.0, 51.0, 52.0, 53.0, 54.0]
|
||
|
||
|
||
def test_load_with_field_subset(tsfile_path):
|
||
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["temperature"])["train"]
|
||
assert ds.column_names == ["device", "time", "temperature"]
|
||
assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
|
||
|
||
|
||
def test_columns_are_lowercased(tsfile_path):
|
||
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["TEMPERATURE", "Humidity"])["train"]
|
||
assert ds.column_names == ["device", "time", "temperature", "humidity"]
|
||
|
||
|
||
def test_columns_request_tag_is_silently_ignored(tsfile_path):
|
||
"""Passing a TAG name in `columns` is a no-op (TAGs are always emitted)."""
|
||
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["device", "temperature"])["train"]
|
||
|
||
assert ds.column_names == ["device", "time", "temperature"]
|
||
assert ds.features["device"].dtype == "string"
|
||
assert ds.features["temperature"].feature.dtype == "float64"
|
||
assert ds["device"] == ["d1"]
|
||
assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
|
||
|
||
|
||
def test_columns_request_time_is_silently_ignored(tsfile_path):
|
||
"""Passing the TIME column name in `columns` is a no-op (TIME is always emitted)."""
|
||
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["time", "temperature"])["train"]
|
||
|
||
# `time` should appear exactly once, and as the real timestamp list — not
|
||
# as a duplicate all-null float64 list column.
|
||
assert ds.column_names == ["device", "time", "temperature"]
|
||
assert ds.features["time"].feature.dtype.startswith("timestamp")
|
||
row = ds[0]
|
||
assert len(row["time"]) == 5
|
||
assert row["time"][0] == datetime(2023, 11, 14, 22, 13, 20)
|
||
assert row["time"][-1] == datetime(2023, 11, 14, 22, 13, 24)
|
||
assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
|
||
|
||
|
||
def test_columns_request_only_time(tsfile_path):
|
||
"""`columns=["time"]` should still produce TAG + TIME, with no FIELD list columns."""
|
||
ds = load_dataset("tsfile", data_files=tsfile_path, columns=["time"])["train"]
|
||
|
||
assert ds.column_names == ["device", "time"]
|
||
assert ds.features["time"].feature.dtype.startswith("timestamp")
|
||
row = ds[0]
|
||
assert row["device"] == "d1"
|
||
assert len(row["time"]) == 5
|
||
assert row["time"][0] == datetime(2023, 11, 14, 22, 13, 20)
|
||
assert row["time"][-1] == datetime(2023, 11, 14, 22, 13, 24)
|
||
|
||
|
||
def test_columns_unknown_field_filled_with_null(tsfile_path):
|
||
ds = load_dataset(
|
||
"tsfile",
|
||
data_files=tsfile_path,
|
||
columns=["temperature", "voltage"], # voltage is absent
|
||
)["train"]
|
||
|
||
assert ds.column_names == ["device", "time", "temperature", "voltage"]
|
||
row = ds[0]
|
||
assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0]
|
||
assert row["voltage"] == [None] * 5
|
||
|
||
|
||
def test_columns_all_unknown_still_returns_time_and_tags(tsfile_path):
|
||
ds = load_dataset(
|
||
"tsfile",
|
||
data_files=tsfile_path,
|
||
columns=["nonexistent_a", "nonexistent_b"],
|
||
)["train"]
|
||
|
||
assert ds.column_names == ["device", "time", "nonexistent_a", "nonexistent_b"]
|
||
row = ds[0]
|
||
assert row["device"] == "d1"
|
||
assert len(row["time"]) == 5
|
||
assert row["nonexistent_a"] == [None] * 5
|
||
assert row["nonexistent_b"] == [None] * 5
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Time-range filtering
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"start, end",
|
||
[
|
||
# pa.scalar from datetime
|
||
(
|
||
pa.scalar(datetime(2023, 11, 14, 22, 13, 21), type=pa.timestamp("ms")),
|
||
pa.scalar(datetime(2023, 11, 14, 22, 13, 23), type=pa.timestamp("ms")),
|
||
),
|
||
# pa.scalar from int epoch
|
||
(
|
||
pa.scalar(T0 + 1000, type=pa.timestamp("ms")),
|
||
pa.scalar(T0 + 3000, type=pa.timestamp("ms")),
|
||
),
|
||
# plain int epoch
|
||
(T0 + 1000, T0 + 3000),
|
||
# datetime
|
||
(datetime(2023, 11, 14, 22, 13, 21), datetime(2023, 11, 14, 22, 13, 23)),
|
||
# ISO-8601 string
|
||
("2023-11-14T22:13:21", "2023-11-14T22:13:23"),
|
||
],
|
||
)
|
||
def test_load_with_time_range_inputs(tsfile_path, start, end):
|
||
ds = load_dataset("tsfile", data_files=tsfile_path, start_time=start, end_time=end)["train"]
|
||
assert len(ds[0]["time"]) == 3
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Multi-device & cross-file folding
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_load_multi_device_one_row_per_device(multi_device_tsfile_path):
|
||
ds = load_dataset("tsfile", data_files=multi_device_tsfile_path)["train"]
|
||
|
||
assert len(ds) == 3
|
||
assert sorted(ds["device"]) == ["d1", "d2", "d3"]
|
||
for row in ds:
|
||
assert len(row["time"]) == 3
|
||
assert row["temperature"] == [10.0, 11.0, 12.0]
|
||
assert row["humidity"] == [50.0, 51.0, 52.0]
|
||
|
||
|
||
def test_schema_evolution_merges_same_device(tsfile_path, evolved_tsfile_path):
|
||
"""Same device d1 in two files → one row, lists merged in time order."""
|
||
ds = load_dataset("tsfile", data_files=[tsfile_path, evolved_tsfile_path])["train"]
|
||
|
||
assert "voltage" in ds.column_names
|
||
assert len(ds) == 1
|
||
row = ds[0]
|
||
assert row["device"] == "d1"
|
||
# 5 (old) + 3 (new) points, fully time-ordered.
|
||
assert len(row["time"]) == 8
|
||
# Old file lacked `voltage` → null on its 5 points; new file fills the rest.
|
||
assert row["voltage"] == [None] * 5 + [220.0, 221.0, 222.0]
|
||
# `temperature` is present in both files, contiguous in the merged order.
|
||
assert row["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0, 30.0, 31.0, 32.0]
|
||
|
||
|
||
def test_multi_file_multi_device_partial_overlap(make_tsfile):
|
||
"""Two files × two devices each, with one device shared.
|
||
|
||
File A: devices {d1, d2}; file B: devices {d2, d3}. The merged dataset
|
||
must have 3 rows (one per unique device), and d2 must have *6* points
|
||
(3 from each file) sorted by time.
|
||
"""
|
||
fa = make_tsfile("a", _write_two_devices_subset, devices=["d1", "d2"], base_ts=T0)
|
||
fb = make_tsfile("b", _write_two_devices_subset, devices=["d2", "d3"], base_ts=T0 + 100_000)
|
||
|
||
ds = load_dataset("tsfile", data_files=[fa, fb])["train"]
|
||
by_dev = {row["device"]: row for row in ds}
|
||
assert set(by_dev) == {"d1", "d2", "d3"}
|
||
assert len(by_dev["d1"]["time"]) == 3
|
||
assert len(by_dev["d3"]["time"]) == 3
|
||
# Shared device gets all 6 points, time-sorted (file A first, then B).
|
||
assert len(by_dev["d2"]["time"]) == 6
|
||
assert by_dev["d2"]["v"] == [0.0, 1.0, 2.0, 0.0, 1.0, 2.0]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Type promotion across files
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_type_promotion_int32_to_int64(make_tsfile):
|
||
int32_path = make_tsfile("narrow", _write_numeric_field, TSDataType.INT32, T_INT32, lambda i: 10 + i)
|
||
int64_path = make_tsfile("wide", _write_numeric_field, TSDataType.INT64, T_INT64, lambda i: 1_000_000 + i)
|
||
|
||
ds = load_dataset("tsfile", data_files=[int32_path, int64_path])["train"]
|
||
assert len(ds) == 1
|
||
assert ds.features["temperature"].feature.dtype == "int64"
|
||
# int32 timestamps come earlier (T_INT32 < T_INT64).
|
||
assert ds[0]["temperature"] == [10, 11, 12, 1_000_000, 1_000_001, 1_000_002]
|
||
|
||
|
||
def test_type_promotion_float_to_double(make_tsfile):
|
||
float_path = make_tsfile("narrow", _write_numeric_field, TSDataType.FLOAT, T_FLOAT, lambda i: 1.5 + i)
|
||
double_path = make_tsfile("wide", _write_single_device)
|
||
|
||
ds = load_dataset("tsfile", data_files=[float_path, double_path])["train"]
|
||
assert len(ds) == 1
|
||
assert ds.features["temperature"].feature.dtype == "float64"
|
||
# double fixture lives at T0..T0+4s; float at T_FLOAT (later).
|
||
assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0, 1.5, 2.5, 3.5]
|
||
|
||
|
||
def test_type_promotion_int32_to_double(make_tsfile):
|
||
int32_path = make_tsfile("int", _write_numeric_field, TSDataType.INT32, T_INT32, lambda i: 10 + i)
|
||
double_path = make_tsfile("double", _write_single_device)
|
||
|
||
ds = load_dataset("tsfile", data_files=[int32_path, double_path])["train"]
|
||
assert len(ds) == 1
|
||
# INT32 + DOUBLE → DOUBLE (two-step widening).
|
||
assert ds.features["temperature"].feature.dtype == "float64"
|
||
assert ds[0]["temperature"] == [20.0, 21.0, 22.0, 23.0, 24.0, 10.0, 11.0, 12.0]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# All-types
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_load_all_supported_types(all_types_tsfile_path):
|
||
ds = load_dataset("tsfile", data_files=all_types_tsfile_path)["train"]
|
||
|
||
assert len(ds) == 1
|
||
assert ds.column_names == [
|
||
"tag",
|
||
"time",
|
||
"col_boolean",
|
||
"col_int32",
|
||
"col_int64",
|
||
"col_float",
|
||
"col_double",
|
||
"col_text",
|
||
"col_string",
|
||
"col_timestamp",
|
||
"col_date",
|
||
"col_blob",
|
||
]
|
||
row = ds[0]
|
||
assert row["tag"] == "d1"
|
||
assert row["col_boolean"] == [True, False, True]
|
||
assert row["col_int32"] == [100, 101, 102]
|
||
assert row["col_int64"] == [1_000_000, 1_000_001, 1_000_002]
|
||
assert row["col_float"] == [1.5, 2.5, 3.5]
|
||
assert row["col_double"] == [100.5, 101.5, 102.5]
|
||
assert row["col_text"] == ["text_0", "text_1", "text_2"]
|
||
assert row["col_string"] == ["str_0", "str_1", "str_2"]
|
||
assert row["col_timestamp"][0] == datetime(2020, 9, 13, 12, 26, 40)
|
||
assert row["col_date"][0] == date(2024, 1, 1)
|
||
assert row["col_date"][2] == date(2024, 1, 3)
|
||
assert row["col_blob"][0] == b"blob0"
|
||
assert row["col_blob"][2] == b"blob2"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Multi-table file: explicit `table_name` selection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_default_table_is_first(two_tables_tsfile_path):
|
||
ds = load_dataset("tsfile", data_files=two_tables_tsfile_path)["train"]
|
||
# `table_a` registered first → default pick.
|
||
assert "a" in ds.column_names
|
||
assert "b" not in ds.column_names
|
||
|
||
|
||
def test_explicit_table_name(two_tables_tsfile_path):
|
||
ds = load_dataset("tsfile", data_files=two_tables_tsfile_path, table_name="table_b")["train"]
|
||
assert "b" in ds.column_names
|
||
assert "a" not in ds.column_names
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Streaming (IterableDataset)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_streaming_yields_same_rows(multi_device_tsfile_path):
|
||
ds = load_dataset("tsfile", data_files=multi_device_tsfile_path, streaming=True)["train"]
|
||
assert isinstance(ds, IterableDataset)
|
||
rows = list(ds)
|
||
assert len(rows) == 3
|
||
assert sorted(r["device"] for r in rows) == ["d1", "d2", "d3"]
|
||
for r in rows:
|
||
assert r["temperature"] == [10.0, 11.0, 12.0]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Timezone
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_load_with_timezone(make_tsfile):
|
||
"""`timestamp_tz="UTC"` round-trips: list values come back tz-aware."""
|
||
path = make_tsfile("tz", _write_single_device)
|
||
ds = load_dataset("tsfile", data_files=path, timestamp_tz="UTC")["train"]
|
||
ts = ds[0]["time"][0]
|
||
assert ts.tzinfo is not None
|
||
# Same wall-clock as the naive case, attached to UTC.
|
||
assert ts == datetime(2023, 11, 14, 22, 13, 20, tzinfo=timezone.utc)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Large-batch / multi-chunk concat
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_large_device_with_small_batch_size(make_tsfile):
|
||
"""Force multiple Arrow batches per device → exercise the concat path."""
|
||
path = make_tsfile("big", _write_large_device, n_points=200)
|
||
ds = load_dataset("tsfile", data_files=path, input_batch_size=64)["train"]
|
||
assert len(ds) == 1
|
||
row = ds[0]
|
||
assert len(row["time"]) == 200
|
||
assert row["v"] == list(range(200))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Duplicate-timestamp detection (cross-file)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_duplicate_timestamp_across_files_raises(make_tsfile):
|
||
"""Same device, same ts in two files → `_finalize_device` must raise."""
|
||
cols = [
|
||
("time", TSDataType.TIMESTAMP, ColumnCategory.TIME),
|
||
("device", TSDataType.STRING, ColumnCategory.TAG),
|
||
("v", TSDataType.DOUBLE, ColumnCategory.FIELD),
|
||
]
|
||
rows = [{"time": 5_000, "device": "d1", "v": 1.0}]
|
||
|
||
a = make_tsfile("dupA", lambda p: _write_tsfile(p, [("mytable", cols, [rows])]))
|
||
b = make_tsfile("dupB", lambda p: _write_tsfile(p, [("mytable", cols, [rows])]))
|
||
|
||
with pytest.raises(Exception) as excinfo:
|
||
load_dataset("tsfile", data_files=[a, b])
|
||
# The ValueError is wrapped by `_prepare_split_single` into a
|
||
# DatasetGenerationError; check the cause chain for the original message.
|
||
chain = [excinfo.value, *(_iter_causes(excinfo.value))]
|
||
assert any("Duplicate timestamp" in str(e) for e in chain)
|
||
|
||
|
||
def _iter_causes(exc: BaseException):
|
||
while exc.__cause__ is not None:
|
||
exc = exc.__cause__
|
||
yield exc
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Error handling
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_on_bad_files_skip(tmp_path, tsfile_path):
|
||
bad = tmp_path / "broken.tsfile"
|
||
bad.write_bytes(b"not a real tsfile")
|
||
|
||
ds = load_dataset(
|
||
"tsfile",
|
||
data_files=[tsfile_path, str(bad)],
|
||
on_bad_files="skip",
|
||
)["train"]
|
||
assert len(ds) == 1
|
||
assert len(ds[0]["time"]) == 5
|
||
|
||
|
||
def test_on_bad_files_warn(tmp_path, tsfile_path, caplog):
|
||
bad = tmp_path / "broken.tsfile"
|
||
bad.write_bytes(b"not a real tsfile")
|
||
|
||
with caplog.at_level(logging.WARNING, logger="datasets.packaged_modules.tsfile.tsfile"):
|
||
ds = load_dataset(
|
||
"tsfile",
|
||
data_files=[tsfile_path, str(bad)],
|
||
on_bad_files="warn",
|
||
)["train"]
|
||
assert len(ds) == 1
|
||
assert any("Skipping bad file" in rec.message for rec in caplog.records)
|
||
|
||
|
||
def test_on_bad_files_default_raises(tmp_path, tsfile_path):
|
||
bad = tmp_path / "broken.tsfile"
|
||
bad.write_bytes(b"not a real tsfile")
|
||
|
||
with pytest.raises(Exception) as excinfo:
|
||
load_dataset("tsfile", data_files=[tsfile_path, str(bad)])
|
||
chain = [excinfo.value, *(_iter_causes(excinfo.value))]
|
||
assert any("not a valid TsFile" in str(e) for e in chain)
|