Files
dpg-gauges/src/dpg_gauges/scales.py

146 lines
4.7 KiB
Python

"""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)