The Principia Machina/TPC-PY/tpc units.py: Difference between revisions
Appearance
m 1 revision imported |
m 1 revision imported |
||
| (One intermediate revision by the same user not shown) | |||
| Line 83: | Line 83: | ||
import tpc_zone as Z | import tpc_zone as Z | ||
TPC_UNITS_VERSION = "0.1. | 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 | # symbol L M T I TH N J factor prefixable | ||
Latest revision as of 02:11, 30 September 2026
# -*- 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)]