from __future__ import annotations
from dataclasses import dataclass
from typing import Sequence
import numpy as np
@dataclass(frozen=True)
[docs]
class PieSlice:
"""One normalized pie slice definition."""
[docs]
class PieDistributionModel:
"""Compute and store normalized pie fractions from values."""
def __init__(self, values: Sequence[float], labels: Sequence[str] | None = None) -> None:
[docs]
self._values = np.array([], dtype=np.float64)
[docs]
self._fractions = np.array([], dtype=np.float64)
[docs]
self._labels: list[str] = []
self.set_values(values, labels=labels)
@property
[docs]
def values(self) -> np.ndarray:
return self._values.copy()
@property
[docs]
def fractions(self) -> np.ndarray:
return self._fractions.copy()
@property
[docs]
def labels(self) -> list[str]:
return self._labels.copy()
[docs]
def set_values(self, values: Sequence[float], labels: Sequence[str] | None = None) -> None:
arr = np.asarray(values, dtype=np.float64).ravel()
if arr.size == 0:
raise ValueError("Pie distribution needs at least one value")
if not np.isfinite(arr).all():
raise ValueError("All pie values must be finite")
if np.any(arr < 0.0):
raise ValueError("Pie values must be >= 0")
total = float(arr.sum())
if total <= 0.0:
raise ValueError("The sum of pie values must be > 0")
self._values = arr
self._fractions = arr / total
if labels is None:
self._labels = [f"Slice {i + 1}" for i in range(arr.size)]
else:
if len(labels) != arr.size:
raise ValueError("labels length must match values length")
self._labels = [str(lbl) for lbl in labels]
[docs]
def slices(self, start_angle_deg: float = 90.0, clockwise: bool = False) -> list[PieSlice]:
sign = -1.0 if clockwise else 1.0
cur = float(start_angle_deg)
out: list[PieSlice] = []
for value, frac, label in zip(self._values, self._fractions, self._labels):
delta = sign * 360.0 * float(frac)
nxt = cur + delta
out.append(PieSlice(float(value), float(frac), cur, nxt, label))
cur = nxt
return out