diff --git a/examples/mcp/server.py b/examples/mcp/server.py
index 730409a..4a4c53b 100644
--- a/examples/mcp/server.py
+++ b/examples/mcp/server.py
@@ -27,7 +27,6 @@
the device's state after that command. Nothing is held or resent. Needs mcp and pyserial (requirements.txt) and python/onemachine from this checkout
(ONEMACHINE_PY=
to use another)."""
import argparse, json, os, re, sys, threading, time
-from decimal import Decimal
from fractions import Fraction
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -125,7 +124,7 @@ def refuse(self, args):
if v > f.high: return None, '%s: %d is above the maximum %d' % (p, v, f.high)
return v, None
hi = self.top() # scaled: the bounds are on the presented value, as in the schema
- x = Fraction(Decimal(repr(float(v)))) # the number as the client wrote it (12.35, not its binary neighbour)
+ x = f.written(v) # the number as the client wrote it (1.1, not its binary neighbour)
if x < 0: return None, '%s: %r is below the minimum 0' % (p, v)
if x > hi: return None, '%s: %r is above the maximum %s' % (p, v, number(hi))
return f.to_raw(v), None
diff --git a/python/onemachine/machine.py b/python/onemachine/machine.py
index 75eade8..7352cd3 100644
--- a/python/onemachine/machine.py
+++ b/python/onemachine/machine.py
@@ -262,10 +262,14 @@ def __repr__(self): return 'Facts(%s %s)' % (self.name, self.kind)
def presented(self, raw):
"""raw -> the presented value, exactly (a Fraction): raw * num / den"""
return Fraction(int(raw) * self.scale[0], self.scale[1])
+ def written(self, value):
+ """a number as the client wrote it, exactly (a Fraction): the shortest decimal that round-trips the double, so 1.1 is 11/10 and not the double's
+ binary value 1.100000000000000088...; a value is compared with a presented bound (presented(high)) through this, never as a float"""
+ return Fraction(Decimal(repr(float(value))))
def to_raw(self, value):
- """presented -> raw: the exact rational of the shortest decimal that round-trips the client's number, times den / num, to the nearest integer,
- halves away from zero. Integer arithmetic only: no round(), no float multiply."""
- x = Fraction(Decimal(repr(float(value)))) * self.scale[1] / self.scale[0]
+ """presented -> raw: the number as written, times den / num, to the nearest integer, halves away from zero. Integer arithmetic only: no round(),
+ no float multiply."""
+ x = self.written(value) * self.scale[1] / self.scale[0]
p, q = abs(x.numerator), x.denominator
r = (2 * p + q) // (2 * q)
return -r if x < 0 else r
@@ -287,7 +291,7 @@ def unpack_text(self, array):
def role_facts(m, name):
"""The Facts of role `name` of Machine m, or KindError. Checks the description against the kind: only the lines the kind prints, the fields it reports and
- commands present with the types it needs, the bound param, distinct values, unique labels, a positive scale for a scaled value, a flag that is unsigned."""
+ commands present with the types it needs, the bound param, distinct values, unique labels, a positive scale, a flag that is unsigned."""
info = m.roles[name]
if info.kind not in KINDS: raise KindError('role %s is of kind %r; python/onemachine has facts for %s' % (name, info.kind, ', '.join(sorted(KINDS))))
k, f = KINDS[info.kind], Facts(name, info.kind)
@@ -331,9 +335,9 @@ def refuse(why): raise KindError('role %s (%s): %s' % (name, info.kind, why))
for line, d in (('scale', info.scales), ('unit', info.units)):
if set(d) != {k.report}: refuse('the description needs one `%s` line, for the field %r (it has %s)' % (line, k.report, sorted(d) or 'none'))
f.scale, f.unit = info.scales[k.report], info.units[k.report]
+ if f.scale[0] <= 0 or f.scale[1] <= 0: refuse('scale %d/%d is not positive' % f.scale)
if info.kind == 'scaled':
if k.upper not in info.params: refuse('no param %r in its description, and the %s kind says it bounds %r' % (k.upper, info.kind, k.command))
- if f.scale[0] <= 0 or f.scale[1] <= 0: refuse('scale %d/%d is not positive' % f.scale)
hi = info.params[k.upper]
if not (0 <= hi <= thi): refuse('bound [0, %d] does not fit the %s field %r' % (hi, f.command_type, k.command))
f.low, f.high = 0, hi
diff --git a/test/role/facts_check.py b/test/role/facts_check.py
index c34fe6f..63ed91a 100644
--- a/test/role/facts_check.py
+++ b/test/role/facts_check.py
@@ -4,7 +4,7 @@
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '../../python'))
from fractions import Fraction
-from onemachine import Machine, StreamLink, KindError, LinkError, role_facts, KINDS
+from onemachine import Machine, StreamLink, SchemaError, KindError, LinkError, role_facts, KINDS
from onemachine.machine import _fnv, OK
from onemachine.schema import Field, Layer
@@ -83,10 +83,39 @@ def after(ls, line, *new): i = ls.index(line); ls[i + 1:i + 1] = list(new)
refused('an analog with no `scale` line', lambda ls: drop(ls, 'scale vin raw 5 1023'), 'vin', 'scale')
refused('an analog whose `unit` is for another field', lambda ls: sub(ls, 'unit vin raw V', 'unit vin other V'), 'vin', 'unit')
refused('a scaled with a scale of 0/10', lambda ls: sub(ls, 'scale duty raw 1 10', 'scale duty raw 0 10'), 'duty', 'not positive')
+refused('an analog with a scale whose numerator is 0 (0/1023)', lambda ls: sub(ls, 'scale vin raw 5 1023', 'scale vin raw 0 1023'), 'vin', 'scale 0/1023', 'not positive')
+refused('an analog with a negative numerator (-5/1023)', lambda ls: sub(ls, 'scale vin raw 5 1023', 'scale vin raw -5 1023'), 'vin', 'scale -5/1023', 'not positive')
+refused('an analog with a negative denominator (5/-1023)', lambda ls: sub(ls, 'scale vin raw 5 1023', 'scale vin raw 5 -1023'), 'vin', 'scale 5/-1023', 'not positive')
+refused('a scaled with a negative numerator (-1/10)', lambda ls: sub(ls, 'scale duty raw 1 10', 'scale duty raw -1 10'), 'duty', 'scale -1/10', 'not positive')
+refused('a scaled with a negative denominator (1/-10)', lambda ls: sub(ls, 'scale duty raw 1 10', 'scale duty raw 1 -10'), 'duty', 'scale 1/-10', 'not positive')
+try: Machine(Fake(rehash([('scale vin raw 5 0' if l == 'scale vin raw 5 1023' else l) for l in lines]))); check('refused: a scale with denominator 0, by the description itself', False)
+except SchemaError as e: check('refused: a scale with denominator 0, by the description itself -> %s' % e, 'denominator 0' in str(e))
refused('a scaled with no `max` param', lambda ls: drop(ls, 'param duty max 1000'), 'duty', "'max'")
refused('a scaled whose max does not fit its field', lambda ls: sub(ls, 'param duty max 1000', 'param duty max 70000'), 'duty', '70000')
check('the machine as it is is accepted by every refusal harness', all(role_facts(Machine(Fake(rehash(list(lines)))), n) for n in m.roles))
+# ---- a value exactly at the presented bound of a scaled role is within it, and a float compared as its binary value is not: 1.1 as a double is above 11/10
+import importlib.util, math
+spec = importlib.util.spec_from_file_location('mcp_server', os.path.join(os.path.dirname(os.path.abspath(__file__)), '../../examples/mcp/server.py'))
+srv = importlib.util.module_from_spec(spec); spec.loader.exec_module(srv) # examples/mcp's server: the consumer that compares a value with the bound
+def edited(edit):
+ ls = list(lines); edit(ls); return Machine(Fake(rehash(ls)))
+for max_raw, bound, binary in ((11, '1.1', 'above'), (4, '0.4', 'above'), (9, '0.9', 'above'), (3, '0.3', 'below')): # duty's scale is 1/10: the bound is max/10
+ mm = edited(lambda ls: sub(ls, 'param duty max 1000', 'param duty max %d' % max_raw))
+ f, x = role_facts(mm, 'duty'), float(bound)
+ top = Fraction(max_raw, 10)
+ check('bound %s: presented(high) is exactly %s' % (bound, top), f.presented(f.high) == top)
+ check('bound %s: the double %r is %s the bound in binary (%s)' % (bound, x, binary, 'the comparison that must not be used' if binary == 'above' else 'a control: both comparisons agree'),
+ (Fraction(x) > top) == (binary == 'above') and (Fraction(x) < top) == (binary == 'below'))
+ check('bound %s: written(%r) is the bound, so the value is within it' % (bound, x), f.written(x) == top and f.written(x) <= f.presented(f.high))
+ up = math.nextafter(x, 10)
+ check('bound %s: the next double above (%r) is above the bound' % (bound, up), f.written(up) > f.presented(f.high))
+ role = {r.name: r for r in srv.model(mm)}['duty']
+ raw, err = role.refuse({'value': x})
+ check('examples/mcp, bound %s: duty_set %r is accepted, raw %d' % (bound, x, max_raw), err is None and raw == max_raw, (raw, err))
+ raw, err = role.refuse({'value': up})
+ check('examples/mcp, bound %s: duty_set %r is refused naming the maximum' % (bound, up), raw is None and 'above the maximum %s' % bound in err, (raw, err))
+
# ---- the field-shape refusals: stub schemas with one field changed
class S:
def __init__(self, **layers): self.l = layers