Merge pull request #647 from AssetOverflow/feat/r3-2-a
feat(rate): R3.2a — exact-rational time-unit conversion primitive [merge after #646]
This commit is contained in:
commit
7e39357e11
2 changed files with 91 additions and 0 deletions
49
generate/rate_comprehension/conversion.py
Normal file
49
generate/rate_comprehension/conversion.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Exact rational time-unit conversion for R3.2 (single-rate only).
|
||||
|
||||
The single new capability of R3.2: a duration whose unit differs from the rate's denominator may be
|
||||
**converted** when both are known time units (``minute`` ↔ ``hour``), instead of refusing. The
|
||||
conversion is **exact rational** (``fractions.Fraction``) — never a float, never a decimal:
|
||||
|
||||
```text
|
||||
30 minutes -> Fraction(1, 2) hour
|
||||
90 minutes -> Fraction(3, 2) hour
|
||||
2 hours -> Fraction(120) minute
|
||||
```
|
||||
|
||||
So ``60 mile/hour × 30 minute`` becomes ``60 × 1/2 = 30 mile`` (exact). A non-time / non-convertible
|
||||
pair (``minute`` ↔ ``mile``) raises ``ConversionError`` and the caller still refuses
|
||||
``rate_unit_mismatch`` — convertibility is the only thing R3.2 adds.
|
||||
|
||||
Deliberately tiny: ONLY ``minute`` ↔ ``hour`` in v1. No length (mile↔km), no currency
|
||||
(dollar↔cent), no compound conversions, no clock-time intervals. Deterministic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
|
||||
class ConversionError(ValueError):
|
||||
"""The units are not a known convertible pair — the caller must refuse."""
|
||||
|
||||
|
||||
#: Each known time unit as an exact rational number of the base unit (one hour).
|
||||
_TIME_IN_HOURS: dict[str, Fraction] = {
|
||||
"hour": Fraction(1),
|
||||
"minute": Fraction(1, 60),
|
||||
}
|
||||
|
||||
|
||||
def is_convertible(from_unit: str, to_unit: str) -> bool:
|
||||
"""Whether *from_unit* and *to_unit* are both known time units (so a conversion exists)."""
|
||||
return from_unit in _TIME_IN_HOURS and to_unit in _TIME_IN_HOURS
|
||||
|
||||
|
||||
def convert_time(value: int, from_unit: str, to_unit: str) -> Fraction:
|
||||
"""Exact rational conversion of *value* ``from_unit`` into *to_unit*. Refuses unknown units."""
|
||||
if not is_convertible(from_unit, to_unit):
|
||||
raise ConversionError(f"no exact conversion {from_unit!r} -> {to_unit!r}")
|
||||
return Fraction(value) * _TIME_IN_HOURS[from_unit] / _TIME_IN_HOURS[to_unit]
|
||||
|
||||
|
||||
__all__ = ["ConversionError", "convert_time", "is_convertible"]
|
||||
42
tests/test_rate_conversion.py
Normal file
42
tests/test_rate_conversion.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Tests for the R3.2 exact-rational time-unit conversion primitive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
|
||||
import pytest
|
||||
|
||||
from generate.rate_comprehension.conversion import ConversionError, convert_time, is_convertible
|
||||
|
||||
|
||||
def test_minutes_to_hours_is_exact_rational() -> None:
|
||||
assert convert_time(30, "minute", "hour") == Fraction(1, 2)
|
||||
assert convert_time(90, "minute", "hour") == Fraction(3, 2)
|
||||
assert convert_time(60, "minute", "hour") == Fraction(1) # exact whole
|
||||
|
||||
|
||||
def test_hours_to_minutes() -> None:
|
||||
assert convert_time(2, "hour", "minute") == Fraction(120)
|
||||
|
||||
|
||||
def test_identity_conversion() -> None:
|
||||
assert convert_time(5, "hour", "hour") == Fraction(5)
|
||||
|
||||
|
||||
def test_non_time_units_refuse() -> None:
|
||||
with pytest.raises(ConversionError):
|
||||
convert_time(30, "minute", "mile")
|
||||
with pytest.raises(ConversionError):
|
||||
convert_time(5, "dollar", "hour") # currency deferred
|
||||
|
||||
|
||||
def test_is_convertible() -> None:
|
||||
assert is_convertible("minute", "hour") and is_convertible("hour", "minute")
|
||||
assert not is_convertible("minute", "mile")
|
||||
assert not is_convertible("kilometer", "mile") # length deferred
|
||||
|
||||
|
||||
def test_result_is_never_a_float() -> None:
|
||||
result = convert_time(45, "minute", "hour")
|
||||
assert isinstance(result, Fraction) and not isinstance(result, float)
|
||||
assert result == Fraction(3, 4)
|
||||
Loading…
Reference in a new issue