Jump to content

The Principia Machina/TPC-PY/tpc units.py

From The Invariance Itself
Revision as of 02:10, 30 September 2026 by BFP Rauch (talk | contribs) (TPC-PY source tpc_units.py (0.1.2), SHA-256 be8b289b1eca5ae4; conformed TPS 0.4.0; PROPOSED, not declared (TPC-PY 0.2.0))
# -*- coding: utf-8 -*-
"""
TPC-PY --- units (TPC step 2, increment 6) --- SELECTED D103.1-D105.1
=====================================================================
SELECTED 2026-09-22: D103.1 (this grammar and catalogue), D104.1 (UNIT
checked and canonicalized at zone entry; tpc_zone 0.3.0), D105.1 (KIND at
the test; CONVENTION on a difference). Spec: tpm_units 0.2.0.
The unit grammar, the commensurability check C-32 asks for (TPS 3.2.9), and
the normalization D40 requires before any contradiction test ("TPX
normalizes before any contradiction test and refuses a cross-entry M-M
test"). D103.1: a DIMENSION-style ASCII token grammar over a closed SI
catalogue. Standard library only; Python 3.8+; Windows/Linux.

GRAMMAR
  UNIT   := "1" | token ( " " token )*          single spaces, ASCII only
  token  := [prefix] symbol [exponent]          exponent := -?[0-9]{1,4}
  A token's exponent applies to the prefixed symbol: cm2 = (0.01 m)^2.
  An absent exponent is 1; a zero exponent drops the token.

CATALOGUE (order is the canonical order)
  SI base, with the gram in place of the kilogram so that kg = k + g:
      m g s A K mol cd
  SI dimensionless: rad sr
  SI derived with special names, degree Celsius excluded (affine):
      Hz N Pa J W C V F Ohm S Wb T H lm lx Bq Gy Sv kat
  SI Table 8, ratio units with an exactly defined value:
      min h d au deg arcmin arcsec ha L t eV
  Refused, with the reason recorded: degC (an offset, not a ratio); Da
  (its value is measured, with an uncertainty the conversion would have
  to carry); Np, B, dB (logarithmic).
  Prefixes: the 24 SI prefixes q r y z a f p n u m c d da h k M G T P E Z Y R Q
  (u for micro, ASCII). Prefixable: every SI unit (including g, rad, sr),
  L, eV, arcsec and t. Not prefixable: min h d (the SI Brochure forbids
  prefixes on the non-SI units of time), ha (already a prefixed unit), au,
  deg, arcmin. The Brochure names mas, uas, pas and prefixed eV; it is
  silent on t, whose kt, Mt, Gt are in wide use. kg takes no second
  prefix (write Mg, not kkg).
  Every string parses in at most one way: the module asserts it at import.
  Every merged exponent, and every exponent of the resulting DIMENSION,
  lies within -9999..9999, so a canonical spelling and its DIMENSION always
  parse again. S15 (tpc_units 0.1.1, proposed): C-32 refuses a DIMENSION
  whose repeated bases sum outside that range with the same words,
  "DIMENSION <D>: an exponent is out of range" (tpc_zone 0.4.1). A KIND is
  derived, written by no author and entering no preimage; it is not bounded
  here, and tpc_form refuses one past 9999 where it reads it (S14).

KIND (D105.1)
  The SI treats the radian and the steradian as the number one, so Hz and
  rad s-1 share a dimension while a value in one is 2 pi times the value in
  the other; the SI also names Bq, Gy and Sv for kinds that share a
  dimension with Hz and J kg-1. Each canonical unit therefore carries a
  KIND: the exponents of angle (rad, deg, arcmin, arcsec; sr counts as
  rad2 and so do lm and lx), of Hz, Bq, Gy and Sv, written "rad2 Hz-1",
  or "none". Two values are compared only when DIMENSION and KIND both
  agree. A KIND difference is a convention difference: the result is
  ["CONVENTION", reason], on which TPX opens an audit block (TPS 4.4.4,
  row 43); it is never compared, and never refused as an error.

CANONICAL SPELLING
  Tokens with the same prefixed symbol merge (exponents add); zero
  exponents drop; tokens sort by the symbol's catalogue position, then by
  the prefix's power of ten; exponent 1 is not written; "1" if none
  remain. "s-2 m kg" -> "m kg s-2". Only spelling is canonical: "km m" is
  "m km", not "1e3 m2".

NUMBERS
  The scale of a unit to coherent SI is computed in IEEE doubles in a fixed
  order - per canonical token, prefix factor times symbol factor, raised to
  its exponent by repeated multiplication (one division for a negative
  exponent), then multiplied into the product in canonical order - so both
  residences produce the same bits. Decimal factors are parsed from their
  exact strings; deg, arcmin and arcsec use pi / 180, pi / 10800 and
  pi / 648000. A scale that overflows or underflows to zero is refused.

REFUSED, as ["REFUSED", reason], never raised; reasons escape what they
quote (zone schemas 6.3).
"""

import math
import re

import tpc_strike as S
import tpc_zone as Z

TPC_UNITS_VERSION = "0.1.2"    # proposed PATCH with TPC-PY 0.2.0 (C-MS-5: TPC-PHP reads the JSON as written, a {} apart from a []); 0.1.1: S15

#          symbol    L  M  T  I TH  N  J   factor           prefixable
CATALOGUE = [
    ("m",      (1, 0, 0, 0, 0, 0, 0), ("d", "1"), True),
    ("g",      (0, 1, 0, 0, 0, 0, 0), ("d", "1e-3"), True),
    ("s",      (0, 0, 1, 0, 0, 0, 0), ("d", "1"), True),
    ("A",      (0, 0, 0, 1, 0, 0, 0), ("d", "1"), True),
    ("K",      (0, 0, 0, 0, 1, 0, 0), ("d", "1"), True),
    ("mol",    (0, 0, 0, 0, 0, 1, 0), ("d", "1"), True),
    ("cd",     (0, 0, 0, 0, 0, 0, 1), ("d", "1"), True),
    ("rad",    (0, 0, 0, 0, 0, 0, 0), ("d", "1"), True),
    ("sr",     (0, 0, 0, 0, 0, 0, 0), ("d", "1"), True),
    ("Hz",     (0, 0, -1, 0, 0, 0, 0), ("d", "1"), True),
    ("N",      (1, 1, -2, 0, 0, 0, 0), ("d", "1"), True),
    ("Pa",     (-1, 1, -2, 0, 0, 0, 0), ("d", "1"), True),
    ("J",      (2, 1, -2, 0, 0, 0, 0), ("d", "1"), True),
    ("W",      (2, 1, -3, 0, 0, 0, 0), ("d", "1"), True),
    ("C",      (0, 0, 1, 1, 0, 0, 0), ("d", "1"), True),
    ("V",      (2, 1, -3, -1, 0, 0, 0), ("d", "1"), True),
    ("F",      (-2, -1, 4, 2, 0, 0, 0), ("d", "1"), True),
    ("Ohm",    (2, 1, -3, -2, 0, 0, 0), ("d", "1"), True),
    ("S",      (-2, -1, 3, 2, 0, 0, 0), ("d", "1"), True),
    ("Wb",     (2, 1, -2, -1, 0, 0, 0), ("d", "1"), True),
    ("T",      (0, 1, -2, -1, 0, 0, 0), ("d", "1"), True),
    ("H",      (2, 1, -2, -2, 0, 0, 0), ("d", "1"), True),
    ("lm",     (0, 0, 0, 0, 0, 0, 1), ("d", "1"), True),
    ("lx",     (-2, 0, 0, 0, 0, 0, 1), ("d", "1"), True),
    ("Bq",     (0, 0, -1, 0, 0, 0, 0), ("d", "1"), True),
    ("Gy",     (2, 0, -2, 0, 0, 0, 0), ("d", "1"), True),
    ("Sv",     (2, 0, -2, 0, 0, 0, 0), ("d", "1"), True),
    ("kat",    (0, 0, -1, 0, 0, 1, 0), ("d", "1"), True),
    ("min",    (0, 0, 1, 0, 0, 0, 0), ("d", "60"), False),
    ("h",      (0, 0, 1, 0, 0, 0, 0), ("d", "3600"), False),
    ("d",      (0, 0, 1, 0, 0, 0, 0), ("d", "86400"), False),
    ("au",     (1, 0, 0, 0, 0, 0, 0), ("d", "149597870700"), False),
    ("deg",    (0, 0, 0, 0, 0, 0, 0), ("pi", 180), False),
    ("arcmin", (0, 0, 0, 0, 0, 0, 0), ("pi", 10800), False),
    ("arcsec", (0, 0, 0, 0, 0, 0, 0), ("pi", 648000), True),
    ("ha",     (2, 0, 0, 0, 0, 0, 0), ("d", "1e4"), False),
    ("L",      (3, 0, 0, 0, 0, 0, 0), ("d", "1e-3"), True),
    ("t",      (0, 1, 0, 0, 0, 0, 0), ("d", "1e3"), True),
    ("eV",     (2, 1, -2, 0, 0, 0, 0), ("d", "1.602176634e-19"), True),
]
# KIND contributions per unit symbol: (angle, Hz, Bq, Gy, Sv)
KIND_BASES = ["rad", "Hz", "Bq", "Gy", "Sv"]
KIND_OF = {"rad": (1, 0, 0, 0, 0), "deg": (1, 0, 0, 0, 0), "arcmin": (1, 0, 0, 0, 0),
           "arcsec": (1, 0, 0, 0, 0), "sr": (2, 0, 0, 0, 0), "lm": (2, 0, 0, 0, 0),
           "lx": (2, 0, 0, 0, 0), "Hz": (0, 1, 0, 0, 0), "Bq": (0, 0, 1, 0, 0),
           "Gy": (0, 0, 0, 1, 0), "Sv": (0, 0, 0, 0, 1)}
EXP_MAX = 9999

REFUSED_UNITS = {
    "degC": "an offset scale, not a ratio",
    "Da": "a measured value; the conversion would carry an uncertainty",
    "Np": "a logarithmic scale", "B": "a logarithmic scale", "dB": "a logarithmic scale",
}
PREFIXES = [("q", -30), ("r", -27), ("y", -24), ("z", -21), ("a", -18), ("f", -15),
            ("p", -12), ("n", -9), ("u", -6), ("m", -3), ("c", -2), ("d", -1),
            ("da", 1), ("h", 2), ("k", 3), ("M", 6), ("G", 9), ("T", 12),
            ("P", 15), ("E", 18), ("Z", 21), ("Y", 24), ("R", 27), ("Q", 30)]

_SYM = dict((c[0], (i, c[1], c[2], c[3])) for i, c in enumerate(CATALOGUE))
_PRE = dict(PREFIXES)
_TOKEN = re.compile(r"([A-Za-z]+)(-?[0-9]{1,4})?\Z")


def _readings(letters):
    """Every (prefix, symbol) reading of a token's letters, prefix None for
    a bare symbol; only prefixable symbols take a prefix."""
    out = []
    if letters in _SYM:
        out.append((None, letters))
    for p in _PRE:
        rest = letters[len(p):]
        if letters.startswith(p) and rest in _SYM and _SYM[rest][3]:
            out.append((p, rest))
    return out


def _assert_unambiguous():
    seen = {}
    for sym in _SYM:
        seen.setdefault(sym, []).append((None, sym))
        if _SYM[sym][3]:
            for p in _PRE:
                seen.setdefault(p + sym, []).append((p, sym))
    for s, rs in seen.items():
        assert len(rs) == 1, "ambiguous unit token %s: %r" % (s, rs)
        assert s not in REFUSED_UNITS, s
    return len(seen)


TOKEN_COUNT = _assert_unambiguous()          # every string parses at most one way
assert TOKEN_COUNT == 807, "unit catalogue changed"   # TPC-PHP checks the same count


def _refuse(msg):
    return ["REFUSED", Z._show(msg)]


def _q(x):
    """What a reason quotes: the string itself, or ? for anything else."""
    return x if isinstance(x, str) else "?"


def _tokens(u):
    """[(prefix, symbol, exponent), ...] merged, or a reason string."""
    if not isinstance(u, str) or u == "":
        return "UNIT %s is not a unit" % _q(u)
    if u == "1":
        return []
    merged = {}
    for tok in u.split(" "):
        m = _TOKEN.match(tok)
        if not m:
            return "UNIT %s is not a unit" % u
        letters, ex = m.group(1), int(m.group(2)) if m.group(2) else 1
        if letters in REFUSED_UNITS:
            return "UNIT %s: %s is refused, %s" % (u, letters, REFUSED_UNITS[letters])
        rs = _readings(letters)
        if not rs:
            for p in _PRE:
                rest = letters[len(p):]
                if letters.startswith(p) and rest in REFUSED_UNITS:
                    return "UNIT %s: %s is refused, %s" % (u, rest, REFUSED_UNITS[rest])
            for p in _PRE:
                rest = letters[len(p):]
                if letters.startswith(p) and rest in _SYM:
                    return "UNIT %s: %s takes no prefix" % (u, rest)
            return "UNIT %s: %s is not a unit symbol" % (u, letters)
        key = rs[0]
        merged[key] = merged.get(key, 0) + ex
    items = [(p, s, e) for (p, s), e in merged.items() if e != 0]
    items.sort(key=lambda t: (_SYM[t[1]][0], _PRE[t[0]] if t[0] else 0))
    if any(abs(e) > EXP_MAX for p, s, e in items) or \
            any(abs(x) > EXP_MAX for x in _dim_exps(items)):
        return "UNIT %s: an exponent is out of range" % u
    return items


def _spell(items):
    if not items:
        return "1"
    return " ".join((p or "") + s + ("" if e == 1 else str(e)) for p, s, e in items)


def _dim_exps(items):
    exps = [0] * len(Z.DIM_BASES)
    for p, s, e in items:
        for j, d in enumerate(_SYM[s][1]):
            exps[j] += d * e
    return exps


def _dimension(items):
    return Z.canon_dimension(dict(zip(Z.DIM_BASES, _dim_exps(items))))


def _kind(items):
    k = [0] * len(KIND_BASES)
    for p, s, e in items:
        if s in KIND_OF:
            for j, c in enumerate(KIND_OF[s]):
                k[j] += c * e
    toks = [KIND_BASES[j] + str(k[j]) for j in range(len(KIND_BASES)) if k[j] != 0]
    return " ".join(toks) if toks else "none"


def _symbol_factor(s):
    kind, v = _SYM[s][2]
    return float(v) if kind == "d" else math.pi / v


def _scale(items):
    total = 1.0
    for p, s, e in items:
        base = _symbol_factor(s)
        if p is not None:
            base = float("1e%d" % _PRE[p]) * base
        r = 1.0
        for _ in range(abs(e)):
            r = r * base
        if e < 0:
            r = math.inf if r == 0.0 else 1.0 / r     # no division by zero in either residence
        total = total * r
    return total


def unit_parse(u):
    """["OK", canonical spelling, canonical DIMENSION, KIND] or ["REFUSED", reason]."""
    items = _tokens(u)
    if isinstance(items, str):
        return _refuse(items)
    return ["OK", _spell(items), _dimension(items), _kind(items)]


def unit_scale(u):
    """["OK", the factor to coherent SI as a double] or ["REFUSED", reason]."""
    items = _tokens(u)
    if isinstance(items, str):
        return _refuse(items)
    f = _scale(items)
    if f == 0.0 or not math.isfinite(f):
        return _refuse("UNIT %s has no finite scale" % u)
    return ["OK", f]


def unit_matches(u, dimension):
    """C-32: ["OK", true or false] - the UNIT is commensurable with the
    DIMENSION - or ["REFUSED", reason] when either does not parse."""
    r = unit_parse(u)
    if r[0] != "OK":
        return r
    d = Z.normalize_dimension(dimension)
    if d is None:
        if Z.dimension_out_of_range(dimension):                           # S15
            return _refuse("DIMENSION %s: an exponent is out of range" % dimension)
        return _refuse("DIMENSION %s is not a dimension" % _q(dimension))
    return ["OK", r[2] == d]


def normalize_measurement(value, uncertainty, u):
    """D40: a VALUE and its UNCERTAINTY (decimal strings) in UNIT, scaled to
    coherent SI: ["OK", x_SI, u_SI, DIMENSION, KIND] or ["REFUSED", reason]."""
    if Z.canon_decimal(value) is None:
        return _refuse("VALUE %s is not a decimal" % _q(value))
    if Z.canon_decimal(uncertainty) is None:
        return _refuse("UNCERTAINTY %s is not a decimal" % _q(uncertainty))
    if Z._dec_sign(uncertainty) < 0:
        return _refuse("UNCERTAINTY %s is negative" % uncertainty)
    items = _tokens(u)
    if isinstance(items, str):
        return _refuse(items)
    f = _scale(items)
    if f == 0.0 or not math.isfinite(f):
        return _refuse("UNIT %s has no finite scale" % u)
    x = float(value) * f
    su = float(uncertainty) * f
    if not (math.isfinite(x) and math.isfinite(su)):
        return _refuse("VALUE %s in UNIT %s is not a finite double" % (value, u))
    return ["OK", x + 0.0, su + 0.0, _dimension(items), _kind(items)]


def _convention(k1, k2):
    return ["CONVENTION", "units of different kind: %s and %s" % (k1, k2)]


def _pair(a, b):
    """Normalize two measurements; refuse unless they share a dimension;
    CONVENTION unless they share a kind."""
    ra = normalize_measurement(*a)
    if ra[0] != "OK":
        return ra, None
    rb = normalize_measurement(*b)
    if rb[0] != "OK":
        return rb, None
    if ra[3] != rb[3]:
        return _refuse("units of different dimension: %s and %s" % (ra[3], rb[3])), None
    if ra[4] != rb[4]:
        return _convention(ra[4], rb[4]), None
    return ra, rb


def mm_contradicts_units(x1, u1, unit1, x2, u2, unit2, k, s=1.0):
    """SR3 M-M after normalization: ["OK", bool], ["CONVENTION", reason] or
    ["REFUSED", reason]."""
    ra, rb = _pair((x1, u1, unit1), (x2, u2, unit2))
    if rb is None:
        return ra
    return ["OK", S.mm_contradicts(ra[1], ra[2], rb[1], rb[2], k, s)]


def lm_contradicts_units(x_l, unit_l, x_m, u_m, unit_m, k, s=1.0):
    """SR3 L-M after normalization: the L value carries no uncertainty."""
    ra, rb = _pair((x_l, "0", unit_l), (x_m, u_m, unit_m))
    if rb is None:
        return ra
    return ["OK", S.lm_contradicts(ra[1], rb[1], rb[2], k, s)]


def sr8_scale_factor_units(measurements):
    """SR8 over [{"x", "u", "unit"}, ...] of one dimension and kind, after
    normalization: ["OK", S], ["CONVENTION", reason] or ["REFUSED", reason].
    Every record is normalized first; a refusal outranks a convention
    difference, which outranks a missing uncertainty."""
    norm, dim, kind, conv = [], None, None, None
    for m in measurements:
        r = normalize_measurement(m["x"], m["u"], m["unit"])
        if r[0] != "OK":
            return r
        if dim is None:
            dim, kind = r[3], r[4]
        elif r[3] != dim:
            return _refuse("units of different dimension: %s and %s" % (dim, r[3]))
        elif r[4] != kind and conv is None:
            conv = _convention(kind, r[4])
        norm.append(r)
    if conv is not None:
        return conv
    out = []
    for r in norm:
        if r[2] <= 0.0:
            return _refuse("every M record needs an uncertainty above zero")
        out.append({"x": r[1], "u": r[2]})
    return ["OK", S.sr8_scale_factor(out)]