dataclass-values 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataclass_values/__init__.py +38 -0
- dataclass_values/py.typed +0 -0
- dataclass_values/space.py +126 -0
- dataclass_values/values.py +317 -0
- dataclass_values-0.1.0.dist-info/METADATA +70 -0
- dataclass_values-0.1.0.dist-info/RECORD +8 -0
- dataclass_values-0.1.0.dist-info/WHEEL +4 -0
- dataclass_values-0.1.0.dist-info/licenses/LICENSE +26 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""dataclass_values: Value(...) carries a number's evidence; Space(obj) samples and checks it.
|
|
2
|
+
|
|
3
|
+
Domain-free and stdlib-only. See README.md.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .space import Space
|
|
7
|
+
from .values import (
|
|
8
|
+
Basis,
|
|
9
|
+
Choice,
|
|
10
|
+
FloatValue,
|
|
11
|
+
IntValue,
|
|
12
|
+
LogUniform,
|
|
13
|
+
Normal,
|
|
14
|
+
StrValue,
|
|
15
|
+
TupleValue,
|
|
16
|
+
Uniform,
|
|
17
|
+
Value,
|
|
18
|
+
meta,
|
|
19
|
+
plain,
|
|
20
|
+
v,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Basis",
|
|
25
|
+
"Choice",
|
|
26
|
+
"FloatValue",
|
|
27
|
+
"IntValue",
|
|
28
|
+
"LogUniform",
|
|
29
|
+
"Normal",
|
|
30
|
+
"Space",
|
|
31
|
+
"StrValue",
|
|
32
|
+
"TupleValue",
|
|
33
|
+
"Uniform",
|
|
34
|
+
"Value",
|
|
35
|
+
"meta",
|
|
36
|
+
"plain",
|
|
37
|
+
"v",
|
|
38
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Space: an instance seen as a space (gymnasium-style), derived from the instance itself.
|
|
2
|
+
|
|
3
|
+
from dataclass_values import Space, Value
|
|
4
|
+
pick = Pose(position=Point(x=Value(0.5, "m", low=0.4, high=0.6), y=0.0, z=0.3))
|
|
5
|
+
Space(pick).sample(rng) # -> a Pose, x drawn in [0.4, 0.6], marked basis=SAMPLED
|
|
6
|
+
Space(pick).contains(measured) # -> the acceptable range in a test
|
|
7
|
+
Space(pick).violations(measured) # -> ["position.x=0.7 outside [0.4, 0.6]"]
|
|
8
|
+
|
|
9
|
+
WHY the same class and a derived space (Zach, Oct 5 2026, "do not re-litigate"): a second
|
|
10
|
+
`PoseSpace` / `CheckedBelt` class "drifts" from the message it shadows. So a field carries its own
|
|
11
|
+
range as a `Value`, which IS the builtin float/int/str/tuple, and `Space(obj)` is a view over the
|
|
12
|
+
instance whose `sample()` returns the ordinary class. Nothing is discarded: the authored instance is
|
|
13
|
+
untouched, and each drawn value keeps its unit, range and source, marked basis=SAMPLED. Call
|
|
14
|
+
`plain()` only at a runtime boundary where you choose to drop the notes.
|
|
15
|
+
|
|
16
|
+
Subclasses add kinds of bound the scalar core knows nothing about (robot_msgs bounds numpy array
|
|
17
|
+
fields) by overriding the two hooks `extra_draw` and `extra_violations`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import random
|
|
23
|
+
from dataclasses import fields, is_dataclass, replace
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .values import META, Basis, TupleValue, Value, meta, plain, within
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Space:
|
|
30
|
+
"""Every `Value` in `instance` with a dist, choices or [low, high] can be drawn and checked."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, instance: Any) -> None:
|
|
33
|
+
self.instance = instance
|
|
34
|
+
|
|
35
|
+
# ── sampling ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def sample(self, rng: random.Random | None = None):
|
|
38
|
+
"""The same class as the instance, every ranged Value redrawn (basis=SAMPLED)."""
|
|
39
|
+
rng = rng or random.Random()
|
|
40
|
+
return self.extra_draw(self._draw(self.instance, rng), rng)
|
|
41
|
+
|
|
42
|
+
def extra_draw(self, drawn, rng: random.Random):
|
|
43
|
+
"""Hook: take the already-drawn instance and redraw whatever else this space bounds."""
|
|
44
|
+
return drawn
|
|
45
|
+
|
|
46
|
+
def _draw(self, x, rng):
|
|
47
|
+
if is_dataclass(x) and not isinstance(x, type):
|
|
48
|
+
return replace(
|
|
49
|
+
x, **{f.name: self._draw(getattr(x, f.name), rng) for f in fields(x) if f.init}
|
|
50
|
+
)
|
|
51
|
+
if isinstance(x, TupleValue) and x.dist is None and x.choices is None:
|
|
52
|
+
return x # a vector is one claim: drawn only through its own choices or dist
|
|
53
|
+
if isinstance(x, (list, tuple)) and not isinstance(x, TupleValue):
|
|
54
|
+
return type(x)(self._draw(i, rng) for i in x)
|
|
55
|
+
m = meta(x)
|
|
56
|
+
if not m or (
|
|
57
|
+
m["dist"] is None and m["choices"] is None and (m["low"] is None or m["high"] is None)
|
|
58
|
+
):
|
|
59
|
+
return x
|
|
60
|
+
if m["dist"] is not None:
|
|
61
|
+
drawn = m["dist"].draw(rng)
|
|
62
|
+
elif m["choices"] is not None:
|
|
63
|
+
drawn = rng.choice(m["choices"])
|
|
64
|
+
else:
|
|
65
|
+
drawn = rng.uniform(m["low"], m["high"])
|
|
66
|
+
if isinstance(drawn, (int, float)) and not isinstance(drawn, bool):
|
|
67
|
+
if m["low"] is not None:
|
|
68
|
+
drawn = max(m["low"], drawn)
|
|
69
|
+
if m["high"] is not None:
|
|
70
|
+
drawn = min(m["high"], drawn)
|
|
71
|
+
if isinstance(x, int):
|
|
72
|
+
drawn = round(drawn)
|
|
73
|
+
if m["step"]:
|
|
74
|
+
base = m["low"] if m["low"] is not None else 0
|
|
75
|
+
drawn = base + round((drawn - base) / m["step"]) * m["step"]
|
|
76
|
+
drawn = round(drawn) if isinstance(x, int) else drawn
|
|
77
|
+
return Value(
|
|
78
|
+
drawn,
|
|
79
|
+
m["unit"],
|
|
80
|
+
**{
|
|
81
|
+
**{k: m[k] for k in META if k != "unit"},
|
|
82
|
+
"basis": Basis.SAMPLED,
|
|
83
|
+
"notes": f"sampled from {x!r}" + (f"; {m['notes']}" if m["notes"] else ""),
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# ── checking ──────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def contains(self, other: Any) -> bool:
|
|
90
|
+
"""True when every ranged field of `other` lies inside this space."""
|
|
91
|
+
return not self.violations(other)
|
|
92
|
+
|
|
93
|
+
def violations(self, other: Any) -> list[str]:
|
|
94
|
+
"""One line per field of `other` outside its range: ``position.x=0.7 outside [0.4, 0.6]``"""
|
|
95
|
+
if type(other) is not type(self.instance):
|
|
96
|
+
raise TypeError(
|
|
97
|
+
f"a {type(self.instance).__name__} space cannot hold a {type(other).__name__}"
|
|
98
|
+
)
|
|
99
|
+
found: list[str] = []
|
|
100
|
+
self._check(self.instance, other, "", found)
|
|
101
|
+
found.extend(self.extra_violations(other))
|
|
102
|
+
return found
|
|
103
|
+
|
|
104
|
+
def extra_violations(self, other: Any) -> list[str]:
|
|
105
|
+
"""Hook: one line per way `other` breaks a bound this space adds beyond the scalar ones."""
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
def _check(self, ranged, other, path, found):
|
|
109
|
+
if is_dataclass(ranged) and not isinstance(ranged, type):
|
|
110
|
+
for f in fields(ranged):
|
|
111
|
+
self._check(
|
|
112
|
+
getattr(ranged, f.name), getattr(other, f.name), f"{path}{f.name}.", found
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
m = meta(ranged)
|
|
116
|
+
if not m:
|
|
117
|
+
return
|
|
118
|
+
name = path.rstrip(".")
|
|
119
|
+
if (
|
|
120
|
+
m["low"] is not None
|
|
121
|
+
and m["high"] is not None
|
|
122
|
+
and not within(other, m["low"], m["high"])
|
|
123
|
+
):
|
|
124
|
+
found.append(f"{name}={plain(other)!r} outside [{m['low']}, {m['high']}]")
|
|
125
|
+
elif m["choices"] is not None and other not in m["choices"]:
|
|
126
|
+
found.append(f"{name}={plain(other)!r} not one of {m['choices']}")
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""A number (or string) that remembers where it came from, and still is the number.
|
|
2
|
+
|
|
3
|
+
width_m = Value(1.2, "m", low=0.6, high=1.85, basis=Basis.CHOSEN, source="…")
|
|
4
|
+
v = Value # optional short alias; the real name is the readable one
|
|
5
|
+
|
|
6
|
+
WHY `Value(...)` reads like a type cast (Zach, Oct 5 2026): "v should be v = Value, v as Value, it's
|
|
7
|
+
like a type casting; the actual thing needs a readable name". `Value(1.2, "m")` returns a
|
|
8
|
+
FloatValue, so `isinstance(x, Value)` and `isinstance(x, float)` are both true.
|
|
9
|
+
WHY a float subclass and not a wrapper: "I definitely don't want to have to do width_m.value". The
|
|
10
|
+
class keeps `width_m: float`, `belt.width_m * 2` is a plain 2.4, ROS/numpy see a float, and the
|
|
11
|
+
evidence rides along.
|
|
12
|
+
WHY the value is first and unnamed, unit optionally second: both are obvious at a glance; everything
|
|
13
|
+
else is keyword-only, so the old positional puzzle (est(…, C, 0.6, 1.85)) cannot come back.
|
|
14
|
+
WHY arithmetic returns a plain number: a derived number is a new claim and must not inherit the
|
|
15
|
+
original's source.
|
|
16
|
+
WHY a tuple carrier: a vector (xyz, a limit pair) is one claim with one source;
|
|
17
|
+
`Value((0.0, 0.0, 0.3), "m")` is still a tuple, and `low`/`high` given as tuples are checked
|
|
18
|
+
elementwise.
|
|
19
|
+
WHY its own package (Zach, Nov 10 2026): "the pydataclass syntax can technically apply to any
|
|
20
|
+
domain! robot_msgs is just a particular domain." Domain-free, stdlib only.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
import random
|
|
27
|
+
from dataclasses import dataclass, fields, is_dataclass, replace
|
|
28
|
+
from enum import StrEnum
|
|
29
|
+
from typing import overload
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Basis(StrEnum):
|
|
33
|
+
"""Where a number comes from (CHOSEN..ASSUMED match bam_facility_model.estimate.Basis)."""
|
|
34
|
+
|
|
35
|
+
CHOSEN = "chosen"
|
|
36
|
+
DATASHEET = "datasheet"
|
|
37
|
+
MARKET = "market"
|
|
38
|
+
ASSUMED = "assumed"
|
|
39
|
+
MEASURED = "measured"
|
|
40
|
+
SAMPLED = "sampled" # drawn by Space.sample() from this value's range, choices or distribution
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- distributions: per-value, first class ---
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Uniform:
|
|
46
|
+
low: float
|
|
47
|
+
high: float
|
|
48
|
+
|
|
49
|
+
def draw(self, rng: random.Random) -> float:
|
|
50
|
+
return rng.uniform(self.low, self.high)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Normal:
|
|
55
|
+
mean: float
|
|
56
|
+
std: float
|
|
57
|
+
|
|
58
|
+
def draw(self, rng: random.Random) -> float:
|
|
59
|
+
return rng.gauss(self.mean, self.std)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class LogUniform:
|
|
64
|
+
low: float
|
|
65
|
+
high: float
|
|
66
|
+
|
|
67
|
+
def draw(self, rng: random.Random) -> float:
|
|
68
|
+
return math.exp(rng.uniform(math.log(self.low), math.log(self.high)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Choice:
|
|
73
|
+
"""A discrete distribution: options with optional weights."""
|
|
74
|
+
|
|
75
|
+
options: tuple
|
|
76
|
+
weights: tuple | None = None
|
|
77
|
+
|
|
78
|
+
def draw(self, rng: random.Random):
|
|
79
|
+
return rng.choices(self.options, weights=self.weights)[0]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
Distribution = Uniform | Normal | LogUniform | Choice
|
|
83
|
+
|
|
84
|
+
META = (
|
|
85
|
+
"unit",
|
|
86
|
+
"low",
|
|
87
|
+
"high",
|
|
88
|
+
"step",
|
|
89
|
+
"choices",
|
|
90
|
+
"dist",
|
|
91
|
+
"basis",
|
|
92
|
+
"source",
|
|
93
|
+
"measured_by",
|
|
94
|
+
"notes",
|
|
95
|
+
"date_accessed",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def within(raw, low, high) -> bool:
|
|
100
|
+
"""low <= raw <= high; for a tuple raw, elementwise (a scalar bound applies to each element)."""
|
|
101
|
+
if isinstance(raw, tuple):
|
|
102
|
+
lows = low if isinstance(low, tuple) else (low,) * len(raw)
|
|
103
|
+
highs = high if isinstance(high, tuple) else (high,) * len(raw)
|
|
104
|
+
if len(lows) != len(raw) or len(highs) != len(raw):
|
|
105
|
+
raise ValueError(f"{raw!r} has {len(raw)} elements but low={low!r}, high={high!r}")
|
|
106
|
+
return all(lo <= x <= hi for lo, x, hi in zip(lows, raw, highs, strict=True))
|
|
107
|
+
return low <= raw <= high
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _rebuild(cls, raw, meta):
|
|
111
|
+
meta = dict(meta)
|
|
112
|
+
unit = meta.pop("unit") # unit is positional-only in the constructor
|
|
113
|
+
return cls(raw, unit, **meta)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Value:
|
|
117
|
+
"""`Value(raw, unit=None, *, low, high, step, choices, dist, basis, source, ...)`.
|
|
118
|
+
|
|
119
|
+
Calling Value(...) picks the carrier for the raw type (FloatValue / IntValue / StrValue /
|
|
120
|
+
TupleValue), each of which subclasses both Value and the builtin. bool and None cannot carry
|
|
121
|
+
evidence: Python forbids subclassing them.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
_base: type = object
|
|
125
|
+
|
|
126
|
+
@overload
|
|
127
|
+
def __new__(
|
|
128
|
+
cls,
|
|
129
|
+
raw: int,
|
|
130
|
+
unit: str | None = None,
|
|
131
|
+
/,
|
|
132
|
+
*,
|
|
133
|
+
low: float | None = None,
|
|
134
|
+
high: float | None = None,
|
|
135
|
+
step: float | None = None,
|
|
136
|
+
choices: tuple | None = None,
|
|
137
|
+
dist: Distribution | None = None,
|
|
138
|
+
basis: Basis | None = None,
|
|
139
|
+
source: str = "",
|
|
140
|
+
measured_by: str = "",
|
|
141
|
+
notes: str = "",
|
|
142
|
+
date_accessed: str = "",
|
|
143
|
+
) -> IntValue: ...
|
|
144
|
+
|
|
145
|
+
@overload
|
|
146
|
+
def __new__(
|
|
147
|
+
cls,
|
|
148
|
+
raw: float,
|
|
149
|
+
unit: str | None = None,
|
|
150
|
+
/,
|
|
151
|
+
*,
|
|
152
|
+
low: float | None = None,
|
|
153
|
+
high: float | None = None,
|
|
154
|
+
step: float | None = None,
|
|
155
|
+
choices: tuple | None = None,
|
|
156
|
+
dist: Distribution | None = None,
|
|
157
|
+
basis: Basis | None = None,
|
|
158
|
+
source: str = "",
|
|
159
|
+
measured_by: str = "",
|
|
160
|
+
notes: str = "",
|
|
161
|
+
date_accessed: str = "",
|
|
162
|
+
) -> FloatValue: ...
|
|
163
|
+
|
|
164
|
+
@overload
|
|
165
|
+
def __new__(
|
|
166
|
+
cls,
|
|
167
|
+
raw: str,
|
|
168
|
+
unit: str | None = None,
|
|
169
|
+
/,
|
|
170
|
+
*,
|
|
171
|
+
low: None = None,
|
|
172
|
+
high: None = None,
|
|
173
|
+
step: None = None,
|
|
174
|
+
choices: tuple | None = None,
|
|
175
|
+
dist: Distribution | None = None,
|
|
176
|
+
basis: Basis | None = None,
|
|
177
|
+
source: str = "",
|
|
178
|
+
measured_by: str = "",
|
|
179
|
+
notes: str = "",
|
|
180
|
+
date_accessed: str = "",
|
|
181
|
+
) -> StrValue: ...
|
|
182
|
+
|
|
183
|
+
@overload
|
|
184
|
+
def __new__(
|
|
185
|
+
cls,
|
|
186
|
+
raw: tuple,
|
|
187
|
+
unit: str | None = None,
|
|
188
|
+
/,
|
|
189
|
+
*,
|
|
190
|
+
low: float | tuple | None = None,
|
|
191
|
+
high: float | tuple | None = None,
|
|
192
|
+
step: float | None = None,
|
|
193
|
+
choices: tuple | None = None,
|
|
194
|
+
dist: Distribution | None = None,
|
|
195
|
+
basis: Basis | None = None,
|
|
196
|
+
source: str = "",
|
|
197
|
+
measured_by: str = "",
|
|
198
|
+
notes: str = "",
|
|
199
|
+
date_accessed: str = "",
|
|
200
|
+
) -> TupleValue: ...
|
|
201
|
+
|
|
202
|
+
def __new__(
|
|
203
|
+
cls,
|
|
204
|
+
raw=None,
|
|
205
|
+
unit=None,
|
|
206
|
+
/,
|
|
207
|
+
*,
|
|
208
|
+
low=None,
|
|
209
|
+
high=None,
|
|
210
|
+
step=None,
|
|
211
|
+
choices=None,
|
|
212
|
+
dist=None,
|
|
213
|
+
basis=None,
|
|
214
|
+
source="",
|
|
215
|
+
measured_by="",
|
|
216
|
+
notes="",
|
|
217
|
+
date_accessed="",
|
|
218
|
+
):
|
|
219
|
+
if cls is Value:
|
|
220
|
+
if isinstance(raw, bool) or raw is None:
|
|
221
|
+
raise TypeError(
|
|
222
|
+
"bool and None cannot carry evidence (Python forbids subclassing them); "
|
|
223
|
+
"leave them plain"
|
|
224
|
+
)
|
|
225
|
+
for base, carrier in (
|
|
226
|
+
(float, FloatValue),
|
|
227
|
+
(int, IntValue),
|
|
228
|
+
(str, StrValue),
|
|
229
|
+
(tuple, TupleValue),
|
|
230
|
+
):
|
|
231
|
+
if isinstance(raw, base):
|
|
232
|
+
cls = carrier
|
|
233
|
+
break
|
|
234
|
+
else:
|
|
235
|
+
raise TypeError(f"Value wraps float, int, str or tuple, not {type(raw).__name__}")
|
|
236
|
+
obj = cls._base.__new__(cls, raw)
|
|
237
|
+
meta = dict(
|
|
238
|
+
unit=unit,
|
|
239
|
+
low=low,
|
|
240
|
+
high=high,
|
|
241
|
+
step=step,
|
|
242
|
+
choices=tuple(choices) if choices is not None else None,
|
|
243
|
+
dist=dist,
|
|
244
|
+
basis=basis,
|
|
245
|
+
source=source,
|
|
246
|
+
measured_by=measured_by,
|
|
247
|
+
notes=notes,
|
|
248
|
+
date_accessed=date_accessed,
|
|
249
|
+
)
|
|
250
|
+
for k, val in meta.items():
|
|
251
|
+
object.__setattr__(obj, k, val)
|
|
252
|
+
if low is not None and high is not None and not within(raw, low, high):
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"{raw!r} outside [{low}, {high}] ({source or unit or ''})".rstrip(" ()")
|
|
255
|
+
)
|
|
256
|
+
if meta["choices"] is not None and raw not in meta["choices"]:
|
|
257
|
+
raise ValueError(f"{raw!r} not one of {meta['choices']}")
|
|
258
|
+
return obj
|
|
259
|
+
|
|
260
|
+
def __init__(self, *args, **kwargs): # all work happens in __new__
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
def __setattr__(self, key, val):
|
|
264
|
+
raise AttributeError(
|
|
265
|
+
f"{type(self).__name__} is immutable; write a new Value(...) in the source"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def __reduce__(self): # copy, deepcopy, asdict and pickle rebuild through __new__
|
|
269
|
+
return (_rebuild, (type(self), self._base(self), self.meta()))
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def value(self):
|
|
273
|
+
"""The plain builtin. New code just uses the value itself."""
|
|
274
|
+
return self._base(self)
|
|
275
|
+
|
|
276
|
+
def meta(self) -> dict:
|
|
277
|
+
return {k: getattr(self, k) for k in META}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class FloatValue(Value, float):
|
|
281
|
+
_base = float
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class IntValue(Value, int):
|
|
285
|
+
_base = int
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class StrValue(Value, str):
|
|
289
|
+
_base = str
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class TupleValue(Value, tuple):
|
|
293
|
+
"""A vector or pair that remembers where it came from.
|
|
294
|
+
|
|
295
|
+
`Value((0.0, 0.0, 0.3), "m", low=(-1, -1, 0), high=(1, 1, 2))`.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
_base = tuple
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
v = Value # the optional short alias
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def meta(x) -> dict | None:
|
|
305
|
+
"""The evidence on a value, or None for a plain one."""
|
|
306
|
+
return x.meta() if isinstance(x, Value) else None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def plain(obj):
|
|
310
|
+
"""The same object with every Value replaced by its builtin: for ROS, JSON and the like."""
|
|
311
|
+
if isinstance(obj, TupleValue):
|
|
312
|
+
return tuple(plain(x) for x in obj)
|
|
313
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
314
|
+
return replace(obj, **{f.name: plain(getattr(obj, f.name)) for f in fields(obj) if f.init})
|
|
315
|
+
if isinstance(obj, (list, tuple)):
|
|
316
|
+
return type(obj)(plain(x) for x in obj)
|
|
317
|
+
return obj.value if isinstance(obj, Value) else obj
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dataclass-values
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Value(1.2, "m", low=0.6, high=1.85, source="..."): a float, int, str or tuple that remembers where it came from, plus a Space derived from any dataclass of them.
|
|
5
|
+
Project-URL: Source, https://github.com/zacharyyamaoka/dataclass_values
|
|
6
|
+
Author: Zach Yamaoka
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: configuration,dataclasses,provenance,sampling,units
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# dataclass-values
|
|
19
|
+
|
|
20
|
+
A number that remembers where it came from, and still is the number. Stdlib only.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from dataclass_values import Basis, Normal, Space, Value, plain
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Belt:
|
|
28
|
+
width_m: float = Value(1.2, "m", low=0.6, high=1.85, basis=Basis.CHOSEN, source="site survey")
|
|
29
|
+
speed_mps: float = Value(0.5, "m/s", dist=Normal(0.5, 0.05), low=0.2, high=0.8)
|
|
30
|
+
|
|
31
|
+
belt = Belt()
|
|
32
|
+
belt.width_m * 2 # 2.4, a plain float
|
|
33
|
+
Space(belt).sample() # a Belt, every ranged field redrawn, basis=SAMPLED
|
|
34
|
+
Space(belt).violations(Belt(9.0, 0.5)) # ["width_m=9.0 outside [0.6, 1.85]"]
|
|
35
|
+
plain(belt) # the same Belt with bare floats, for ROS / JSON
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
The syntax applies to any domain, so it is its own package: robot_msgs is one vocabulary built on it,
|
|
41
|
+
py_dataclass_viewer is one app built on it (Zach, 2026-11-10: "the pydataclass syntax can technically
|
|
42
|
+
apply to any domain! robot_msgs is just a particular domain.").
|
|
43
|
+
|
|
44
|
+
Zach's rule (Oct 5 2026, do not re-litigate):
|
|
45
|
+
|
|
46
|
+
- `Value(1.2, "m", low=0.6, high=1.85, basis=Basis.CHOSEN, source="...")` IS the builtin: a
|
|
47
|
+
float/int/str/tuple subclass. "I definitely don't want to have to do `width_m.value`."
|
|
48
|
+
- Spaces are derived: `Space(obj).sample()` returns the same class, every ranged value marked
|
|
49
|
+
`basis=SAMPLED`. No second `PoseSpace` class to drift from the one it shadows.
|
|
50
|
+
- `plain()` strips the notes at a runtime boundary. "We can use Value wherever we use a raw value
|
|
51
|
+
normally and we can always strip it back to the plain value."
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
- `Value(raw, unit=None, *, low, high, step, choices, dist, basis, source, measured_by, notes, date_accessed)`
|
|
56
|
+
returns `FloatValue | IntValue | StrValue | TupleValue` (also exported). Refused at construction when
|
|
57
|
+
outside `[low, high]` (tuples elementwise) or not in `choices`. `v` is a short alias.
|
|
58
|
+
- `meta(x)` the evidence dict or `None`; `plain(obj)` strips every `Value`, through dataclasses,
|
|
59
|
+
lists and tuples.
|
|
60
|
+
- `Basis`: `CHOSEN DATASHEET MARKET ASSUMED MEASURED SAMPLED` (a `StrEnum`).
|
|
61
|
+
- `Uniform`, `Normal`, `LogUniform`, `Choice`: per-value distributions with `.draw(rng)`.
|
|
62
|
+
- `Space(instance)`: `.sample(rng=None)`, `.contains(other)`, `.violations(other)` (dotted field
|
|
63
|
+
paths). Subclasses add bounds by overriding the hooks `extra_draw(drawn, rng)` and
|
|
64
|
+
`extra_violations(other)`; robot_msgs uses them for numpy array bounds.
|
|
65
|
+
|
|
66
|
+
## Tests
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
env -u VIRTUAL_ENV uv run --isolated --extra test python -m pytest -q
|
|
70
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
dataclass_values/__init__.py,sha256=O-Ci9D894Il6YnsWU4nUwiHMY7YhmxY7ydwHJEB1nu4,574
|
|
2
|
+
dataclass_values/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
dataclass_values/space.py,sha256=6Rm-CVlL8bmkY0XXiszCp6GMq4Kg7r1HKjnIs_j8hVM,5840
|
|
4
|
+
dataclass_values/values.py,sha256=NPPo2lNOPkM7iNBsqhVydIPGmD4YSAxYtbXbpiby2jE,9518
|
|
5
|
+
dataclass_values-0.1.0.dist-info/METADATA,sha256=3sVYy-dSeB8-oIvic6jzLE57WlPPc-wlc1PSU5IKNLs,3237
|
|
6
|
+
dataclass_values-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
dataclass_values-0.1.0.dist-info/licenses/LICENSE,sha256=6bCPolDAhPTDhDz1aKT0ErCY9wlydSa1utkKkDysbQE,1221
|
|
8
|
+
dataclass_values-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zach Yamaoka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
src/robot_msgs/ros/vision_msgs.py embeds message definitions from
|
|
26
|
+
https://github.com/ros-perception/vision_msgs (tag 4.1.1), licensed Apache-2.0.
|