coeftable 0.3.1__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {coeftable-0.3.1 → coeftable-0.4.0}/.gitignore +2 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/CHANGELOG.md +5 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/PKG-INFO +57 -1
- {coeftable-0.3.1 → coeftable-0.4.0}/README.md +56 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/pyproject.toml +1 -1
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/__init__.py +5 -9
- coeftable-0.4.0/src/coeftable/_axis.py +55 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/_version.py +2 -2
- coeftable-0.4.0/src/coeftable/annotations.py +266 -0
- coeftable-0.4.0/src/coeftable/errors.py +9 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/series.py +2 -51
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/spec.py +127 -54
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/svg.py +132 -3
- coeftable-0.4.0/tests/test_annotations.py +200 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_frame.py +223 -1
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_public_api.py +2 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_series.py +25 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_sparkline.py +426 -6
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_svg.py +266 -1
- {coeftable-0.3.1 → coeftable-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/.github/release.yml +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/.github/workflows/ci.yml +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/.github/workflows/post-release.yml +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/.github/workflows/publish.yml +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/.github/workflows/release.yml +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/.pre-commit-config.yaml +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/LICENSE +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/Makefile +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/docs/images/example.png +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/docs/images/trend-example.png +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/noxfile.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/collapsible.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/format.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/frame.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/grid.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/render.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/theme.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_collapsible.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_format.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_package.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_render.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_spec.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_theme.py +0 -0
- {coeftable-0.3.1 → coeftable-0.4.0}/uv.lock +0 -0
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## Unreleased
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
- Add typed rule and band annotations to forest plots and sparklines, including row-specific field binding and domain-aware layering.
|
|
5
|
+
|
|
1
6
|
<a id="v0.3.1"></a>
|
|
2
7
|
# [v0.3.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.1) - 2026-08-14
|
|
3
8
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coeftable
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Publication-quality summary tables for estimates with uncertainty.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kylejcaron/coeftable
|
|
6
6
|
Project-URL: Repository, https://github.com/kylejcaron/coeftable
|
|
@@ -439,3 +439,59 @@ absolute = pl.DataFrame(
|
|
|
439
439
|
.sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
|
|
440
440
|
)
|
|
441
441
|
```
|
|
442
|
+
|
|
443
|
+
## Plot annotations
|
|
444
|
+
|
|
445
|
+
`ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
|
|
446
|
+
sparkline. A numeric, date, or datetime coordinate is a literal; a string is
|
|
447
|
+
the name of a scalar column on the main table frame. A missing field value
|
|
448
|
+
leaves that annotation out of that row, which makes row-specific marks
|
|
449
|
+
possible:
|
|
450
|
+
|
|
451
|
+
```python
|
|
452
|
+
import polars as pl
|
|
453
|
+
import coeftable as ct
|
|
454
|
+
|
|
455
|
+
annotated = pl.DataFrame(
|
|
456
|
+
{
|
|
457
|
+
"metric": ["Revenue", "Latency"],
|
|
458
|
+
"estimate": [1.2, -0.4],
|
|
459
|
+
"lower": [0.8, -0.8],
|
|
460
|
+
"upper": [1.6, 0.1],
|
|
461
|
+
# Only Revenue receives the second vertical rule.
|
|
462
|
+
"target": [1.5, None],
|
|
463
|
+
"trend": [[1.0, 1.2, 1.4], [-0.1, -0.3, -0.4]],
|
|
464
|
+
"guard_low": [0.9, -0.6],
|
|
465
|
+
"guard_high": [1.6, 0.0],
|
|
466
|
+
}
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
(
|
|
470
|
+
ct.CoefTable(annotated, rows="metric")
|
|
471
|
+
.estimate("Effect", "estimate", ci=("lower", "upper"))
|
|
472
|
+
.forest(
|
|
473
|
+
"Effect plot",
|
|
474
|
+
of="Effect",
|
|
475
|
+
annotations=(ct.Rule("target", axis="x"),),
|
|
476
|
+
)
|
|
477
|
+
.sparkline(
|
|
478
|
+
"Trend",
|
|
479
|
+
value="trend",
|
|
480
|
+
annotations=(ct.Band("guard_low", "guard_high", axis="y"),),
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Forest annotations use `axis="x"` only. Sparklines accept `axis="x"` and
|
|
486
|
+
`axis="y"`; use the former for a shared time or sequence position and the
|
|
487
|
+
latter for a value threshold or range. `layer="underlay"` (the default for
|
|
488
|
+
bands) draws before the plot; `layer="overlay"` (the default for rules) draws
|
|
489
|
+
after it. `affect_domain=True` by default expands an automatic axis domain to
|
|
490
|
+
include the annotation; set it to `False` to keep the existing domain and
|
|
491
|
+
allow the mark to be clipped. `ylim` overrides the Forest x-domain and the
|
|
492
|
+
Sparkline y-domain, so annotations on those axes do not expand them. Sparkline
|
|
493
|
+
x annotations still participate in their shared x-domain; `max_ylim` can cap
|
|
494
|
+
the Sparkline y-domain and clip or omit distant marks. Annotations supplement
|
|
495
|
+
rather than replace
|
|
496
|
+
`ref`: `ref` remains the built-in semantic reference that controls colors and
|
|
497
|
+
its optional dashed line.
|
|
@@ -385,3 +385,59 @@ absolute = pl.DataFrame(
|
|
|
385
385
|
.sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
|
|
386
386
|
)
|
|
387
387
|
```
|
|
388
|
+
|
|
389
|
+
## Plot annotations
|
|
390
|
+
|
|
391
|
+
`ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
|
|
392
|
+
sparkline. A numeric, date, or datetime coordinate is a literal; a string is
|
|
393
|
+
the name of a scalar column on the main table frame. A missing field value
|
|
394
|
+
leaves that annotation out of that row, which makes row-specific marks
|
|
395
|
+
possible:
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
import polars as pl
|
|
399
|
+
import coeftable as ct
|
|
400
|
+
|
|
401
|
+
annotated = pl.DataFrame(
|
|
402
|
+
{
|
|
403
|
+
"metric": ["Revenue", "Latency"],
|
|
404
|
+
"estimate": [1.2, -0.4],
|
|
405
|
+
"lower": [0.8, -0.8],
|
|
406
|
+
"upper": [1.6, 0.1],
|
|
407
|
+
# Only Revenue receives the second vertical rule.
|
|
408
|
+
"target": [1.5, None],
|
|
409
|
+
"trend": [[1.0, 1.2, 1.4], [-0.1, -0.3, -0.4]],
|
|
410
|
+
"guard_low": [0.9, -0.6],
|
|
411
|
+
"guard_high": [1.6, 0.0],
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
(
|
|
416
|
+
ct.CoefTable(annotated, rows="metric")
|
|
417
|
+
.estimate("Effect", "estimate", ci=("lower", "upper"))
|
|
418
|
+
.forest(
|
|
419
|
+
"Effect plot",
|
|
420
|
+
of="Effect",
|
|
421
|
+
annotations=(ct.Rule("target", axis="x"),),
|
|
422
|
+
)
|
|
423
|
+
.sparkline(
|
|
424
|
+
"Trend",
|
|
425
|
+
value="trend",
|
|
426
|
+
annotations=(ct.Band("guard_low", "guard_high", axis="y"),),
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Forest annotations use `axis="x"` only. Sparklines accept `axis="x"` and
|
|
432
|
+
`axis="y"`; use the former for a shared time or sequence position and the
|
|
433
|
+
latter for a value threshold or range. `layer="underlay"` (the default for
|
|
434
|
+
bands) draws before the plot; `layer="overlay"` (the default for rules) draws
|
|
435
|
+
after it. `affect_domain=True` by default expands an automatic axis domain to
|
|
436
|
+
include the annotation; set it to `False` to keep the existing domain and
|
|
437
|
+
allow the mark to be clipped. `ylim` overrides the Forest x-domain and the
|
|
438
|
+
Sparkline y-domain, so annotations on those axes do not expand them. Sparkline
|
|
439
|
+
x annotations still participate in their shared x-domain; `max_ylim` can cap
|
|
440
|
+
the Sparkline y-domain and clip or omit distant marks. Annotations supplement
|
|
441
|
+
rather than replace
|
|
442
|
+
`ref`: `ref` remains the built-in semantic reference that controls colors and
|
|
443
|
+
its optional dashed line.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from importlib.metadata import PackageNotFoundError, version
|
|
4
4
|
|
|
5
|
+
from coeftable.annotations import Band, Rule
|
|
6
|
+
from coeftable.errors import ColumnNotFoundError, SpecError
|
|
5
7
|
from coeftable.format import (
|
|
6
8
|
CalendarStep,
|
|
7
9
|
CIStyle,
|
|
@@ -11,15 +13,7 @@ from coeftable.format import (
|
|
|
11
13
|
Percent,
|
|
12
14
|
TimeFormat,
|
|
13
15
|
)
|
|
14
|
-
from coeftable.spec import
|
|
15
|
-
CoefTable,
|
|
16
|
-
ColumnNotFoundError,
|
|
17
|
-
Estimate,
|
|
18
|
-
Forest,
|
|
19
|
-
Passthrough,
|
|
20
|
-
Sparkline,
|
|
21
|
-
SpecError,
|
|
22
|
-
)
|
|
16
|
+
from coeftable.spec import CoefTable, Estimate, Forest, Passthrough, Sparkline
|
|
23
17
|
from coeftable.theme import Theme, role_for
|
|
24
18
|
|
|
25
19
|
try:
|
|
@@ -28,6 +22,7 @@ except PackageNotFoundError: # pragma: no cover
|
|
|
28
22
|
__version__ = "0.0.0.dev0"
|
|
29
23
|
|
|
30
24
|
__all__ = [
|
|
25
|
+
"Band",
|
|
31
26
|
"CIStyle",
|
|
32
27
|
"CalendarStep",
|
|
33
28
|
"CoefTable",
|
|
@@ -39,6 +34,7 @@ __all__ = [
|
|
|
39
34
|
"Number",
|
|
40
35
|
"Passthrough",
|
|
41
36
|
"Percent",
|
|
37
|
+
"Rule",
|
|
42
38
|
"Sparkline",
|
|
43
39
|
"SpecError",
|
|
44
40
|
"Theme",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared axis coercion helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# Deliberately naive: paired with elapsed-time subtraction in _epoch_seconds
|
|
11
|
+
# so relative spacing never depends on the host machine's local timezone.
|
|
12
|
+
_EPOCH = datetime.datetime(1970, 1, 1)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _epoch_seconds(value: datetime.date) -> float:
|
|
16
|
+
"""Convert a date/datetime to seconds since the Unix epoch.
|
|
17
|
+
|
|
18
|
+
A timezone-aware `datetime` is converted to UTC first; a naive
|
|
19
|
+
`datetime` (or a plain `date`, read as midnight) is measured as an
|
|
20
|
+
elapsed-time delta from a naive epoch, never through
|
|
21
|
+
`datetime.timestamp()` -- which reads a naive value against the host's
|
|
22
|
+
*local* timezone and would make relative spacing depend on where the
|
|
23
|
+
code runs.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(value, datetime.datetime):
|
|
26
|
+
if value.tzinfo is not None:
|
|
27
|
+
value = value.astimezone(datetime.UTC).replace(tzinfo=None)
|
|
28
|
+
return (value - _EPOCH).total_seconds()
|
|
29
|
+
return (datetime.datetime(value.year, value.month, value.day) - _EPOCH).total_seconds()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _detect_temporal(values: Iterable[Any]) -> bool:
|
|
33
|
+
"""Return True when the first non-missing value is a date or datetime."""
|
|
34
|
+
for value in values:
|
|
35
|
+
if value is not None:
|
|
36
|
+
return isinstance(value, datetime.date)
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _coerce_temporal(values: Iterable[Any]) -> list[float | None]:
|
|
41
|
+
"""Coerce raw date/datetime values to epoch seconds, `None` for missing.
|
|
42
|
+
|
|
43
|
+
A bare `None` is missing directly; pandas' `NaT` is not `None` but is
|
|
44
|
+
still an `isinstance(..., datetime.datetime)` whose epoch delta
|
|
45
|
+
degenerates to NaN rather than raising, so the result is checked for
|
|
46
|
+
NaN too -- mirroring `coerce_numeric`'s own missing-value handling.
|
|
47
|
+
"""
|
|
48
|
+
out: list[float | None] = []
|
|
49
|
+
for value in values:
|
|
50
|
+
if value is None:
|
|
51
|
+
out.append(None)
|
|
52
|
+
continue
|
|
53
|
+
seconds = _epoch_seconds(value)
|
|
54
|
+
out.append(None if math.isnan(seconds) else seconds)
|
|
55
|
+
return out
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Typed plot-annotation declarations and frame-aware resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import narwhals as nw
|
|
12
|
+
|
|
13
|
+
from coeftable._axis import _coerce_temporal
|
|
14
|
+
from coeftable.errors import SpecError
|
|
15
|
+
from coeftable.format import coerce_numeric
|
|
16
|
+
|
|
17
|
+
type Axis = Literal["x", "y"]
|
|
18
|
+
type AxisKind = Literal["numeric", "temporal"]
|
|
19
|
+
type Layer = Literal["underlay", "overlay"]
|
|
20
|
+
type Dash = Literal["solid", "dashed", "dotted"]
|
|
21
|
+
type AnnotationSource = int | float | dt.date | dt.datetime | str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_coordinate(value: AnnotationSource, *, name: str) -> None:
|
|
25
|
+
"""Validate a declaration's literal-or-field coordinate."""
|
|
26
|
+
if isinstance(value, bool) or not isinstance(value, (int, float, dt.date, str)):
|
|
27
|
+
raise SpecError(f"Annotation {name} must be a numeric/temporal literal or field name.")
|
|
28
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
29
|
+
raise SpecError(f"Annotation {name} must be finite.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_unit_interval(value: float, *, name: str) -> None:
|
|
33
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
34
|
+
raise SpecError(f"Annotation {name} must be a finite number between 0 and 1.")
|
|
35
|
+
if not 0.0 <= value <= 1.0:
|
|
36
|
+
raise SpecError(f"Annotation {name} must be between 0 and 1.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Rule:
|
|
41
|
+
"""A line annotation at one axis coordinate."""
|
|
42
|
+
|
|
43
|
+
at: AnnotationSource
|
|
44
|
+
axis: Axis
|
|
45
|
+
layer: Layer = "overlay"
|
|
46
|
+
affect_domain: bool = True
|
|
47
|
+
color: str | None = None
|
|
48
|
+
opacity: float = 1.0
|
|
49
|
+
width: float = 1.0
|
|
50
|
+
dash: Dash = "dashed"
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
"""Validate declaration-only rule styling and coordinates."""
|
|
54
|
+
_validate_coordinate(self.at, name="coordinate")
|
|
55
|
+
if self.layer not in ("underlay", "overlay"):
|
|
56
|
+
raise SpecError(
|
|
57
|
+
f"Annotation layer must be 'underlay' or 'overlay'; got {self.layer!r}."
|
|
58
|
+
)
|
|
59
|
+
_validate_unit_interval(self.opacity, name="opacity")
|
|
60
|
+
if isinstance(self.width, bool) or not isinstance(self.width, (int, float)):
|
|
61
|
+
raise SpecError("Annotation width must be a finite positive number.")
|
|
62
|
+
if not math.isfinite(self.width) or self.width <= 0:
|
|
63
|
+
raise SpecError("Annotation width must be a finite positive number.")
|
|
64
|
+
if self.dash not in ("solid", "dashed", "dotted"):
|
|
65
|
+
raise SpecError(
|
|
66
|
+
f"Annotation dash must be 'solid', 'dashed', or 'dotted'; got {self.dash!r}."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Band:
|
|
72
|
+
"""A shaded interval annotation between two axis coordinates."""
|
|
73
|
+
|
|
74
|
+
start: AnnotationSource
|
|
75
|
+
end: AnnotationSource
|
|
76
|
+
axis: Axis
|
|
77
|
+
layer: Layer = "underlay"
|
|
78
|
+
affect_domain: bool = True
|
|
79
|
+
color: str | None = None
|
|
80
|
+
opacity: float = 0.12
|
|
81
|
+
|
|
82
|
+
def __post_init__(self) -> None:
|
|
83
|
+
"""Validate declaration-only band styling and coordinates."""
|
|
84
|
+
_validate_coordinate(self.start, name="start")
|
|
85
|
+
_validate_coordinate(self.end, name="end")
|
|
86
|
+
if self.layer not in ("underlay", "overlay"):
|
|
87
|
+
raise SpecError(
|
|
88
|
+
f"Annotation layer must be 'underlay' or 'overlay'; got {self.layer!r}."
|
|
89
|
+
)
|
|
90
|
+
_validate_unit_interval(self.opacity, name="opacity")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
type Annotation = Rule | Band
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ResolvedRule:
|
|
98
|
+
"""A fully resolved line annotation."""
|
|
99
|
+
|
|
100
|
+
at: float
|
|
101
|
+
axis: Axis
|
|
102
|
+
layer: Layer
|
|
103
|
+
affect_domain: bool
|
|
104
|
+
color: str | None
|
|
105
|
+
opacity: float
|
|
106
|
+
width: float
|
|
107
|
+
dash: Dash
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ResolvedBand:
|
|
112
|
+
"""A fully resolved interval annotation."""
|
|
113
|
+
|
|
114
|
+
start: float
|
|
115
|
+
end: float
|
|
116
|
+
axis: Axis
|
|
117
|
+
layer: Layer
|
|
118
|
+
affect_domain: bool
|
|
119
|
+
color: str | None
|
|
120
|
+
opacity: float
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
type ResolvedAnnotation = ResolvedRule | ResolvedBand
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class PreparedAnnotations:
|
|
128
|
+
"""Resolved annotations grouped in frame-row order."""
|
|
129
|
+
|
|
130
|
+
by_row: tuple[tuple[ResolvedAnnotation, ...], ...]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def annotation_sources(annotations: Sequence[Annotation]) -> tuple[str, ...]:
|
|
134
|
+
"""Return source-field names once, in declaration order."""
|
|
135
|
+
sources: dict[str, None] = {}
|
|
136
|
+
for annotation in annotations:
|
|
137
|
+
coordinates = (
|
|
138
|
+
(annotation.at,)
|
|
139
|
+
if isinstance(annotation, Rule)
|
|
140
|
+
else (annotation.start, annotation.end)
|
|
141
|
+
)
|
|
142
|
+
for coordinate in coordinates:
|
|
143
|
+
if isinstance(coordinate, str):
|
|
144
|
+
sources.setdefault(coordinate, None)
|
|
145
|
+
return tuple(sources)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _coerce_values(
|
|
149
|
+
values: Sequence[Any],
|
|
150
|
+
*,
|
|
151
|
+
kind: AxisKind,
|
|
152
|
+
context: str,
|
|
153
|
+
) -> list[float | None]:
|
|
154
|
+
present = [value for value in values if value is not None]
|
|
155
|
+
if kind == "numeric":
|
|
156
|
+
if any(isinstance(value, (bool, dt.date)) for value in present):
|
|
157
|
+
raise SpecError(f"{context} must be numeric for a numeric axis.")
|
|
158
|
+
try:
|
|
159
|
+
resolved = coerce_numeric(values, subject=context)
|
|
160
|
+
except TypeError as error:
|
|
161
|
+
raise SpecError(str(error)) from None
|
|
162
|
+
elif kind == "temporal":
|
|
163
|
+
if any(not isinstance(value, dt.date) for value in present):
|
|
164
|
+
raise SpecError(f"{context} must be temporal for a temporal axis.")
|
|
165
|
+
resolved = _coerce_temporal(values)
|
|
166
|
+
else:
|
|
167
|
+
raise SpecError(f"{context} has unsupported axis kind {kind!r}.")
|
|
168
|
+
|
|
169
|
+
if any(value is not None and not math.isfinite(value) for value in resolved):
|
|
170
|
+
raise SpecError(f"{context} must be finite.")
|
|
171
|
+
return resolved
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def prepare_annotations(
|
|
175
|
+
annotations: Sequence[Annotation],
|
|
176
|
+
frame: nw.DataFrame,
|
|
177
|
+
*,
|
|
178
|
+
axis_kinds: Mapping[Axis, AxisKind],
|
|
179
|
+
plot_label: str,
|
|
180
|
+
row_identities: Sequence[Any],
|
|
181
|
+
) -> PreparedAnnotations:
|
|
182
|
+
"""Resolve annotation literals and fields into one ordered tuple per row."""
|
|
183
|
+
row_count = len(frame)
|
|
184
|
+
if len(row_identities) != row_count:
|
|
185
|
+
raise SpecError(
|
|
186
|
+
f"{plot_label} annotation rows do not match the frame: "
|
|
187
|
+
f"{len(row_identities)} identities for {row_count} rows."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
source_cache: dict[str, list[Any]] = {}
|
|
191
|
+
|
|
192
|
+
def values_for(
|
|
193
|
+
source: AnnotationSource, *, kind: AxisKind, context: str
|
|
194
|
+
) -> list[float | None]:
|
|
195
|
+
if isinstance(source, str):
|
|
196
|
+
if source not in frame.columns:
|
|
197
|
+
raise SpecError(f"{context} names missing column {source!r}.")
|
|
198
|
+
if source not in source_cache:
|
|
199
|
+
source_cache[source] = frame[source].to_list()
|
|
200
|
+
raw = source_cache[source]
|
|
201
|
+
else:
|
|
202
|
+
raw = [source] * row_count
|
|
203
|
+
return _coerce_values(raw, kind=kind, context=context)
|
|
204
|
+
|
|
205
|
+
resolved_rows: list[list[ResolvedAnnotation]] = [[] for _ in range(row_count)]
|
|
206
|
+
for index, annotation in enumerate(annotations):
|
|
207
|
+
if annotation.axis not in axis_kinds:
|
|
208
|
+
raise SpecError(
|
|
209
|
+
f"{plot_label} annotation {index} uses unsupported axis {annotation.axis!r}."
|
|
210
|
+
)
|
|
211
|
+
kind = axis_kinds[annotation.axis]
|
|
212
|
+
prefix = f"{plot_label} annotation {index}"
|
|
213
|
+
if isinstance(annotation, Rule):
|
|
214
|
+
positions = values_for(annotation.at, kind=kind, context=f"{prefix} at")
|
|
215
|
+
for row, position in enumerate(positions):
|
|
216
|
+
if position is not None:
|
|
217
|
+
resolved_rows[row].append(
|
|
218
|
+
ResolvedRule(
|
|
219
|
+
at=position,
|
|
220
|
+
axis=annotation.axis,
|
|
221
|
+
layer=annotation.layer,
|
|
222
|
+
affect_domain=annotation.affect_domain,
|
|
223
|
+
color=annotation.color,
|
|
224
|
+
opacity=annotation.opacity,
|
|
225
|
+
width=annotation.width,
|
|
226
|
+
dash=annotation.dash,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
starts = values_for(annotation.start, kind=kind, context=f"{prefix} start")
|
|
232
|
+
ends = values_for(annotation.end, kind=kind, context=f"{prefix} end")
|
|
233
|
+
for row, (start, end) in enumerate(zip(starts, ends, strict=True)):
|
|
234
|
+
if start is None or end is None:
|
|
235
|
+
continue
|
|
236
|
+
if start > end:
|
|
237
|
+
raise SpecError(
|
|
238
|
+
f"{prefix} row {row_identities[row]!r}: "
|
|
239
|
+
f"band start {start!r} must not exceed end {end!r}."
|
|
240
|
+
)
|
|
241
|
+
resolved_rows[row].append(
|
|
242
|
+
ResolvedBand(
|
|
243
|
+
start=start,
|
|
244
|
+
end=end,
|
|
245
|
+
axis=annotation.axis,
|
|
246
|
+
layer=annotation.layer,
|
|
247
|
+
affect_domain=annotation.affect_domain,
|
|
248
|
+
color=annotation.color,
|
|
249
|
+
opacity=annotation.opacity,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return PreparedAnnotations(by_row=tuple(tuple(marks) for marks in resolved_rows))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def domain_values(marks: Sequence[ResolvedAnnotation], *, axis: Axis) -> list[float]:
|
|
257
|
+
"""Return domain-affecting positions for one axis, preserving mark order."""
|
|
258
|
+
values: list[float] = []
|
|
259
|
+
for mark in marks:
|
|
260
|
+
if mark.axis != axis or not mark.affect_domain:
|
|
261
|
+
continue
|
|
262
|
+
if isinstance(mark, ResolvedRule):
|
|
263
|
+
values.append(mark.at)
|
|
264
|
+
else:
|
|
265
|
+
values.extend((mark.start, mark.end))
|
|
266
|
+
return values
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Shared exceptions for invalid table specifications."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SpecError(ValueError):
|
|
5
|
+
"""Raised when a table specification is internally inconsistent."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ColumnNotFoundError(KeyError):
|
|
9
|
+
"""Raised when a specification names a column absent from the frame."""
|
|
@@ -10,16 +10,15 @@ coerces values and detects a temporal x -- callers only ever see a `Series`.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
-
import datetime
|
|
14
13
|
import math
|
|
15
|
-
from collections.abc import Iterable
|
|
16
14
|
from dataclasses import dataclass
|
|
17
15
|
from typing import Any, overload
|
|
18
16
|
|
|
19
17
|
import narwhals as nw
|
|
20
18
|
|
|
19
|
+
from coeftable._axis import _coerce_temporal, _detect_temporal
|
|
20
|
+
from coeftable.errors import SpecError
|
|
21
21
|
from coeftable.format import coerce_numeric
|
|
22
|
-
from coeftable.spec import SpecError
|
|
23
22
|
|
|
24
23
|
|
|
25
24
|
@dataclass(frozen=True)
|
|
@@ -73,54 +72,6 @@ def _nan_to_none(values: list[Any]) -> list[Any]:
|
|
|
73
72
|
return [None if isinstance(v, float) and math.isnan(v) else v for v in values]
|
|
74
73
|
|
|
75
74
|
|
|
76
|
-
# Deliberately naive: paired with elapsed-time subtraction in _epoch_seconds
|
|
77
|
-
# so relative spacing never depends on the host machine's local timezone.
|
|
78
|
-
_EPOCH = datetime.datetime(1970, 1, 1)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _epoch_seconds(value: datetime.date) -> float:
|
|
82
|
-
"""Convert a date/datetime to seconds since the Unix epoch.
|
|
83
|
-
|
|
84
|
-
A timezone-aware `datetime` is converted to UTC first; a naive
|
|
85
|
-
`datetime` (or a plain `date`, read as midnight) is measured as an
|
|
86
|
-
elapsed-time delta from a naive epoch, never through
|
|
87
|
-
`datetime.timestamp()` -- which reads a naive value against the host's
|
|
88
|
-
*local* timezone and would make relative spacing depend on where the
|
|
89
|
-
code runs.
|
|
90
|
-
"""
|
|
91
|
-
if isinstance(value, datetime.datetime):
|
|
92
|
-
if value.tzinfo is not None:
|
|
93
|
-
value = value.astimezone(datetime.UTC).replace(tzinfo=None)
|
|
94
|
-
return (value - _EPOCH).total_seconds()
|
|
95
|
-
return (datetime.datetime(value.year, value.month, value.day) - _EPOCH).total_seconds()
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _detect_temporal(values: Iterable[Any]) -> bool:
|
|
99
|
-
"""Return True when the first non-missing value is a date or datetime."""
|
|
100
|
-
for value in values:
|
|
101
|
-
if value is not None:
|
|
102
|
-
return isinstance(value, datetime.date)
|
|
103
|
-
return False
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def _coerce_temporal(values: Iterable[Any]) -> list[float | None]:
|
|
107
|
-
"""Coerce raw date/datetime values to epoch seconds, `None` for missing.
|
|
108
|
-
|
|
109
|
-
A bare `None` is missing directly; pandas' `NaT` is not `None` but is
|
|
110
|
-
still an `isinstance(..., datetime.datetime)` whose epoch delta
|
|
111
|
-
degenerates to NaN rather than raising, so the result is checked for
|
|
112
|
-
NaN too -- mirroring `coerce_numeric`'s own missing-value handling.
|
|
113
|
-
"""
|
|
114
|
-
out: list[float | None] = []
|
|
115
|
-
for value in values:
|
|
116
|
-
if value is None:
|
|
117
|
-
out.append(None)
|
|
118
|
-
continue
|
|
119
|
-
seconds = _epoch_seconds(value)
|
|
120
|
-
out.append(None if math.isnan(seconds) else seconds)
|
|
121
|
-
return out
|
|
122
|
-
|
|
123
|
-
|
|
124
75
|
def _build_series(
|
|
125
76
|
y_raw: list[Any],
|
|
126
77
|
lower_raw: list[Any] | None,
|