Coverage for src/jointview/columns.py: 100%
22 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 06:44 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 06:44 +0000
1"""Which columns the app can draw, and cutting a chosen pair down to its common sample.
3This is the vocabulary both halves of the app are built on, and it belongs to neither
4of them. :mod:`jointview.plot` draws what :func:`aligned` produces and
5:mod:`jointview.stats` summarises it, so the column names and the shaping rules live
6here rather than in either — two peers reaching into a third, instead of one reaching
7into the other.
9``PERIOD`` is the clearest case. It is the name :func:`aligned` writes the x-axis under
10and the name the statistics read it back out of: a data contract between the two, not
11a fact about plotting.
12"""
14from __future__ import annotations
16import polars as pl
18PERIOD = "period"
21def series_columns(frame: pl.DataFrame) -> list[str]:
22 """The columns that can be drawn: every numeric one.
24 >>> import datetime as dt, polars as pl
25 >>> frame = pl.DataFrame({"date": [dt.date(2024, 1, 1)], "nav": [1.0], "label": ["a"]})
26 >>> series_columns(frame)
27 ['nav']
28 """
29 return [name for name, dtype in frame.schema.items() if dtype.is_numeric()]
32def date_column(frame: pl.DataFrame) -> str | None:
33 """The first temporal column, which becomes the x-axis. None means row number.
35 >>> import datetime as dt, polars as pl
36 >>> date_column(pl.DataFrame({"when": [dt.date(2024, 1, 1)], "nav": [1.0]}))
37 'when'
38 >>> date_column(pl.DataFrame({"nav": [1.0]})) is None
39 True
40 """
41 return next((name for name, dtype in frame.schema.items() if dtype.is_temporal()), None)
44def default_pair(frame: pl.DataFrame) -> tuple[int, int]:
45 """Indices into :func:`series_columns` to open on — the first two series.
47 A frame with a single series opens on it twice, rather than refusing to draw.
49 >>> import polars as pl
50 >>> default_pair(pl.DataFrame({"a": [1.0], "b": [2.0]}))
51 (0, 1)
52 >>> default_pair(pl.DataFrame({"only": [1.0]}))
53 (0, 0)
54 """
55 names = series_columns(frame)
56 if not names:
57 raise ValueError("frame has no numeric columns to plot") # noqa: TRY003
58 return 0, 1 if len(names) > 1 else 0
61def aligned(frame: pl.DataFrame, a: str, b: str) -> pl.DataFrame:
62 """The two series on their common sample: ``period``, ``a``, ``b``, in order.
64 Both the picture and the summary tables are built from this, so the numbers
65 beside the chart always describe the lines in it. Renaming also sidesteps
66 Vega-Lite's field-shorthand escaping and lets ``a`` and ``b`` be the same column.
68 The three names are fixed whatever the columns were called, the rows come out
69 sorted by period, and a date where either series is missing is not part of the
70 sample — here the frame arrives unsorted and with a gap on the 2nd:
72 >>> import datetime as dt, polars as pl
73 >>> frame = pl.DataFrame(
74 ... {
75 ... "date": [dt.date(2024, 1, 3), dt.date(2024, 1, 1), dt.date(2024, 1, 2)],
76 ... "x": [3.0, 1.0, None],
77 ... "y": [30.0, 10.0, 20.0],
78 ... }
79 ... )
80 >>> pair = aligned(frame, "x", "y")
81 >>> pair.columns
82 ['period', 'a', 'b']
83 >>> pair["a"].to_list()
84 [1.0, 3.0]
85 """
86 for column in (a, b):
87 if column not in frame.columns:
88 raise KeyError(f"no column {column!r} in frame") # noqa: TRY003
89 if not frame.schema[column].is_numeric():
90 raise TypeError(f"column {column!r} is {frame.schema[column]}, which cannot be drawn") # noqa: TRY003
92 date = date_column(frame)
93 period = pl.col(date).alias(PERIOD) if date else pl.int_range(pl.len()).alias(PERIOD)
94 data = frame.select(period, pl.col(a).alias("a"), pl.col(b).alias("b"))
95 return data.drop_nulls().sort(PERIOD)