"""Pure scale, formatting, and value mapping helpers.""" from __future__ import annotations import math from collections.abc import Sequence from typing import Literal from .exceptions import GaugeConfigError, GaugeValueError from .types import GaugeMarker, ThresholdBand def validate_range(min_value: float, max_value: float) -> None: if not math.isfinite(min_value) or not math.isfinite(max_value): msg = "gauge range values must be finite" raise GaugeConfigError(msg) if min_value >= max_value: msg = "min_value must be less than max_value" raise GaugeConfigError(msg) def is_finite_number(value: object) -> bool: return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value) def clamp_value(value: float, min_value: float, max_value: float) -> float: validate_range(min_value, max_value) if not math.isfinite(value): msg = "value must be finite" raise GaugeValueError(msg) return min(max(value, min_value), max_value) def normalize_value( value: float, min_value: float, max_value: float, *, clamp: bool = True ) -> float: validate_range(min_value, max_value) if clamp: value = clamp_value(value, min_value, max_value) if not math.isfinite(value): msg = "value must be finite" raise GaugeValueError(msg) return (value - min_value) / (max_value - min_value) def value_to_angle( value: float, min_value: float, max_value: float, start_angle: float, end_angle: float, *, clamp: bool = True, ) -> float: fraction = normalize_value(value, min_value, max_value, clamp=clamp) return start_angle + (end_angle - start_angle) * fraction def compass_heading(value: float) -> float: if not math.isfinite(value): msg = "heading value must be finite" raise GaugeValueError(msg) return value % 360.0 def fill_fraction(value: float, min_value: float, max_value: float, *, clamp: bool = True) -> float: return normalize_value(value, min_value, max_value, clamp=clamp) def fill_span( value: float, min_value: float, max_value: float, *, fill_mode: Literal["clamp", "centered"] = "clamp", clamp: bool = True, ) -> tuple[float, float]: fraction = fill_fraction(value, min_value, max_value, clamp=clamp) if fill_mode == "clamp": return 0.0, fraction zero = fill_fraction(0.0, min_value, max_value, clamp=True) return min(zero, fraction), max(zero, fraction) def normalized_span( start: float, end: float, min_value: float, max_value: float ) -> tuple[float, float]: first = fill_fraction(start, min_value, max_value, clamp=True) second = fill_fraction(end, min_value, max_value, clamp=True) return min(first, second), max(first, second) def active_segments( value: float, min_value: float, max_value: float, segments: int, *, clamp: bool = True ) -> int: if segments <= 0: msg = "segments must be greater than zero" raise GaugeConfigError(msg) fraction = fill_fraction(value, min_value, max_value, clamp=clamp) return min(max(math.ceil(fraction * segments), 0), segments) def generate_ticks(min_value: float, max_value: float, count: int) -> list[float]: validate_range(min_value, max_value) if count < 2: msg = "tick count must be at least 2" raise GaugeConfigError(msg) step = (max_value - min_value) / (count - 1) return [min_value + step * index for index in range(count)] def format_gauge_value(value: float, precision: int = 0, unit: str = "") -> str: if precision < 0: msg = "precision must be non-negative" raise GaugeConfigError(msg) if not math.isfinite(value): return "--" formatted = f"{value:.{precision}f}" return f"{formatted} {unit}" if unit else formatted def validate_thresholds( thresholds: Sequence[ThresholdBand], min_value: float, max_value: float ) -> None: validate_range(min_value, max_value) for threshold in thresholds: if not math.isfinite(threshold.start) or not math.isfinite(threshold.end): msg = "threshold values must be finite" raise GaugeConfigError(msg) def validate_markers(markers: Sequence[GaugeMarker], min_value: float, max_value: float) -> None: validate_range(min_value, max_value) for marker in markers: if not math.isfinite(marker.value): msg = "marker values must be finite" raise GaugeConfigError(msg) def clamped_threshold( threshold: ThresholdBand, min_value: float, max_value: float ) -> tuple[float, float]: start = clamp_value(threshold.start, min_value, max_value) end = clamp_value(threshold.end, min_value, max_value) return min(start, end), max(start, end)