Coverage for src/dummypy/grid.py: 100%
27 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-02 20:30 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-02 20:30 +0000
1"""The :class:`Grid` value type for the dummypy analytics library.
3Responsibility split:
5- :func:`_build_grids` owns the *data-generation* concern — turning a size
6 ``n`` into the two coordinate DataFrames. It is a pure function of ``n``.
7- :class:`Grid` owns the *model* concern — validating ``n``, holding the
8 generated ``x``/``y`` frames, and exposing behaviour (:meth:`Grid.diff`).
10Keeping generation in a standalone function keeps ``Grid`` a thin, testable
11value type rather than a class that both stores and manufactures its data.
12"""
14import attrs
15import numpy as np
16import pandas as pd
19def _check_n(_instance: object, _attribute: "attrs.Attribute[int]", value: object) -> None:
20 """Reject non-integer or negative grid sizes with a clear error.
22 Args:
23 _instance: The Grid instance being validated (unused).
24 _attribute: The attrs attribute being validated (unused).
25 value: The proposed value for ``n``.
27 Raises:
28 TypeError: If ``value`` is not an integer (bool is rejected too).
29 ValueError: If ``value`` is negative.
30 """
31 # bool is a subclass of int; reject it to avoid Grid(n=True) surprises.
32 if not isinstance(value, int) or isinstance(value, bool):
33 msg = f"Grid size n must be an integer, got {type(value).__name__}"
34 raise TypeError(msg)
35 if value < 0:
36 msg = f"Grid size n must be non-negative, got {value}"
37 raise ValueError(msg)
40def _build_grids(n: int) -> tuple[pd.DataFrame, pd.DataFrame]:
41 """Build the ``(x, y)`` coordinate frames for a grid of size ``n``.
43 This is the data-generation concern, kept separate from the :class:`Grid`
44 model. ``y`` has each row equal to ``0..n``; ``x`` is its transpose. Both
45 are square with side ``n + 1`` and share string coordinate labels.
47 Args:
48 n: Non-negative grid size (already validated by the caller).
50 Returns:
51 An ``(x, y)`` tuple of DataFrames, where ``x == y.T``.
52 """
53 nn = np.arange(n + 1)
54 cols = [str(i) for i in nn]
55 data = np.tile(nn, (n + 1, 1))
56 y = pd.DataFrame(data, index=pd.Index(cols), columns=pd.Index(cols))
57 return y.T, y
60@attrs.frozen
61class Grid:
62 """A grid representing data points for analytics calculations.
64 Holds two coordinate DataFrames, ``x`` and ``y`` (with ``x == y.T``),
65 generated from the grid size ``n`` by :func:`_build_grids`.
67 Instances are **immutable**. ``x`` and ``y`` are derived from ``n``, so
68 letting any of the three be reassigned would break the invariants the
69 validator and :func:`_build_grids` establish at construction: a new ``x``
70 need not be ``y.T``, and a new ``n`` would not rebuild the frames it is
71 supposed to describe. Build a new :class:`Grid` instead.
73 Args:
74 n: Maximum size for the grid (default: 10). Must be a non-negative
75 integer.
77 Raises:
78 TypeError: If ``n`` is not an integer (e.g. a float or a bool).
79 ValueError: If ``n`` is negative.
81 Examples:
82 Both frames are square with side ``n + 1``, and ``x`` is the transpose
83 of ``y``:
85 >>> grid = Grid(n=3)
86 >>> grid.x.shape
87 (4, 4)
88 >>> bool((grid.x == grid.y.T).all().all())
89 True
91 Only ``n`` is part of the repr, since ``x`` and ``y`` are derived:
93 >>> grid
94 Grid(n=3)
96 A negative size is rejected, and so is a float or a bool:
98 >>> Grid(n=-1)
99 Traceback (most recent call last):
100 ...
101 ValueError: Grid size n must be non-negative, got -1
103 >>> Grid(n=2.0)
104 Traceback (most recent call last):
105 ...
106 TypeError: Grid size n must be an integer, got float
108 Instances are immutable — build a new grid rather than reassigning:
110 >>> grid.n = 5 # doctest: +IGNORE_EXCEPTION_DETAIL
111 Traceback (most recent call last):
112 ...
113 attr.exceptions.FrozenInstanceError
114 """
116 n: int = attrs.field(init=True, repr=True, default=10, validator=_check_n)
117 x: pd.DataFrame = attrs.field(repr=False, init=False)
118 y: pd.DataFrame = attrs.field(repr=False, init=False)
120 def __attrs_post_init__(self) -> None:
121 """Populate the x and y coordinate frames from ``n``.
123 Uses :func:`object.__setattr__` because the class is frozen — the
124 standard attrs idiom for a derived attribute on an immutable class.
125 Keeping the single :func:`_build_grids` call here preserves the
126 generation-vs-model split described in the module docstring.
127 """
128 x, y = _build_grids(self.n)
129 object.__setattr__(self, "x", x)
130 object.__setattr__(self, "y", y)
132 def diff(self) -> pd.DataFrame:
133 """Returns a grid of differences.
135 Returns:
136 A fresh DataFrame of element-wise differences (x - y), computed
137 anew on each call.
139 Examples:
140 The value at ``(i, j)`` is ``i - j``, so the frame is antisymmetric
141 and its diagonal is zero:
143 >>> grid = Grid(n=3)
144 >>> grid.diff().loc["2", "1"]
145 np.int64(1)
146 >>> grid.diff().loc["1", "2"]
147 np.int64(-1)
149 Each call returns a fresh frame, so mutating one cannot corrupt the
150 grid it came from:
152 >>> grid.diff() is grid.diff()
153 False
154 """
155 return self.x - self.y