openodd-py 0.2.2__tar.gz → 0.3.0__tar.gz

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.
Files changed (50) hide show
  1. {openodd_py-0.2.2 → openodd_py-0.3.0}/Cargo.lock +1 -1
  2. {openodd_py-0.2.2 → openodd_py-0.3.0}/Cargo.toml +1 -1
  3. {openodd_py-0.2.2 → openodd_py-0.3.0}/PKG-INFO +21 -1
  4. {openodd_py-0.2.2 → openodd_py-0.3.0}/README.md +20 -0
  5. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/__init__.py +11 -0
  6. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/_core.pyi +11 -0
  7. openodd_py-0.3.0/python/openodd/cells.py +76 -0
  8. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/items.py +12 -0
  9. openodd_py-0.3.0/src/cells.rs +209 -0
  10. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/items.rs +23 -2
  11. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/lib.rs +1 -0
  12. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/model.rs +1 -0
  13. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/python.rs +57 -0
  14. {openodd_py-0.2.2 → openodd_py-0.3.0}/tests/differential/run.py +6 -1
  15. openodd_py-0.3.0/tests/test_cells.py +83 -0
  16. {openodd_py-0.2.2 → openodd_py-0.3.0}/.gitattributes +0 -0
  17. {openodd_py-0.2.2 → openodd_py-0.3.0}/.github/workflows/ci.yml +0 -0
  18. {openodd_py-0.2.2 → openodd_py-0.3.0}/.github/workflows/release-label.yml +0 -0
  19. {openodd_py-0.2.2 → openodd_py-0.3.0}/.github/workflows/release.yml +0 -0
  20. {openodd_py-0.2.2 → openodd_py-0.3.0}/.gitignore +0 -0
  21. {openodd_py-0.2.2 → openodd_py-0.3.0}/LICENSE +0 -0
  22. {openodd_py-0.2.2 → openodd_py-0.3.0}/NOTICE +0 -0
  23. {openodd_py-0.2.2 → openodd_py-0.3.0}/docs/versioning.md +0 -0
  24. {openodd_py-0.2.2 → openodd_py-0.3.0}/pyproject.toml +0 -0
  25. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/backend.py +0 -0
  26. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/loader.py +0 -0
  27. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/model.py +0 -0
  28. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/odds/default/default.binding.yaml +0 -0
  29. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/odds/default/default.yaml +0 -0
  30. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/py.typed +0 -0
  31. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/report.py +0 -0
  32. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/sampler.py +0 -0
  33. {openodd_py-0.2.2 → openodd_py-0.3.0}/python/openodd/sources.py +0 -0
  34. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/error.rs +0 -0
  35. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/loader.rs +0 -0
  36. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/report.rs +0 -0
  37. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/sampler.rs +0 -0
  38. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/sources.rs +0 -0
  39. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/transmission.rs +0 -0
  40. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/units.rs +0 -0
  41. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/value.rs +0 -0
  42. {openodd_py-0.2.2 → openodd_py-0.3.0}/src/yaml.rs +0 -0
  43. {openodd_py-0.2.2 → openodd_py-0.3.0}/tests/differential/cases.py +0 -0
  44. {openodd_py-0.2.2 → openodd_py-0.3.0}/tests/differential/compare.py +0 -0
  45. {openodd_py-0.2.2 → openodd_py-0.3.0}/tests/differential/difftest_probes.py +0 -0
  46. {openodd_py-0.2.2 → openodd_py-0.3.0}/tests/differential/report_run.py +0 -0
  47. {openodd_py-0.2.2 → openodd_py-0.3.0}/tests/test_api.py +0 -0
  48. {openodd_py-0.2.2 → openodd_py-0.3.0}/tests/test_differential.py +0 -0
  49. {openodd_py-0.2.2 → openodd_py-0.3.0}/tests/test_sampling.py +0 -0
  50. {openodd_py-0.2.2 → openodd_py-0.3.0}/tools/bump_version.py +0 -0
@@ -263,7 +263,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
263
263
 
264
264
  [[package]]
265
265
  name = "openodd"
266
- version = "0.2.2"
266
+ version = "0.3.0"
267
267
  dependencies = [
268
268
  "flate2",
269
269
  "fs2",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "openodd"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  edition = "2021"
5
5
  rust-version = "1.82"
6
6
  description = "ASAM OpenODD 1.0: read, evaluate, version and sample Operational Design Domains"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openodd-py
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Topic :: Scientific/Engineering
@@ -44,6 +44,26 @@ odd.revision # sha256 of the files it was read from
44
44
  odd.archive() # those files as a deterministic .tar.gz
45
45
  ```
46
46
 
47
+ ### Coverage
48
+
49
+ A coverage document records, besides each item's buckets, how a run spent its
50
+ time across the ODD's buckets: `OddCells` adds up steps, seconds and metres per
51
+ *cell* -- one bucket of each attribute, as its cover item buckets it -- so a
52
+ consumer can slice runs by any attributes without the per-step values:
53
+
54
+ ```python
55
+ cells = openodd.OddCells(odd)
56
+ cells.add(values, seconds=dt, meters=ds) # every step: each attribute's value
57
+ cells.to_dict()
58
+ # {"attributes": ["speed", "rain"], "rows": [[["[30, 60]", "heavy"], 812, 40.6, 588.2], ...]}
59
+ ```
60
+
61
+ A missing value is `<missing>`, a number outside the buckets `<below>` / `<above>`.
62
+ Each item's definition (`describe()`) names the measure its coverage is read from
63
+ (`amount`: `hits`, `counted.seconds`, ...) and, for an ODD attribute's item, the
64
+ attribute (`attribute`). `openodd.__version__` is the installed version, for
65
+ coverage files to say what wrote them.
66
+
47
67
  ## Development
48
68
 
49
69
  ```bash
@@ -29,6 +29,26 @@ odd.revision # sha256 of the files it was read from
29
29
  odd.archive() # those files as a deterministic .tar.gz
30
30
  ```
31
31
 
32
+ ### Coverage
33
+
34
+ A coverage document records, besides each item's buckets, how a run spent its
35
+ time across the ODD's buckets: `OddCells` adds up steps, seconds and metres per
36
+ *cell* -- one bucket of each attribute, as its cover item buckets it -- so a
37
+ consumer can slice runs by any attributes without the per-step values:
38
+
39
+ ```python
40
+ cells = openodd.OddCells(odd)
41
+ cells.add(values, seconds=dt, meters=ds) # every step: each attribute's value
42
+ cells.to_dict()
43
+ # {"attributes": ["speed", "rain"], "rows": [[["[30, 60]", "heavy"], 812, 40.6, 588.2], ...]}
44
+ ```
45
+
46
+ A missing value is `<missing>`, a number outside the buckets `<below>` / `<above>`.
47
+ Each item's definition (`describe()`) names the measure its coverage is read from
48
+ (`amount`: `hits`, `counted.seconds`, ...) and, for an ODD attribute's item, the
49
+ attribute (`attribute`). `openodd.__version__` is the installed version, for
50
+ coverage files to say what wrote them.
51
+
32
52
  ## Development
33
53
 
34
54
  ```bash
@@ -16,7 +16,10 @@ What measures the attributes -- a simulator, a recorded log -- is the
16
16
  application's :class:`Backend`.
17
17
  """
18
18
 
19
+ from importlib import metadata as _metadata
20
+
19
21
  from ._core import GitSourceError, OpenOddError, UnitError, Units, convert, normalize_unit
22
+ from .cells import MISSING, OddCells
20
23
  from .backend import Backend, Probe, default_backend, import_callable, set_default_backend
21
24
  from .items import (
22
25
  ABOVE_RANGE,
@@ -65,7 +68,15 @@ from .model import (
65
68
  read_probe,
66
69
  )
67
70
 
71
+ try:
72
+ __version__: str = _metadata.version("openodd-py")
73
+ except _metadata.PackageNotFoundError: # a source tree, not installed
74
+ __version__ = "0+unknown"
75
+
68
76
  __all__ = [
77
+ "MISSING",
78
+ "OddCells",
79
+ "__version__",
69
80
  "COVERAGE_SCHEMA",
70
81
  "CoverageReport",
71
82
  "MergedEntry",
@@ -47,8 +47,19 @@ class Units:
47
47
  def convert(self, value: float, from_unit: str, to_unit: str) -> float: ...
48
48
  def check_type(self, unit: str, unit_type: str) -> None: ...
49
49
 
50
+ class Cells:
51
+ def __init__(self, odd: Any) -> None: ...
52
+ @property
53
+ def attributes(self) -> list[str]: ...
54
+ @property
55
+ def ticks(self) -> int: ...
56
+ def cell_of(self, values: list[Any]) -> list[str]: ...
57
+ def add(self, values: list[Any], seconds: float = 0.0, meters: float = 0.0) -> None: ...
58
+ def to_json(self) -> str: ...
59
+
50
60
  UNDECIDED: Any
51
61
  CACHE_ENV: str
62
+ MISSING: str
52
63
 
53
64
  def convert(value: float, from_unit: str, to_unit: str) -> float: ...
54
65
  def normalize_unit(unit: str) -> str: ...
@@ -0,0 +1,76 @@
1
+ """Cells: how long a run spent in each combination of the ODD's buckets.
2
+
3
+ Every step, each attribute with buckets puts its value in one (its cover
4
+ item's ``bucket_of``); the labels together are the step's *cell*.
5
+ :class:`OddCells` adds up steps, seconds and metres per cell, so a consumer can
6
+ slice a run by any attributes afterwards -- a matrix of two of them inside a
7
+ third's bucket -- without keeping every step's values, and without bucketing
8
+ them again by rules of its own::
9
+
10
+ cells = OddCells(odd)
11
+ for step in run: # every attribute's value, in order
12
+ cells.add(step.values, seconds=step.dt, meters=step.ds)
13
+ cells.to_dict()
14
+ # {"attributes": ["scenery.speed_limit", "environment.rain"],
15
+ # "rows": [[["[50, 60)", "none"], 812, 40.6, 588.2], ...]}
16
+
17
+ A missing value is :data:`MISSING`; a number outside the buckets
18
+ ``<below>``/``<above>``; a category none of the values names keeps its own
19
+ label. Attributes without buckets are left out of the cell.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from collections.abc import Mapping, Sequence
26
+ from typing import TYPE_CHECKING, Any, Union
27
+
28
+ from . import _core
29
+ from .model import to_core
30
+
31
+ if TYPE_CHECKING:
32
+ from .model import OddDefinition
33
+
34
+ __all__ = ["MISSING", "OddCells"]
35
+
36
+ #: The label of a step whose value was missing.
37
+ MISSING: str = _core.MISSING
38
+
39
+
40
+ class OddCells:
41
+ """Steps, seconds and metres per cell of *odd*'s attributes."""
42
+
43
+ def __init__(self, odd: "OddDefinition") -> None:
44
+ self._names = [a.name for a in odd.attributes]
45
+ self._core = _core.Cells(odd._core)
46
+
47
+ @property
48
+ def attributes(self) -> list[str]:
49
+ """The attributes with buckets: what a cell's labels are of, in order."""
50
+ return list(self._core.attributes)
51
+
52
+ @property
53
+ def ticks(self) -> int:
54
+ """Steps counted so far."""
55
+ return int(self._core.ticks)
56
+
57
+ def _values(self, values: Union[Sequence[Any], Mapping[str, Any]]) -> list[Any]:
58
+ if isinstance(values, Mapping):
59
+ return [to_core(values.get(name)) for name in self._names]
60
+ return [to_core(v) for v in values]
61
+
62
+ def cell_of(self, values: Union[Sequence[Any], Mapping[str, Any]]) -> list[str]:
63
+ """The cell of one step: *values* by attribute name, or one per
64
+ attribute of the ODD in its order."""
65
+ return list(self._core.cell_of(self._values(values)))
66
+
67
+ def add(self, values: Union[Sequence[Any], Mapping[str, Any]], seconds: float = 0.0,
68
+ meters: float = 0.0) -> None:
69
+ """Count one step that took *seconds* and covered *meters*."""
70
+ self._core.add(self._values(values), float(seconds), float(meters))
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ """``{"attributes": [...], "rows": [[[label, ...], ticks, seconds, meters], ...]}``,
74
+ most steps first (ties by labels); seconds and metres to 3 decimals."""
75
+ out: dict[str, Any] = json.loads(self._core.to_json())
76
+ return out
@@ -119,6 +119,8 @@ class CoverItem:
119
119
  group: CoverGroup = CoverGroup.SCENARIO
120
120
  cover_by: str = "hits"
121
121
  min_stay: Optional[float] = None
122
+ #: The ODD attribute the item buckets, when it is one's.
123
+ attribute: Optional[str] = None
122
124
 
123
125
  #: Bucket edges of a numeric item, ascending; empty for a categorical one.
124
126
  edges: list[float] = field(init=False, default_factory=list)
@@ -173,6 +175,7 @@ class CoverItem:
173
175
  self.group = group
174
176
  self.cover_by = bucketing.cover_by
175
177
  self.min_stay = bucketing.min_stay
178
+ self.attribute = bucketing.attribute
176
179
  self._bucketing = bucketing
177
180
  return self
178
181
 
@@ -181,6 +184,12 @@ class CoverItem:
181
184
  """Whether the buckets are ranges rather than values."""
182
185
  return bool(self.edges)
183
186
 
187
+ @property
188
+ def amount(self) -> str:
189
+ """The measure a bucket's coverage is read from: ``cover_by``, or with
190
+ a ``min_stay`` the stays long enough to count (``counted.<cover_by>``)."""
191
+ return f"counted.{self.cover_by}" if self.min_stay is not None else self.cover_by
192
+
184
193
  def bucket_of(self, value: Any) -> str:
185
194
  """The label of the bucket *value* falls in.
186
195
 
@@ -208,6 +217,8 @@ class CoverItem:
208
217
  "target": self.target,
209
218
  "cover_by": self.cover_by,
210
219
  "min_stay": self.min_stay,
220
+ "amount": self.amount,
221
+ **({"attribute": self.attribute} if self.attribute is not None else {}),
211
222
  }
212
223
 
213
224
 
@@ -267,4 +278,5 @@ class CrossItem:
267
278
  "target": self.target,
268
279
  "cover_by": self.cover_by,
269
280
  "min_stay": self.min_stay,
281
+ "amount": f"counted.{self.cover_by}" if self.min_stay is not None else self.cover_by,
270
282
  }
@@ -0,0 +1,209 @@
1
+ //! Cells: how long a run spent in each combination of the ODD's buckets.
2
+ //!
3
+ //! Every step, each attribute with buckets (a cover item) puts its value in a
4
+ //! bucket; the tuple of those labels is the step's *cell*. A [`CellCounter`]
5
+ //! adds up the steps, seconds and metres per cell, so a consumer can slice the
6
+ //! run by any attributes afterwards without the per-step values -- and buckets
7
+ //! them exactly as the cover items do, with no second copy of the rules.
8
+ //!
9
+ //! A missing value is [`MISSING`]; a numeric value below or above every bucket
10
+ //! is [`BELOW_RANGE`] / [`ABOVE_RANGE`]; a categorical value that is none of
11
+ //! the values keeps its own label (as [`CoverItem::bucket_of`] does).
12
+
13
+ use indexmap::IndexMap;
14
+ use serde_json::{json, Value as Json};
15
+
16
+ use crate::error::{Error, Result};
17
+ use crate::items::CoverItem;
18
+ use crate::model::Odd;
19
+ use crate::value::Value;
20
+
21
+ /// The label of a step whose value was missing (or could not be bucketed).
22
+ pub const MISSING: &str = "<missing>";
23
+
24
+ /// Steps, seconds and metres per cell of an ODD's attributes.
25
+ #[derive(Debug, Clone)]
26
+ pub struct CellCounter {
27
+ /// The attributes with buckets, in the ODD's order: the cell's labels.
28
+ pub attributes: Vec<String>,
29
+ /// For each of the ODD's attributes (all of them, in order), its item.
30
+ items: Vec<Option<CoverItem>>,
31
+ cells: IndexMap<Vec<String>, (u64, f64, f64)>,
32
+ }
33
+
34
+ fn round3(x: f64) -> f64 {
35
+ (x * 1000.0).round() / 1000.0
36
+ }
37
+
38
+ impl CellCounter {
39
+ pub fn new(odd: &Odd) -> Self {
40
+ let items: Vec<Option<CoverItem>> = odd.attributes.iter().map(|a| a.item.clone()).collect();
41
+ let attributes = odd
42
+ .attributes
43
+ .iter()
44
+ .filter(|a| a.item.is_some())
45
+ .map(|a| a.name.clone())
46
+ .collect();
47
+ CellCounter {
48
+ attributes,
49
+ items,
50
+ cells: IndexMap::new(),
51
+ }
52
+ }
53
+
54
+ /// The cell of one step: `values` has one value per attribute of the ODD
55
+ /// (every attribute, in the ODD's order, as samples are recorded).
56
+ pub fn cell_of(&self, values: &[Value]) -> Result<Vec<String>> {
57
+ if values.len() != self.items.len() {
58
+ return Err(Error::value(format!(
59
+ "cells: {} values for {} attributes",
60
+ values.len(),
61
+ self.items.len()
62
+ )));
63
+ }
64
+ Ok(self
65
+ .items
66
+ .iter()
67
+ .zip(values)
68
+ .filter_map(|(item, value)| item.as_ref().map(|item| (item, value)))
69
+ .map(|(item, value)| match value {
70
+ Value::Missing | Value::Open => MISSING.to_string(),
71
+ v => item.bucket_of(v).unwrap_or_else(|_| MISSING.to_string()),
72
+ })
73
+ .collect())
74
+ }
75
+
76
+ /// Count one step that took `seconds` and covered `meters`.
77
+ pub fn add(&mut self, values: &[Value], seconds: f64, meters: f64) -> Result<()> {
78
+ let cell = self.cell_of(values)?;
79
+ let entry = self.cells.entry(cell).or_insert((0, 0.0, 0.0));
80
+ entry.0 += 1;
81
+ entry.1 += seconds.max(0.0);
82
+ entry.2 += meters.max(0.0);
83
+ Ok(())
84
+ }
85
+
86
+ /// Steps counted so far.
87
+ pub fn ticks(&self) -> u64 {
88
+ self.cells.values().map(|c| c.0).sum()
89
+ }
90
+
91
+ /// The cells: labels, steps, seconds, metres; most steps first, then by labels.
92
+ pub fn rows(&self) -> Vec<(Vec<String>, u64, f64, f64)> {
93
+ let mut rows: Vec<_> = self
94
+ .cells
95
+ .iter()
96
+ .map(|(k, (t, s, m))| (k.clone(), *t, round3(*s), round3(*m)))
97
+ .collect();
98
+ rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
99
+ rows
100
+ }
101
+
102
+ /// `{"attributes": [...], "rows": [[[label, ...], ticks, seconds, meters], ...]}`.
103
+ pub fn to_json(&self) -> Json {
104
+ json!({
105
+ "attributes": self.attributes,
106
+ "rows": self.rows().into_iter()
107
+ .map(|(labels, t, s, m)| json!([labels, t, s, m]))
108
+ .collect::<Vec<_>>(),
109
+ })
110
+ }
111
+ }
112
+
113
+ #[cfg(test)]
114
+ mod tests {
115
+ use super::*;
116
+ use crate::items::{Buckets, Criteria};
117
+ use crate::model::{Attribute, Probe};
118
+
119
+ fn odd() -> Odd {
120
+ let mut speed =
121
+ Attribute::new("speed", Probe::Missing, "km/h", "", Criteria::default()).unwrap();
122
+ speed
123
+ .set_buckets(Buckets::Edges(vec![0.0, 30.0, 60.0]))
124
+ .unwrap();
125
+ let mut rain = Attribute::new("rain", Probe::Missing, "", "", Criteria::default()).unwrap();
126
+ rain.set_buckets(Buckets::Values(vec![
127
+ Value::Str("none".into()),
128
+ Value::Str("heavy".into()),
129
+ ]))
130
+ .unwrap();
131
+ let free = Attribute::new("free", Probe::Missing, "", "", Criteria::default()).unwrap();
132
+ Odd::new("t", vec![speed, free, rain], vec![], None, "").unwrap()
133
+ }
134
+
135
+ #[test]
136
+ fn buckets_like_the_items() {
137
+ let c = CellCounter::new(&odd());
138
+ assert_eq!(c.attributes, vec!["speed", "rain"]);
139
+ let cell = |v: Vec<Value>| c.cell_of(&v).unwrap();
140
+ assert_eq!(
141
+ cell(vec![
142
+ Value::Float(30.0),
143
+ Value::Int(3),
144
+ Value::Str("none".into())
145
+ ]),
146
+ vec!["[30, 60]", "none"]
147
+ );
148
+ assert_eq!(
149
+ cell(vec![
150
+ Value::Float(-1.0),
151
+ Value::Missing,
152
+ Value::Str("fog".into())
153
+ ]),
154
+ vec!["<below>", "fog"]
155
+ );
156
+ assert_eq!(
157
+ cell(vec![Value::Float(61.0), Value::Missing, Value::Missing]),
158
+ vec!["<above>", MISSING]
159
+ );
160
+ assert_eq!(
161
+ cell(vec![Value::Str("x".into()), Value::Missing, Value::Open]),
162
+ vec![MISSING, MISSING]
163
+ );
164
+ assert!(c.cell_of(&[Value::Missing]).is_err());
165
+ }
166
+
167
+ #[test]
168
+ fn counts_and_orders() {
169
+ let mut c = CellCounter::new(&odd());
170
+ let a = vec![
171
+ Value::Float(10.0),
172
+ Value::Missing,
173
+ Value::Str("none".into()),
174
+ ];
175
+ let b = vec![
176
+ Value::Float(40.0),
177
+ Value::Missing,
178
+ Value::Str("heavy".into()),
179
+ ];
180
+ let z = vec![
181
+ Value::Float(5.0),
182
+ Value::Missing,
183
+ Value::Str("heavy".into()),
184
+ ];
185
+ c.add(&a, 0.05, 0.1).unwrap();
186
+ c.add(&b, 0.05, 0.1).unwrap();
187
+ c.add(&b, 0.0333333, 0.2).unwrap();
188
+ c.add(&z, 0.05, -1.0).unwrap();
189
+ assert_eq!(c.ticks(), 4);
190
+ let rows = c.rows();
191
+ assert_eq!(
192
+ rows[0],
193
+ (
194
+ vec!["[30, 60]".to_string(), "heavy".to_string()],
195
+ 2,
196
+ 0.083,
197
+ 0.3
198
+ )
199
+ );
200
+ // ties by labels: "[0, 30)" < ... ("heavy" < "none")
201
+ assert_eq!(rows[1].0, vec!["[0, 30)", "heavy"]);
202
+ assert_eq!(rows[1].3, 0.0); // negative displacement is not distance
203
+ assert_eq!(rows[2].0, vec!["[0, 30)", "none"]);
204
+ assert_eq!(
205
+ c.to_json()["rows"][0],
206
+ json!([["[30, 60]", "heavy"], 2, 0.083, 0.3])
207
+ );
208
+ }
209
+ }
@@ -65,6 +65,16 @@ impl Default for Criteria {
65
65
  }
66
66
 
67
67
  impl Criteria {
68
+ /// The measure a bucket's coverage is read from: `cover_by`, or with a
69
+ /// `min_stay` the stays long enough to count (`counted.<cover_by>`).
70
+ pub fn amount(&self) -> String {
71
+ if self.min_stay.is_some() {
72
+ format!("counted.{}", self.cover_by)
73
+ } else {
74
+ self.cover_by.clone()
75
+ }
76
+ }
77
+
68
78
  /// Refuse a criterion that cannot be met or measured on `event`.
69
79
  pub fn check(&self, what: &str, event: &str) -> Result<()> {
70
80
  if !COVER_MEASURES.contains(&self.cover_by.as_str()) {
@@ -133,6 +143,8 @@ pub struct CoverItem {
133
143
  pub edges: Vec<f64>,
134
144
  /// Bucket labels, in order.
135
145
  pub labels: Vec<String>,
146
+ /// The ODD attribute the item buckets, when it is one's (`odd.<name>`).
147
+ pub attribute: Option<String>,
136
148
  }
137
149
 
138
150
  fn duplicates(names: &[String]) -> Vec<String> {
@@ -170,6 +182,7 @@ impl CoverItem {
170
182
  values: Vec::new(),
171
183
  edges: Vec::new(),
172
184
  labels: Vec::new(),
185
+ attribute: None,
173
186
  };
174
187
  let name = item.name.clone();
175
188
  match buckets {
@@ -267,8 +280,11 @@ impl CoverItem {
267
280
  }
268
281
 
269
282
  /// The item's definition, as written to a coverage file.
283
+ ///
284
+ /// `amount` names the measure a bucket's coverage is read from (see
285
+ /// [`Criteria::amount`]); `attribute` is there for an ODD attribute's item.
270
286
  pub fn describe(&self) -> Json {
271
- json!({
287
+ let mut out = json!({
272
288
  "name": self.name,
273
289
  "group": self.group.as_str(),
274
290
  "text": self.text,
@@ -279,7 +295,12 @@ impl CoverItem {
279
295
  "target": number_json(self.criteria.target),
280
296
  "cover_by": self.criteria.cover_by,
281
297
  "min_stay": self.criteria.min_stay,
282
- })
298
+ "amount": self.criteria.amount(),
299
+ });
300
+ if let Some(attribute) = &self.attribute {
301
+ out["attribute"] = json!(attribute);
302
+ }
303
+ out
283
304
  }
284
305
  }
285
306
 
@@ -10,6 +10,7 @@
10
10
  // `!(x > 0.0)` is meant: it is true for NaN, which must be refused too.
11
11
  #![allow(clippy::neg_cmp_op_on_partial_ord)]
12
12
 
13
+ pub mod cells;
13
14
  pub mod error;
14
15
  pub mod items;
15
16
  pub mod loader;
@@ -492,6 +492,7 @@ impl Attribute {
492
492
  )?;
493
493
  item.unit = self.unit.clone();
494
494
  item.text = self.text.clone();
495
+ item.attribute = Some(self.name.clone());
495
496
  self.item = Some(item);
496
497
  Ok(())
497
498
  }
@@ -251,6 +251,16 @@ impl Bucketing {
251
251
  fn group(&self) -> &'static str {
252
252
  self.inner.group.as_str()
253
253
  }
254
+ /// The ODD attribute the item buckets, when it is one's.
255
+ #[getter]
256
+ fn attribute(&self) -> Option<String> {
257
+ self.inner.attribute.clone()
258
+ }
259
+ /// The measure a bucket's coverage is read from.
260
+ #[getter]
261
+ fn amount(&self) -> String {
262
+ self.inner.criteria.amount()
263
+ }
254
264
 
255
265
  /// The label of the bucket `value` falls in.
256
266
  fn bucket_of(&self, value: &Bound<'_, PyAny>) -> PyResult<String> {
@@ -258,6 +268,51 @@ impl Bucketing {
258
268
  }
259
269
  }
260
270
 
271
+ // ---------------------------------------------------------------------------
272
+ // Cells
273
+ // ---------------------------------------------------------------------------
274
+
275
+ /// Steps, seconds and metres per combination of an ODD's buckets.
276
+ #[pyclass(module = "openodd._core")]
277
+ struct Cells {
278
+ inner: crate::cells::CellCounter,
279
+ }
280
+
281
+ #[pymethods]
282
+ impl Cells {
283
+ #[new]
284
+ fn new(odd: &Odd) -> Self {
285
+ Cells {
286
+ inner: crate::cells::CellCounter::new(&odd.inner),
287
+ }
288
+ }
289
+ /// The attributes with buckets: the labels of a cell, in order.
290
+ #[getter]
291
+ fn attributes(&self) -> Vec<String> {
292
+ self.inner.attributes.clone()
293
+ }
294
+ /// Steps counted so far.
295
+ #[getter]
296
+ fn ticks(&self) -> u64 {
297
+ self.inner.ticks()
298
+ }
299
+ /// The cell of one step (one value per attribute of the ODD, in order).
300
+ fn cell_of(&self, values: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<String>> {
301
+ let values = values.iter().map(to_value).collect::<PyResult<Vec<_>>>()?;
302
+ self.inner.cell_of(&values).map_err(err)
303
+ }
304
+ /// Count one step that took `seconds` and covered `meters`.
305
+ #[pyo3(signature = (values, seconds=0.0, meters=0.0))]
306
+ fn add(&mut self, values: Vec<Bound<'_, PyAny>>, seconds: f64, meters: f64) -> PyResult<()> {
307
+ let values = values.iter().map(to_value).collect::<PyResult<Vec<_>>>()?;
308
+ self.inner.add(&values, seconds, meters).map_err(err)
309
+ }
310
+ /// `{"attributes": [...], "rows": [[[label, ...], ticks, seconds, meters], ...]}`.
311
+ fn to_json(&self) -> String {
312
+ self.inner.to_json().to_string()
313
+ }
314
+ }
315
+
261
316
  // ---------------------------------------------------------------------------
262
317
  // Conditions, attributes, modules
263
318
  // ---------------------------------------------------------------------------
@@ -1537,6 +1592,8 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
1537
1592
  m.add("CACHE_ENV", crate::sources::CACHE_ENV)?;
1538
1593
  m.add_class::<Undecided>()?;
1539
1594
  m.add_class::<Bucketing>()?;
1595
+ m.add_class::<Cells>()?;
1596
+ m.add("MISSING", crate::cells::MISSING)?;
1540
1597
  m.add_class::<Condition>()?;
1541
1598
  m.add_class::<Attribute>()?;
1542
1599
  m.add_class::<Module>()?;
@@ -18,6 +18,9 @@ from typing import Any
18
18
  sys.path.insert(0, str(Path(__file__).parent))
19
19
  from cases import all_cases, contexts # noqa: E402
20
20
 
21
+ #: Keys of an item's definition the reference does not write.
22
+ NEWER_KEYS = ("amount", "attribute")
23
+
21
24
 
22
25
  def plain(x: Any) -> Any:
23
26
  if isinstance(x, float):
@@ -60,7 +63,9 @@ def run(impl: str, case: dict[str, Any]) -> dict[str, Any]:
60
63
  for a in odd.attributes
61
64
  ],
62
65
  "modules": [m.describe() for m in odd.modules],
63
- "situations": [m.item.describe() for m in odd.situations()],
66
+ # amount / attribute came after the reference: compare what both write.
67
+ "situations": [{k: v for k, v in m.item.describe().items() if k not in NEWER_KEYS}
68
+ for m in odd.situations()],
64
69
  "unmeasured": odd.unmeasured(),
65
70
  }
66
71
  verdicts = []
@@ -0,0 +1,83 @@
1
+ """Cells: steps per combination of an ODD's buckets, bucketed as the items are."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+
7
+ import pytest
8
+
9
+ import openodd
10
+ from openodd import (
11
+ ABOVE_RANGE,
12
+ BELOW_RANGE,
13
+ MISSING,
14
+ CoverItem,
15
+ OddAttribute,
16
+ OddCells,
17
+ OddDefinition,
18
+ OddModule,
19
+ SamplingEvent,
20
+ )
21
+
22
+
23
+ class Rain(enum.Enum):
24
+ NONE = "none"
25
+ HEAVY = "heavy"
26
+
27
+
28
+ def _odd() -> OddDefinition:
29
+ speed = OddAttribute("speed", lambda c: c["speed"], unit="km/h", buckets=[0, 30, 60],
30
+ cover_by="seconds", min_stay=2)
31
+ free = OddAttribute("free", lambda c: c["free"]) # no buckets: not in a cell
32
+ rain = OddAttribute("rain", lambda c: c["rain"], values=list(Rain))
33
+ root = OddModule("root", include_and=[speed.less_than(60)])
34
+ return OddDefinition("cells", [speed, free, rain], [root])
35
+
36
+
37
+ def test_a_cell_is_the_items_buckets():
38
+ odd = _odd()
39
+ cells = OddCells(odd)
40
+ assert cells.attributes == ["speed", "rain"]
41
+ speed, rain = (a.item for a in odd.attributes if a.item is not None)
42
+ for value in (0, 29.9, 30, 60):
43
+ assert cells.cell_of([value, 1, Rain.NONE])[0] == speed.bucket_of(value)
44
+ assert cells.cell_of([-1, None, Rain.HEAVY]) == [BELOW_RANGE, rain.bucket_of(Rain.HEAVY)]
45
+ assert cells.cell_of([61, None, None]) == [ABOVE_RANGE, MISSING]
46
+ assert cells.cell_of([None, None, "fog"]) == [MISSING, "fog"]
47
+ assert cells.cell_of({"speed": 10, "rain": Rain.NONE}) == ["[0, 30)", "NONE"]
48
+ with pytest.raises(ValueError):
49
+ cells.cell_of([1, 2])
50
+
51
+
52
+ def test_cells_count_steps_seconds_and_metres():
53
+ cells = OddCells(_odd())
54
+ for _ in range(2):
55
+ cells.add([40, None, Rain.HEAVY], seconds=0.0333333, meters=0.5)
56
+ cells.add([10, None, Rain.NONE], seconds=0.05, meters=0.25)
57
+ cells.add([10, None, Rain.HEAVY], seconds=0.05)
58
+ assert cells.ticks == 4
59
+ assert cells.to_dict() == {
60
+ "attributes": ["speed", "rain"],
61
+ "rows": [
62
+ [["[30, 60]", "HEAVY"], 2, 0.067, 1.0],
63
+ [["[0, 30)", "HEAVY"], 1, 0.05, 0.0], # ties by labels
64
+ [["[0, 30)", "NONE"], 1, 0.05, 0.25],
65
+ ],
66
+ }
67
+
68
+
69
+ def test_items_say_their_attribute_and_amount():
70
+ odd = _odd()
71
+ speed = next(a.item for a in odd.attributes if a.name == "speed")
72
+ described = speed.describe()
73
+ assert described["attribute"] == "speed" and described["amount"] == "counted.seconds"
74
+ rain = next(a.item for a in odd.attributes if a.name == "rain").describe()
75
+ assert rain["attribute"] == "rain" and rain["amount"] == "hits"
76
+ plain = CoverItem("gap", lambda c: c, buckets=[0, 1], event=SamplingEvent.TICK,
77
+ cover_by="meters")
78
+ assert plain.describe()["amount"] == "meters" and "attribute" not in plain.describe()
79
+ assert [i.describe()["attribute"] for i in odd.cover_items()] == ["speed", "rain"]
80
+
81
+
82
+ def test_the_version_is_the_distributions():
83
+ assert openodd.__version__ and openodd.__version__[0].isdigit()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes