rounding_report
Round numbers correctly and report measurements: significant figures or decimal places in seven modes (half up, half even, half down, up, down, ceiling, floor) with scientific and engineering notation, all done in Decimal; and first-order propagation of independent uncertainties through sums, products, quotients and powers, with the result rounded to the uncertainty's significant figures (9.81 +- 0.0234 becomes 9.81 +- 0.02).
Install
/tools install rounding_report /tools install rounding_report@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · rounding_report 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool rounding_report; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
operation | string | yes | one of "round", "report", "propagate" | |
value | number | string | no | round, report | |
sig_figs | integer | no | ≥ 1; ≤ 50 | |
decimals | integer | no | Decimal places; negative rounds to tens, hundreds.... ≥ -50; ≤ 50 | |
mode | string | no | "half_even" | one of "half_up", "half_even", "half_down", "up", "down", "ceiling", "floor" |
uncertainty | number | string | no | report: standard uncertainty | |
kind | string | no | propagate. one of "sum", "product", "quotient", "power" | |
terms | array of object | no | items ≥ 1 | |
constant | number | string | no | 1 | Exact factor of a product |
uncertainty_sig_figs | integer | no | 1 | one of 1, 2 |
Input schema (JSON)
{
"type": "object",
"required": [
"operation"
],
"additionalProperties": false,
"properties": {
"operation": {
"type": "string",
"enum": [
"round",
"report",
"propagate"
]
},
"value": {
"type": [
"number",
"string"
],
"description": "round, report"
},
"sig_figs": {
"type": "integer",
"minimum": 1,
"maximum": 50
},
"decimals": {
"type": "integer",
"minimum": -50,
"maximum": 50,
"description": "Decimal places; negative rounds to tens, hundreds..."
},
"mode": {
"type": "string",
"enum": [
"half_up",
"half_even",
"half_down",
"up",
"down",
"ceiling",
"floor"
],
"default": "half_even"
},
"uncertainty": {
"type": [
"number",
"string"
],
"description": "report: standard uncertainty"
},
"kind": {
"type": "string",
"enum": [
"sum",
"product",
"quotient",
"power"
],
"description": "propagate"
},
"terms": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"value"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string"
},
"value": {
"type": [
"number",
"string"
]
},
"uncertainty": {
"type": [
"number",
"string"
],
"default": 0
},
"coefficient": {
"type": [
"number",
"string"
],
"default": 1,
"description": "sum"
},
"power": {
"type": [
"number",
"string"
],
"default": 1,
"description": "product, power"
}
}
}
},
"constant": {
"type": [
"number",
"string"
],
"default": 1,
"description": "Exact factor of a product"
},
"uncertainty_sig_figs": {
"type": "integer",
"enum": [
1,
2
],
"default": 1
}
}
}
Example
Input
{
"operation": "report",
"value": "9.81",
"uncertainty": "0.0234"
}
Output (stdout)
{
"reported_value": "9.81",
"reported_uncertainty": "0.02"
}
Files
The command the install card registers is python3 rounding_report.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
rounding_report.py · sha256 bf87106cb61ea92510a8ad9f62d46e61a1ace83ba9230147deaeccb7ce9d4a24
#!/usr/bin/env python3
"""Rounding and uncertainty reporting in Decimal: round a value to significant figures or
decimal places in any of seven modes (half up, half even, half down, up, down, ceiling, floor),
with scientific and engineering notation; and propagate independent standard uncertainties
through sums, products, quotients and powers to first order, reporting the result rounded to
the uncertainty's significant figures (9.81 +- 0.0234 is reported as 9.81 +- 0.02).
Input (JSON on stdin):
operation "round" | "report" | "propagate"
round: value, sig_figs or decimals, mode (default half_even)
report: value and uncertainty: both rounded for reporting (uncertainty_sig_figs, mode)
propagate: kind "sum" | "product" | "quotient" | "power"
terms [{"value", "uncertainty", "coefficient" (sum) | "power" (product)}]
constant (product, exact, default 1)
uncertainty_sig_figs 1 (default) or 2, mode for the reported rounding
Values are numbers or decimal strings; floats are read through their shortest repr.
"""
import json
import sys
from decimal import (ROUND_CEILING, ROUND_DOWN, ROUND_FLOOR, ROUND_HALF_DOWN, ROUND_HALF_EVEN, ROUND_HALF_UP, ROUND_UP,
Decimal, InvalidOperation, localcontext)
MODES = {"half_up": ROUND_HALF_UP, "half_even": ROUND_HALF_EVEN, "half_down": ROUND_HALF_DOWN, "up": ROUND_UP,
"down": ROUND_DOWN, "ceiling": ROUND_CEILING, "floor": ROUND_FLOOR}
PREC = 60
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def dec(value, field):
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
fail(f"{field} must be a number or a decimal string")
try:
out = Decimal(str(value).strip())
except InvalidOperation:
fail(f"{field} must be a number or a decimal string")
if not out.is_finite():
fail(f"{field} must be finite")
if abs(out.adjusted()) > 10000:
fail(f"{field} is out of range")
return out
def mode_of(request):
mode = request.get("mode", "half_even")
if not isinstance(mode, str) or mode not in MODES:
fail("mode must be one of " + ", ".join(MODES))
return mode
def plain(d):
"""Positional notation without an exponent."""
return format(d, "f")
def round_sig(x, sig, mode):
if x == 0:
return Decimal(0).quantize(Decimal(1).scaleb(-(sig - 1)))
with localcontext() as ctx:
ctx.prec = PREC + 10
exp = x.adjusted() - (sig - 1)
r = x.quantize(Decimal(1).scaleb(exp), rounding=MODES[mode])
if r != 0 and r.adjusted() > x.adjusted(): # 9.96 -> 10.0: one digit too many
r = x.quantize(Decimal(1).scaleb(exp + 1), rounding=MODES[mode])
return r
def round_dp(x, places, mode):
with localcontext() as ctx:
ctx.prec = PREC + 10
return x.quantize(Decimal(1).scaleb(-places), rounding=MODES[mode])
def scientific(r, sig):
"""d.ddd e N with the given number of significant digits."""
if r == 0:
return "0" + ("." + "0" * (sig - 1) if sig > 1 else "") + "e0"
exp = r.adjusted()
mant = r.scaleb(-exp)
mant = mant.quantize(Decimal(1).scaleb(-(sig - 1)))
return f"{plain(mant)}e{exp}"
def engineering(r, sig):
if r == 0:
return "0e0"
exp = r.adjusted()
eng = exp - exp % 3
mant = r.scaleb(-eng)
places = max(sig - 1 - (exp - eng), 0)
mant = mant.quantize(Decimal(1).scaleb(-places))
return f"{plain(mant)}e{eng}"
def sig_count(r, sig=None):
return sig if sig is not None else len(r.as_tuple().digits)
def do_round(request):
x = dec(request.get("value"), "value")
mode = mode_of(request)
has_sig, has_dp = "sig_figs" in request, "decimals" in request
if has_sig == has_dp:
fail("give sig_figs or decimals (one of them)")
if has_sig:
sig = request["sig_figs"]
if isinstance(sig, bool) or not isinstance(sig, int) or not 1 <= sig <= 50:
fail("sig_figs must be an integer from 1 to 50")
fn = lambda m: round_sig(x, sig, m)
else:
places = request["decimals"]
if isinstance(places, bool) or not isinstance(places, int) or not -50 <= places <= 50:
fail("decimals must be an integer from -50 to 50")
fn = lambda m: round_dp(x, places, m)
r = fn(mode)
shown_sig = sig if has_sig else max(len(r.as_tuple().digits) if r != 0 else 1, 1)
out = {"operation": "round", "value": str(x), "mode": mode, "result": plain(r),
"scientific": scientific(r, shown_sig), "engineering": engineering(r, shown_sig),
"by_mode": {m: plain(fn(m)) for m in MODES}}
if has_sig:
out["sig_figs"] = sig
else:
out["decimals"] = places
return out
def propagate(request):
kind = request.get("kind")
if kind not in ("sum", "product", "quotient", "power"):
fail("kind must be sum, product, quotient or power")
terms = request.get("terms")
if not isinstance(terms, list) or not terms or len(terms) > 1000:
fail("terms must be a list of 1 to 1000 {value, uncertainty} objects")
if kind == "quotient" and len(terms) != 2:
fail("quotient takes exactly two terms: numerator and denominator")
if kind == "power" and len(terms) != 1:
fail("power takes exactly one term with its power")
parsed = []
for i, t in enumerate(terms):
if not isinstance(t, dict) or set(t) - {"value", "uncertainty", "coefficient", "power", "name"}:
fail(f"terms[{i}] must be an object with value, uncertainty and coefficient or power")
value = dec(t.get("value"), f"terms[{i}].value")
u = dec(t.get("uncertainty", 0), f"terms[{i}].uncertainty")
if u < 0:
fail(f"terms[{i}].uncertainty must not be negative")
if kind == "sum":
if "power" in t:
fail("a sum takes coefficient, not power")
weight = dec(t.get("coefficient", 1), f"terms[{i}].coefficient")
else:
if "coefficient" in t:
fail("a product takes power, not coefficient (use constant for a factor)")
weight = dec(t.get("power", 1), f"terms[{i}].power")
if kind == "quotient":
weight = Decimal(1) if i == 0 else Decimal(-1)
if kind == "power" and "power" not in t:
fail("power needs the term's power")
if value == 0:
fail(f"terms[{i}].value is zero: relative uncertainty is undefined in a product")
if value < 0 and weight != weight.to_integral_value():
fail(f"terms[{i}]: a negative value cannot take a fractional power")
parsed.append((value, u, weight))
sig_u = request.get("uncertainty_sig_figs", 1)
if sig_u not in (1, 2) or isinstance(sig_u, bool):
fail("uncertainty_sig_figs must be 1 or 2")
mode = mode_of(request)
with localcontext() as ctx:
ctx.prec = PREC
if kind == "sum":
q = sum((c * v for v, _, c in parsed), Decimal(0))
var = sum(((c * u) ** 2 for _, u, c in parsed), Decimal(0))
u_q = var.sqrt()
formula = "q = sum c_i x_i; u(q) = sqrt(sum (c_i u_i)^2)"
else:
constant = dec(request.get("constant", 1), "constant")
q = constant
rel2 = Decimal(0)
for v, u, p in parsed:
q *= v ** p if p != p.to_integral_value() else v ** int(p)
rel2 += (p * u / v) ** 2
u_q = abs(q) * rel2.sqrt()
formula = "q = c prod x_i^p_i; u(q)/|q| = sqrt(sum (p_i u_i / x_i)^2)"
relative = (u_q / abs(q)) if q != 0 else None
with localcontext() as ctx:
ctx.prec = 15
q_show, u_show = (+q).normalize(), (+u_q).normalize()
out = {"operation": "propagate", "kind": kind, "value": plain(q_show), "uncertainty": plain(u_show), "relative_uncertainty": str(relative.quantize(Decimal("1e-12"))) if relative is not None else None,
"formula": formula, "assumption": "independent inputs, first-order (linear) propagation, standard uncertainties"}
out.update(report(q, u_q, sig_u, mode))
return out
def report(q, u_q, sig_u, mode):
if u_q == 0:
return {"reported": f"{plain(q)} (no uncertainty)"}
u_r = round_sig(u_q, sig_u, mode)
with localcontext() as ctx:
ctx.prec = PREC + 10
q_r = q.quantize(Decimal(1).scaleb(u_r.as_tuple().exponent), rounding=MODES[mode])
return {"reported_value": plain(q_r), "reported_uncertainty": plain(u_r), "reported": f"{plain(q_r)} ± {plain(u_r)}",
"rounding": f"uncertainty to {sig_u} significant figure{'s' if sig_u == 2 else ''}, value to the same decimal place, mode {mode}"}
def do_report(request):
q = dec(request.get("value"), "value")
u = dec(request.get("uncertainty"), "uncertainty")
if u < 0:
fail("uncertainty must not be negative")
sig_u = request.get("uncertainty_sig_figs", 1)
if sig_u not in (1, 2) or isinstance(sig_u, bool):
fail("uncertainty_sig_figs must be 1 or 2")
out = {"operation": "report", "value": str(q), "uncertainty": str(u)}
out.update(report(q, u, sig_u, mode_of(request)))
return out
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
op = request.get("operation")
if op == "round":
out = do_round(request)
elif op == "propagate":
out = propagate(request)
elif op == "report":
out = do_report(request)
else:
fail("operation must be round, report or propagate")
print(json.dumps(out, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except (ArithmeticError, ValueError, RecursionError) as error:
fail(f"the numbers are out of the range this tool can compute: {type(error).__name__}")
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/rounding_report/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/rounding_report/1.0.0/rounding_report.py