blob: 7da46854844006d42bfb0849c8d87ad6c7e3cb7d [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.
#
"""Top-level dataset accessors for TsFile shards."""
from collections import defaultdict
from dataclasses import dataclass, field
import heapq
import os
import sys
from typing import Dict, List, Optional, Tuple, Union
import warnings
import numpy as np
from . import index_cache
from .formatting import format_dataframe_table
from .metadata import (
MODEL_TABLE,
MODEL_TREE,
SeriesPath,
TableEntry,
_normalize_tag_values,
build_logical_series_components,
build_logical_series_path,
split_logical_series_path,
)
from .merge import build_aligned_matrix, merge_time_value_parts, merge_timestamp_parts
from .timeseries import AlignedTimeseries, Timeseries
DeviceKey = Tuple[str, tuple]
SeriesRefKey = Tuple[int, int]
SeriesRef = Tuple[object, int, int]
_QUERY_START = np.iinfo(np.int64).min
_QUERY_END = np.iinfo(np.int64).max
_DATACLASS_SLOTS = {"slots": True} if sys.version_info >= (3, 10) else {}
# Overlap position reads use chunked k-way merge. Keep the default chunk small
# enough to avoid large read amplification for `series[i]` / short slices, but
# large enough to avoid excessive query_by_row round-trips when overlap spans
# multiple shards.
_OVERLAP_ROW_CHUNK_SIZE = 256
@dataclass(**_DATACLASS_SLOTS)
class _DataFrameCatalog:
"""TsFileDataFrame's cross-file unified catalog: merges each tsfile's
``MetadataCatalog`` into one user-facing global view.
"""
# Model kind for the entire load set: "table" or "tree". A single
# TsFileDataFrame is not allowed to mix table-model and tree-model files.
model: Optional[str] = None
# Shared table schema references keyed by table name.
table_entries: Dict[str, TableEntry] = field(default_factory=dict)
# Stable logical device order, each item is (table_name, tag_values).
devices: List[DeviceKey] = field(default_factory=list)
# Map one logical device key to its dataframe-local device index. The key's
# tag tuple keeps interior nulls (None) and drops trailing ones, so every
# device -- including null-tagged ones -- resolves by a single direct lookup.
device_index: Dict[DeviceKey, int] = field(default_factory=dict)
# Aggregated (min_time, max_time) per logical device, computed once at
# registration so query-time lookups are O(1).
device_time_bounds: List[Tuple[Optional[int], Optional[int]]] = field(
default_factory=list
)
# Stable logical series order, each item is (device_idx, field_idx).
series: List[SeriesRefKey] = field(default_factory=list)
# For each logical series: which shards hold it, plus its (device_id,
# field_idx) coordinates inside each shard.
series_shards: Dict[SeriesRefKey, List[SeriesRef]] = field(default_factory=dict)
def _expand_paths(paths: Union[str, List[str]]) -> List[str]:
"""Normalize file/directory inputs into a validated list of absolute TsFile paths."""
if isinstance(paths, str):
paths = [paths]
expanded = []
for path in paths:
if os.path.isdir(path):
tsfiles = sorted(
os.path.join(root, name)
for root, _, files in os.walk(path)
for name in files
if name.endswith(".tsfile")
)
if not tsfiles:
raise FileNotFoundError(f"No .tsfile files found in directory: {path}")
expanded.extend(tsfiles)
else:
expanded.append(path)
resolved = []
for path in expanded:
if not os.path.exists(path):
raise FileNotFoundError(f"TsFile not found: {path}")
resolved.append(os.path.abspath(path))
return resolved
def _series_lookup_hint(name: str) -> str:
return f"Series not found: '{name}'. Use df.list_timeseries() to inspect available series."
def _validate_table_schema(
existing: TableEntry, incoming: TableEntry, file_path: str
) -> None:
"""Reject same-name tables whose tag/field layout differs across shards."""
if (
existing.tag_columns == incoming.tag_columns
and existing.tag_types == incoming.tag_types
and existing.field_columns == incoming.field_columns
):
return
raise ValueError(
f"Incompatible schema for table '{incoming.table_name}' in '{file_path}'. "
f"Expected tags={list(existing.tag_columns)}, tag_types={list(existing.tag_types)}, "
f"fields={list(existing.field_columns)} but found "
f"tags={list(incoming.tag_columns)}, tag_types={list(incoming.tag_types)}, "
f"fields={list(incoming.field_columns)}."
)
def _merge_tree_table_entries(existing: TableEntry, incoming: TableEntry) -> TableEntry:
"""Widen two per-file synthetic tree tables into one global table.
Tree TsFiles carry no authored schema, so each file synthesizes its own
table (root name, ``_col_i`` tag columns sized to that file's deepest
device, fields = the measurements present in that file). When several tree
files are loaded together we widen to their union: field columns are
unioned (existing order first, then new measurements) and the tag
columns/types grow to the deepest file's depth so shallower devices pad
with nulls. Per-(device, field) ownership stays exact because each shard is
keyed by the global field index resolved by measurement name.
"""
if len(incoming.tag_columns) > len(existing.tag_columns):
tag_columns, tag_types = incoming.tag_columns, incoming.tag_types
else:
tag_columns, tag_types = existing.tag_columns, existing.tag_types
field_columns = list(existing.field_columns)
seen = set(field_columns)
for name in incoming.field_columns:
if name not in seen:
seen.add(name)
field_columns.append(name)
return TableEntry(
table_name=existing.table_name,
tag_columns=tag_columns,
tag_types=tag_types,
field_columns=tuple(field_columns),
)
def _register_reader(
readers: Dict[str, object],
index: _DataFrameCatalog,
file_path: str,
reader,
) -> None:
"""Merge one reader's catalog into the dataframe-wide logical index."""
cur_tsfile_model = reader.model_kind
if index.model is None:
index.model = cur_tsfile_model
elif index.model != cur_tsfile_model:
raise ValueError(
f"Mixed table-model and tree-model TsFiles detected. The first "
f"loaded file is {index.model!r} but '{file_path}' is "
f"{cur_tsfile_model!r}. A single TsFileDataFrame load set must be "
f"entirely table-model or entirely tree-model."
)
readers[file_path] = reader
catalog = reader.catalog
for table_entry in catalog.table_entries:
existing_entry = index.table_entries.get(table_entry.table_name)
if existing_entry is None:
index.table_entries[table_entry.table_name] = table_entry
elif index.model == MODEL_TREE:
# Tree shards carry no authored schema; widen the synthetic table to
# the union of fields and the deepest tag layout across files
# instead of rejecting differing subsets/depths.
index.table_entries[table_entry.table_name] = _merge_tree_table_entries(
existing_entry, table_entry
)
else:
_validate_table_schema(existing_entry, table_entry, file_path)
for device_id, device_entry in enumerate(catalog.device_entries):
table_entry = catalog.table_entries[device_entry.table_id]
device_key = (table_entry.table_name, tuple(device_entry.tag_values))
device_idx = index.device_index.get(device_key)
if device_idx is None:
device_idx = len(index.devices)
index.device_index[device_key] = device_idx
index.devices.append(device_key)
index.device_time_bounds.append(
(device_entry.min_time, device_entry.max_time)
)
else:
cur_min, cur_max = index.device_time_bounds[device_idx]
new_min = (
device_entry.min_time
if cur_min is None
else min(cur_min, device_entry.min_time)
)
new_max = (
device_entry.max_time
if cur_max is None
else max(cur_max, device_entry.max_time)
)
index.device_time_bounds[device_idx] = (new_min, new_max)
# Register every (device, field) pair the reader physically holds. The
# logical series is keyed by the GLOBAL field index (resolved by measurement
# name against the merged table), while the per-shard tuple keeps the
# reader-local field index for the read path. This lets tree shards with
# different field subsets/orders merge without mis-mapping measurements
# (for table model the global and local indices always coincide).
for device_id, field_idx in catalog.series_stats_by_ref:
device_entry = catalog.device_entries[device_id]
reader_table_entry = catalog.table_entries[device_entry.table_id]
field_name = reader_table_entry.field_columns[field_idx]
device_key = (reader_table_entry.table_name, tuple(device_entry.tag_values))
device_idx = index.device_index[device_key]
global_field_idx = index.table_entries[
reader_table_entry.table_name
].get_field_index(field_name)
series_ref = (device_idx, global_field_idx)
if series_ref not in index.series_shards:
index.series.append(series_ref)
index.series_shards[series_ref] = []
index.series_shards[series_ref].append((reader, device_id, field_idx))
def _build_runtime_series_stats(refs: List[SeriesRef]) -> dict:
"""Build shared-timeline series stats from native timeline metadata."""
min_time = None
max_time = None
count = 0
for reader, device_id, field_idx in refs:
info = reader.get_series_info_by_ref(device_id, field_idx)
shard_min = info["timeline_min_time"]
shard_max = info["timeline_max_time"]
shard_count = info["timeline_length"]
if shard_count == 0:
continue
count += shard_count
min_time = shard_min if min_time is None else min(min_time, shard_min)
max_time = shard_max if max_time is None else max(max_time, shard_max)
return {
"min_time": min_time,
"max_time": max_time,
"count": count,
}
def _merge_field_timestamps(series_name: str, refs: List[SeriesRef]) -> np.ndarray:
"""Load and merge the full timestamp axis for one logical series on demand."""
# This is intentionally lazy because it is one of the most expensive dataset
# paths: it reads the full timestamp axis for the logical series across all
# shards. Today this happens only when callers explicitly ask for
# `Timeseries.timestamps`.
time_parts = []
for reader, device_id, field_idx in refs:
ts_arr, _ = reader.read_series_by_ref(
device_id, field_idx, _QUERY_START, _QUERY_END
)
if len(ts_arr) > 0:
time_parts.append(ts_arr)
if not time_parts:
merged_timestamps = np.array([], dtype=np.int64)
elif len(time_parts) == 1:
merged_timestamps = time_parts[0]
else:
try:
merged_timestamps = merge_timestamp_parts(time_parts, validate_unique=True)
except ValueError as e:
message = str(e)
duplicate_suffix = message.removeprefix("Duplicate timestamp ")
duplicate_suffix = duplicate_suffix.removesuffix(" found across shards.")
raise ValueError(
f"Duplicate timestamp {duplicate_suffix} found for series '{series_name}' across shards. "
f"Cross-shard duplicate timestamps are not supported."
) from e
return merged_timestamps
def _read_field_by_position(
series_name: str,
refs: List[SeriesRef],
offset: int,
limit: int,
) -> Tuple[np.ndarray, np.ndarray]:
"""Read one logical series by global position without materializing timestamps for non-overlapping shards."""
if limit <= 0:
return np.array([], dtype=np.int64), np.array([], dtype=np.float64)
infos = []
for reader, device_id, field_idx in refs:
series_info = reader.get_series_info_by_ref(device_id, field_idx)
infos.append(
{
"length": series_info["timeline_length"],
"min_time": series_info["timeline_min_time"],
"max_time": series_info["timeline_max_time"],
"table_name": series_info["table_name"],
"column_name": series_info["column_name"],
"device_id": series_info["device_id"],
"field_idx": series_info["field_idx"],
"tag_columns": series_info["tag_columns"],
"tag_values": series_info["tag_values"],
}
)
ordered = sorted(
zip(refs, infos), key=lambda item: (item[1]["min_time"], item[1]["max_time"])
)
if _has_time_range_overlap([info for _, info in ordered]):
return _read_field_by_position_overlap(series_name, ordered, offset, limit)
remaining_offset = offset
remaining_limit = limit
time_parts = []
value_parts = []
for (reader, device_id, field_idx), info in ordered:
shard_count = info["length"]
if remaining_offset >= shard_count:
remaining_offset -= shard_count
continue
local_limit = min(remaining_limit, shard_count - remaining_offset)
ts_arr, values = reader.read_series_by_row(
device_id, field_idx, remaining_offset, local_limit
)
if len(ts_arr) > 0:
time_parts.append(ts_arr)
value_parts.append(values)
remaining_limit -= local_limit
remaining_offset = 0
if remaining_limit <= 0:
break
if not time_parts:
return np.array([], dtype=np.int64), np.array([], dtype=np.float64)
return np.concatenate(time_parts), np.concatenate(value_parts)
def _has_time_range_overlap(infos: List[dict]) -> bool:
previous_max = None
for info in infos:
if info["min_time"] is None or info["max_time"] is None:
continue
if previous_max is not None and info["min_time"] <= previous_max:
return True
previous_max = (
info["max_time"]
if previous_max is None
else max(previous_max, info["max_time"])
)
return False
def _read_field_by_position_overlap(
series_name: str,
ordered: List[Tuple[SeriesRef, dict]],
offset: int,
limit: int,
) -> Tuple[np.ndarray, np.ndarray]:
"""Merge overlapping shard streams lazily until the requested global window is covered."""
total_count = sum(info["length"] for _, info in ordered)
if offset >= total_count:
return np.array([], dtype=np.int64), np.array([], dtype=np.float64)
chunk_size = max(_OVERLAP_ROW_CHUNK_SIZE, limit)
states = []
heap = []
def fill_state(state_idx: int) -> bool:
state = states[state_idx]
while state["buffer_index"] >= len(state["timestamps"]):
remaining = state["length"] - state["next_offset"]
if remaining <= 0:
state["exhausted"] = True
return False
local_limit = min(chunk_size, remaining)
reader, device_id, field_idx = state["ref"]
ts_arr, val_arr = reader.read_series_by_row(
device_id, field_idx, state["next_offset"], local_limit
)
state["next_offset"] += len(ts_arr)
state["timestamps"] = ts_arr
state["values"] = val_arr
state["buffer_index"] = 0
if len(ts_arr) > 0:
return True
state["exhausted"] = True
return False
return True
for ref, info in ordered:
state_idx = len(states)
states.append(
{
"ref": ref,
"length": info["length"],
"next_offset": 0,
"timestamps": np.array([], dtype=np.int64),
"values": np.array([], dtype=np.float64),
"buffer_index": 0,
"exhausted": False,
}
)
if fill_state(state_idx):
heapq.heappush(heap, (int(states[state_idx]["timestamps"][0]), state_idx))
skipped = 0
output_timestamps = []
output_values = []
last_timestamp = None
while heap and len(output_timestamps) < limit:
current_ts, state_idx = heapq.heappop(heap)
if last_timestamp is not None and current_ts == last_timestamp:
raise ValueError(
f"Duplicate timestamp {current_ts} found for series '{series_name}' across shards. "
f"Cross-shard duplicate timestamps are not supported."
)
state = states[state_idx]
buffer_index = state["buffer_index"]
current_value = float(state["values"][buffer_index])
state["buffer_index"] += 1
if fill_state(state_idx):
next_ts = int(state["timestamps"][state["buffer_index"]])
heapq.heappush(heap, (next_ts, state_idx))
last_timestamp = current_ts
if skipped < offset:
skipped += 1
continue
output_timestamps.append(current_ts)
output_values.append(current_value)
return np.asarray(output_timestamps, dtype=np.int64), np.asarray(
output_values, dtype=np.float64
)
def _build_field_stats(refs: List[SeriesRef]) -> dict:
"""Aggregate per-series timeline statistics for dataframe display."""
min_time = None
max_time = None
count = 0
for reader, device_id, field_idx in refs:
info = reader.get_series_info_by_ref(device_id, field_idx)
shard_min = info["timeline_min_time"]
shard_max = info["timeline_max_time"]
shard_count = info["timeline_length"]
if shard_count == 0:
continue
count += shard_count
min_time = shard_min if min_time is None else min(min_time, shard_min)
max_time = shard_max if max_time is None else max(max_time, shard_max)
return {
"min_time": min_time,
"max_time": max_time,
"count": count,
}
class _LocIndexer:
"""Implement ``.loc[start_time:end_time, series_list]`` for aligned reads."""
def __init__(self, dataframe: "TsFileDataFrame"):
self._df = dataframe
def _parse_key(self, key):
if not isinstance(key, tuple) or len(key) != 2:
raise ValueError(
"loc requires exactly 2 arguments: tsdf.loc[start_time:end_time, series_list]"
)
time_slice, series_spec = key
if isinstance(time_slice, slice):
start_time = _QUERY_START if time_slice.start is None else time_slice.start
end_time = _QUERY_END if time_slice.stop is None else time_slice.stop
elif isinstance(time_slice, (int, np.integer)):
start_time = end_time = int(time_slice)
else:
raise TypeError(f"Time index must be slice or int, got {type(time_slice)}")
if isinstance(series_spec, (str, int, np.integer)):
series_spec = [series_spec]
series_refs = []
series_names = []
for item in series_spec:
if isinstance(item, (int, np.integer)):
idx = int(item)
if idx < 0:
idx += len(self._df._index.series)
if idx < 0 or idx >= len(self._df._index.series):
raise IndexError(f"Series index {item} out of range")
series_ref = self._df._index.series[idx]
elif isinstance(item, str):
series_ref = self._df._resolve_series_name(item)
else:
raise TypeError(
f"Series specifier must be int or str, got {type(item)}"
)
series_refs.append(series_ref)
series_names.append(self._df._build_series_name(series_ref))
return start_time, end_time, series_refs, series_names
def _query_aligned(
self,
start_time: int,
end_time: int,
series_refs: List[SeriesRefKey],
series_names: List[str],
):
"""Batch aligned reads by reader/device, then merge per-series fragments."""
self._df._assert_open()
groups = defaultdict(list)
for col_idx, series_ref in enumerate(series_refs):
device_idx, field_idx = series_ref
min_time_dev, max_time_dev = self._df._index.device_time_bounds[device_idx]
if (
max_time_dev is None
or max_time_dev < start_time
or (min_time_dev is not None and min_time_dev > end_time)
):
continue
_, table_entry, _ = self._df._get_series_components(series_ref)
field_name = table_entry.field_columns[field_idx]
for reader, device_id, reader_field_idx in self._df._index.series_shards[
series_ref
]:
groups[(id(reader), device_id)].append(
(
col_idx,
reader_field_idx,
field_name,
series_names[col_idx],
reader,
device_id,
)
)
series_time_parts = defaultdict(list)
series_value_parts = defaultdict(list)
for entries in groups.values():
reader = entries[0][4]
device_id = entries[0][5]
field_indices = list(dict.fromkeys(entry[1] for entry in entries))
ts_arr, field_vals = reader.read_device_fields_by_time_range(
device_id, field_indices, start_time, end_time
)
if len(ts_arr) == 0:
continue
appended_series = set()
for _, _, field_name, series_name, _, _ in entries:
if series_name in appended_series:
continue
appended_series.add(series_name)
series_time_parts[series_name].append(ts_arr)
series_value_parts[series_name].append(field_vals[field_name])
series_data = {}
for name in series_names:
series_data[name] = merge_time_value_parts(
series_time_parts[name], series_value_parts[name]
)
return build_aligned_matrix(series_names, series_data)
def __getitem__(self, key) -> AlignedTimeseries:
start_time, end_time, series_refs, series_names = self._parse_key(key)
timestamps, values = self._query_aligned(
start_time, end_time, series_refs, series_names
)
return AlignedTimeseries(timestamps, values, series_names)
class TsFileDataFrame:
"""Lazy-loaded unified numeric dataset view over multiple TsFile shards."""
def __init__(
self,
paths: Union[str, List[str]],
show_progress: bool = True,
use_cache: bool = True,
):
self._paths = _expand_paths(paths)
self._show_progress = show_progress
self._use_cache = use_cache
# Resolve from the ORIGINAL paths arg (before expansion): the on-disk
# cache is only enabled when a single directory is passed. None means
# "no cache location" -- single-file / list inputs behave as before.
self._cache_path = index_cache.resolve_cache_path(paths) if use_cache else None
self._readers: Dict[str, object] = {}
self._index = _DataFrameCatalog()
self._is_view = False
self._root = None
self._closed = False
self._load_metadata()
@classmethod
def _from_subset(
cls, parent: "TsFileDataFrame", series_refs: List[SeriesRefKey]
) -> "TsFileDataFrame":
"""Create a lightweight view that reuses the parent's readers and caches."""
obj = object.__new__(cls)
obj._root = parent._root if parent._is_view else parent
obj._is_view = True
obj._paths = parent._paths
obj._show_progress = parent._show_progress
obj._readers = parent._readers
# Reuse the parent's full mapping but restrict the membership scope to
# the requested subset.
subset_refs = list(series_refs)
parent_shards = parent._index.series_shards
subset_shards = {ref: parent_shards[ref] for ref in subset_refs}
obj._index = _DataFrameCatalog(
model=parent._index.model,
table_entries=parent._index.table_entries,
devices=parent._index.devices,
device_index=parent._index.device_index,
device_time_bounds=parent._index.device_time_bounds,
series=subset_refs,
series_shards=subset_shards,
)
obj._closed = False
return obj
def _owner(self) -> "TsFileDataFrame":
return self._root if self._is_view else self
def _assert_open(self):
if self._owner()._closed:
raise RuntimeError("Current TsFileDataFrame is closed.")
def _load_metadata(self):
"""Build the logical cross-file index and the derived per-series caches."""
from .reader import TsFileSeriesReader
loaded_from_cache = False
if self._use_cache and self._cache_path and os.path.exists(self._cache_path):
loaded_from_cache = self._load_metadata_from_cache(TsFileSeriesReader)
if not loaded_from_cache:
if len(self._paths) >= 2:
self._load_metadata_parallel(TsFileSeriesReader)
else:
self._load_metadata_serial(TsFileSeriesReader)
if not self._index.series:
raise ValueError("No valid time series found in the provided TsFile files")
# Persist the freshly built catalogs so the next load skips the walk.
# Only after a fresh build (never re-writing a cache we just read) and
# only when non-empty (the empty check above already guarded this).
if not loaded_from_cache and self._use_cache and self._cache_path:
self._write_index_cache()
def _load_metadata_from_cache(self, reader_class) -> bool:
"""Rebuild readers + index from the on-disk cache; False falls back to a fresh build."""
try:
cached_paths, catalogs = index_cache.load_catalogs(self._cache_path)
except Exception:
# Corrupt / old / unreadable cache -> rebuild from source.
return False
# Source files are not validated (per design), but the resolved file
# SET must match: cached device/series indices are positional, so a
# changed set (file added/removed) would misalign. Rebuild if so.
if cached_paths != self._paths:
return False
catalog_by_path = dict(zip(cached_paths, catalogs))
total = len(self._paths)
self._show_loading_progress(0, total)
# Iterate in self._paths order so _register_reader replays the exact
# same merge order as a fresh build -> identical series/device order.
for index, file_path in enumerate(self._paths, start=1):
_register_reader(
self._readers,
self._index,
file_path,
reader_class.from_cached_catalog(
file_path, catalog_by_path[file_path], show_progress=False
),
)
self._show_loading_progress(index, total)
self._show_loading_progress(
total, total, sum(reader.series_count for reader in self._readers.values())
)
return True
def _write_index_cache(self):
"""Write per-file catalogs to the cache; best-effort (warn on failure)."""
# Emit catalogs in self._paths order so the reload replays merges
# identically. self._readers is keyed by file_path for both build paths.
catalogs = [self._readers[path].catalog for path in self._paths]
try:
index_cache.save_catalogs(self._cache_path, list(self._paths), catalogs)
except Exception as e:
warnings.warn(
f"Failed to write TsFile index cache: {e}",
RuntimeWarning,
stacklevel=2,
)
def _show_loading_progress(self, done: int, total: int, total_series: int = None):
if not self._show_progress or total <= 0:
return
if total_series is None:
sys.stderr.write(f"\rLoading TsFile shards: {done}/{total}")
else:
sys.stderr.write(
f"\rLoading TsFile shards: {done}/{total} ({total_series} series) ... done\n"
)
sys.stderr.flush()
def _load_metadata_serial(self, reader_class):
total = len(self._paths)
self._show_loading_progress(0, total)
for index, file_path in enumerate(self._paths, start=1):
_register_reader(
self._readers,
self._index,
file_path,
reader_class(
file_path, show_progress=self._show_progress and total == 1
),
)
if total > 1:
self._show_loading_progress(index, total)
self._show_loading_progress(
total, total, sum(reader.series_count for reader in self._readers.values())
)
def _load_metadata_parallel(self, reader_class):
from concurrent.futures import ThreadPoolExecutor, as_completed
def open_file(file_path):
return file_path, reader_class(file_path, show_progress=False)
total = len(self._paths)
self._show_loading_progress(0, total)
with ThreadPoolExecutor(
max_workers=min(total, os.cpu_count() or 4)
) as executor:
futures = {executor.submit(open_file, path): path for path in self._paths}
results = {}
done = 0
for future in as_completed(futures):
file_path, reader = future.result()
results[file_path] = reader
done += 1
self._show_loading_progress(done, total)
self._show_loading_progress(
total, total, sum(reader.series_count for reader in results.values())
)
for file_path in self._paths:
_register_reader(
self._readers,
self._index,
file_path,
results[file_path],
)
def _get_series_components(
self, series_ref: SeriesRefKey
) -> Tuple[DeviceKey, TableEntry, int]:
device_idx, field_idx = series_ref
device_key = self._index.devices[device_idx]
return device_key, self._index.table_entries[device_key[0]], field_idx
def _build_series_name(self, series_ref: SeriesRefKey) -> SeriesPath:
device_key, table_entry, field_idx = self._get_series_components(series_ref)
table_name, tag_values = device_key
field_name = table_entry.field_columns[field_idx]
return build_logical_series_path(
table_name, tag_values, field_name, table_entry.tag_columns
)
def _resolve_series_name(self, series_name) -> SeriesRefKey:
"""Resolve a ``SeriesPath`` or path string (``\\N`` = null tag) to a ref.
Every device has a unique position-preserving key, so this is a single
direct lookup -- no sparse/compressed fallback and no ambiguity.
"""
if isinstance(series_name, SeriesPath):
table_name, tag_parts, field_name = (
series_name.table,
list(series_name.tags),
series_name.field,
)
else:
try:
parts = split_logical_series_path(series_name)
except ValueError as exc:
raise KeyError(_series_lookup_hint(series_name)) from exc
if len(parts) < 2:
raise KeyError(_series_lookup_hint(series_name))
table_name, field_name, tag_parts = parts[0], parts[-1], parts[1:-1]
if table_name not in self._index.table_entries:
raise KeyError(_series_lookup_hint(series_name))
table_entry = self._index.table_entries[table_name]
try:
field_idx = table_entry.get_field_index(field_name)
except ValueError as exc:
raise KeyError(_series_lookup_hint(series_name)) from exc
device_key = (table_name, _normalize_tag_values(tag_parts))
device_idx = self._index.device_index.get(device_key)
if device_idx is None:
raise KeyError(_series_lookup_hint(series_name))
series_ref = (device_idx, field_idx)
if series_ref not in self._index.series_shards:
raise KeyError(_series_lookup_hint(series_name))
return series_ref
def _build_series_info(self, series_ref: SeriesRefKey) -> dict:
device_idx, field_idx = series_ref
device_key, table_entry, _ = self._get_series_components(series_ref)
# Aggregate per-shard timeline stats lazily on demand for this series.
field_stats = _build_field_stats(self._index.series_shards[series_ref])
# Pad short tag tuples (tree-model devices whose path is shorter than
# the synthetic table's max depth) with None so positional access by
# `_col_i` index always lands on a defined cell.
tag_values_ordered = list(device_key[1])
if len(tag_values_ordered) < len(table_entry.tag_columns):
tag_values_ordered.extend(
[None] * (len(table_entry.tag_columns) - len(tag_values_ordered))
)
return {
"table_name": table_entry.table_name,
"field": table_entry.field_columns[field_idx],
"tag_columns": table_entry.tag_columns,
"tag_values": dict(zip(table_entry.tag_columns, tag_values_ordered)),
"tag_values_ordered": tag_values_ordered,
"min_time": field_stats["min_time"],
"max_time": field_stats["max_time"],
"count": field_stats["count"],
}
def __len__(self) -> int:
return len(self._index.series)
@property
def model(self) -> str:
return self._index.model
def list_timeseries(self, path_prefix: str = "") -> List[SeriesPath]:
if not path_prefix:
return [
self._build_series_name(series_ref) for series_ref in self._index.series
]
try:
prefix_parts = split_logical_series_path(path_prefix)
except ValueError:
return []
matched = []
for series_ref in self._index.series:
device_key, table_entry, field_idx = self._get_series_components(series_ref)
components = build_logical_series_components(
table_entry.table_name,
device_key[1],
table_entry.field_columns[field_idx],
table_entry.tag_columns,
)
if prefix_parts == components[: len(prefix_parts)]:
matched.append(self._build_series_name(series_ref))
return matched
def list_timeseries_metadata(self, path_prefix: str = ""):
"""Return a pandas DataFrame of per-series metadata.
The returned frame is indexed by the logical series name and includes
per-series ``field``, time-bound (start/end) statistics, observation
``count``, and the per-device tag values (named ``_col_1``, ``_col_2``,
... in tree mode, or by their declared tag-column names in table
mode). Time bounds are exposed as ``pandas.Timestamp`` for ergonomic
comparison; ``count`` is an integer.
``path_prefix`` filters by the same logical-path prefix semantics as
``list_timeseries`` (no prefix returns the full catalog).
"""
import pandas as pd
# Reuse list_timeseries to apply prefix filtering, then map names back
# to the underlying series_ref (this respects view subsetting too).
names = self.list_timeseries(path_prefix)
rows = []
for series_name in names:
series_ref = self._resolve_series_name(series_name)
info = self._build_series_info(series_ref)
row = {
"field": info["field"],
"start_time": pd.to_datetime(info["min_time"], unit="ms"),
"end_time": pd.to_datetime(info["max_time"], unit="ms"),
"count": int(info["count"]),
}
if self._index.model != MODEL_TREE:
row["table"] = info["table_name"]
tag_columns = info["tag_columns"]
tag_values_ordered = info["tag_values_ordered"]
for column, value in zip(tag_columns, tag_values_ordered):
row[column] = value
rows.append((series_name, row))
if not rows:
columns = ["field", "start_time", "end_time", "count"]
if self._index.model != MODEL_TREE:
columns.insert(0, "table")
columns.extend(self._collect_tag_columns())
return pd.DataFrame(columns=columns)
index = [name for name, _ in rows]
data = [row for _, row in rows]
df = pd.DataFrame(data, index=index)
# Stable, predictable column order: leading bookkeeping, then tags.
leading = ["field", "start_time", "end_time", "count"]
if self._index.model != MODEL_TREE:
leading.insert(0, "table")
tag_order = list(self._collect_tag_columns())
ordered_columns = leading + [c for c in tag_order if c in df.columns]
# Preserve any extra columns at the end (defensive against schema drift).
for extra in df.columns:
if extra not in ordered_columns:
ordered_columns.append(extra)
return df.reindex(columns=ordered_columns)
def _get_timeseries(self, series_ref: SeriesRefKey) -> Timeseries:
self._assert_open()
series_name = self._build_series_name(series_ref)
return Timeseries(
series_name,
self._index.series_shards[series_ref],
_build_runtime_series_stats(self._index.series_shards[series_ref]),
self._assert_open,
lambda: _merge_field_timestamps(
series_name, self._index.series_shards[series_ref]
),
lambda offset, limit: _read_field_by_position(
series_name, self._index.series_shards[series_ref], offset, limit
),
)
def __getitem__(self, key):
try:
import pandas as pd
if isinstance(key, pd.Series) and key.dtype == bool:
selected = [self._index.series[idx] for idx in key.index[key]]
return TsFileDataFrame._from_subset(self, selected)
except ImportError:
pass
if isinstance(key, (int, np.integer)):
idx = int(key)
if idx < 0:
idx += len(self._index.series)
if idx < 0 or idx >= len(self._index.series):
raise IndexError(
f"Index {idx} out of range [0, {len(self._index.series)})"
)
return self._get_timeseries(self._index.series[idx])
if isinstance(key, str):
try:
return self._get_timeseries(self._resolve_series_name(key))
except KeyError:
pass
valid_columns = {"field", "start_time", "end_time", "count"}
if self._index.model != MODEL_TREE:
valid_columns.add("table")
valid_columns.update(self._collect_tag_columns())
if key not in valid_columns:
raise KeyError(_series_lookup_hint(key))
import pandas as pd
values = []
for series_ref in self._index.series:
info = self._build_series_info(series_ref)
if key == "table":
values.append(info["table_name"])
elif key == "field":
values.append(info["field"])
elif key == "start_time":
values.append(info["min_time"])
elif key == "end_time":
values.append(info["max_time"])
elif key == "count":
values.append(info["count"])
else:
values.append(info["tag_values"].get(key))
return pd.Series(values, name=key)
if isinstance(key, slice):
return TsFileDataFrame._from_subset(
self,
[
self._index.series[idx]
for idx in range(*key.indices(len(self._index.series)))
],
)
if isinstance(key, list):
selected = []
for item in key:
if not isinstance(item, (int, np.integer)):
raise TypeError(
f"List index must contain integers, got {type(item)}"
)
idx = int(item)
if idx < 0:
idx += len(self._index.series)
if idx < 0 or idx >= len(self._index.series):
raise IndexError(
f"Index {item} out of range [0, {len(self._index.series)})"
)
selected.append(self._index.series[idx])
return TsFileDataFrame._from_subset(self, selected)
raise TypeError(f"Unsupported key type: {type(key)}")
@property
def loc(self):
return _LocIndexer(self)
def _collect_tag_columns(self) -> List[str]:
seen = {}
for table_name, _ in self._index.devices:
for column in self._index.table_entries[table_name].tag_columns:
seen.setdefault(column, True)
return list(seen.keys())
@staticmethod
def _preview_indices(
indices: List[int], max_rows: int
) -> Tuple[List[int], bool, int]:
total = len(indices)
if total <= max_rows:
return indices, False, total
head = max_rows // 2
tail = max_rows - head
return list(indices[:head]) + list(indices[-tail:]), True, head
def _format_table(self, indices=None, max_rows: int = 20) -> str:
if indices is None:
indices = list(range(len(self._index.series)))
else:
indices = list(indices)
preview_indices, truncated, split_index = self._preview_indices(
indices, max_rows
)
is_tree = self._index.model == MODEL_TREE
rows = []
for idx in preview_indices:
series_ref = self._index.series[idx]
info = self._build_series_info(series_ref)
row = {
"index": idx,
"field": info["field"],
"start_time": info["min_time"],
"end_time": info["max_time"],
"count": info["count"],
}
if not is_tree:
row["table"] = info["table_name"]
row.update(info["tag_values"])
rows.append(row)
return format_dataframe_table(
rows,
self._collect_tag_columns(),
total_count=len(indices),
truncated=truncated,
split_index=split_index,
is_table_model=not is_tree,
)
def _repr_header(self) -> str:
total = len(self._index.series)
model_marker = self._index.model
if self._is_view:
return (
f"TsFileDataFrame({model_marker} model, {total} time series, "
f"subset of {len(self._root._index.series)})\n"
)
return (
f"TsFileDataFrame({model_marker} model, {total} time series, "
f"{len(self._paths)} files)\n"
)
def __repr__(self):
return self._repr_header() + self._format_table()
def __str__(self):
return self.__repr__()
def show(self, max_rows: int = 20):
print(self._repr_header() + self._format_table(max_rows=max_rows))
def close(self):
if self._is_view:
warnings.warn(
"close() on a subset TsFileDataFrame is a no-op; only the root dataframe owns the readers.",
RuntimeWarning,
stacklevel=2,
)
return
if self._closed:
return
for reader in self._readers.values():
reader.close()
self._readers.clear()
self._closed = True
def __del__(self):
try:
if not getattr(self, "_is_view", False):
self.close()
except Exception:
pass
def __enter__(self):
return self
def __exit__(self, *args):
self.close()