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.
Files changed (54) hide show
  1. {openodd_py-0.2.2 → openodd_py-0.4.0}/Cargo.lock +1 -1
  2. {openodd_py-0.2.2 → openodd_py-0.4.0}/Cargo.toml +1 -1
  3. {openodd_py-0.2.2 → openodd_py-0.4.0}/PKG-INFO +39 -2
  4. {openodd_py-0.2.2 → openodd_py-0.4.0}/README.md +38 -1
  5. {openodd_py-0.2.2 → openodd_py-0.4.0}/pyproject.toml +3 -0
  6. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/__init__.py +18 -0
  7. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/_core.pyi +11 -0
  8. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/backend.py +23 -0
  9. openodd_py-0.4.0/python/openodd/cells.py +76 -0
  10. openodd_py-0.4.0/python/openodd/cod.py +261 -0
  11. openodd_py-0.4.0/python/openodd/collector.py +658 -0
  12. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/items.py +25 -0
  13. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/report.py +17 -2
  14. openodd_py-0.4.0/src/cells.rs +209 -0
  15. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/items.rs +23 -2
  16. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/lib.rs +1 -0
  17. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/model.rs +1 -0
  18. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/python.rs +57 -0
  19. {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/run.py +6 -1
  20. openodd_py-0.4.0/tests/test_cells.py +83 -0
  21. openodd_py-0.4.0/tests/test_cod.py +202 -0
  22. openodd_py-0.4.0/tests/test_collector.py +386 -0
  23. {openodd_py-0.2.2 → openodd_py-0.4.0}/.gitattributes +0 -0
  24. {openodd_py-0.2.2 → openodd_py-0.4.0}/.github/workflows/ci.yml +0 -0
  25. {openodd_py-0.2.2 → openodd_py-0.4.0}/.github/workflows/release-label.yml +0 -0
  26. {openodd_py-0.2.2 → openodd_py-0.4.0}/.github/workflows/release.yml +0 -0
  27. {openodd_py-0.2.2 → openodd_py-0.4.0}/.gitignore +0 -0
  28. {openodd_py-0.2.2 → openodd_py-0.4.0}/LICENSE +0 -0
  29. {openodd_py-0.2.2 → openodd_py-0.4.0}/NOTICE +0 -0
  30. {openodd_py-0.2.2 → openodd_py-0.4.0}/docs/versioning.md +0 -0
  31. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/loader.py +0 -0
  32. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/model.py +0 -0
  33. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/odds/default/default.binding.yaml +0 -0
  34. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/odds/default/default.yaml +0 -0
  35. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/py.typed +0 -0
  36. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/sampler.py +0 -0
  37. {openodd_py-0.2.2 → openodd_py-0.4.0}/python/openodd/sources.py +0 -0
  38. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/error.rs +0 -0
  39. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/loader.rs +0 -0
  40. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/report.rs +0 -0
  41. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/sampler.rs +0 -0
  42. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/sources.rs +0 -0
  43. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/transmission.rs +0 -0
  44. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/units.rs +0 -0
  45. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/value.rs +0 -0
  46. {openodd_py-0.2.2 → openodd_py-0.4.0}/src/yaml.rs +0 -0
  47. {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/cases.py +0 -0
  48. {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/compare.py +0 -0
  49. {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/difftest_probes.py +0 -0
  50. {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/differential/report_run.py +0 -0
  51. {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/test_api.py +0 -0
  52. {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/test_differential.py +0 -0
  53. {openodd_py-0.2.2 → openodd_py-0.4.0}/tests/test_sampling.py +0 -0
  54. {openodd_py-0.2.2 → openodd_py-0.4.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.4.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.4.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.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*; the core only judges values.
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*; the core only judges values.
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,6 +16,9 @@ classifiers = [
16
16
  "Topic :: Scientific/Engineering",
17
17
  ]
18
18
 
19
+ [project.scripts]
20
+ openodd-coverage = "openodd.report:main"
21
+
19
22
  [project.urls]
20
23
  Repository = "https://github.com/hakuturu583/openodd_py"
21
24
 
@@ -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