blob: 12b847e9eb3ddfc7eba6316eb25e49afad7e0b42 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
import numpy as np
import pandas as pd
import pytest
from tsfile.dataset import dataframe as dataframe_module
from tsfile import (
ColumnCategory,
ColumnSchema,
TSDataType,
TableSchema,
TsFileTableWriter,
)
from tsfile import AlignedTimeseries, SeriesPath, Timeseries, TsFileDataFrame
from tsfile.dataset.formatting import format_timestamp
from tsfile.dataset.metadata import (
MetadataCatalog,
SeriesStats,
build_series_path,
resolve_series_path,
)
from tsfile.dataset.reader import (
MODEL_TABLE,
TsFileSeriesReader,
_build_exact_tag_filter,
)
def _write_weather_file(path, start):
schema = TableSchema(
"weather",
[
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
df = pd.DataFrame(
{
"time": [start, start + 1, start + 2],
"device": ["device_a", "device_a", "device_a"],
"temperature": [20.0, 21.5, 23.0],
"humidity": [50.0, 52.0, 55.0],
}
)
with TsFileTableWriter(str(path), schema) as writer:
writer.write_dataframe(df)
def _write_weather_rows_file(path, rows):
schema = TableSchema(
"weather",
[
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
df = pd.DataFrame(rows)
with TsFileTableWriter(str(path), schema) as writer:
writer.write_dataframe(df)
def _write_empty_weather_file(path):
schema = TableSchema(
"weather",
[
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
with TsFileTableWriter(str(path), schema):
pass
def _write_numeric_and_text_file(path):
schema = TableSchema(
"weather",
[
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("status", TSDataType.STRING, ColumnCategory.FIELD),
],
)
df = pd.DataFrame(
{
"time": [0, 1, 2],
"device": ["device_a", "device_a", "device_a"],
"temperature": [20.0, np.nan, 23.5],
"status": ["ok", "warn", "ok"],
}
)
with TsFileTableWriter(str(path), schema) as writer:
writer.write_dataframe(df)
def _write_partial_numeric_rows_file(path):
schema = TableSchema(
"weather",
[
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
df = pd.DataFrame(
{
"time": [0, 1],
"device": ["device_a", "device_a"],
"temperature": [np.nan, 21.0],
"humidity": [50.0, 51.0],
}
)
with TsFileTableWriter(str(path), schema) as writer:
writer.write_dataframe(df)
def _write_weather_with_extra_field_file(path, start):
schema = TableSchema(
"weather",
[
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("pressure", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
df = pd.DataFrame(
{
"time": [start, start + 1],
"device": ["device_a", "device_a"],
"temperature": [20.0, 21.0],
"humidity": [50.0, 51.0],
"pressure": [1000.0, 1001.0],
}
)
with TsFileTableWriter(str(path), schema) as writer:
writer.write_dataframe(df)
def _write_multi_tag_file(path):
schema = TableSchema(
"weather",
[
ColumnSchema("city", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("humidity", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("status", TSDataType.STRING, ColumnCategory.FIELD),
],
)
df = pd.DataFrame(
{
"time": [0, 1, 0, 1],
"city": ["beijing", "beijing", "shanghai", "shanghai"],
"device": ["device_a", "device_a", "device_b", "device_b"],
"temperature": [20.0, 21.0, 24.0, 25.0],
"humidity": [50.0, 51.0, 60.0, 61.0],
"status": ["ok", "ok", "warn", "warn"],
}
)
with TsFileTableWriter(str(path), schema) as writer:
writer.write_dataframe(df)
def _write_special_tag_file(path):
schema = TableSchema(
"weather",
[
ColumnSchema("city", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
df = pd.DataFrame(
{
"time": [0, 1],
"city": ["bei.jing", "bei.jing"],
"device": [r"dev\1", r"dev\1"],
"temperature": [20.0, 21.0],
}
)
with TsFileTableWriter(str(path), schema) as writer:
writer.write_dataframe(df)
def test_dataset_top_level_imports():
assert TsFileDataFrame.__module__ == "tsfile.dataset.dataframe"
assert Timeseries.__module__ == "tsfile.dataset.timeseries"
assert AlignedTimeseries.__module__ == "tsfile.dataset.timeseries"
def test_format_timestamp_preserves_millisecond_precision():
assert "." not in format_timestamp(1000)
assert format_timestamp(1).endswith(".001")
def test_dataset_basic_access_patterns(tmp_path, capsys):
path1 = tmp_path / "part1.tsfile"
path2 = tmp_path / "part2.tsfile"
_write_weather_file(path1, 0)
_write_weather_file(path2, 3)
with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as tsdf:
assert len(tsdf) == 2
first = tsdf[0]
assert isinstance(first, Timeseries)
assert first.name in tsdf.list_timeseries()
assert len(first) == 6
assert first[0] == 20.0
assert first[-1] == 23.0
assert "Timeseries(" in repr(first)
by_name = tsdf[first.name]
assert isinstance(by_name, Timeseries)
assert by_name.name == first.name
subset = tsdf[:1]
assert isinstance(subset, TsFileDataFrame)
assert len(subset) == 1
selected = tsdf[[0, 1]]
assert isinstance(selected, TsFileDataFrame)
assert len(selected) == 2
aligned = tsdf.loc[0:5, [0, 1]]
assert isinstance(aligned, AlignedTimeseries)
assert aligned.shape == (6, 2)
aligned_negative = tsdf.loc[0:5, [-1]]
assert isinstance(aligned_negative, AlignedTimeseries)
assert aligned_negative.shape == (6, 1)
assert list(tsdf["field"]) == ["temperature", "humidity"]
assert "TsFileDataFrame(table model, 2 time series, 2 files)" in repr(tsdf)
aligned.show(2)
assert "AlignedTimeseries(6 rows, 2 series)" in capsys.readouterr().out
def test_dataset_loc_aligns_timestamp_union_and_preserves_requested_order(tmp_path):
path = tmp_path / "weather_sparse.tsfile"
_write_weather_rows_file(
path,
{
"time": [0, 1, 2],
"device": ["device_a", "device_a", "device_a"],
"temperature": [10.0, np.nan, 30.0],
"humidity": [np.nan, 200.0, 300.0],
},
)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
aligned = tsdf.loc[
0:2,
[
"weather.device_a.humidity",
"weather.device_a.temperature",
],
]
assert isinstance(aligned, AlignedTimeseries)
assert aligned.series_names == [
"weather.device_a.humidity",
"weather.device_a.temperature",
]
np.testing.assert_array_equal(
aligned.timestamps, np.array([0, 1, 2], dtype=np.int64)
)
assert aligned.shape == (3, 2)
assert np.isnan(aligned.values[0, 0])
assert aligned.values[0, 1] == 10.0
assert aligned.values[1, 0] == 200.0
assert np.isnan(aligned.values[1, 1])
assert aligned.values[2, 0] == 300.0
assert aligned.values[2, 1] == 30.0
def test_dataset_reads_nullable_tag_devices_in_isolation(tmp_path):
path = tmp_path / "nullable_tags.tsfile"
schema = TableSchema(
"sensors",
[
ColumnSchema("region", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("temperature", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
# Non-trailing null: region IS NULL, device='alpha'.
null_region = pd.DataFrame(
{
"time": [0, 1, 2],
"region": [None, None, None],
"device": ["alpha", "alpha", "alpha"],
"temperature": [10.0, 11.0, 12.0],
}
)
# Trailing null: region='north', device IS NULL. Shares the region prefix
# with the fully specified device below to exercise device isolation.
null_device = pd.DataFrame(
{
"time": [0, 1, 2],
"region": ["north", "north", "north"],
"device": [None, None, None],
"temperature": [20.0, 21.0, 22.0],
}
)
full = pd.DataFrame(
{
"time": [0, 1, 2],
"region": ["north", "north", "north"],
"device": ["beta", "beta", "beta"],
"temperature": [30.0, 31.0, 32.0],
}
)
with TsFileTableWriter(str(path), schema) as writer:
writer.write_dataframe(null_region)
writer.write_dataframe(null_device)
writer.write_dataframe(full)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
series = tsdf.list_timeseries()
# Null tags keep their position via the \N marker; trailing nulls drop.
assert set(series) == {
"sensors.\\N.alpha.temperature",
"sensors.north.temperature",
"sensors.north.beta.temperature",
}
# list_timeseries returns SeriesPath objects carrying structured tags.
by_tags = {sp.tags: sp for sp in series}
assert (None, "alpha") in by_tags
assert ("north",) in by_tags
assert ("north", "beta") in by_tags
ordered = sorted(series)
aligned = tsdf.loc[:, ordered]
by_name = dict(zip(aligned.series_names, aligned.values.T))
# Non-trailing null device reads its own data (previously crashed / NaN).
np.testing.assert_array_equal(
by_name["sensors.\\N.alpha.temperature"], np.array([10.0, 11.0, 12.0])
)
# Trailing-null device must NOT merge with the fully specified north.beta.
np.testing.assert_array_equal(
by_name["sensors.north.temperature"], np.array([20.0, 21.0, 22.0])
)
np.testing.assert_array_equal(
by_name["sensors.north.beta.temperature"], np.array([30.0, 31.0, 32.0])
)
def test_series_path_object_roundtrip_and_escaping():
from tsfile.dataset.metadata import split_logical_series_path
sp = SeriesPath("tbl", ("a.b", None, "x"), "f")
assert isinstance(sp, str)
assert sp.table == "tbl"
assert sp.tags == ("a.b", None, "x")
assert sp.field == "f"
# A dot in a value is escaped; a null tag uses the collision-proof \N marker.
assert str(sp) == "tbl.a\\.b.\\N.x.f"
# Splitting round-trips: the escaped dot stays in the value, \N decodes to None.
assert split_logical_series_path(str(sp)) == ["tbl", "a.b", None, "x", "f"]
# Trailing nulls are dropped (mirroring device-id normalization).
assert SeriesPath("tbl", ("a", None), "f").tags == ("a",)
assert str(SeriesPath("tbl", ("a", None), "f")) == "tbl.a.f"
def test_series_path_construction_forms_are_equivalent():
explicit = SeriesPath("tbl", (None, "sensorA"), "temperature")
flat = SeriesPath(["tbl", None, "sensorA", "temperature"]) # [table, *tags, field]
from_string = SeriesPath("tbl.\\N.sensorA.temperature")
for sp in (explicit, flat, from_string):
assert sp == "tbl.\\N.sensorA.temperature"
assert sp.table == "tbl"
assert sp.tags == (None, "sensorA")
assert sp.field == "temperature"
# A no-tag table is just [table, field].
assert SeriesPath(["tbl", "f"]).tags == ()
with pytest.raises(ValueError):
SeriesPath(["tbl"])
def test_split_logical_series_path_null_marker_only_whole_component():
from tsfile.dataset.metadata import split_logical_series_path
# \N is a null tag only as a complete component.
assert split_logical_series_path("a.\\N.b.f") == ["a", None, "b", "f"]
assert split_logical_series_path("a.\\N.\\N.f") == ["a", None, None, "f"]
# A real value "\N" (doubled backslash) stays a string, never null.
assert split_logical_series_path("a.\\\\N.b.f") == ["a", "\\N", "b", "f"]
# \N mixed with other characters is invalid input and fails fast, instead of
# being silently parsed as a null tag (which could resolve the wrong device).
for bad in (
"tbl.a\\N.b.f", # characters before the marker
"tbl.\\Nfoo.x.f", # characters after the marker
"a.\\N\\N.f", # two markers in one component
"a.\\N\\.b.f", # an escape after the marker
):
with pytest.raises(ValueError, match="Invalid series path"):
split_logical_series_path(bad)
def test_dataset_null_tag_positions_and_string_null_are_distinct(tmp_path):
path = tmp_path / "null_positions.tsfile"
schema = TableSchema(
"a",
[
ColumnSchema("t1", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("t2", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("t3", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("v", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
rows = {
(None, "b", "c"): 10.0, # null at position 1
("b", None, "c"): 20.0, # null at position 2 (distinct from the above)
("null", "b", "c"): 30.0, # the literal string "null", not a real null
}
with TsFileTableWriter(str(path), schema) as writer:
for tags, base in rows.items():
writer.write_dataframe(
pd.DataFrame(
{
"time": [0, 1],
"t1": [tags[0], tags[0]],
"t2": [tags[1], tags[1]],
"t3": [tags[2], tags[2]],
"v": [base, base + 1],
}
)
)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
series = tsdf.list_timeseries()
# Nothing collapses: three physically distinct devices stay distinct.
assert len(series) == 3
by_tags = {sp.tags: sp for sp in series}
assert (None, "b", "c") in by_tags # null position 1
assert ("b", None, "c") in by_tags # null position 2
assert ("null", "b", "c") in by_tags # the string "null"
# Each device reads its own data via SeriesPath and via the \N string form.
for tags, base in rows.items():
sp = by_tags[tags]
np.testing.assert_array_equal(
tsdf.loc[:, sp].values.ravel(), np.array([base, base + 1])
)
np.testing.assert_array_equal(
tsdf.loc[:, str(sp)].values.ravel(), np.array([base, base + 1])
)
# A hand-built SeriesPath resolves to the same null-tag device.
hand = SeriesPath("a", (None, "b", "c"), "v")
np.testing.assert_array_equal(
tsdf.loc[:, hand].values.ravel(), np.array([10.0, 11.0])
)
def test_dataset_loc_supports_single_timestamp_and_mixed_series_specifiers(tmp_path):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
aligned = tsdf.loc[1, [0, "weather.device_a.humidity"]]
assert isinstance(aligned, AlignedTimeseries)
assert aligned.series_names == [
"weather.device_a.temperature",
"weather.device_a.humidity",
]
np.testing.assert_array_equal(aligned.timestamps, np.array([1], dtype=np.int64))
np.testing.assert_array_equal(aligned.values, np.array([[21.5, 52.0]]))
def test_dataset_loc_dedups_repeated_series_specifiers(tmp_path):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
humidity = "weather.device_a.humidity"
humidity_idx = tsdf.list_timeseries().index(humidity)
# 1) name + matching idx pointing at the same series.
aligned_two_dup = tsdf.loc[0:2, [humidity, humidity_idx]]
assert aligned_two_dup.shape == (3, 2)
np.testing.assert_array_equal(
aligned_two_dup.timestamps, np.array([0, 1, 2], dtype=np.int64)
)
np.testing.assert_array_equal(
aligned_two_dup.values,
np.array([[50.0, 50.0], [52.0, 52.0], [55.0, 55.0]]),
)
# 2) same name twice -- single-group, single-key dedup path.
aligned_name_twice = tsdf.loc[0:2, [humidity, humidity]]
assert aligned_name_twice.shape == (3, 2)
np.testing.assert_array_equal(
aligned_name_twice.values,
np.array([[50.0, 50.0], [52.0, 52.0], [55.0, 55.0]]),
)
# 3) duplicate among other distinct series must not regress the
# historically-passing case either.
aligned_mixed = tsdf.loc[
0:2, [humidity, "weather.device_a.temperature", humidity_idx]
]
assert aligned_mixed.shape == (3, 3)
np.testing.assert_array_equal(
aligned_mixed.timestamps, np.array([0, 1, 2], dtype=np.int64)
)
np.testing.assert_array_equal(
aligned_mixed.values,
np.array(
[
[50.0, 20.0, 50.0],
[52.0, 21.5, 52.0],
[55.0, 23.0, 55.0],
]
),
)
def test_dataset_loc_supports_open_ended_ranges_and_negative_series_index(tmp_path):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 100)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
aligned = tsdf.loc[:101, [-1]]
assert isinstance(aligned, AlignedTimeseries)
assert aligned.series_names == ["weather.device_a.humidity"]
np.testing.assert_array_equal(
aligned.timestamps, np.array([100, 101], dtype=np.int64)
)
np.testing.assert_array_equal(aligned.values, np.array([[50.0], [52.0]]))
def test_dataset_loc_with_nulls_does_not_expand_beyond_requested_time_range(tmp_path):
path = tmp_path / "weather_sparse_range.tsfile"
_write_weather_rows_file(
path,
{
"time": [0, 1, 2, 3],
"device": ["device_a", "device_a", "device_a", "device_a"],
"temperature": [10.0, np.nan, np.nan, 40.0],
"humidity": [np.nan, 20.0, np.nan, 50.0],
},
)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
aligned = tsdf.loc[
1:2,
[
"weather.device_a.temperature",
"weather.device_a.humidity",
],
]
assert isinstance(aligned, AlignedTimeseries)
np.testing.assert_array_equal(
aligned.timestamps, np.array([1, 2], dtype=np.int64)
)
assert aligned.shape == (2, 2)
assert np.isnan(aligned.values[0, 0])
assert aligned.values[0, 1] == 20.0
assert np.isnan(aligned.values[1, 0])
assert np.isnan(aligned.values[1, 1])
def test_dataset_loc_single_timestamp_with_nulls_keeps_exact_time_window(tmp_path):
path = tmp_path / "weather_sparse_point.tsfile"
_write_weather_rows_file(
path,
{
"time": [0, 1, 2],
"device": ["device_a", "device_a", "device_a"],
"temperature": [10.0, np.nan, 30.0],
"humidity": [np.nan, 20.0, 40.0],
},
)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
aligned = tsdf.loc[
1,
[
"weather.device_a.temperature",
"weather.device_a.humidity",
],
]
assert isinstance(aligned, AlignedTimeseries)
np.testing.assert_array_equal(aligned.timestamps, np.array([1], dtype=np.int64))
assert aligned.shape == (1, 2)
assert np.isnan(aligned.values[0, 0])
assert aligned.values[0, 1] == 20.0
def test_dataset_repr_only_builds_preview_rows(tmp_path, monkeypatch):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
tsdf._index.series = [(0, 0)] * 1000
built_rows = []
def fake_build_series_info(series_ref):
built_rows.append(series_ref)
return {
"table_name": "weather",
"field": "temperature",
"tag_columns": ("device",),
"tag_values": {"device": "device_a"},
"min_time": 0,
"max_time": 2,
"count": 3,
}
def fail_build_series_name(_series_ref):
raise AssertionError(
"__repr__ should not build full series names for preview output"
)
monkeypatch.setattr(tsdf, "_build_series_info", fake_build_series_info)
monkeypatch.setattr(tsdf, "_build_series_name", fail_build_series_name)
rendered = repr(tsdf)
assert "TsFileDataFrame(table model, 1000 time series, 1 files)" in rendered
assert "..." in rendered
assert len(built_rows) == 20
def test_dataset_exposes_only_numeric_fields_and_keeps_nan(tmp_path):
path = tmp_path / "numeric_and_text.tsfile"
_write_numeric_and_text_file(path)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
assert tsdf.list_timeseries() == ["weather.device_a.temperature"]
series = tsdf[0]
assert series.name == "weather.device_a.temperature"
assert len(series) == 3
assert series.stats == {"start_time": 0, "end_time": 2, "count": 3}
assert np.isnan(series[1])
np.testing.assert_array_equal(
series.timestamps, np.array([0, 1, 2], dtype=np.int64)
)
sliced = series[:]
assert sliced.shape == (3,)
assert np.isnan(sliced[1])
assert sliced[2] == 23.5
assert series[1:1].shape == (0,)
def test_dataset_omits_table_model_phantom_series_for_skipped_cells(tmp_path):
"""Schema-declared fields that a device never wrote must NOT appear.
The dataset surface treats a series as "data physically written for one
(device, field) pair". A Tablet that skips ``add_value_by_name`` for a
column produces a chunk with ``length=0``; that cell is not a real series
and must not be exposed via ``list_timeseries`` / ``len(tsdf)`` /
``series_shards`` -- table-model and tree-model behave identically here.
"""
from tsfile import Tablet
path = tmp_path / "sparse_table.tsfile"
schema = TableSchema(
"bench",
[
ColumnSchema("device", TSDataType.STRING, ColumnCategory.TAG),
ColumnSchema("v1", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("v2", TSDataType.DOUBLE, ColumnCategory.FIELD),
ColumnSchema("v3", TSDataType.DOUBLE, ColumnCategory.FIELD),
],
)
with TsFileTableWriter(str(path), schema) as writer:
# d1: writes only v1 and v2 (skip v3)
t1 = Tablet(
["device", "v1", "v2", "v3"],
[
TSDataType.STRING,
TSDataType.DOUBLE,
TSDataType.DOUBLE,
TSDataType.DOUBLE,
],
1,
)
t1.add_timestamp(0, 1)
t1.add_value_by_name("device", 0, "d1")
t1.add_value_by_name("v1", 0, 100.0)
t1.add_value_by_name("v2", 0, 200.0)
writer.write_table(t1)
# d2: writes only v1 and v3 (skip v2)
t2 = Tablet(
["device", "v1", "v2", "v3"],
[
TSDataType.STRING,
TSDataType.DOUBLE,
TSDataType.DOUBLE,
TSDataType.DOUBLE,
],
1,
)
t2.add_timestamp(0, 2)
t2.add_value_by_name("device", 0, "d2")
t2.add_value_by_name("v1", 0, 110.0)
t2.add_value_by_name("v3", 0, 330.0)
writer.write_table(t2)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
# 4 real cells: (d1,v1), (d1,v2), (d2,v1), (d2,v3); NO phantoms.
assert len(tsdf) == 4
assert sorted(tsdf.list_timeseries()) == [
"bench.d1.v1",
"bench.d1.v2",
"bench.d2.v1",
"bench.d2.v3",
]
with pytest.raises(KeyError):
tsdf["bench.d1.v3"]
with pytest.raises(KeyError):
tsdf["bench.d2.v2"]
# series_count must report the 4 physically-present series, not the 2x3
# schema cross-product -- regression guard for the sparse-schema case
# (catalog and reader must agree).
reader = TsFileSeriesReader(str(path), show_progress=False)
try:
assert reader.series_count == 4
assert reader.catalog.series_count == 4
finally:
reader.close()
def test_dataset_timeseries_supports_negative_step_slices(tmp_path):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
series = tsdf[0]
np.testing.assert_array_equal(series[::-1], np.array([23.0, 21.5, 20.0]))
np.testing.assert_array_equal(series[::-2], np.array([23.0, 20.0]))
def test_dataset_metadata_discovery_uses_all_numeric_fields(tmp_path):
path = tmp_path / "partial_numeric_rows.tsfile"
_write_partial_numeric_rows_file(path)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
assert tsdf.list_timeseries() == [
"weather.device_a.temperature",
"weather.device_a.humidity",
]
assert list(tsdf["count"]) == [2, 2]
assert list(tsdf["start_time"]) == [0, 0]
assert list(tsdf["end_time"]) == [1, 1]
def test_dataset_rejects_duplicate_timestamps_across_shards(tmp_path):
path1 = tmp_path / "part1.tsfile"
path2 = tmp_path / "part2.tsfile"
_write_weather_file(path1, 0)
_write_weather_file(path2, 2)
with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as tsdf:
series = tsdf["weather.device_a.temperature"]
with pytest.raises(ValueError, match="Duplicate timestamp"):
_ = series.timestamps
def test_dataset_overlap_position_access_avoids_full_timestamp_materialization(
tmp_path, monkeypatch
):
path1 = tmp_path / "part1.tsfile"
path2 = tmp_path / "part2.tsfile"
_write_weather_rows_file(
path1,
{
"time": [0, 2, 4],
"device": ["device_a", "device_a", "device_a"],
"temperature": [10.0, 30.0, 50.0],
"humidity": [100.0, 300.0, 500.0],
},
)
_write_weather_rows_file(
path2,
{
"time": [1, 3, 5],
"device": ["device_a", "device_a", "device_a"],
"temperature": [20.0, 40.0, 60.0],
"humidity": [200.0, 400.0, 600.0],
},
)
def fail_merge(*_args, **_kwargs):
raise AssertionError(
"full timestamp merge should not run for overlap position reads"
)
monkeypatch.setattr(dataframe_module, "_merge_field_timestamps", fail_merge)
with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as tsdf:
series = tsdf["weather.device_a.temperature"]
assert series[0] == 10.0
assert series[1] == 20.0
assert series[4] == 50.0
np.testing.assert_array_equal(series[1:5], np.array([20.0, 30.0, 40.0, 50.0]))
def test_dataset_rejects_data_access_after_close(tmp_path):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
tsdf = TsFileDataFrame(str(path), show_progress=False)
series = tsdf[0]
tsdf.close()
with pytest.raises(RuntimeError, match="TsFileDataFrame is closed"):
_ = tsdf[0]
with pytest.raises(RuntimeError, match="TsFileDataFrame is closed"):
_ = series[0]
def test_subset_close_warns_and_does_not_close_root(tmp_path):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
subset = tsdf[:1]
with pytest.warns(RuntimeWarning, match="no-op"):
subset.close()
series = tsdf[0]
assert series[0] == 20.0
def test_dataset_rejects_incompatible_table_schemas_across_shards(tmp_path):
path1 = tmp_path / "part1.tsfile"
path2 = tmp_path / "part2.tsfile"
_write_weather_file(path1, 0)
_write_weather_with_extra_field_file(path2, 2)
with pytest.raises(ValueError, match="Incompatible schema for table 'weather'"):
TsFileDataFrame([str(path1), str(path2)], show_progress=False)
def test_dataset_skips_empty_tsfile_shards(tmp_path):
empty_path = tmp_path / "empty.tsfile"
data_path = tmp_path / "part.tsfile"
_write_empty_weather_file(empty_path)
_write_weather_file(data_path, 0)
with TsFileDataFrame(
[str(empty_path), str(data_path)], show_progress=False
) as tsdf:
assert tsdf.list_timeseries() == [
"weather.device_a.temperature",
"weather.device_a.humidity",
]
def test_reader_allows_empty_tsfile(tmp_path):
path = tmp_path / "empty.tsfile"
_write_empty_weather_file(path)
reader = TsFileSeriesReader(str(path), show_progress=False)
try:
assert reader.series_paths == []
assert reader.catalog.series_count == 0
finally:
reader.close()
def test_dataset_multi_tag_metadata_discovery(tmp_path):
path = tmp_path / "multi_tag.tsfile"
_write_multi_tag_file(path)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
assert tsdf.list_timeseries() == [
"weather.beijing.device_a.temperature",
"weather.beijing.device_a.humidity",
"weather.shanghai.device_b.temperature",
"weather.shanghai.device_b.humidity",
]
summary = (
pd.DataFrame(
{
"series_path": tsdf.list_timeseries(),
"table": tsdf["table"],
"city": tsdf["city"],
"device": tsdf["device"],
"field": tsdf["field"],
"start_time": tsdf["start_time"],
"end_time": tsdf["end_time"],
"count": tsdf["count"],
}
)
.sort_values(["city", "device", "field"])
.reset_index(drop=True)
)
assert list(summary.columns) == [
"series_path",
"table",
"city",
"device",
"field",
"start_time",
"end_time",
"count",
]
assert list(summary["city"]) == ["beijing", "beijing", "shanghai", "shanghai"]
assert list(summary["device"]) == [
"device_a",
"device_a",
"device_b",
"device_b",
]
assert list(summary["field"]) == [
"humidity",
"temperature",
"humidity",
"temperature",
]
assert list(summary["count"]) == [2, 2, 2, 2]
def test_dataset_series_paths_escape_special_tag_values(tmp_path):
path = tmp_path / "special_tag.tsfile"
_write_special_tag_file(path)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
expected_path = r"weather.bei\.jing.dev\\1.temperature"
assert tsdf.list_timeseries() == [expected_path]
series = tsdf[expected_path]
assert isinstance(series, Timeseries)
assert series.name == expected_path
assert list(tsdf["city"]) == ["bei.jing"]
assert list(tsdf["device"]) == [r"dev\1"]
def test_reader_series_paths_escape_special_tag_values(tmp_path):
path = tmp_path / "special_tag.tsfile"
_write_special_tag_file(path)
reader = TsFileSeriesReader(str(path), show_progress=False)
try:
expected_path = r"weather.bei\.jing.dev\\1.temperature"
assert reader.series_paths == [expected_path]
info = reader.get_series_info(expected_path)
assert info["tag_values"] == {"city": "bei.jing", "device": r"dev\1"}
finally:
reader.close()
def test_reader_catalog_shares_device_metadata_and_resolves_paths(tmp_path):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 100)
reader = TsFileSeriesReader(str(path), show_progress=False)
try:
assert reader.series_paths == [
"weather.device_a.temperature",
"weather.device_a.humidity",
]
assert len(reader.catalog.table_entries) == 1
assert len(reader.catalog.device_entries) == 1
assert reader.catalog.series_count == 2
by_path = reader.get_series_info("weather.device_a.temperature")
by_ref = reader.get_series_info_by_ref(0, 0)
assert by_ref == by_path
assert by_ref["tag_values"] == {"device": "device_a"}
ts_arr, values = reader.read_series_by_ref(0, 0, 100, 102)
np.testing.assert_array_equal(ts_arr, np.array([100, 101, 102]))
np.testing.assert_array_equal(values, np.array([20.0, 21.5, 23.0]))
finally:
reader.close()
def test_reader_read_series_by_row_retries_across_native_row_query_boundaries():
"""read_series_by_row pulls TsBlocks via read_arrow_batch and must keep
re-issuing query_table_by_row when the underlying native call stops at
an internal block boundary before the caller's window is filled."""
import pyarrow as pa
class _FakeResultSet:
def __init__(self, times, values):
self._batch = pa.table(
{
"time": pa.array(times, type=pa.int64()),
"totalcloudcover": pa.array(values, type=pa.float64()),
}
)
self._delivered = False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
return False
def read_arrow_batch(self):
if self._delivered or self._batch.num_rows == 0:
return None
self._delivered = True
return self._batch
class _FakeNativeReader:
def __init__(self, timestamps, values, boundary):
self._timestamps = timestamps
self._values = values
self._boundary = boundary
def query_table_by_row(
self,
table_name,
column_names,
offset=0,
limit=-1,
tag_filter=None,
batch_size=0,
):
assert table_name == "pvf"
assert column_names == ["totalcloudcover"]
assert tag_filter is None
assert batch_size > 0, "row reads should use batch (Arrow) mode"
if limit < 0:
stop = len(self._timestamps)
else:
stop = min(offset + limit, len(self._timestamps))
# Simulate the native quirk where one query stops at the next
# internal block boundary; callers must re-issue from the
# advanced offset to complete a large logical window.
chunk_stop = min(stop, ((offset // self._boundary) + 1) * self._boundary)
return _FakeResultSet(
self._timestamps[offset:chunk_stop],
self._values[offset:chunk_stop],
)
reader = object.__new__(TsFileSeriesReader)
reader._model_kind = MODEL_TABLE
reader._reader = _FakeNativeReader(
np.arange(30, dtype=np.int64), np.arange(30, dtype=np.float64), boundary=10
)
reader._catalog = MetadataCatalog()
table_id = reader._catalog.add_table("pvf", (), (), ("totalcloudcover",))
device_id = reader._catalog.add_device(table_id, (), 0, 29)
ts_arr, values = reader.read_series_by_row(device_id, 0, 5, 12)
np.testing.assert_array_equal(ts_arr, np.arange(5, 17, dtype=np.int64))
np.testing.assert_array_equal(values, np.arange(5, 17, dtype=np.float64))
def test_series_path_resolution_allows_prefix_tag_values():
catalog = MetadataCatalog()
table_id = catalog.add_table(
"weather",
("site", "device", "region"),
(TSDataType.STRING, TSDataType.STRING, TSDataType.STRING),
("temperature",),
)
device_id = catalog.add_device(table_id, ("site_a", "device_a"), 0, 1)
catalog.series_stats_by_ref[(device_id, 0)] = SeriesStats(
length=1,
min_time=0,
max_time=0,
timeline_length=1,
timeline_min_time=0,
timeline_max_time=0,
)
series_path = build_series_path(catalog, device_id, 0)
assert series_path == "weather.site_a.device_a.temperature"
assert resolve_series_path(catalog, series_path) == (table_id, device_id, 0)
def test_series_path_resolution_allows_missing_trailing_tag_value():
catalog = MetadataCatalog()
table_id = catalog.add_table(
"weather",
("device",),
(TSDataType.STRING,),
("temperature",),
)
device_id = catalog.add_device(table_id, (), 0, 1)
catalog.series_stats_by_ref[(device_id, 0)] = SeriesStats(
length=1,
min_time=0,
max_time=0,
timeline_length=1,
timeline_min_time=0,
timeline_max_time=0,
)
series_path = build_series_path(catalog, device_id, 0)
assert series_path == "weather.temperature"
assert resolve_series_path(catalog, series_path) == (table_id, device_id, 0)
def test_series_path_resolution_uses_named_tags_for_sparse_non_prefix_values():
catalog = MetadataCatalog()
table_id = catalog.add_table(
"weather",
("city", "device", "region"),
(TSDataType.STRING, TSDataType.STRING, TSDataType.STRING),
("temperature",),
)
device_id = catalog.add_device(table_id, (None, "device_a", None), 0, 1)
catalog.series_stats_by_ref[(device_id, 0)] = SeriesStats(
length=1,
min_time=0,
max_time=0,
timeline_length=1,
timeline_min_time=0,
timeline_max_time=0,
)
series_path = build_series_path(catalog, device_id, 0)
# The leading null tag is preserved at its position via the \N marker.
assert series_path == "weather.\\N.device_a.temperature"
assert series_path.tags == (None, "device_a")
assert resolve_series_path(catalog, series_path) == (table_id, device_id, 0)
# The plain string form (with \N) round-trips to the same device.
assert resolve_series_path(catalog, str(series_path)) == (table_id, device_id, 0)
def test_resolve_series_path_rejects_wrong_tag_count():
catalog = MetadataCatalog()
table_id = catalog.add_table(
"weather",
("city", "device"),
(TSDataType.STRING, TSDataType.STRING),
("temperature",),
)
device_id = catalog.add_device(table_id, ("beijing", "d1"), 0, 1)
catalog.series_stats_by_ref[(device_id, 0)] = {
"length": 1,
"min_time": 0,
"max_time": 0,
"timeline_length": 1,
"timeline_min_time": 0,
"timeline_max_time": 0,
}
assert resolve_series_path(catalog, "weather.beijing.d1.temperature") == (
table_id,
device_id,
0,
)
# An extra tag must NOT be silently truncated into a match.
with pytest.raises(ValueError, match="Series not found"):
resolve_series_path(catalog, "weather.beijing.d1.extra.temperature")
# Too few tags has no matching device either.
with pytest.raises(ValueError, match="Series not found"):
resolve_series_path(catalog, "weather.beijing.temperature")
def test_reader_metadata_tag_values_trim_trailing_none():
class _Group:
segments = ("weather", "device_a", None, None)
assert TsFileSeriesReader._metadata_tag_values(_Group(), 3) == ("device_a",)
assert TsFileSeriesReader._metadata_tag_values(_Group(), 1) == ("device_a",)
def test_exact_tag_filter_uses_is_null_for_none_tag_values():
from tsfile.tag_filter import AndTagFilter, ComparisonTagFilter
only_null = _build_exact_tag_filter({"device": None})
assert isinstance(only_null, ComparisonTagFilter)
assert only_null.op == ComparisonTagFilter.IS_NULL
assert only_null.column_name == "device"
mixed = _build_exact_tag_filter({"city": "beijing", "device": None})
assert isinstance(mixed, AndTagFilter)
assert isinstance(mixed.left, ComparisonTagFilter)
assert mixed.left.op == ComparisonTagFilter.EQ
assert mixed.left.value == "beijing"
assert isinstance(mixed.right, ComparisonTagFilter)
assert mixed.right.op == ComparisonTagFilter.IS_NULL
assert mixed.right.column_name == "device"
def _tag_filter_has_is_null(tag_filter) -> bool:
from tsfile.tag_filter import ComparisonTagFilter
if isinstance(tag_filter, ComparisonTagFilter):
return tag_filter.op == ComparisonTagFilter.IS_NULL
for attr in ("left", "right", "filter"):
child = getattr(tag_filter, attr, None)
if child is not None and _tag_filter_has_is_null(child):
return True
return False
def test_reader_exact_match_with_none_tag_values_issues_is_null_query():
captured = {}
class _EmptyResultSet:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read_arrow_batch(self):
return None
def next(self):
return False
class _FakeNativeReader:
def query_table(self, *args, **kwargs):
captured["table"] = kwargs.get("tag_filter")
return _EmptyResultSet()
def query_table_by_row(self, *args, **kwargs):
captured["row"] = kwargs.get("tag_filter")
return _EmptyResultSet()
reader = object.__new__(TsFileSeriesReader)
reader._model_kind = MODEL_TABLE
reader._reader = _FakeNativeReader()
reader._catalog = MetadataCatalog()
table_id = reader._catalog.add_table(
"weather",
("city", "device", "region"),
(TSDataType.STRING, TSDataType.STRING, TSDataType.STRING),
("temperature",),
)
device_id = reader._catalog.add_device(table_id, (None, "device_a", "north"), 0, 1)
reader._catalog.series_stats_by_ref[(device_id, 0)] = SeriesStats(
length=2,
min_time=0,
max_time=1,
timeline_length=2,
timeline_min_time=0,
timeline_max_time=1,
)
# Both read paths now issue a native query that encodes the null tag as
# IS NULL instead of failing fast.
reader.read_series_by_ref(device_id, 0, 0, 1)
reader.read_series_by_row(device_id, 0, 0, 2)
assert _tag_filter_has_is_null(captured["table"])
assert _tag_filter_has_is_null(captured["row"])
def test_dataframe_resolves_named_sparse_tag_series_path():
tsdf = object.__new__(TsFileDataFrame)
tsdf._index = dataframe_module._DataFrameCatalog()
tsdf._index.table_entries["weather"] = dataframe_module.TableEntry(
table_name="weather",
tag_columns=("city", "device", "region"),
tag_types=(TSDataType.STRING, TSDataType.STRING, TSDataType.STRING),
field_columns=("temperature",),
)
device_key = ("weather", (None, "device_a"))
tsdf._index.devices = [device_key]
tsdf._index.device_index = {device_key: 0}
tsdf._index.device_time_bounds = [(0, 1)]
tsdf._index.series = [(0, 0)]
tsdf._index.series_shards = {(0, 0): []}
assert tsdf.list_timeseries() == ["weather.\\N.device_a.temperature"]
# Resolvable by the \N string form and by the returned SeriesPath itself.
assert tsdf._resolve_series_name("weather.\\N.device_a.temperature") == (0, 0)
assert tsdf._resolve_series_name(tsdf.list_timeseries()[0]) == (0, 0)
def test_dataframe_list_timeseries_filters_named_sparse_tag_prefix():
tsdf = object.__new__(TsFileDataFrame)
tsdf._index = dataframe_module._DataFrameCatalog()
tsdf._index.table_entries["weather"] = dataframe_module.TableEntry(
table_name="weather",
tag_columns=("city", "device", "region"),
tag_types=(TSDataType.STRING, TSDataType.STRING, TSDataType.STRING),
field_columns=("temperature",),
)
tsdf._index.devices = [
("weather", (None, "device_a")),
("weather", ("beijing", "device_b")),
]
tsdf._index.device_index = {
("weather", (None, "device_a")): 0,
("weather", ("beijing", "device_b")): 1,
}
tsdf._index.device_time_bounds = [(0, 1), (0, 1)]
tsdf._index.series = [(0, 0), (1, 0)]
tsdf._index.series_shards = {(0, 0): [], (1, 0): []}
# Prefix matching is position-aware: "weather.\N" selects the null-city
# device, "weather.beijing" selects the fully specified one.
assert tsdf.list_timeseries("weather.\\N") == ["weather.\\N.device_a.temperature"]
assert tsdf.list_timeseries("weather.beijing") == [
"weather.beijing.device_b.temperature"
]
def test_dataframe_list_timeseries_prefix_can_skip_full_name_build(
tmp_path, monkeypatch
):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
tsdf._index.series = [(0, 0)] * 1000
def fail_build_series_name(_series_ref):
raise AssertionError(
"list_timeseries(prefix) should not build full names for non-matching series"
)
monkeypatch.setattr(tsdf, "_build_series_name", fail_build_series_name)
assert tsdf.list_timeseries("pvf") == []
def test_series_path_resolution_distinguishes_null_position():
catalog = MetadataCatalog()
table_id = catalog.add_table(
"weather",
("city", "device"),
(TSDataType.STRING, TSDataType.STRING),
("temperature",),
)
first_id = catalog.add_device(table_id, ("beijing", None), 0, 1) # device IS NULL
second_id = catalog.add_device(table_id, (None, "beijing"), 0, 1) # city IS NULL
for device_id in (first_id, second_id):
catalog.series_stats_by_ref[(device_id, 0)] = SeriesStats(
length=1,
min_time=0,
max_time=0,
timeline_length=1,
timeline_min_time=0,
timeline_max_time=0,
)
# Null position is preserved, so these two devices get distinct paths
# (previously both compressed to "weather.beijing.temperature" -> ambiguous).
first_path = build_series_path(catalog, first_id, 0)
second_path = build_series_path(catalog, second_id, 0)
assert first_path == "weather.beijing.temperature"
assert second_path == "weather.\\N.beijing.temperature"
assert first_path != second_path
# Each resolves unambiguously back to its own device.
assert resolve_series_path(catalog, first_path) == (table_id, first_id, 0)
assert resolve_series_path(catalog, second_path) == (table_id, second_id, 0)
def test_reader_show_progress_reports_start_immediately(tmp_path, capsys):
path = tmp_path / "weather.tsfile"
_write_weather_file(path, 0)
reader = TsFileSeriesReader(str(path), show_progress=True)
try:
stderr = capsys.readouterr().err
assert "Reading TsFile metadata: 0/1" in stderr
assert "Reading TsFile metadata: 1 table(s), 2 series ... done" in stderr
finally:
reader.close()
def test_dataframe_parallel_show_progress_reports_start_immediately(tmp_path, capsys):
path1 = tmp_path / "part1.tsfile"
path2 = tmp_path / "part2.tsfile"
_write_weather_file(path1, 0)
_write_weather_file(path2, 3)
with TsFileDataFrame([str(path1), str(path2)], show_progress=True):
pass
stderr = capsys.readouterr().err
assert "Loading TsFile shards: 0/2" in stderr
assert "Loading TsFile shards: 2/2 (4 series) ... done" in stderr
# --- Tree-model tests -------------------------------------------------------
def _write_tree_file(path):
"""Tree-model TsFile with two devices; the second device is shorter and
only has one of the two declared measurements, exercising None-pad +
union-field paths in the synthetic table layer.
"""
from tsfile import (
Field,
RowRecord,
TimeseriesSchema,
TsFileWriter,
)
writer = TsFileWriter(str(path))
writer.register_timeseries(
"root.ln.wf01.wt01", TimeseriesSchema("status", TSDataType.INT32)
)
writer.register_timeseries(
"root.ln.wf01.wt01", TimeseriesSchema("temperature", TSDataType.DOUBLE)
)
writer.register_timeseries(
"root.ln.wf02.wt02", TimeseriesSchema("status", TSDataType.INT32)
)
for t in range(5):
writer.write_row_record(
RowRecord(
"root.ln.wf01.wt01",
t,
[
Field("status", t, TSDataType.INT32),
Field("temperature", float(t) + 0.5, TSDataType.DOUBLE),
],
)
)
writer.write_row_record(
RowRecord(
"root.ln.wf02.wt02",
t,
[Field("status", t * 2, TSDataType.INT32)],
)
)
writer.close()
def test_dataset_tree_model_metadata_and_repr(tmp_path):
path = tmp_path / "tree.tsfile"
_write_tree_file(path)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
assert tsdf.model == "tree"
assert len(tsdf) == 3
assert sorted(tsdf.list_timeseries()) == [
"root.ln.wf01.wt01.status",
"root.ln.wf01.wt01.temperature",
"root.ln.wf02.wt02.status",
]
rendered = repr(tsdf)
# Header carries the model marker; tag headers use _col_i (1-based).
assert "TsFileDataFrame(tree model, 3 time series, 1 files)" in rendered
assert "_col_1" in rendered and "_col_2" in rendered and "_col_3" in rendered
assert "table" not in rendered.splitlines()[1] # no 'table' header
# Metadata column projection: _col_i and field; 'table' is rejected.
assert list(tsdf["_col_1"]) == ["ln", "ln", "ln"]
assert list(tsdf["_col_3"]) == ["wt01", "wt01", "wt02"]
assert list(tsdf["field"]) == ["status", "temperature", "status"]
with pytest.raises(KeyError):
tsdf["table"]
def test_dataset_tree_model_series_access(tmp_path):
path = tmp_path / "tree.tsfile"
_write_tree_file(path)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
ts = tsdf["root.ln.wf01.wt01.temperature"]
assert isinstance(ts, Timeseries)
assert ts.name == "root.ln.wf01.wt01.temperature"
assert len(ts) == 5
np.testing.assert_array_equal(ts.timestamps, np.arange(5, dtype=np.int64))
# __getitem__ slice routes through _read_series_by_row_tree.
first_three = ts[0:3]
np.testing.assert_array_equal(first_three, np.array([0.5, 1.5, 2.5]))
# Aligned read across two co-located series.
aligned = tsdf.loc[
0:5,
[
"root.ln.wf01.wt01.temperature",
"root.ln.wf01.wt01.status",
],
]
assert isinstance(aligned, AlignedTimeseries)
assert aligned.shape == (5, 2)
np.testing.assert_array_equal(aligned.timestamps, np.arange(5, dtype=np.int64))
def test_dataset_tree_model_list_timeseries_metadata(tmp_path):
path = tmp_path / "tree.tsfile"
_write_tree_file(path)
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
meta = tsdf.list_timeseries_metadata()
assert isinstance(meta, pd.DataFrame)
assert list(meta.columns) == [
"field",
"start_time",
"end_time",
"count",
"_col_1",
"_col_2",
"_col_3",
]
assert sorted(meta.index.tolist()) == sorted(tsdf.list_timeseries())
# Time bounds surface as pandas.Timestamp for ergonomic comparison.
assert pd.api.types.is_datetime64_any_dtype(meta["start_time"])
assert pd.api.types.is_datetime64_any_dtype(meta["end_time"])
# Per-series count comes from the catalog, not the synthetic union.
assert meta.loc["root.ln.wf01.wt01.temperature", "count"] == 5
assert meta.loc["root.ln.wf02.wt02.status", "count"] == 5
def test_dataset_rejects_mixed_model_load(tmp_path):
table_path = tmp_path / "weather.tsfile"
tree_path = tmp_path / "tree.tsfile"
_write_weather_file(table_path, 0)
_write_tree_file(tree_path)
with pytest.raises(ValueError, match="Mixed table-model and tree-model"):
TsFileDataFrame([str(table_path), str(tree_path)], show_progress=False)
def _write_tree_rows(path, device_measurements, t_start=0, t_count=3):
"""Write a tree-model TsFile from a device -> [(measurement, dtype)] map.
Each device's measurements are written for timestamps
``t_start .. t_start + t_count - 1``; numeric values are ``float(t) + 0.5``
(INT32 measurements use ``t``), so series read back deterministically.
"""
from tsfile import Field, RowRecord, TimeseriesSchema, TsFileWriter
writer = TsFileWriter(str(path))
for device, measurements in device_measurements.items():
for name, dtype in measurements:
writer.register_timeseries(device, TimeseriesSchema(name, dtype))
for t in range(t_start, t_start + t_count):
for device, measurements in device_measurements.items():
fields = [
Field(name, (t if dtype == TSDataType.INT32 else float(t) + 0.5), dtype)
for name, dtype in measurements
]
writer.write_row_record(RowRecord(device, t, fields))
writer.close()
def test_dataset_tree_model_merges_identical_structure_across_files(tmp_path):
# Two tree files, same device/measurement, disjoint time ranges: one logical
# series whose shards and time bounds merge.
path1 = tmp_path / "t1.tsfile"
path2 = tmp_path / "t2.tsfile"
_write_tree_rows(path1, {"root.a.b": [("m1", TSDataType.DOUBLE)]}, t_start=0)
_write_tree_rows(path2, {"root.a.b": [("m1", TSDataType.DOUBLE)]}, t_start=10)
with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as tsdf:
assert tsdf.model == "tree"
assert tsdf.list_timeseries() == ["root.a.b.m1"]
ts = tsdf["root.a.b.m1"]
assert len(ts) == 6
np.testing.assert_array_equal(
ts.timestamps, np.array([0, 1, 2, 10, 11, 12], dtype=np.int64)
)
meta = tsdf.list_timeseries_metadata()
assert meta.loc["root.a.b.m1", "count"] == 6
def test_dataset_tree_model_unions_fields_across_files(tmp_path):
# Same device, different measurement subsets across files -> union of fields.
path1 = tmp_path / "t1.tsfile"
path2 = tmp_path / "t2.tsfile"
_write_tree_rows(path1, {"root.a.b": [("m1", TSDataType.DOUBLE)]}, t_start=0)
_write_tree_rows(path2, {"root.a.b": [("m2", TSDataType.DOUBLE)]}, t_start=0)
with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as tsdf:
assert tsdf.model == "tree"
assert sorted(tsdf.list_timeseries()) == ["root.a.b.m1", "root.a.b.m2"]
# Each field reads its own file's data, not the other's.
np.testing.assert_array_equal(tsdf["root.a.b.m1"][:], np.array([0.5, 1.5, 2.5]))
np.testing.assert_array_equal(tsdf["root.a.b.m2"][:], np.array([0.5, 1.5, 2.5]))
# Aligned read spans the unioned fields on the one device.
aligned = tsdf.loc[0:2, ["root.a.b.m1", "root.a.b.m2"]]
assert aligned.shape == (3, 2)
np.testing.assert_array_equal(
aligned.timestamps, np.array([0, 1, 2], dtype=np.int64)
)
def test_dataset_tree_model_unions_different_depths_across_files(tmp_path):
# Files with different max depth -> global tag layout widens; the shallower
# device pads its deepest tag column with null.
path1 = tmp_path / "t1.tsfile"
path2 = tmp_path / "t2.tsfile"
_write_tree_rows(path1, {"root.a.b": [("m1", TSDataType.DOUBLE)]}, t_start=0)
_write_tree_rows(path2, {"root.a.b.c": [("m1", TSDataType.DOUBLE)]}, t_start=0)
with TsFileDataFrame([str(path1), str(path2)], show_progress=False) as tsdf:
assert tsdf.model == "tree"
assert sorted(tsdf.list_timeseries()) == ["root.a.b.c.m1", "root.a.b.m1"]
meta = tsdf.list_timeseries_metadata()
# Global depth widened to the deeper file (a, b, c -> _col_3 exists).
assert "_col_3" in meta.columns
assert meta.loc["root.a.b.c.m1", "_col_3"] == "c"
# The shallower device's deepest tag column is null (padded).
assert pd.isna(meta.loc["root.a.b.m1", "_col_3"])
# Each device still reads its own data.
np.testing.assert_array_equal(tsdf["root.a.b.m1"][:], np.array([0.5, 1.5, 2.5]))
np.testing.assert_array_equal(
tsdf["root.a.b.c.m1"][:], np.array([0.5, 1.5, 2.5])
)
def test_dataset_tree_model_omits_non_numeric_measurements(tmp_path):
# The dataset surface is numeric (float64); a STRING tree measurement must
# be dropped, not surfaced as a series that crashes on read.
from tsfile import Field, RowRecord, TimeseriesSchema, TsFileWriter
path = tmp_path / "tree_mixed.tsfile"
writer = TsFileWriter(str(path))
writer.register_timeseries("root.a.b", TimeseriesSchema("temp", TSDataType.DOUBLE))
writer.register_timeseries(
"root.a.b", TimeseriesSchema("status", TSDataType.STRING)
)
for t in range(3):
writer.write_row_record(
RowRecord(
"root.a.b",
t,
[
Field("temp", float(t) + 0.5, TSDataType.DOUBLE),
Field("status", "ok", TSDataType.STRING),
],
)
)
writer.close()
with TsFileDataFrame(str(path), show_progress=False) as tsdf:
# Only the numeric measurement is exposed; the STRING one is dropped.
assert tsdf.list_timeseries() == ["root.a.b.temp"]
with pytest.raises(KeyError):
tsdf["root.a.b.status"]
np.testing.assert_array_equal(
tsdf["root.a.b.temp"][:], np.array([0.5, 1.5, 2.5])
)