openodd-py 0.2.2__tar.gz → 0.4.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.
- {openodd_py-0.2.2 → openodd_py-0.4.0}/Cargo.lock +1 -1
- {openodd_py-0.2.2 → openodd_py-0.4.0}/Cargo.toml +1 -1
- {openodd_py-0.2.2 → openodd_py-0.4.0}/PKG-INFO +39 -2
- {openodd_py-0.2.2 → openodd_py-0.4.0}/README.md +38 -1
- {openodd_py-0.2.2 → openodd_py-0.4.0}/pyproject.toml +3 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/__init__.py +18 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/_core.pyi +11 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/backend.py +23 -0
- openodd_py-0.4.0/python/openodd/cells.py +76 -0
- openodd_py-0.4.0/python/openodd/cod.py +261 -0
- openodd_py-0.4.0/python/openodd/collector.py +658 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/items.py +25 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/report.py +17 -2
- openodd_py-0.4.0/src/cells.rs +209 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/items.rs +23 -2
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/lib.rs +1 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/model.rs +1 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/python.rs +57 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/run.py +6 -1
- openodd_py-0.4.0/tests/test_cells.py +83 -0
- openodd_py-0.4.0/tests/test_cod.py +202 -0
- openodd_py-0.4.0/tests/test_collector.py +386 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/.gitattributes +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/.github/workflows/ci.yml +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/.github/workflows/release-label.yml +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/.github/workflows/release.yml +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/.gitignore +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/LICENSE +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/NOTICE +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/docs/versioning.md +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/loader.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/model.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/odds/default/default.binding.yaml +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/odds/default/default.yaml +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/py.typed +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/sampler.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/sources.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/error.rs +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/loader.rs +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/report.rs +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/sampler.rs +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/sources.rs +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/transmission.rs +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/units.rs +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/value.rs +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/src/yaml.rs +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/cases.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/compare.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/difftest_probes.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/report_run.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/test_api.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/test_differential.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/test_sampling.py +0 -0
- {openodd_py-0.2.2 → openodd_py-0.4.0}/tools/bump_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openodd-py
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Topic :: Scientific/Engineering
|
|
@@ -25,8 +25,19 @@ Project-URL: Repository, https://github.com/hakuturu583/openodd_py
|
|
|
25
25
|
(see [docs/versioning.md](docs/versioning.md)), and the transmission packs into a
|
|
26
26
|
deterministic archive the ODD reads back from exactly.
|
|
27
27
|
|
|
28
|
+
- **Cover** what runs drive: `CoverageCollector` samples cover items and the ODD's
|
|
29
|
+
attributes step by step (hits, seconds, metres, entries, `min_stay`, situations,
|
|
30
|
+
steps outside the ODD) and writes a coverage file; `merge_coverage` /
|
|
31
|
+
`openodd-coverage` merge any number into a report, and `--export-cod` writes each
|
|
32
|
+
run's ODD samples as an OpenODD COD table (section 8.3) with its manifest and
|
|
33
|
+
taxonomy.
|
|
34
|
+
- **Sample** the next runs from the ODD (`OddSampler`), aimed at what coverage
|
|
35
|
+
left uncovered.
|
|
36
|
+
|
|
28
37
|
What measures the attributes -- a simulator, a recorded log -- is the application's
|
|
29
|
-
*backend
|
|
38
|
+
*backend*: a subclass of `openodd.Backend` naming its probes, how a run sets an
|
|
39
|
+
attribute (`knob`), and where the ego is (`position`, `geolocation`). Everything
|
|
40
|
+
else is this library's.
|
|
30
41
|
|
|
31
42
|
A `default` ODD ships with the package (`openodd.bundled_odd()`,
|
|
32
43
|
[python/openodd/odds/default](python/openodd/odds/default)): every attribute a road-vehicle
|
|
@@ -42,8 +53,34 @@ odd = openodd.load_odd_binding("urban.yaml", backend=backend)
|
|
|
42
53
|
verdict = odd.evaluate(odd.sample(context))
|
|
43
54
|
odd.revision # sha256 of the files it was read from
|
|
44
55
|
odd.archive() # those files as a deterministic .tar.gz
|
|
56
|
+
|
|
57
|
+
collector = openodd.CoverageCollector(items, odd=odd, backend=backend)
|
|
58
|
+
collector.start(context, 0.0)
|
|
59
|
+
collector.tick(context, 0.05) # after every step
|
|
60
|
+
collector.end(context, 0.05)
|
|
61
|
+
collector.write(Path("run_coverage.json"), "my_scenario")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Coverage
|
|
65
|
+
|
|
66
|
+
A coverage document records, besides each item's buckets, how a run spent its
|
|
67
|
+
time across the ODD's buckets: `OddCells` adds up steps, seconds and metres per
|
|
68
|
+
*cell* -- one bucket of each attribute, as its cover item buckets it -- so a
|
|
69
|
+
consumer can slice runs by any attributes without the per-step values:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
cells = openodd.OddCells(odd)
|
|
73
|
+
cells.add(values, seconds=dt, meters=ds) # every step: each attribute's value
|
|
74
|
+
cells.to_dict()
|
|
75
|
+
# {"attributes": ["speed", "rain"], "rows": [[["[30, 60]", "heavy"], 812, 40.6, 588.2], ...]}
|
|
45
76
|
```
|
|
46
77
|
|
|
78
|
+
A missing value is `<missing>`, a number outside the buckets `<below>` / `<above>`.
|
|
79
|
+
Each item's definition (`describe()`) names the measure its coverage is read from
|
|
80
|
+
(`amount`: `hits`, `counted.seconds`, ...) and, for an ODD attribute's item, the
|
|
81
|
+
attribute (`attribute`). `openodd.__version__` is the installed version, for
|
|
82
|
+
coverage files to say what wrote them.
|
|
83
|
+
|
|
47
84
|
## Development
|
|
48
85
|
|
|
49
86
|
```bash
|
|
@@ -10,8 +10,19 @@
|
|
|
10
10
|
(see [docs/versioning.md](docs/versioning.md)), and the transmission packs into a
|
|
11
11
|
deterministic archive the ODD reads back from exactly.
|
|
12
12
|
|
|
13
|
+
- **Cover** what runs drive: `CoverageCollector` samples cover items and the ODD's
|
|
14
|
+
attributes step by step (hits, seconds, metres, entries, `min_stay`, situations,
|
|
15
|
+
steps outside the ODD) and writes a coverage file; `merge_coverage` /
|
|
16
|
+
`openodd-coverage` merge any number into a report, and `--export-cod` writes each
|
|
17
|
+
run's ODD samples as an OpenODD COD table (section 8.3) with its manifest and
|
|
18
|
+
taxonomy.
|
|
19
|
+
- **Sample** the next runs from the ODD (`OddSampler`), aimed at what coverage
|
|
20
|
+
left uncovered.
|
|
21
|
+
|
|
13
22
|
What measures the attributes -- a simulator, a recorded log -- is the application's
|
|
14
|
-
*backend
|
|
23
|
+
*backend*: a subclass of `openodd.Backend` naming its probes, how a run sets an
|
|
24
|
+
attribute (`knob`), and where the ego is (`position`, `geolocation`). Everything
|
|
25
|
+
else is this library's.
|
|
15
26
|
|
|
16
27
|
A `default` ODD ships with the package (`openodd.bundled_odd()`,
|
|
17
28
|
[python/openodd/odds/default](python/openodd/odds/default)): every attribute a road-vehicle
|
|
@@ -27,8 +38,34 @@ odd = openodd.load_odd_binding("urban.yaml", backend=backend)
|
|
|
27
38
|
verdict = odd.evaluate(odd.sample(context))
|
|
28
39
|
odd.revision # sha256 of the files it was read from
|
|
29
40
|
odd.archive() # those files as a deterministic .tar.gz
|
|
41
|
+
|
|
42
|
+
collector = openodd.CoverageCollector(items, odd=odd, backend=backend)
|
|
43
|
+
collector.start(context, 0.0)
|
|
44
|
+
collector.tick(context, 0.05) # after every step
|
|
45
|
+
collector.end(context, 0.05)
|
|
46
|
+
collector.write(Path("run_coverage.json"), "my_scenario")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Coverage
|
|
50
|
+
|
|
51
|
+
A coverage document records, besides each item's buckets, how a run spent its
|
|
52
|
+
time across the ODD's buckets: `OddCells` adds up steps, seconds and metres per
|
|
53
|
+
*cell* -- one bucket of each attribute, as its cover item buckets it -- so a
|
|
54
|
+
consumer can slice runs by any attributes without the per-step values:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
cells = openodd.OddCells(odd)
|
|
58
|
+
cells.add(values, seconds=dt, meters=ds) # every step: each attribute's value
|
|
59
|
+
cells.to_dict()
|
|
60
|
+
# {"attributes": ["speed", "rain"], "rows": [[["[30, 60]", "heavy"], 812, 40.6, 588.2], ...]}
|
|
30
61
|
```
|
|
31
62
|
|
|
63
|
+
A missing value is `<missing>`, a number outside the buckets `<below>` / `<above>`.
|
|
64
|
+
Each item's definition (`describe()`) names the measure its coverage is read from
|
|
65
|
+
(`amount`: `hits`, `counted.seconds`, ...) and, for an ODD attribute's item, the
|
|
66
|
+
attribute (`attribute`). `openodd.__version__` is the installed version, for
|
|
67
|
+
coverage files to say what wrote them.
|
|
68
|
+
|
|
32
69
|
## Development
|
|
33
70
|
|
|
34
71
|
```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,
|
|
@@ -41,6 +44,8 @@ from .loader import (
|
|
|
41
44
|
load_openodd,
|
|
42
45
|
read_archive_manifest,
|
|
43
46
|
)
|
|
47
|
+
from .cod import CodExport, export_cod, export_cods
|
|
48
|
+
from .collector import CROSS_SEPARATOR, CoverageCollector
|
|
44
49
|
from .report import (
|
|
45
50
|
COVERAGE_SCHEMA,
|
|
46
51
|
CoverageReport,
|
|
@@ -65,7 +70,20 @@ from .model import (
|
|
|
65
70
|
read_probe,
|
|
66
71
|
)
|
|
67
72
|
|
|
73
|
+
try:
|
|
74
|
+
__version__: str = _metadata.version("openodd-py")
|
|
75
|
+
except _metadata.PackageNotFoundError: # a source tree, not installed
|
|
76
|
+
__version__ = "0+unknown"
|
|
77
|
+
|
|
68
78
|
__all__ = [
|
|
79
|
+
"CROSS_SEPARATOR",
|
|
80
|
+
"CodExport",
|
|
81
|
+
"CoverageCollector",
|
|
82
|
+
"MISSING",
|
|
83
|
+
"OddCells",
|
|
84
|
+
"__version__",
|
|
85
|
+
"export_cod",
|
|
86
|
+
"export_cods",
|
|
69
87
|
"COVERAGE_SCHEMA",
|
|
70
88
|
"CoverageReport",
|
|
71
89
|
"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: ...
|
|
@@ -18,6 +18,11 @@ is missing. A probe that raises is read as missing too.
|
|
|
18
18
|
The sampler (:mod:`.sampler`) asks the backend the other way round as well:
|
|
19
19
|
how a run *sets* an attribute (:meth:`Backend.knob`), so an application that
|
|
20
20
|
can stage conditions -- a simulator's weather -- can be sampled from an ODD.
|
|
21
|
+
The coverage collector (:mod:`.collector`) asks it where the ego is
|
|
22
|
+
(:meth:`Backend.position`, :meth:`Backend.geolocation`).
|
|
23
|
+
|
|
24
|
+
An application subclasses :class:`Backend` for what is its own and leaves the
|
|
25
|
+
rest -- the model, the reader, coverage, the sampler -- to this library.
|
|
21
26
|
"""
|
|
22
27
|
|
|
23
28
|
from __future__ import annotations
|
|
@@ -112,6 +117,24 @@ class Backend:
|
|
|
112
117
|
raise ValueError(f"the {self.name} backend has no {key!r} readers")
|
|
113
118
|
return self.readers[key](value)
|
|
114
119
|
|
|
120
|
+
# -- what the collector asks on every step ---------------------------------
|
|
121
|
+
|
|
122
|
+
def position(self, context: Any) -> Optional[tuple[float, float, float]]:
|
|
123
|
+
"""Where the ego is in *context*, in metres (``None``: unknown).
|
|
124
|
+
|
|
125
|
+
The collector measures the distance a step stands for with it (an
|
|
126
|
+
item's ``cover_by="meters"``). A backend that can tell overrides it.
|
|
127
|
+
"""
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def geolocation(self, context: Any) -> Optional[tuple[float, float]]:
|
|
131
|
+
"""The ego's latitude and longitude in *context* (``None``: unknown).
|
|
132
|
+
|
|
133
|
+
Kept with every step's ODD samples, it is the spatial extent of an
|
|
134
|
+
exported COD (:mod:`.cod`). A backend that can tell overrides it.
|
|
135
|
+
"""
|
|
136
|
+
return None
|
|
137
|
+
|
|
115
138
|
# -- what the Rust reader asks while it reads a binding --------------------
|
|
116
139
|
|
|
117
140
|
def reader_keys(self) -> list[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
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Export a run's ODD samples as an ASAM OpenODD 1.0 current operational domain.
|
|
2
|
+
|
|
3
|
+
The coverage file keeps, per tick, the time, the ego's position and every ODD
|
|
4
|
+
attribute's value (``odd.samples``). This turns them into OpenODD's exchange
|
|
5
|
+
format for what was observed: a COD table (section 8.3), a manifest linking
|
|
6
|
+
its columns to the taxonomy (8.3.4), and the taxonomy itself, which has to
|
|
7
|
+
travel with a COD (6.1.4.5).
|
|
8
|
+
|
|
9
|
+
* ``TEMPORAL_EXTENT`` is the run's start time (UTC) plus the elapsed time.
|
|
10
|
+
* ``SPATIAL_EXTENT`` is ``"latitude longitude"``, as the backend's
|
|
11
|
+
:meth:`~.backend.Backend.geolocation` gave it. A tick without an ego
|
|
12
|
+
position is left out: the column must not be empty.
|
|
13
|
+
* A number's column is ``name;unit``; a categorical holds its literal; a
|
|
14
|
+
boolean ``true`` or ``false``; a missing value is empty, which OpenODD reads
|
|
15
|
+
as unknown (8.3.2).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import csv
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from datetime import datetime, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
from ._core import Units
|
|
30
|
+
|
|
31
|
+
__all__ = ["CodExport", "export_cod", "export_cods"]
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CodExport:
|
|
37
|
+
"""The files written for one run."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, cod: Path, manifest: Path, taxonomy: Path, rows: int) -> None:
|
|
40
|
+
self.cod = cod
|
|
41
|
+
self.manifest = manifest
|
|
42
|
+
self.taxonomy = taxonomy
|
|
43
|
+
#: Rows written; ticks without an ego position are not.
|
|
44
|
+
self.rows = rows
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _kind(meta: dict[str, Any], column: list[Any]) -> str:
|
|
48
|
+
"""``boolean``, ``integer``, ``categorical`` or ``number``."""
|
|
49
|
+
values = meta.get("values")
|
|
50
|
+
seen = [v for v in column if v is not None]
|
|
51
|
+
if values is not None and set(values) <= {"false", "true"}:
|
|
52
|
+
return "boolean"
|
|
53
|
+
if all(isinstance(v, bool) for v in seen) and seen and values is None:
|
|
54
|
+
return "boolean"
|
|
55
|
+
if values is not None:
|
|
56
|
+
if all(re.fullmatch(r"-?\d+", str(v)) for v in values):
|
|
57
|
+
return "integer"
|
|
58
|
+
return "categorical"
|
|
59
|
+
if seen and all(isinstance(v, str) for v in seen):
|
|
60
|
+
return "categorical"
|
|
61
|
+
return "number"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _place(name: str, names: set[str]) -> tuple[list[str], str]:
|
|
65
|
+
"""Where an attribute goes in the taxonomy: its records, and its key.
|
|
66
|
+
|
|
67
|
+
A dotted name nests one record per part, except past a part that is an
|
|
68
|
+
attribute itself: ``env.rain.duration`` beside ``env.rain`` is the key
|
|
69
|
+
``rain.duration`` under ``env``, as OpenODD writes a measure of an element.
|
|
70
|
+
"""
|
|
71
|
+
parts = name.split(".")
|
|
72
|
+
for i in range(1, len(parts)):
|
|
73
|
+
if ".".join(parts[:i]) in names:
|
|
74
|
+
return parts[: i - 1], ".".join(parts[i - 1 :])
|
|
75
|
+
return parts[:-1], parts[-1]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unit_type(unit: str) -> Optional[str]:
|
|
79
|
+
return Units().unit_type(unit) if unit else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_PLAIN = re.compile(r"[A-Za-z_][A-Za-z0-9_.]*( [A-Za-z_][A-Za-z0-9_.]*)*")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _scalar(text: str) -> str:
|
|
86
|
+
"""*text* as a YAML scalar: plain when it is a name, else quoted (JSON is YAML)."""
|
|
87
|
+
if _PLAIN.fullmatch(text) and text.lower() not in (
|
|
88
|
+
"true", "false", "yes", "no", "on", "off", "null", "none", "y", "n", "~",
|
|
89
|
+
):
|
|
90
|
+
return text
|
|
91
|
+
return json.dumps(text)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _yaml(node: dict[str, Any], indent: int = 0) -> list[str]:
|
|
95
|
+
"""A taxonomy (nested mappings of strings and lists of strings) as block YAML."""
|
|
96
|
+
lines: list[str] = []
|
|
97
|
+
pad = " " * indent
|
|
98
|
+
for key, value in node.items():
|
|
99
|
+
if isinstance(value, dict):
|
|
100
|
+
lines.append(f"{pad}{_scalar(str(key))}:")
|
|
101
|
+
lines += _yaml(value, indent + 1)
|
|
102
|
+
elif isinstance(value, list):
|
|
103
|
+
literals = ", ".join(json.dumps(str(v)) for v in value)
|
|
104
|
+
lines.append(f"{pad}{_scalar(str(key))}: [{literals}]")
|
|
105
|
+
else:
|
|
106
|
+
lines.append(f"{pad}{_scalar(str(key))}: {_scalar(str(value))}")
|
|
107
|
+
return lines
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _taxonomy(
|
|
111
|
+
attributes: list[dict[str, Any]], kinds: list[str], columns: list[list[Any]]
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""An OpenODD YAML taxonomy of the attributes, nested by their dotted names."""
|
|
114
|
+
root: dict[str, Any] = {}
|
|
115
|
+
names = {str(meta["name"]) for meta in attributes}
|
|
116
|
+
for meta, kind, column in zip(attributes, kinds, columns):
|
|
117
|
+
parents, leaf = _place(str(meta["name"]), names)
|
|
118
|
+
node = root
|
|
119
|
+
for part in parents:
|
|
120
|
+
node = node.setdefault(part, {})
|
|
121
|
+
if kind == "boolean":
|
|
122
|
+
node[leaf] = "boolean"
|
|
123
|
+
elif kind == "integer":
|
|
124
|
+
node[leaf] = "integer count"
|
|
125
|
+
elif kind == "categorical":
|
|
126
|
+
# Every literal a cell can hold: the declared ones, else those seen.
|
|
127
|
+
declared = meta.get("values")
|
|
128
|
+
literals = (
|
|
129
|
+
[str(v) for v in declared]
|
|
130
|
+
if declared is not None
|
|
131
|
+
else sorted({str(v) for v in column if v is not None})
|
|
132
|
+
)
|
|
133
|
+
node[leaf] = literals
|
|
134
|
+
else:
|
|
135
|
+
unit_type = _unit_type(str(meta.get("unit", ""))) or "count"
|
|
136
|
+
node[leaf] = f"float {unit_type}"
|
|
137
|
+
return {"TAXONOMY": root}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cell(value: Any, kind: str) -> str:
|
|
141
|
+
if value is None:
|
|
142
|
+
return ""
|
|
143
|
+
if kind == "boolean":
|
|
144
|
+
return "true" if value in (True, "true") else "false"
|
|
145
|
+
return str(value)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def export_cod(doc: dict[str, Any], out_dir: Path, stem: str) -> Optional[CodExport]:
|
|
149
|
+
"""Write *doc*'s ODD samples (a coverage document) as a COD under *out_dir*.
|
|
150
|
+
|
|
151
|
+
Returns ``None`` when the document has no samples (no ODD, or a coverage
|
|
152
|
+
file from before samples were recorded).
|
|
153
|
+
"""
|
|
154
|
+
odd = doc.get("odd") or {}
|
|
155
|
+
samples = odd.get("samples")
|
|
156
|
+
if not samples or not samples.get("rows"):
|
|
157
|
+
return None
|
|
158
|
+
attributes: list[dict[str, Any]] = list(samples.get("attributes", ()))
|
|
159
|
+
rows: list[list[Any]] = list(samples["rows"])
|
|
160
|
+
columns = [[row[3 + i] for row in rows] for i in range(len(attributes))]
|
|
161
|
+
kinds = [_kind(meta, column) for meta, column in zip(attributes, columns)]
|
|
162
|
+
started = _start(odd.get("started_at"))
|
|
163
|
+
|
|
164
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
cod = out_dir / f"{stem}_cod.csv"
|
|
166
|
+
manifest = out_dir / f"{stem}_cod_manifest.csv"
|
|
167
|
+
taxonomy = out_dir / f"{stem}_taxonomy.yml"
|
|
168
|
+
|
|
169
|
+
headers = [
|
|
170
|
+
f"{meta['name']};{meta['unit']}"
|
|
171
|
+
if kind == "number" and meta.get("unit")
|
|
172
|
+
else str(meta["name"])
|
|
173
|
+
for meta, kind in zip(attributes, kinds)
|
|
174
|
+
]
|
|
175
|
+
written = 0
|
|
176
|
+
with cod.open("w", newline="", encoding="utf-8") as handle:
|
|
177
|
+
writer = csv.writer(handle)
|
|
178
|
+
writer.writerow(["TEMPORAL_EXTENT", "SPATIAL_EXTENT", *headers])
|
|
179
|
+
for row in rows:
|
|
180
|
+
elapsed, lat, lon = row[0], row[1], row[2]
|
|
181
|
+
if lat is None or lon is None:
|
|
182
|
+
continue
|
|
183
|
+
when = started + timedelta(seconds=float(elapsed))
|
|
184
|
+
writer.writerow(
|
|
185
|
+
[
|
|
186
|
+
when.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
|
|
187
|
+
f"{lat:.7f} {lon:.7f}",
|
|
188
|
+
*(_cell(v, k) for v, k in zip(row[3:], kinds)),
|
|
189
|
+
]
|
|
190
|
+
)
|
|
191
|
+
written += 1
|
|
192
|
+
|
|
193
|
+
names = {str(meta["name"]) for meta in attributes}
|
|
194
|
+
leaves = [_place(str(meta["name"]), names)[1] for meta in attributes]
|
|
195
|
+
with manifest.open("w", newline="", encoding="utf-8") as handle:
|
|
196
|
+
writer = csv.writer(handle)
|
|
197
|
+
writer.writerow(
|
|
198
|
+
[
|
|
199
|
+
"FIELD_ID",
|
|
200
|
+
"FILE_NAME",
|
|
201
|
+
"TYPE",
|
|
202
|
+
"COLUMN",
|
|
203
|
+
"TAXONOMY_ID",
|
|
204
|
+
"UNIT",
|
|
205
|
+
"COMMENT",
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
writer.writerow(
|
|
209
|
+
["001", taxonomy.name, "taxonomy", "", "", "", "Taxonomy of the ODD"]
|
|
210
|
+
)
|
|
211
|
+
for n, (meta, header, leaf, kind) in enumerate(
|
|
212
|
+
zip(attributes, headers, leaves, kinds), start=2
|
|
213
|
+
):
|
|
214
|
+
unique = leaves.count(leaf) == 1
|
|
215
|
+
writer.writerow(
|
|
216
|
+
[
|
|
217
|
+
f"{n:03d}",
|
|
218
|
+
cod.name,
|
|
219
|
+
"cod",
|
|
220
|
+
header,
|
|
221
|
+
leaf if unique else meta["name"],
|
|
222
|
+
meta.get("unit", "") if kind == "number" else "",
|
|
223
|
+
meta["name"],
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
taxonomy.write_text(
|
|
228
|
+
"\n".join(_yaml(_taxonomy(attributes, kinds, columns))) + "\n",
|
|
229
|
+
encoding="utf-8",
|
|
230
|
+
)
|
|
231
|
+
return CodExport(cod, manifest, taxonomy, written)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _start(started_at: Any) -> datetime:
|
|
235
|
+
"""The run's start time; the epoch when it was not recorded."""
|
|
236
|
+
if isinstance(started_at, str):
|
|
237
|
+
try:
|
|
238
|
+
# Python 3.10 reads "+00:00", not "Z".
|
|
239
|
+
return datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
240
|
+
except ValueError:
|
|
241
|
+
pass
|
|
242
|
+
return datetime(1970, 1, 1)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def export_cods(documents: Sequence[tuple[Path, dict[str, Any]]], out_dir: Path) -> list[CodExport]:
|
|
246
|
+
"""Export every run's ODD samples (``(path, coverage document)`` each) as a COD."""
|
|
247
|
+
exported = []
|
|
248
|
+
used: set[str] = set()
|
|
249
|
+
for path, doc in documents:
|
|
250
|
+
stem = path.name.removesuffix(".json").removesuffix("_coverage")
|
|
251
|
+
unique, n = stem, 1
|
|
252
|
+
while unique in used: # runs of one scenario in different directories
|
|
253
|
+
n += 1
|
|
254
|
+
unique = f"{stem}-{n}"
|
|
255
|
+
used.add(unique)
|
|
256
|
+
result = export_cod(doc, out_dir, unique)
|
|
257
|
+
if result is None:
|
|
258
|
+
logger.warning("coverage: %s has no ODD samples to export", path)
|
|
259
|
+
else:
|
|
260
|
+
exported.append(result)
|
|
261
|
+
return exported
|