This is an automated email from the ASF dual-hosted git repository.

ColinLeeo pushed a commit to branch dataframe_desc
in repository https://gitbox.apache.org/repos/asf/tsfile.git

commit 6cdcc9aba172f0a913d6cc0a2acb8a6f71563914
Author: ColinLee <[email protected]>
AuthorDate: Fri Aug 7 17:25:20 2026 +0800

    Add dataframe descriptions and device statistics
---
 python/README-zh.md                 |  43 ++++-
 python/README.md                    |  44 ++++-
 python/tests/test_tsfile_dataset.py | 231 ++++++++++++++++++++++
 python/tsfile/dataset/dataframe.py  | 370 +++++++++++++++++++++++++++++++++++-
 4 files changed, 683 insertions(+), 5 deletions(-)

diff --git a/python/README-zh.md b/python/README-zh.md
index 660c001e8..91dc06698 100644
--- a/python/README-zh.md
+++ b/python/README-zh.md
@@ -37,6 +37,47 @@
 
 你可以在 `./examples/examples.py` 中找到读写示例。
 
+## TsFileDataFrame 列角色描述
+
+`TsFileDataFrame` 可以加载表和列的 JSON 描述文件。列值使用 `C` 表示协变量,
+使用 `T` 表示目标变量:
+
+```json
+{
+  "weather": {
+    "temperature": "C",
+    "humidity": "T"
+  }
+}
+```
+
+创建 dataframe 时传入描述文件路径。`get` 和 `set` 提供对 JSON 顶层键的字典式
+访问,其中 `set` 会将更新后的描述写回文件;另外还可以按表获取协变量列、目标
+变量列及其数量:
+
+```python
+df = TsFileDataFrame("weather.tsfile", description_path="description.json")
+df.get("weather")
+df.get_covariate_columns("weather")
+df.get_target_columns("weather")
+df.get_covariate_column_count("weather")
+df.get_target_column_count("weather")
+df.get_device_count("weather")
+df.get_device_node_counts("weather")
+df.get_device_point_counts("weather")
+df.get_device_statistics("weather")
+df.get_device_point_count("weather", "device_a")
+df.get_device_stats("weather", {"device": "device_a"})
+df.list_device_metadata("weather")
+df.set("weather", {"temperature": "T", "humidity": "T"})
+df.close()
+```
+
+这里的节点数量指设备下实际存在的数值时间序列数量。`get_device_node_counts`
+使用设备有序标签值元组作为 key。`get_device_statistics` 还会返回点数、非空值数量
+和时间范围,`list_device_metadata` 则将标签展开为带名称的列。其中 `point_count`
+遵循 DataFrame 的时间线数量(包含 NaN 行),`value_count` 只统计非空值。
+
 ---
 
 ## 如何贡献
@@ -66,4 +107,4 @@ mvn -P with-cpp,with-python clean verify
 
 ```sh
 python setup.py build_ext --inplace
-```
\ No newline at end of file
+```
diff --git a/python/README.md b/python/README.md
index 51cb498ec..42332c215 100644
--- a/python/README.md
+++ b/python/README.md
@@ -34,6 +34,49 @@ This directory contains the Python implementation of TsFile. 
The Python version
 
 The source code can be found in the `./tsfile` directory. Files ending with 
`.pyx` and `.pyd` are wrapper code written in Cython. The `tsfile/tsfile.py` 
defines some user interfaces. You can find some examples of reading and writing 
in the `.examples/examples.py`.
 
+## TsFileDataFrame column roles
+
+`TsFileDataFrame` can load a JSON description for table columns. Use `C` for a
+covariate and `T` for a target column:
+
+```json
+{
+  "weather": {
+    "temperature": "C",
+    "humidity": "T"
+  }
+}
+```
+
+Pass the description path when opening the dataframe. `get` and `set` provide
+dictionary-style access to top-level JSON values; `set` writes the updated
+description back to disk.
+
+```python
+df = TsFileDataFrame("weather.tsfile", description_path="description.json")
+df.get("weather")
+df.get_covariate_columns("weather")
+df.get_target_columns("weather")
+df.get_covariate_column_count("weather")
+df.get_target_column_count("weather")
+df.get_device_count("weather")
+df.get_device_node_counts("weather")
+df.get_device_point_counts("weather")
+df.get_device_statistics("weather")
+df.get_device_point_count("weather", "device_a")
+df.get_device_stats("weather", {"device": "device_a"})
+df.list_device_metadata("weather")
+df.set("weather", {"temperature": "T", "humidity": "T"})
+df.close()
+```
+
+Device node counts refer to the physically present numeric time series under
+each device. `get_device_node_counts` uses each device's ordered tag-value 
tuple
+as its key, while `get_device_statistics` also reports point/value counts and
+time bounds, and `list_device_metadata` expands those tags into named columns.
+`point_count` follows the DataFrame timeline count (including NaN rows), while
+`value_count` counts non-null values.
+
 
 ## How to make contributions
 
@@ -60,4 +103,3 @@ Build by python command:
 ```sh
 python setup.py build_ext --inplace
 ```
-
diff --git a/python/tests/test_tsfile_dataset.py 
b/python/tests/test_tsfile_dataset.py
index 12b847e9e..53d272f0b 100644
--- a/python/tests/test_tsfile_dataset.py
+++ b/python/tests/test_tsfile_dataset.py
@@ -16,6 +16,8 @@
 # under the License.
 #
 
+import json
+
 import numpy as np
 import pandas as pd
 import pytest
@@ -257,6 +259,212 @@ def test_dataset_basic_access_patterns(tmp_path, capsys):
         assert "AlignedTimeseries(6 rows, 2 series)" in capsys.readouterr().out
 
 
+def test_dataset_description_get_set_and_column_roles(tmp_path):
+    path = tmp_path / "weather.tsfile"
+    description_path = tmp_path / "description.json"
+    _write_weather_file(path, 0)
+    description_path.write_text(
+        json.dumps(
+            {
+                "weather": {
+                    "temperature": "C",
+                    "humidity": "T",
+                }
+            }
+        ),
+        encoding="utf-8",
+    )
+
+    with TsFileDataFrame(
+        str(path),
+        show_progress=False,
+        description_path=description_path,
+    ) as tsdf:
+        assert tsdf.get("weather") == {
+            "temperature": "C",
+            "humidity": "T",
+        }
+        assert tsdf.get("missing") is None
+        assert tsdf.get("missing", {"default": True}) == {"default": True}
+        assert tsdf.get_covariate_columns("weather") == ["temperature"]
+        assert tsdf.get_target_columns("weather") == ["humidity"]
+        assert tsdf.get_covariate_column_count("weather") == 1
+        assert tsdf.get_target_column_count("weather") == 1
+        assert tsdf.get_covariate_columns("missing") == []
+        assert tsdf.get_target_column_count("missing") == 0
+
+        description = tsdf.get("weather")
+        description["temperature"] = "T"
+        assert tsdf.get_covariate_columns("weather") == ["temperature"]
+
+        with pytest.raises(TypeError, match="must be JSON serializable"):
+            tsdf.set("invalid", object())
+        assert tsdf.get("invalid") is None
+
+        subset = tsdf[:1]
+        subset.set(
+            "weather",
+            {
+                "temperature": "T",
+                "humidity": "T",
+            },
+        )
+        assert tsdf.get_covariate_column_count("weather") == 0
+        assert tsdf.get_target_columns("weather") == ["temperature", 
"humidity"]
+
+    assert json.loads(description_path.read_text(encoding="utf-8")) == {
+        "weather": {
+            "temperature": "T",
+            "humidity": "T",
+        }
+    }
+
+
+def test_dataset_description_set_creates_missing_file(tmp_path):
+    path = tmp_path / "weather.tsfile"
+    description_path = tmp_path / "nested" / "description.json"
+    _write_weather_file(path, 0)
+
+    with TsFileDataFrame(
+        str(path),
+        show_progress=False,
+        description_path=description_path,
+    ) as tsdf:
+        assert tsdf.get("weather") is None
+        tsdf.set("weather", {"temperature": "C", "humidity": "T"})
+
+    assert json.loads(description_path.read_text(encoding="utf-8")) == {
+        "weather": {"temperature": "C", "humidity": "T"}
+    }
+
+
+def test_dataset_description_rejects_invalid_json_and_roles(tmp_path):
+    path = tmp_path / "weather.tsfile"
+    description_path = tmp_path / "description.json"
+    _write_weather_file(path, 0)
+
+    description_path.write_text("[]", encoding="utf-8")
+    with pytest.raises(ValueError, match="must be a JSON object"):
+        TsFileDataFrame(
+            str(path),
+            show_progress=False,
+            description_path=description_path,
+        )
+
+    description_path.write_text(
+        json.dumps({"weather": {"temperature": "X"}}), encoding="utf-8"
+    )
+    with TsFileDataFrame(
+        str(path),
+        show_progress=False,
+        description_path=description_path,
+    ) as tsdf:
+        with pytest.raises(ValueError, match="expected 'C' or 'T'"):
+            tsdf.get_covariate_columns("weather")
+
+        tsdf.set("weather", {"temperature": ["C"]})
+        with pytest.raises(ValueError, match="expected 'C' or 'T'"):
+            tsdf.get_target_columns("weather")
+
+
+def test_dataset_device_counts_and_metadata_follow_dataframe_view(tmp_path):
+    path = tmp_path / "multi_device.tsfile"
+    _write_multi_tag_file(path)
+
+    with TsFileDataFrame(str(path), show_progress=False) as tsdf:
+        assert tsdf.get_device_count("weather") == 2
+        assert tsdf.get_device_node_counts("weather") == {
+            ("beijing", "device_a"): 2,
+            ("shanghai", "device_b"): 2,
+        }
+        assert tsdf.get_device_point_counts("weather") == {
+            ("beijing", "device_a"): 4,
+            ("shanghai", "device_b"): 4,
+        }
+        assert tsdf.get_device_point_count("weather", ("beijing", "device_a")) 
== 4
+        assert (
+            tsdf.get_device_stats("weather", {"city": "beijing", "device": 
"device_a"})[
+                "point_count"
+            ]
+            == 4
+        )
+        assert tsdf.get_device_statistics("weather") == {
+            ("beijing", "device_a"): {
+                "node_count": 2,
+                "point_count": 4,
+                "value_count": 4,
+                "start_time": 0,
+                "end_time": 1,
+            },
+            ("shanghai", "device_b"): {
+                "node_count": 2,
+                "point_count": 4,
+                "value_count": 4,
+                "start_time": 0,
+                "end_time": 1,
+            },
+        }
+
+        device_metadata = tsdf.list_device_metadata("weather")
+        assert list(device_metadata.columns) == [
+            "table",
+            "node_count",
+            "point_count",
+            "value_count",
+            "start_time",
+            "end_time",
+            "city",
+            "device",
+        ]
+        assert device_metadata[
+            ["city", "device", "node_count", "point_count", "value_count"]
+        ].to_dict("records") == [
+            {
+                "city": "beijing",
+                "device": "device_a",
+                "node_count": 2,
+                "point_count": 4,
+                "value_count": 4,
+            },
+            {
+                "city": "shanghai",
+                "device": "device_b",
+                "node_count": 2,
+                "point_count": 4,
+                "value_count": 4,
+            },
+        ]
+
+        subset = tsdf[[0, 2]]
+        assert subset.get_device_count("weather") == 2
+        assert subset.get_device_node_counts("weather") == {
+            ("beijing", "device_a"): 1,
+            ("shanghai", "device_b"): 1,
+        }
+        assert subset.get_device_point_counts("weather") == {
+            ("beijing", "device_a"): 2,
+            ("shanghai", "device_b"): 2,
+        }
+
+        assert tsdf.get_device_count("missing") == 0
+        assert tsdf.get_device_node_counts("missing") == {}
+
+    numeric_path = tmp_path / "numeric_and_text.tsfile"
+    _write_numeric_and_text_file(numeric_path)
+    with TsFileDataFrame(str(numeric_path), show_progress=False) as tsdf:
+        # The NaN row is part of the returned time series timeline, but is not
+        # counted as a non-null value by native value statistics.
+        assert tsdf.get_device_statistics("weather") == {
+            ("device_a",): {
+                "node_count": 1,
+                "point_count": 3,
+                "value_count": 2,
+                "start_time": 0,
+                "end_time": 2,
+            }
+        }
+
+
 def 
test_dataset_loc_aligns_timestamp_union_and_preserves_requested_order(tmp_path):
     path = tmp_path / "weather_sparse.tsfile"
     _write_weather_rows_file(
@@ -1479,6 +1687,29 @@ def test_dataset_tree_model_metadata_and_repr(tmp_path):
         with pytest.raises(KeyError):
             tsdf["table"]
 
+        assert tsdf.get_device_count("root") == 2
+        assert tsdf.get_device_node_counts("root") == {
+            ("ln", "wf01", "wt01"): 2,
+            ("ln", "wf02", "wt02"): 1,
+        }
+        assert tsdf.get_device_point_counts("root") == {
+            ("ln", "wf01", "wt01"): 10,
+            ("ln", "wf02", "wt02"): 5,
+        }
+        assert tsdf.get_device_point_count("root", ("ln", "wf02", "wt02")) == 5
+        device_metadata = tsdf.list_device_metadata("root")
+        assert list(device_metadata.columns) == [
+            "node_count",
+            "point_count",
+            "value_count",
+            "start_time",
+            "end_time",
+            "_col_1",
+            "_col_2",
+            "_col_3",
+        ]
+        assert device_metadata["node_count"].tolist() == [2, 1]
+
 
 def test_dataset_tree_model_series_access(tmp_path):
     path = tmp_path / "tree.tsfile"
diff --git a/python/tsfile/dataset/dataframe.py 
b/python/tsfile/dataset/dataframe.py
index a65b231bc..0e61f1e15 100644
--- a/python/tsfile/dataset/dataframe.py
+++ b/python/tsfile/dataset/dataframe.py
@@ -19,11 +19,14 @@
 """Top-level dataset accessors for TsFile shards."""
 
 from collections import defaultdict
+import copy
 from dataclasses import dataclass, field
 import heapq
+import json
 import os
 import sys
-from typing import Dict, List, Optional, Tuple, Union
+import tempfile
+from typing import Any, Dict, List, Optional, Tuple, Union
 import warnings
 
 import numpy as np
@@ -55,6 +58,56 @@ _DATACLASS_SLOTS = {"slots": True} if sys.version_info >= 
(3, 10) else {}
 # multiple shards.
 _OVERLAP_ROW_CHUNK_SIZE = 256
 
+_COVARIATE_ROLE = "C"
+_TARGET_ROLE = "T"
+_DESCRIPTION_ROLES = {_COVARIATE_ROLE, _TARGET_ROLE}
+
+
+def _load_dataframe_description(description_path: Optional[str]) -> dict:
+    """Load a dataframe description JSON object.
+
+    A missing optional description file is treated as an empty description so
+    callers can create it later through :meth:`TsFileDataFrame.set`.
+    """
+    if description_path is None:
+        return {}
+
+    try:
+        with open(description_path, "r", encoding="utf-8") as description_file:
+            description = json.load(description_file)
+    except FileNotFoundError:
+        return {}
+    except json.JSONDecodeError as exc:
+        raise ValueError(
+            f"Invalid dataframe description JSON in '{description_path}': 
{exc.msg}"
+        ) from exc
+
+    if not isinstance(description, dict):
+        raise ValueError(
+            f"Dataframe description in '{description_path}' must be a JSON 
object"
+        )
+    return description
+
+
+def _write_dataframe_description(description_path: str, description: dict) -> 
None:
+    """Atomically write a dataframe description JSON object to disk."""
+    directory = os.path.dirname(description_path) or "."
+    os.makedirs(directory, exist_ok=True)
+    fd, temporary_path = tempfile.mkstemp(
+        prefix=".tsfile-description-", suffix=".tmp", dir=directory
+    )
+    try:
+        with os.fdopen(fd, "w", encoding="utf-8") as description_file:
+            json.dump(description, description_file, ensure_ascii=False, 
indent=2)
+            description_file.write("\n")
+        os.replace(temporary_path, description_path)
+    except Exception:
+        try:
+            os.unlink(temporary_path)
+        except FileNotFoundError:
+            pass
+        raise
+
 
 @dataclass(**_DATACLASS_SLOTS)
 class _DataFrameCatalog:
@@ -618,11 +671,37 @@ class _LocIndexer:
 
 
 class TsFileDataFrame:
-    """Lazy-loaded unified numeric dataset view over multiple TsFile shards."""
+    """Lazy-loaded unified numeric dataset view over multiple TsFile shards.
+
+    ``description_path`` optionally points to a JSON object describing the
+    columns in each table.  A description uses the following shape::
+
+        {
+          "weather": {
+            "temperature": "C",
+            "humidity": "T"
+          }
+        }
 
-    def __init__(self, paths: Union[str, List[str]], show_progress: bool = 
True):
+    ``C`` marks a covariate column and ``T`` marks a target column.  The
+    description is independent of the TsFile data and is shared by dataframe
+    subsets created through slicing or boolean selection.
+    """
+
+    def __init__(
+        self,
+        paths: Union[str, List[str]],
+        show_progress: bool = True,
+        description_path: Optional[Union[str, os.PathLike]] = None,
+    ):
         self._paths = _expand_paths(paths)
         self._show_progress = show_progress
+        self._description_path = (
+            os.path.abspath(os.fspath(description_path))
+            if description_path is not None
+            else None
+        )
+        self._description = _load_dataframe_description(self._description_path)
         self._readers: Dict[str, object] = {}
         self._index = _DataFrameCatalog()
         self._is_view = False
@@ -640,6 +719,8 @@ class TsFileDataFrame:
         obj._is_view = True
         obj._paths = parent._paths
         obj._show_progress = parent._show_progress
+        obj._description_path = parent._description_path
+        obj._description = parent._description
         obj._readers = parent._readers
         # Reuse the parent's full mapping but restrict the membership scope to
         # the requested subset.
@@ -661,6 +742,88 @@ class TsFileDataFrame:
     def _owner(self) -> "TsFileDataFrame":
         return self._root if self._is_view else self
 
+    def get(self, key: str, default: Any = None) -> Any:
+        """Return a top-level value from the JSON description.
+
+        The returned value is copied, so mutating a dictionary or list from a
+        ``get`` call cannot change the in-memory description without going
+        through :meth:`set`.
+        """
+        if not isinstance(key, str):
+            raise TypeError(f"Description key must be a string, got 
{type(key)}")
+        owner = self._owner()
+        return copy.deepcopy(owner._description.get(key, default))
+
+    def set(self, key: str, value: Any) -> None:
+        """Set a top-level JSON description value and persist it if configured.
+
+        ``value`` must be JSON serializable.  If no ``description_path`` was
+        supplied, the update is kept in memory for the lifetime of the root
+        dataframe and can still be read through :meth:`get` or the role APIs.
+        """
+        if not isinstance(key, str):
+            raise TypeError(f"Description key must be a string, got 
{type(key)}")
+
+        owner = self._owner()
+        updated_description = copy.deepcopy(owner._description)
+        updated_description[key] = copy.deepcopy(value)
+
+        try:
+            json.dumps(updated_description, ensure_ascii=False)
+        except (TypeError, ValueError) as exc:
+            raise TypeError("Description value must be JSON serializable") 
from exc
+
+        if owner._description_path is not None:
+            _write_dataframe_description(owner._description_path, 
updated_description)
+        owner._description = updated_description
+
+    def _get_role_columns(self, table_name: str, role: str) -> List[str]:
+        if not isinstance(table_name, str):
+            raise TypeError(
+                f"Description table name must be a string, got 
{type(table_name)}"
+            )
+
+        table_description = self._owner()._description.get(table_name)
+        if table_description is None:
+            return []
+        if not isinstance(table_description, dict):
+            raise ValueError(
+                f"Description for table '{table_name}' must be a JSON object"
+            )
+
+        invalid_roles = {
+            column: column_role
+            for column, column_role in table_description.items()
+            if not isinstance(column_role, str) or column_role not in 
_DESCRIPTION_ROLES
+        }
+        if invalid_roles:
+            column, column_role = next(iter(invalid_roles.items()))
+            raise ValueError(
+                f"Invalid role {column_role!r} for column '{column}' in table "
+                f"'{table_name}'; expected 'C' or 'T'"
+            )
+        return [
+            column
+            for column, column_role in table_description.items()
+            if column_role == role
+        ]
+
+    def get_covariate_columns(self, table_name: str) -> List[str]:
+        """Return the columns marked ``C`` for ``table_name``."""
+        return self._get_role_columns(table_name, _COVARIATE_ROLE)
+
+    def get_target_columns(self, table_name: str) -> List[str]:
+        """Return the columns marked ``T`` for ``table_name``."""
+        return self._get_role_columns(table_name, _TARGET_ROLE)
+
+    def get_covariate_column_count(self, table_name: str) -> int:
+        """Return the number of columns marked ``C`` for ``table_name``."""
+        return len(self.get_covariate_columns(table_name))
+
+    def get_target_column_count(self, table_name: str) -> int:
+        """Return the number of columns marked ``T`` for ``table_name``."""
+        return len(self.get_target_columns(table_name))
+
     def _assert_open(self):
         if self._owner()._closed:
             raise RuntimeError("Current TsFileDataFrame is closed.")
@@ -826,6 +989,207 @@ class TsFileDataFrame:
     def model(self) -> str:
         return self._index.model
 
+    def _get_device_node_counts_by_index(self, table_name: str) -> Dict[int, 
int]:
+        """Count unique physical series per device in the current dataframe 
view."""
+        if not isinstance(table_name, str):
+            raise TypeError(f"Table name must be a string, got 
{type(table_name)}")
+
+        node_indices_by_device: Dict[int, set] = {}
+        seen_series = set()
+        for device_idx, field_idx in self._index.series:
+            device_table_name, _ = self._index.devices[device_idx]
+            if device_table_name != table_name:
+                continue
+
+            series_ref = (device_idx, field_idx)
+            if series_ref in seen_series:
+                continue
+            seen_series.add(series_ref)
+            node_indices_by_device.setdefault(device_idx, set()).add(field_idx)
+
+        return {
+            device_idx: len(node_indices)
+            for device_idx, node_indices in node_indices_by_device.items()
+        }
+
+    def _get_device_statistics_by_index(self, table_name: str) -> Dict[int, 
dict]:
+        """Aggregate physical-series statistics per device in the current 
view."""
+        node_counts_by_device = 
self._get_device_node_counts_by_index(table_name)
+        point_counts_by_device: Dict[int, int] = {}
+        value_counts_by_device: Dict[int, int] = {}
+        seen_series = set()
+        for device_idx, field_idx in self._index.series:
+            if device_idx not in node_counts_by_device:
+                continue
+
+            series_ref = (device_idx, field_idx)
+            if series_ref in seen_series:
+                continue
+            seen_series.add(series_ref)
+
+            for reader, reader_device_id, reader_field_idx in 
self._index.series_shards[
+                series_ref
+            ]:
+                series_info = reader.get_series_info_by_ref(
+                    reader_device_id, reader_field_idx
+                )
+                point_counts_by_device[device_idx] = 
point_counts_by_device.get(
+                    device_idx, 0
+                ) + int(series_info["timeline_length"])
+                value_counts_by_device[device_idx] = 
value_counts_by_device.get(
+                    device_idx, 0
+                ) + int(series_info["length"])
+
+        statistics_by_device = {}
+        for device_idx, node_count in node_counts_by_device.items():
+            if device_idx < len(self._index.device_time_bounds):
+                min_time, max_time = self._index.device_time_bounds[device_idx]
+            else:
+                min_time, max_time = None, None
+            statistics_by_device[device_idx] = {
+                "node_count": node_count,
+                # point_count follows Timeseries.stats['count']: every row on
+                # every physically present node, including NaN rows.
+                "point_count": point_counts_by_device.get(device_idx, 0),
+                # value_count counts only non-null values from native stats.
+                "value_count": value_counts_by_device.get(device_idx, 0),
+                "start_time": min_time,
+                "end_time": max_time,
+            }
+        return statistics_by_device
+
+    def get_device_count(self, table_name: str) -> int:
+        """Return the number of devices represented under ``table_name``.
+
+        A subset dataframe only counts devices that own at least one series in
+        that subset. A table that is not present returns ``0``.
+        """
+        return len(self._get_device_node_counts_by_index(table_name))
+
+    def get_device_node_counts(self, table_name: str) -> Dict[Tuple[Any, ...], 
int]:
+        """Return ``{device_tag_values: node_count}`` for ``table_name``.
+
+        Each key is the device's ordered tag-value tuple. For tree-model files,
+        it is the tuple of device path segments after the root. ``node_count``
+        is the number of unique, physically present numeric time series for the
+        device in the current dataframe view.
+        """
+        counts_by_index = self._get_device_node_counts_by_index(table_name)
+        return {
+            self._index.devices[device_idx][1]: counts_by_index[device_idx]
+            for device_idx in range(len(self._index.devices))
+            if device_idx in counts_by_index
+        }
+
+    def get_device_statistics(self, table_name: str) -> Dict[Tuple[Any, ...], 
dict]:
+        """Return per-device counts and time bounds for ``table_name``.
+
+        Each value contains ``node_count``, ``point_count`` (the sum of the
+        per-node timeline counts), ``value_count`` (the sum of non-null 
values),
+        ``start_time``, and ``end_time``. The key is the device's ordered
+        tag-value tuple.
+        """
+        statistics_by_index = self._get_device_statistics_by_index(table_name)
+        return {
+            self._index.devices[device_idx][1]: stats
+            for device_idx, stats in statistics_by_index.items()
+        }
+
+    def get_device_point_counts(self, table_name: str) -> Dict[Tuple[Any, 
...], int]:
+        """Return ``{device_tag_values: point_count}`` for ``table_name``."""
+        return {
+            tag_values: stats["point_count"]
+            for tag_values, stats in 
self.get_device_statistics(table_name).items()
+        }
+
+    def _resolve_device_index(self, table_name: str, device) -> int:
+        """Resolve a public device selector to a dataframe-local device 
index."""
+        if not isinstance(table_name, str):
+            raise TypeError(f"Table name must be a string, got 
{type(table_name)}")
+        table_entry = self._index.table_entries.get(table_name)
+        if table_entry is None:
+            raise KeyError(f"Table not found: '{table_name}'")
+
+        if isinstance(device, SeriesPath):
+            if device.table != table_name:
+                raise KeyError(
+                    f"Device {device!r} does not belong to table 
'{table_name}'"
+                )
+            tag_values = device.tags
+        elif isinstance(device, dict):
+            tag_values = tuple(device.get(column) for column in 
table_entry.tag_columns)
+        elif isinstance(device, (tuple, list)):
+            tag_values = tuple(device)
+        elif len(table_entry.tag_columns) == 1:
+            tag_values = (device,)
+        else:
+            raise TypeError(
+                "A device with multiple tag columns must be identified by an "
+                "ordered tuple/list or a tag-value dictionary"
+            )
+
+        device_idx = self._index.device_index.get(
+            (table_name, _normalize_tag_values(tag_values))
+        )
+        if device_idx is None:
+            raise KeyError(f"Device not found in table '{table_name}': 
{device!r}")
+        if device_idx not in self._get_device_node_counts_by_index(table_name):
+            raise KeyError(
+                f"Device is not represented in the current dataframe view: 
{device!r}"
+            )
+        return device_idx
+
+    def get_device_stats(self, table_name: str, device) -> dict:
+        """Return statistics for one device selected by tags or 
``SeriesPath``."""
+        device_idx = self._resolve_device_index(table_name, device)
+        return 
self._get_device_statistics_by_index(table_name)[device_idx].copy()
+
+    def get_device_point_count(self, table_name: str, device) -> int:
+        """Return the timeline point count for one selected device."""
+        return self.get_device_stats(table_name, device)["point_count"]
+
+    def list_device_metadata(self, table_name: str):
+        """Return a pandas DataFrame with one row and statistics per device.
+
+        Declared tag columns are expanded into named columns. Short tag tuples
+        are padded with ``None`` so nullable table tags and tree devices of
+        different depths remain position preserving.
+        """
+        import pandas as pd
+
+        statistics_by_index = self._get_device_statistics_by_index(table_name)
+        table_entry = self._index.table_entries.get(table_name)
+        tag_columns = list(table_entry.tag_columns) if table_entry else []
+        leading_columns = [
+            "node_count",
+            "point_count",
+            "value_count",
+            "start_time",
+            "end_time",
+        ]
+        if self._index.model != MODEL_TREE:
+            leading_columns.insert(0, "table")
+        columns = leading_columns + tag_columns
+
+        rows = []
+        for device_idx in range(len(self._index.devices)):
+            if device_idx not in statistics_by_index:
+                continue
+            _, tag_values = self._index.devices[device_idx]
+            ordered_tag_values = list(tag_values)
+            if len(ordered_tag_values) < len(tag_columns):
+                ordered_tag_values.extend(
+                    [None] * (len(tag_columns) - len(ordered_tag_values))
+                )
+
+            row = statistics_by_index[device_idx].copy()
+            if self._index.model != MODEL_TREE:
+                row["table"] = table_name
+            row.update(zip(tag_columns, ordered_tag_values))
+            rows.append(row)
+
+        return pd.DataFrame(rows, columns=columns)
+
     def list_timeseries(self, path_prefix: str = "") -> List[SeriesPath]:
         if not path_prefix:
             return [

Reply via email to