Source code for pystatsbio.power._common
"""Shared result types and helpers for power/sample size calculations."""
from __future__ import annotations
import math
from collections.abc import Callable
from dataclasses import dataclass
from scipy.optimize import brentq
[docs]
@dataclass(frozen=True)
class PowerResult:
"""Result of a power/sample size calculation.
Exactly one of n, power, or effect_size will have been solved for
(the parameter passed as None). The others are the user-supplied inputs.
"""
n: int | None
power: float | None
effect_size: float | None
alpha: float
alternative: str
method: str
note: str = ""
[docs]
def summary(self) -> str:
"""Human-readable summary, similar to R's print.power.htest."""
lines = [self.method, ""]
if self.n is not None:
lines.append(f" n = {self.n}")
if self.effect_size is not None:
lines.append(f" effect size = {self.effect_size:.6f}")
lines.append(f" alpha = {self.alpha}")
if self.power is not None:
lines.append(f" power = {self.power:.6f}")
lines.append(f" alternative = {self.alternative}")
if self.note:
lines.append("")
lines.append(f"NOTE: {self.note}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Shared validation
# ---------------------------------------------------------------------------
def _check_power_args(
*,
n: int | float | None,
effect: float | None,
power: float | None,
alpha: float,
effect_name: str = "effect_size",
) -> str:
"""Validate power-analysis inputs. Return the name of the parameter to solve for.
Rules
-----
- Exactly one of *n*, *effect*, *power* must be ``None``.
- *alpha* must be in (0, 1).
- If provided, *n* must be >= 2.
- If provided, *power* must be in (0, 1).
- If provided, *effect* must be finite.
Returns
-------
str
``'n'``, ``'effect'``, or ``'power'`` — the parameter to solve for.
Raises
------
ValueError
On any validation failure.
"""
none_count = sum(x is None for x in (n, effect, power))
if none_count != 1:
raise ValueError(
f"Exactly one of n, {effect_name}, power must be None "
f"(got {none_count} None values)"
)
if not (0.0 < alpha < 1.0):
raise ValueError(f"alpha must be in (0, 1), got {alpha}")
if n is not None:
if n < 2:
raise ValueError(f"n must be >= 2, got {n}")
if power is not None:
if not (0.0 < power < 1.0):
raise ValueError(f"power must be in (0, 1), got {power}")
if effect is not None:
if not math.isfinite(effect):
raise ValueError(f"{effect_name} must be finite, got {effect}")
if n is None:
return "n"
if effect is None:
return "effect"
return "power"
# ---------------------------------------------------------------------------
# Shared root-finding
# ---------------------------------------------------------------------------
def _solve_parameter(
func: Callable[[float], float],
target: float,
bracket: tuple[float, float],
*,
xtol: float = 1e-10,
maxiter: int = 1000,
) -> float:
"""Solve ``func(x) == target`` via Brent's method.
Parameters
----------
func : callable
Monotonic function of one variable (e.g. computes power as f(n)).
target : float
Target value (e.g. desired power).
bracket : tuple
``(lower, upper)`` bracket. ``func(lower) - target`` and
``func(upper) - target`` must have opposite signs.
Returns
-------
float
The solution *x* such that ``func(x) ≈ target``.
Raises
------
ValueError
If the bracket does not straddle the target (no sign change).
"""
lo, hi = bracket
f_lo = func(lo) - target
f_hi = func(hi) - target
# Check bracket validity
if f_lo * f_hi > 0:
raise ValueError(
f"Cannot solve: target {target:.6f} is outside achievable range "
f"[{func(lo):.6f}, {func(hi):.6f}] for the given parameters. "
f"Try different input values."
)
return brentq(lambda x: func(x) - target, lo, hi, xtol=xtol, maxiter=maxiter)