Back to report index

iolabs-common (shared config layer) f7e9f7c: AI3D-382 Use module imports (Google style) in touched files

Miroslav Simko <ms@iolabs.ch> 2026-09-02T07:50:14+02:00

Commit #92 ยท 117 snippets

 src/iolabs/common/color_intensity_data.py |  12 +-
 src/iolabs/common/segment_points_io.py    | 110 +++----
 tests/test_color_intensity_data.py        |  34 +-
 tests/test_segment_points_io.py           | 529 +++++++++++++++---------------
 4 files changed, 346 insertions(+), 339 deletions(-)
Importance #1: src/iolabs/common/segment_points_io.py @@ -710,9 +710,9 @@
710 Returns:710 Returns:
711 ``(rows, streamable)``. ``rows`` is ``-1`` when the header is711 ``(rows, streamable)``. ``rows`` is ``-1`` when the header is
712 unreadable (missing member, corrupt archive, unsupported NPY version).712 unreadable (missing member, corrupt archive, unsupported NPY version).
713 """713 """
714 npz_path = Path(path)714 npz_path = pathlib.Path(path)
715 try:715 try:
716 with zipfile.ZipFile(npz_path) as archive:716 with zipfile.ZipFile(npz_path) as archive:
717 info = archive.getinfo("points.npy")717 info = archive.getinfo("points.npy")
718 stored = info.compress_type == zipfile.ZIP_STORED718 stored = info.compress_type == zipfile.ZIP_STORED
Importance #2: src/iolabs/common/segment_points_io.py @@ -733,9 +733,9 @@
733 # caught here so a partial write degrades to "unreadable" instead.733 # caught here so a partial write degrades to "unreadable" instead.
734 return -1, False734 return -1, False
735735
736736
737def _iter_points_chunks_streamed(path: Path, chunk_points: int) -> Iterator[np.ndarray]:737def _iter_points_chunks_streamed(path: pathlib.Path, chunk_points: int) -> abc.Iterator[np.ndarray]:
738 """Stream the ``points.npy`` member of a ZIP_STORED npz in row chunks."""738 """Stream the ``points.npy`` member of a ZIP_STORED npz in row chunks."""
739 with zipfile.ZipFile(path) as archive, archive.open("points.npy") as handle:739 with zipfile.ZipFile(path) as archive, archive.open("points.npy") as handle:
740 version = np.lib.format.read_magic(handle)740 version = np.lib.format.read_magic(handle)
741 if version == (1, 0):741 if version == (1, 0):
Importance #3: src/iolabs/common/color_intensity_data.py @@ -7,10 +7,10 @@
7field is left to that subclass's ``__post_init__`` to derive, as the7field is left to that subclass's ``__post_init__`` to derive, as the
8constructor could not accept it.8constructor could not accept it.
9"""9"""
1010
11from collections.abc import Callable11import dataclasses
12from dataclasses import dataclass, field, fields12from collections import abc
13from typing import TypeVar13from typing import TypeVar
1414
15import numpy as np15import numpy as np
1616
Importance #4: src/iolabs/common/color_intensity_data.py @@ -28,9 +28,9 @@
28#: ``type(self)``, so a subclass stays its own type through them.28#: ``type(self)``, so a subclass stays its own type through them.
29ColorIntensityDataT = TypeVar("ColorIntensityDataT", bound="ColorIntensityData")29ColorIntensityDataT = TypeVar("ColorIntensityDataT", bound="ColorIntensityData")
3030
3131
32@dataclass32@dataclasses.dataclass
33class ColorIntensityData:33class ColorIntensityData:
34 """Stores per-point RGB, intensity, scan angle, and return-count arrays.34 """Stores per-point RGB, intensity, scan angle, and return-count arrays.
3535
36 Attributes:36 Attributes:
Importance #5: src/iolabs/common/color_intensity_data.py @@ -53,9 +53,9 @@
53 green: np.ndarray53 green: np.ndarray
54 blue: np.ndarray54 blue: np.ndarray
55 intensity: np.ndarray55 intensity: np.ndarray
56 scan_angle_rank: np.ndarray56 scan_angle_rank: np.ndarray
57 number_of_returns: np.ndarray | None = field(default=None, kw_only=True)57 number_of_returns: np.ndarray | None = dataclasses.field(default=None, kw_only=True)
5858
59 def __post_init__(self) -> None:59 def __post_init__(self) -> None:
60 """Zero-fill an omitted ``number_of_returns``, or coerce a supplied one to uint8.60 """Zero-fill an omitted ``number_of_returns``, or coerce a supplied one to uint8.
6161
Importance #6: src/iolabs/common/color_intensity_data.py @@ -79,9 +79,9 @@
79 source=type(self).__name__,79 source=type(self).__name__,
80 )80 )
8181
82 def _map_fields(82 def _map_fields(
83 self: ColorIntensityDataT, transform: Callable[[str], np.ndarray]83 self: ColorIntensityDataT, transform: abc.Callable[[str], np.ndarray]
84 ) -> ColorIntensityDataT:84 ) -> ColorIntensityDataT:
85 """Rebuild this instance's type by applying *transform* to every field name.85 """Rebuild this instance's type by applying *transform* to every field name.
8686
87 Only ``init=True`` fields are passed to the constructor: a subclass may87 Only ``init=True`` fields are passed to the constructor: a subclass may
Importance #7: src/iolabs/common/color_intensity_data.py @@ -91,9 +91,9 @@
91 """91 """
92 return type(self)(92 return type(self)(
93 **{93 **{
94 data_field.name: transform(data_field.name)94 data_field.name: transform(data_field.name)
95 for data_field in fields(self)95 for data_field in dataclasses.fields(self)
96 if data_field.init96 if data_field.init
97 }97 }
98 )98 )
9999
Importance #8: src/iolabs/common/segment_points_io.py @@ -34,26 +34,26 @@
34and consumers working in the local frame ignore it. This module never34and consumers working in the local frame ignore it. This module never
35applies, negates, or bakes in a sign.35applies, negates, or bakes in a sign.
36"""36"""
3737
38import dataclasses
39import fnmatch
38import functools40import functools
39import json41import json
40import logging42import logging
43import pathlib
44import types
41import zipfile45import zipfile
42from collections.abc import Callable, Iterator, Mapping, Sequence46from collections import abc
43from dataclasses import dataclass
44from fnmatch import fnmatch
45from pathlib import Path
46from types import MappingProxyType
4747
48import numpy as np48import numpy as np
4949
50from . import _dtype_coercion50from . import _dtype_coercion
5151
52logger = logging.getLogger(__name__)52logger = logging.getLogger(__name__)
5353
54#: Builds a member for a record that predates its key, given the point count.54#: Builds a member for a record that predates its key, given the point count.
55FillFactory = Callable[[int], np.ndarray]55FillFactory = abc.Callable[[int], np.ndarray]
5656
57#: The point-record key whose row count defines ``N`` for every other member.57#: The point-record key whose row count defines ``N`` for every other member.
58POINTS_KEY = "points"58POINTS_KEY = "points"
5959
Importance #9: src/iolabs/common/segment_points_io.py @@ -63,9 +63,9 @@
63#: Storage dtype of :data:`NUMBER_OF_RETURNS_KEY` (LAS carries 3 bits, values 1-7).63#: Storage dtype of :data:`NUMBER_OF_RETURNS_KEY` (LAS carries 3 bits, values 1-7).
64NUMBER_OF_RETURNS_DTYPE = np.uint864NUMBER_OF_RETURNS_DTYPE = np.uint8
6565
6666
67@dataclass(frozen=True)67@dataclasses.dataclass(frozen=True)
68class PointFieldSpec:68class PointFieldSpec:
69 """How one member of the point record is validated, stored and synthesised.69 """How one member of the point record is validated, stored and synthesised.
7070
71 Attributes:71 Attributes:
Importance #10: src/iolabs/common/segment_points_io.py @@ -108,9 +108,9 @@
108108
109#: The point-record contract: ordered key -> spec. Adding a field to the NPZ109#: The point-record contract: ordered key -> spec. Adding a field to the NPZ
110#: schema means adding an entry here (plus teaching producers to emit it); the110#: schema means adding an entry here (plus teaching producers to emit it); the
111#: load/save/merge machinery below is entirely registry-driven.111#: load/save/merge machinery below is entirely registry-driven.
112POINT_RECORD_SCHEMA: Mapping[str, PointFieldSpec] = MappingProxyType(112POINT_RECORD_SCHEMA: abc.Mapping[str, PointFieldSpec] = types.MappingProxyType(
113 {113 {
114 POINTS_KEY: PointFieldSpec(required=True, columns=3, noun="coordinate"),114 POINTS_KEY: PointFieldSpec(required=True, columns=3, noun="coordinate"),
115 "red": PointFieldSpec(required=True),115 "red": PointFieldSpec(required=True),
116 "green": PointFieldSpec(required=True),116 "green": PointFieldSpec(required=True),
Importance #11: src/iolabs/common/segment_points_io.py @@ -127,9 +127,9 @@
127)127)
128128
129129
130def _schema_keys(130def _schema_keys(
131 schema: Mapping[str, PointFieldSpec], *, required: bool | None = None131 schema: abc.Mapping[str, PointFieldSpec], *, required: bool | None = None
132) -> tuple[str, ...]:132) -> tuple[str, ...]:
133 """List the schema's keys in registry order, optionally by required-ness."""133 """List the schema's keys in registry order, optionally by required-ness."""
134 return tuple(134 return tuple(
135 key for key, spec in schema.items() if required is None or spec.required is required135 key for key, spec in schema.items() if required is None or spec.required is required
Importance #12: src/iolabs/common/segment_points_io.py @@ -190,10 +190,10 @@
190 return "(N,)" if spec.columns is None else f"(N, {spec.columns})"190 return "(N,)" if spec.columns is None else f"(N, {spec.columns})"
191191
192192
193def _validate_record_shapes(193def _validate_record_shapes(
194 record: Mapping[str, np.ndarray],194 record: abc.Mapping[str, np.ndarray],
195 schema: Mapping[str, PointFieldSpec],195 schema: abc.Mapping[str, PointFieldSpec],
196 *,196 *,
197 source: str,197 source: str,
198) -> int:198) -> int:
199 """Check every present member against its spec's shape and return ``N``.199 """Check every present member against its spec's shape and return ``N``.
Importance #13: src/iolabs/common/segment_points_io.py @@ -227,9 +227,9 @@
227 )227 )
228 return n_points228 return n_points
229229
230230
231def load_points_npz(path: str | Path) -> PointRecord:231def load_points_npz(path: str | pathlib.Path) -> PointRecord:
232 """Load one ``*_run3_points.npz``-style file and validate the schema.232 """Load one ``*_run3_points.npz``-style file and validate the schema.
233233
234 Every required key of :data:`POINT_RECORD_SCHEMA` must be stored; ``points``234 Every required key of :data:`POINT_RECORD_SCHEMA` must be stored; ``points``
235 must be shape ``(N, 3)`` and every ancillary array (``red``, ``green``,235 must be shape ``(N, 3)`` and every ancillary array (``red``, ``green``,
Importance #14: src/iolabs/common/segment_points_io.py @@ -254,9 +254,9 @@
254 ValueError: A required key is missing, an array has the wrong shape, or254 ValueError: A required key is missing, an array has the wrong shape, or
255 a member with a declared storage dtype is stored with a255 a member with a declared storage dtype is stored with a
256 non-integer/bool dtype or values outside that dtype's range.256 non-integer/bool dtype or values outside that dtype's range.
257 """257 """
258 npz_path = Path(path)258 npz_path = pathlib.Path(path)
259 schema = POINT_RECORD_SCHEMA259 schema = POINT_RECORD_SCHEMA
260 required_keys = _schema_keys(schema, required=True)260 required_keys = _schema_keys(schema, required=True)
261 with np.load(npz_path) as data:261 with np.load(npz_path) as data:
262 missing = [key for key in required_keys if key not in data.files]262 missing = [key for key in required_keys if key not in data.files]
Importance #15: src/iolabs/common/segment_points_io.py @@ -285,9 +285,9 @@
285 record[key] = spec.fill(n_points)285 record[key] = spec.fill(n_points)
286 return {key: record[key] for key in schema}286 return {key: record[key] for key in schema}
287287
288288
289def save_points_npz(path: str | Path, record: Mapping[str, np.ndarray]) -> Path:289def save_points_npz(path: str | pathlib.Path, record: abc.Mapping[str, np.ndarray]) -> pathlib.Path:
290 """Write a point record as a compressed NPZ (inverse of :func:`load_points_npz`).290 """Write a point record as a compressed NPZ (inverse of :func:`load_points_npz`).
291291
292 Every key in :data:`POINT_RECORD_KEYS` must be present, ``number_of_returns``292 Every key in :data:`POINT_RECORD_KEYS` must be present, ``number_of_returns``
293 included: it is optional on load only, to read datasets that predate it.293 included: it is optional on load only, to read datasets that predate it.
Importance #16: src/iolabs/common/segment_points_io.py @@ -303,9 +303,9 @@
303 Raises:303 Raises:
304 ValueError: A key is missing, an array has the wrong shape, or a member304 ValueError: A key is missing, an array has the wrong shape, or a member
305 with a declared storage dtype cannot be cast to it losslessly.305 with a declared storage dtype cannot be cast to it losslessly.
306 """306 """
307 npz_path = Path(path)307 npz_path = pathlib.Path(path)
308 schema = POINT_RECORD_SCHEMA308 schema = POINT_RECORD_SCHEMA
309 keys = _schema_keys(schema)309 keys = _schema_keys(schema)
310 missing = [key for key in keys if key not in record]310 missing = [key for key in keys if key not in record]
311 if missing:311 if missing:
Importance #17: src/iolabs/common/segment_points_io.py @@ -323,9 +323,9 @@
323 np.savez_compressed(npz_path, **payload)323 np.savez_compressed(npz_path, **payload)
324 return npz_path324 return npz_path
325325
326326
327def mask_record(record: Mapping[str, np.ndarray], mask: np.ndarray) -> PointRecord:327def mask_record(record: abc.Mapping[str, np.ndarray], mask: np.ndarray) -> PointRecord:
328 """Select the same points from every member of a point record.328 """Select the same points from every member of a point record.
329329
330 Schema-agnostic: whatever keys the record carries are all indexed with330 Schema-agnostic: whatever keys the record carries are all indexed with
331 *mask* along axis 0, so ``points`` keeps its ``(n, 3)`` rows while the331 *mask* along axis 0, so ``points`` keeps its ``(n, 3)`` rows while the
Importance #18: src/iolabs/common/segment_points_io.py @@ -367,9 +367,9 @@
367 )367 )
368 return {key: array[mask] for key, array in arrays.items()}368 return {key: array[mask] for key, array in arrays.items()}
369369
370370
371def concat_records(records: Sequence[Mapping[str, np.ndarray]]) -> PointRecord:371def concat_records(records: abc.Sequence[abc.Mapping[str, np.ndarray]]) -> PointRecord:
372 """Concatenate point records member-by-member along axis 0.372 """Concatenate point records member-by-member along axis 0.
373373
374 Schema-agnostic: every key of the first record is concatenated across all374 Schema-agnostic: every key of the first record is concatenated across all
375 records, so ``points`` grows by rows and the ancillary members by elements.375 records, so ``points`` grows by rows and the ancillary members by elements.
Importance #19: src/iolabs/common/segment_points_io.py @@ -408,17 +408,17 @@
408 }408 }
409409
410410
411def load_segment_points(411def load_segment_points(
412 files: Sequence[str | Path],412 files: abc.Sequence[str | pathlib.Path],
413) -> tuple[PointRecord, np.ndarray, list[str]]:413) -> tuple[PointRecord, np.ndarray, list[str]]:
414 """Load and concatenate multiple point NPZs for one segment.414 """Load and concatenate multiple point NPZs for one segment.
415415
416 Returns ``(merged_record, point_file_ids, file_stems)`` where416 Returns ``(merged_record, point_file_ids, file_stems)`` where
417 ``point_file_ids[i]`` is the index into *files* for merged point ``i``,417 ``point_file_ids[i]`` is the index into *files* for merged point ``i``,
418 and ``file_stems`` holds ``Path(f).stem`` for each input in order.418 and ``file_stems`` holds ``Path(f).stem`` for each input in order.
419 """419 """
420 npz_files = [Path(path) for path in files]420 npz_files = [pathlib.Path(path) for path in files]
421 if not npz_files:421 if not npz_files:
422 raise FileNotFoundError("No *_points.npz files provided for segment load")422 raise FileNotFoundError("No *_points.npz files provided for segment load")
423423
424 records: list[PointRecord] = []424 records: list[PointRecord] = []
Importance #20: src/iolabs/common/segment_points_io.py @@ -441,9 +441,9 @@
441 return merged, point_file_ids, file_stems441 return merged, point_file_ids, file_stems
442442
443443
444def geoshift_from_mapping(444def geoshift_from_mapping(
445 mapping: Mapping[str, object],445 mapping: abc.Mapping[str, object],
446 *,446 *,
447 source: str = "geoshift mapping",447 source: str = "geoshift mapping",
448) -> np.ndarray:448) -> np.ndarray:
449 """Convert an already-parsed geoshift mapping to a shape ``(3,)`` float64 array.449 """Convert an already-parsed geoshift mapping to a shape ``(3,)`` float64 array.
Importance #21: src/iolabs/common/segment_points_io.py @@ -464,16 +464,16 @@
464 Raises:464 Raises:
465 ValueError: If *mapping* is not an object, a key is missing, or a465 ValueError: If *mapping* is not an object, a key is missing, or a
466 value is not numeric.466 value is not numeric.
467 """467 """
468 if not isinstance(mapping, Mapping):468 if not isinstance(mapping, abc.Mapping):
469 raise ValueError(469 raise ValueError(
470 f"{source}: geoshift JSON must be an object with keys x/y/z, "470 f"{source}: geoshift JSON must be an object with keys x/y/z, "
471 f"got {type(mapping).__name__}"471 f"got {type(mapping).__name__}"
472 )472 )
473 values: Mapping[str, object] = mapping473 values: abc.Mapping[str, object] = mapping
474 nested = mapping.get("geoshift")474 nested = mapping.get("geoshift")
475 if isinstance(nested, Mapping):475 if isinstance(nested, abc.Mapping):
476 values = nested476 values = nested
477477
478 missing = [key for key in ("x", "y", "z") if key not in values]478 missing = [key for key in ("x", "y", "z") if key not in values]
479 if missing:479 if missing:
Importance #22: src/iolabs/common/segment_points_io.py @@ -489,24 +489,24 @@
489 f"{[values['x'], values['y'], values['z']]!r}"489 f"{[values['x'], values['y'], values['z']]!r}"
490 ) from exc490 ) from exc
491491
492492
493def load_geoshift(path: str | Path) -> np.ndarray:493def load_geoshift(path: str | pathlib.Path) -> np.ndarray:
494 """Load a Step-3 ``run3_geoshift.json`` as a shape ``(3,)`` float64 array.494 """Load a Step-3 ``run3_geoshift.json`` as a shape ``(3,)`` float64 array.
495495
496 Expected JSON layout (written by segmentation-trajectory SegmentMapper)::496 Expected JSON layout (written by segmentation-trajectory SegmentMapper)::
497497
498 {"x": float, "y": float, "z": float}498 {"x": float, "y": float, "z": float}
499499
500 The value is returned as recorded; no sign is applied (see module docstring).500 The value is returned as recorded; no sign is applied (see module docstring).
501 """501 """
502 geoshift_path = Path(path)502 geoshift_path = pathlib.Path(path)
503 with geoshift_path.open(encoding="utf-8") as handle:503 with geoshift_path.open(encoding="utf-8") as handle:
504 data = json.load(handle)504 data = json.load(handle)
505 return geoshift_from_mapping(data, source=str(geoshift_path))505 return geoshift_from_mapping(data, source=str(geoshift_path))
506506
507507
508def geoshift_candidate_paths(directory: str | Path) -> list[Path]:508def geoshift_candidate_paths(directory: str | pathlib.Path) -> list[pathlib.Path]:
509 """List the ``run3_geoshift.json`` paths the fleet's three conventions use.509 """List the ``run3_geoshift.json`` paths the fleet's three conventions use.
510510
511 In lookup order:511 In lookup order:
512512
Importance #23: src/iolabs/common/segment_points_io.py @@ -523,17 +523,17 @@
523523
524 Returns:524 Returns:
525 The candidate paths in lookup order (existence is not checked).525 The candidate paths in lookup order (existence is not checked).
526 """526 """
527 base = Path(directory)527 base = pathlib.Path(directory)
528 return [528 return [
529 base / GEOSHIFT_NAME,529 base / GEOSHIFT_NAME,
530 base / LANE_POINTS_DIR_NAME / GEOSHIFT_NAME,530 base / LANE_POINTS_DIR_NAME / GEOSHIFT_NAME,
531 base.parent / GEOSHIFT_NAME,531 base.parent / GEOSHIFT_NAME,
532 ]532 ]
533533
534534
535def find_geoshift_or_none(directory: str | Path) -> np.ndarray | None:535def find_geoshift_or_none(directory: str | pathlib.Path) -> np.ndarray | None:
536 """Find and load a geoshift near *directory*, or return ``None``.536 """Find and load a geoshift near *directory*, or return ``None``.
537537
538 Searches :func:`geoshift_candidate_paths` in order and loads the first538 Searches :func:`geoshift_candidate_paths` in order and loads the first
539 existing file. Datasets processed without a geoshift have no such file and539 existing file. Datasets processed without a geoshift have no such file and
Importance #24: src/iolabs/common/segment_points_io.py @@ -561,9 +561,9 @@
561 logger.debug("Using geoshift %s for %s", existing[0], directory)561 logger.debug("Using geoshift %s for %s", existing[0], directory)
562 return load_geoshift(existing[0])562 return load_geoshift(existing[0])
563563
564564
565def find_geoshift(directory: str | Path) -> np.ndarray:565def find_geoshift(directory: str | pathlib.Path) -> np.ndarray:
566 """Find and load a geoshift near *directory*, raising when absent.566 """Find and load a geoshift near *directory*, raising when absent.
567567
568 Args:568 Args:
569 directory: Dataset root, ``lane_points`` directory, or segment directory.569 directory: Dataset root, ``lane_points`` directory, or segment directory.
Importance #25: src/iolabs/common/segment_points_io.py @@ -603,12 +603,12 @@
603 ) from exc603 ) from exc
604604
605605
606def _normalize_file_patterns(file_patterns: object, *, source: str) -> list[str]:606def _normalize_file_patterns(file_patterns: object, *, source: str) -> list[str]:
607 raw_patterns: Sequence[object]607 raw_patterns: abc.Sequence[object]
608 if isinstance(file_patterns, str):608 if isinstance(file_patterns, str):
609 raw_patterns = [file_patterns]609 raw_patterns = [file_patterns]
610 elif isinstance(file_patterns, Sequence):610 elif isinstance(file_patterns, abc.Sequence):
611 raw_patterns = file_patterns611 raw_patterns = file_patterns
612 else:612 else:
613 raise ValueError(613 raise ValueError(
614 f"{source} must be a string or a sequence of strings, got "614 f"{source} must be a string or a sequence of strings, got "
Importance #26: src/iolabs/common/segment_points_io.py @@ -623,9 +623,9 @@
623 return normalized623 return normalized
624624
625625
626def normalize_segment_file_blacklist(626def normalize_segment_file_blacklist(
627 mapping: Mapping[object, object] | None,627 mapping: abc.Mapping[object, object] | None,
628) -> dict[int, list[str]]:628) -> dict[int, list[str]]:
629 """Normalize a per-segment fnmatch blacklist mapping.629 """Normalize a per-segment fnmatch blacklist mapping.
630630
631 Keys may be ints or ``segment_<idx>`` strings. Values are a string or a631 Keys may be ints or ``segment_<idx>`` strings. Values are a string or a
Importance #27: src/iolabs/common/segment_points_io.py @@ -633,9 +633,9 @@
633 the same segment are merged and sorted.633 the same segment are merged and sorted.
634 """634 """
635 if mapping is None:635 if mapping is None:
636 return {}636 return {}
637 if not isinstance(mapping, Mapping):637 if not isinstance(mapping, abc.Mapping):
638 raise ValueError("npz_blacklist_by_segment must be a mapping of segment to files")638 raise ValueError("npz_blacklist_by_segment must be a mapping of segment to files")
639639
640 normalized: dict[int, set[str]] = {}640 normalized: dict[int, set[str]] = {}
641 for segment_key, file_patterns in mapping.items():641 for segment_key, file_patterns in mapping.items():
Importance #28: src/iolabs/common/segment_points_io.py @@ -653,27 +653,27 @@
653 }653 }
654654
655655
656def filter_segment_files(656def filter_segment_files(
657 files: Sequence[str | Path],657 files: abc.Sequence[str | pathlib.Path],
658 segment_index: int,658 segment_index: int,
659 blacklist: Mapping[int, Sequence[str]],659 blacklist: abc.Mapping[int, abc.Sequence[str]],
660) -> tuple[list[Path], list[Path]]:660) -> tuple[list[pathlib.Path], list[pathlib.Path]]:
661 """Split input files into kept and blacklisted buckets via fnmatch.661 """Split input files into kept and blacklisted buckets via fnmatch.
662662
663 Patterns from ``blacklist[segment_index]`` are matched against both the663 Patterns from ``blacklist[segment_index]`` are matched against both the
664 file name and the full POSIX path. Returns ``(kept_files, excluded_files)``.664 file name and the full POSIX path. Returns ``(kept_files, excluded_files)``.
665 """665 """
666 kept_files: list[Path] = []666 kept_files: list[pathlib.Path] = []
667 excluded_files: list[Path] = []667 excluded_files: list[pathlib.Path] = []
668 file_patterns = list(blacklist.get(segment_index, []))668 file_patterns = list(blacklist.get(segment_index, []))
669669
670 for raw_path in files:670 for raw_path in files:
671 npz_path = Path(raw_path)671 npz_path = pathlib.Path(raw_path)
672 path_text = npz_path.as_posix()672 path_text = npz_path.as_posix()
673 file_name = npz_path.name673 file_name = npz_path.name
674 is_blacklisted = any(674 is_blacklisted = any(
675 fnmatch(file_name, pattern) or fnmatch(path_text, pattern)675 fnmatch.fnmatch(file_name, pattern) or fnmatch.fnmatch(path_text, pattern)
676 for pattern in file_patterns676 for pattern in file_patterns
677 )677 )
678 if is_blacklisted:678 if is_blacklisted:
679 excluded_files.append(npz_path)679 excluded_files.append(npz_path)
Importance #29: src/iolabs/common/segment_points_io.py @@ -695,9 +695,9 @@
695 )695 )
696 return kept_files, excluded_files696 return kept_files, excluded_files
697697
698698
699def read_points_header(path: str | Path) -> tuple[int, bool]:699def read_points_header(path: str | pathlib.Path) -> tuple[int, bool]:
700 """Return ``(row_count, chunk_streamable)`` for a record's ``points.npy`` member.700 """Return ``(row_count, chunk_streamable)`` for a record's ``points.npy`` member.
701701
702 Reads only the NPY header inside the NPZ ZIP container, so the point data702 Reads only the NPY header inside the NPZ ZIP container, so the point data
703 is never materialised. Streaming needs an uncompressed (``ZIP_STORED``)703 is never materialised. Streaming needs an uncompressed (``ZIP_STORED``)
Importance #30: src/iolabs/common/segment_points_io.py @@ -753,9 +753,9 @@
753 buffer = handle.read(count * row_bytes)753 buffer = handle.read(count * row_bytes)
754 yield np.frombuffer(buffer, dtype=dtype).reshape(count, cols).copy()754 yield np.frombuffer(buffer, dtype=dtype).reshape(count, cols).copy()
755755
756756
757def iter_points_chunks(path: str | Path, chunk_points: int) -> Iterator[np.ndarray]:757def iter_points_chunks(path: str | pathlib.Path, chunk_points: int) -> abc.Iterator[np.ndarray]:
758 """Yield a record's ``points`` rows, chunked when the member is streamable.758 """Yield a record's ``points`` rows, chunked when the member is streamable.
759759
760 Records at or below *chunk_points* rows -- and any record whose760 Records at or below *chunk_points* rows -- and any record whose
761 ``points.npy`` member is compressed, Fortran-ordered or otherwise761 ``points.npy`` member is compressed, Fortran-ordered or otherwise
Importance #31: src/iolabs/common/segment_points_io.py @@ -774,9 +774,9 @@
774 Yields:774 Yields:
775 ``(rows_i, 3)`` point chunks in file order; the final chunk holds the775 ``(rows_i, 3)`` point chunks in file order; the final chunk holds the
776 remainder and may be shorter.776 remainder and may be shorter.
777 """777 """
778 npz_path = Path(path)778 npz_path = pathlib.Path(path)
779 rows, streamable = read_points_header(npz_path)779 rows, streamable = read_points_header(npz_path)
780 if streamable and chunk_points > 0 and rows > chunk_points:780 if streamable and chunk_points > 0 and rows > chunk_points:
781 logger.info(781 logger.info(
782 "%s: streaming %d points in chunks of %d",782 "%s: streaming %d points in chunks of %d",
Importance #32: src/iolabs/common/segment_points_io.py @@ -789,9 +789,9 @@
789 with np.load(npz_path) as data:789 with np.load(npz_path) as data:
790 yield np.asarray(data["points"])790 yield np.asarray(data["points"])
791791
792792
793@dataclass(frozen=True)793@dataclasses.dataclass(frozen=True)
794class RecordSpan:794class RecordSpan:
795 """One input record's identity and row range within a concatenated cloud.795 """One input record's identity and row range within a concatenated cloud.
796796
797 Attributes:797 Attributes:
Importance #33: src/iolabs/common/segment_points_io.py @@ -809,9 +809,9 @@
809 """One past the last global row index contributed by this file."""809 """One past the last global row index contributed by this file."""
810 return self.offset + self.count810 return self.offset + self.count
811811
812812
813def discover_run3_files(segment_dir: str | Path) -> list[Path]:813def discover_run3_files(segment_dir: str | pathlib.Path) -> list[pathlib.Path]:
814 """List a segment directory's ``*_run3_points.npz`` records in load order.814 """List a segment directory's ``*_run3_points.npz`` records in load order.
815815
816 Partial writes carry a trailing marker suffix (``*.npz.part`` and friends);816 Partial writes carry a trailing marker suffix (``*.npz.part`` and friends);
817 those are skipped rather than handed to a loader that would crash on them.817 those are skipped rather than handed to a loader that would crash on them.
Importance #34: src/iolabs/common/segment_points_io.py @@ -822,14 +822,14 @@
822 Returns:822 Returns:
823 Sorted, complete record paths. Empty when the directory is missing or823 Sorted, complete record paths. Empty when the directory is missing or
824 holds no records.824 holds no records.
825 """825 """
826 seg_dir = Path(segment_dir)826 seg_dir = pathlib.Path(segment_dir)
827 if not seg_dir.is_dir():827 if not seg_dir.is_dir():
828 logger.warning("Segment directory missing: %s", seg_dir)828 logger.warning("Segment directory missing: %s", seg_dir)
829 return []829 return []
830830
831 found: list[Path] = []831 found: list[pathlib.Path] = []
832 for path in sorted(seg_dir.glob(f"{RUN3_POINTS_GLOB}*")):832 for path in sorted(seg_dir.glob(f"{RUN3_POINTS_GLOB}*")):
833 if not path.name.endswith(RUN3_POINTS_SUFFIX):833 if not path.name.endswith(RUN3_POINTS_SUFFIX):
834 logger.info("Skipping incomplete/partial run3 record: %s", path.name)834 logger.info("Skipping incomplete/partial run3 record: %s", path.name)
835 continue835 continue
Importance #35: src/iolabs/common/segment_points_io.py @@ -837,11 +837,11 @@
837 return found837 return found
838838
839839
840def concat_points_npz(840def concat_points_npz(
841 files: Sequence[str | Path],841 files: abc.Sequence[str | pathlib.Path],
842 *,842 *,
843 target_dtypes: Mapping[str, np.dtype] | None = None,843 target_dtypes: abc.Mapping[str, np.dtype] | None = None,
844) -> tuple[PointRecord, list[RecordSpan]]:844) -> tuple[PointRecord, list[RecordSpan]]:
845 """Concatenate point records after validating a consistent schema.845 """Concatenate point records after validating a consistent schema.
846846
847 Each file must satisfy the :func:`load_points_npz` contract. By default847 Each file must satisfy the :func:`load_points_npz` contract. By default
Importance #36: src/iolabs/common/segment_points_io.py @@ -872,9 +872,9 @@
872 FileNotFoundError: If *files* is empty.872 FileNotFoundError: If *files* is empty.
873 ValueError: If a file violates the point-record contract or its dtypes873 ValueError: If a file violates the point-record contract or its dtypes
874 disagree with the first file's (for keys without a target dtype).874 disagree with the first file's (for keys without a target dtype).
875 """875 """
876 npz_files = [Path(path) for path in files]876 npz_files = [pathlib.Path(path) for path in files]
877 if not npz_files:877 if not npz_files:
878 raise FileNotFoundError("No *_points.npz files provided for concatenation")878 raise FileNotFoundError("No *_points.npz files provided for concatenation")
879879
880 casts: dict[str, np.dtype] = {880 casts: dict[str, np.dtype] = {
Importance #37: src/iolabs/common/segment_points_io.py @@ -911,11 +911,11 @@
911 return concat_records(records), spans911 return concat_records(records), spans
912912
913913
914def load_run3_segment(914def load_run3_segment(
915 segment_dir: str | Path,915 segment_dir: str | pathlib.Path,
916 *,916 *,
917 target_dtypes: Mapping[str, np.dtype] | None = None,917 target_dtypes: abc.Mapping[str, np.dtype] | None = None,
918) -> tuple[PointRecord, list[RecordSpan]]:918) -> tuple[PointRecord, list[RecordSpan]]:
919 """Discover and concatenate one segment's run3 records.919 """Discover and concatenate one segment's run3 records.
920920
921 Combines :func:`discover_run3_files` (sorted glob, ``.part`` skipped) with921 Combines :func:`discover_run3_files` (sorted glob, ``.part`` skipped) with
Importance #38: src/iolabs/common/segment_points_io.py @@ -931,9 +931,9 @@
931 Raises:931 Raises:
932 FileNotFoundError: If the directory holds no complete run3 record.932 FileNotFoundError: If the directory holds no complete run3 record.
933 ValueError: If the records' schema or dtypes disagree.933 ValueError: If the records' schema or dtypes disagree.
934 """934 """
935 seg_dir = Path(segment_dir)935 seg_dir = pathlib.Path(segment_dir)
936 files = discover_run3_files(seg_dir)936 files = discover_run3_files(seg_dir)
937 if not files:937 if not files:
938 raise FileNotFoundError(f"No {RUN3_POINTS_GLOB} in {seg_dir}")938 raise FileNotFoundError(f"No {RUN3_POINTS_GLOB} in {seg_dir}")
939 return concat_points_npz(files, target_dtypes=target_dtypes)939 return concat_points_npz(files, target_dtypes=target_dtypes)
Importance #39: tests/test_color_intensity_data.py @@ -1,16 +1,16 @@
1"""Tests for ColorIntensityData selection and concatenation operations."""1"""Tests for ColorIntensityData selection and concatenation operations."""
2from dataclasses import dataclass, field2import dataclasses
33
4import numpy as np4import numpy as np
5import pytest5import pytest
66
7from iolabs.common.color_intensity_data import ColorIntensityData7from iolabs.common import color_intensity_data
88
99
10def _make_sample(n: int = 5, offset: int = 0) -> ColorIntensityData:10def _make_sample(n: int = 5, offset: int = 0) -> color_intensity_data.ColorIntensityData:
11 """Create a sample ColorIntensityData with n points and optional array offset."""11 """Create a sample ColorIntensityData with n points and optional array offset."""
12 return ColorIntensityData(12 return color_intensity_data.ColorIntensityData(
13 red=np.arange(offset, offset + n, dtype=np.uint8),13 red=np.arange(offset, offset + n, dtype=np.uint8),
14 green=np.arange(offset + 10, offset + 10 + n, dtype=np.uint8),14 green=np.arange(offset + 10, offset + 10 + n, dtype=np.uint8),
15 blue=np.arange(offset + 20, offset + 20 + n, dtype=np.uint8),15 blue=np.arange(offset + 20, offset + 20 + n, dtype=np.uint8),
16 intensity=np.arange(offset + 100, offset + 100 + n, dtype=np.float64),16 intensity=np.arange(offset + 100, offset + 100 + n, dtype=np.float64),
Importance #40: tests/test_color_intensity_data.py @@ -85,9 +85,9 @@
85 """Tests for the AI3D-382 number_of_returns field."""85 """Tests for the AI3D-382 number_of_returns field."""
8686
87 def test_defaults_to_zeros_when_omitted(self):87 def test_defaults_to_zeros_when_omitted(self):
88 """Callers predating AI3D-382 get 0 (unknown) per point, never 1."""88 """Callers predating AI3D-382 get 0 (unknown) per point, never 1."""
89 data = ColorIntensityData(89 data = color_intensity_data.ColorIntensityData(
90 red=np.zeros(4, dtype=np.uint8),90 red=np.zeros(4, dtype=np.uint8),
91 green=np.zeros(4, dtype=np.uint8),91 green=np.zeros(4, dtype=np.uint8),
92 blue=np.zeros(4, dtype=np.uint8),92 blue=np.zeros(4, dtype=np.uint8),
93 intensity=np.zeros(4, dtype=np.float64),93 intensity=np.zeros(4, dtype=np.float64),
Importance #41: tests/test_color_intensity_data.py @@ -99,9 +99,9 @@
99 np.testing.assert_array_equal(data.number_of_returns, np.zeros(4, dtype=np.uint8))99 np.testing.assert_array_equal(data.number_of_returns, np.zeros(4, dtype=np.uint8))
100100
101 def test_zero_fill_survives_mask_and_append(self):101 def test_zero_fill_survives_mask_and_append(self):
102 """A defaulted field stays aligned through the mask/concat paths."""102 """A defaulted field stays aligned through the mask/concat paths."""
103 legacy = ColorIntensityData(103 legacy = color_intensity_data.ColorIntensityData(
104 red=np.zeros(3, dtype=np.uint8),104 red=np.zeros(3, dtype=np.uint8),
105 green=np.zeros(3, dtype=np.uint8),105 green=np.zeros(3, dtype=np.uint8),
106 blue=np.zeros(3, dtype=np.uint8),106 blue=np.zeros(3, dtype=np.uint8),
107 intensity=np.zeros(3, dtype=np.float64),107 intensity=np.zeros(3, dtype=np.float64),
Importance #42: tests/test_color_intensity_data.py @@ -123,10 +123,10 @@
123123
124 def test_extra_field_flows_through_mask_and_append(self):124 def test_extra_field_flows_through_mask_and_append(self):
125 """A subclass field is masked and concatenated by the generic transforms."""125 """A subclass field is masked and concatenated by the generic transforms."""
126126
127 @dataclass127 @dataclasses.dataclass
128 class WithClassification(ColorIntensityData):128 class WithClassification(color_intensity_data.ColorIntensityData):
129 classification: np.ndarray | None = None129 classification: np.ndarray | None = None
130130
131 def _make(n: int, offset: int) -> WithClassification:131 def _make(n: int, offset: int) -> WithClassification:
132 base = _make_sample(n, offset)132 base = _make_sample(n, offset)
Importance #43: tests/test_color_intensity_data.py @@ -160,11 +160,11 @@
160160
161 def test_non_init_subclass_field_is_not_passed_to_the_constructor(self):161 def test_non_init_subclass_field_is_not_passed_to_the_constructor(self):
162 """A derived ``init=False`` field must not break the generic transforms."""162 """A derived ``init=False`` field must not break the generic transforms."""
163163
164 @dataclass164 @dataclasses.dataclass
165 class WithPointCount(ColorIntensityData):165 class WithPointCount(color_intensity_data.ColorIntensityData):
166 point_count: int = field(init=False, default=0)166 point_count: int = dataclasses.field(init=False, default=0)
167167
168 def __post_init__(self) -> None:168 def __post_init__(self) -> None:
169 super().__post_init__()169 super().__post_init__()
170 self.point_count = len(self.red)170 self.point_count = len(self.red)
Importance #44: tests/test_color_intensity_data.py @@ -195,10 +195,10 @@
195195
196 def test_subclass_may_declare_a_required_field(self):196 def test_subclass_may_declare_a_required_field(self):
197 """A trailing defaulted field would make a required subclass field a TypeError."""197 """A trailing defaulted field would make a required subclass field a TypeError."""
198198
199 @dataclass199 @dataclasses.dataclass
200 class WithRequiredClassification(ColorIntensityData):200 class WithRequiredClassification(color_intensity_data.ColorIntensityData):
201 classification: np.ndarray201 classification: np.ndarray
202202
203 data = WithRequiredClassification(203 data = WithRequiredClassification(
204 red=np.zeros(3, dtype=np.uint8),204 red=np.zeros(3, dtype=np.uint8),
Importance #45: tests/test_color_intensity_data.py @@ -217,9 +217,9 @@
217 assert len(merged.classification) == 5217 assert len(merged.classification) == 5
218218
219 def test_pre_ai3d_382_fields_still_take_positional_args(self):219 def test_pre_ai3d_382_fields_still_take_positional_args(self):
220 """The five original fields keep their positional order for old call sites."""220 """The five original fields keep their positional order for old call sites."""
221 data = ColorIntensityData(221 data = color_intensity_data.ColorIntensityData(
222 np.zeros(2, dtype=np.uint8),222 np.zeros(2, dtype=np.uint8),
223 np.zeros(2, dtype=np.uint8),223 np.zeros(2, dtype=np.uint8),
224 np.zeros(2, dtype=np.uint8),224 np.zeros(2, dtype=np.uint8),
225 np.zeros(2, dtype=np.float64),225 np.zeros(2, dtype=np.float64),
Importance #46: tests/test_color_intensity_data.py @@ -230,9 +230,9 @@
230230
231 def test_number_of_returns_is_not_positional(self):231 def test_number_of_returns_is_not_positional(self):
232 """Passing it as a sixth positional arg is a TypeError, not a silent mismatch."""232 """Passing it as a sixth positional arg is a TypeError, not a silent mismatch."""
233 with pytest.raises(TypeError):233 with pytest.raises(TypeError):
234 ColorIntensityData(234 color_intensity_data.ColorIntensityData(
235 np.zeros(2, dtype=np.uint8),235 np.zeros(2, dtype=np.uint8),
236 np.zeros(2, dtype=np.uint8),236 np.zeros(2, dtype=np.uint8),
237 np.zeros(2, dtype=np.uint8),237 np.zeros(2, dtype=np.uint8),
238 np.zeros(2, dtype=np.float64),238 np.zeros(2, dtype=np.float64),
Importance #47: tests/test_color_intensity_data.py @@ -244,11 +244,11 @@
244class TestNumberOfReturnsDtypeContract:244class TestNumberOfReturnsDtypeContract:
245 """The constructor enforces the same uint8 contract as segment_points_io."""245 """The constructor enforces the same uint8 contract as segment_points_io."""
246246
247 @staticmethod247 @staticmethod
248 def _make(number_of_returns: np.ndarray) -> ColorIntensityData:248 def _make(number_of_returns: np.ndarray) -> color_intensity_data.ColorIntensityData:
249 n = len(number_of_returns)249 n = len(number_of_returns)
250 return ColorIntensityData(250 return color_intensity_data.ColorIntensityData(
251 red=np.zeros(n, dtype=np.uint8),251 red=np.zeros(n, dtype=np.uint8),
252 green=np.zeros(n, dtype=np.uint8),252 green=np.zeros(n, dtype=np.uint8),
253 blue=np.zeros(n, dtype=np.uint8),253 blue=np.zeros(n, dtype=np.uint8),
254 intensity=np.zeros(n, dtype=np.float64),254 intensity=np.zeros(n, dtype=np.float64),
Importance #48: tests/test_segment_points_io.py @@ -2,43 +2,21 @@
22
3import functools3import functools
4import json4import json
5import logging5import logging
6from pathlib import Path6import pathlib
7from types import MappingProxyType7import types
88
9import numpy as np9import numpy as np
10import pytest10import pytest
1111
12from iolabs.common import segment_points_io12from iolabs.common import segment_points_io
13from iolabs.common.segment_points_io import (13
14 GEOSHIFT_NAME,14# Import-time snapshot of the shipped registry object. Monkeypatching the
15 NUMBER_OF_RETURNS_KEY,15# module attribute must not mutate this binding; the leak test below checks
16 OPTIONAL_POINT_RECORD_KEYS,16# both this snapshot and the live ``segment_points_io.POINT_RECORD_SCHEMA``.
17 POINT_RECORD_KEYS,17_POINT_RECORD_SCHEMA_AT_IMPORT = segment_points_io.POINT_RECORD_SCHEMA
18 POINT_RECORD_SCHEMA,18_POINT_RECORD_KEYS_AT_IMPORT = segment_points_io.POINT_RECORD_KEYS
19 REQUIRED_POINT_RECORD_KEYS,
20 PointFieldSpec,
21 RecordSpan,
22 concat_points_npz,
23 concat_records,
24 discover_run3_files,
25 filter_segment_files,
26 find_geoshift,
27 find_geoshift_or_none,
28 geoshift_candidate_paths,
29 geoshift_from_mapping,
30 iter_points_chunks,
31 load_geoshift,
32 load_points_npz,
33 load_run3_segment,
34 load_segment_points,
35 mask_record,
36 normalize_segment_file_blacklist,
37 parse_segment_key,
38 read_points_header,
39 save_points_npz,
40)
4119
4220
43def _make_record(n: int, *, seed: int = 0) -> dict[str, np.ndarray]:21def _make_record(n: int, *, seed: int = 0) -> dict[str, np.ndarray]:
44 rng = np.random.default_rng(seed)22 rng = np.random.default_rng(seed)
Importance #49: tests/test_segment_points_io.py @@ -55,284 +33,301 @@
5533
56def _make_legacy_record(n: int, *, seed: int = 0) -> dict[str, np.ndarray]:34def _make_legacy_record(n: int, *, seed: int = 0) -> dict[str, np.ndarray]:
57 """Build a pre-AI3D-382 record: every key except ``number_of_returns``."""35 """Build a pre-AI3D-382 record: every key except ``number_of_returns``."""
58 record = _make_record(n, seed=seed)36 record = _make_record(n, seed=seed)
59 del record[NUMBER_OF_RETURNS_KEY]37 del record[segment_points_io.NUMBER_OF_RETURNS_KEY]
60 return record38 return record
6139
6240
63def test_save_load_points_npz_round_trip(tmp_path: Path) -> None:41def test_save_load_points_npz_round_trip(tmp_path: pathlib.Path) -> None:
64 record = _make_record(7, seed=1)42 record = _make_record(7, seed=1)
65 path = tmp_path / "scan_a_run3_points.npz"43 path = tmp_path / "scan_a_run3_points.npz"
66 save_points_npz(path, record)44 segment_points_io.save_points_npz(path, record)
6745
68 loaded = load_points_npz(path)46 loaded = segment_points_io.load_points_npz(path)
69 assert list(loaded.keys()) == list(POINT_RECORD_KEYS)47 assert list(loaded.keys()) == list(segment_points_io.POINT_RECORD_KEYS)
70 for key in POINT_RECORD_KEYS:48 for key in segment_points_io.POINT_RECORD_KEYS:
71 np.testing.assert_array_equal(loaded[key], record[key])49 np.testing.assert_array_equal(loaded[key], record[key])
7250
7351
74def test_load_points_npz_rejects_missing_keys(tmp_path: Path) -> None:52def test_load_points_npz_rejects_missing_keys(tmp_path: pathlib.Path) -> None:
75 path = tmp_path / "broken.npz"53 path = tmp_path / "broken.npz"
76 np.savez_compressed(path, points=np.zeros((2, 3), dtype=np.float32), red=np.zeros(2))54 np.savez_compressed(path, points=np.zeros((2, 3), dtype=np.float32), red=np.zeros(2))
77 with pytest.raises(ValueError, match="missing required key"):55 with pytest.raises(ValueError, match="missing required key"):
78 load_points_npz(path)56 segment_points_io.load_points_npz(path)
7957
8058
81def test_load_points_npz_rejects_bad_points_shape(tmp_path: Path) -> None:59def test_load_points_npz_rejects_bad_points_shape(tmp_path: pathlib.Path) -> None:
82 record = _make_record(3, seed=2)60 record = _make_record(3, seed=2)
83 record["points"] = np.zeros((3, 2), dtype=np.float32)61 record["points"] = np.zeros((3, 2), dtype=np.float32)
84 path = tmp_path / "bad_shape.npz"62 path = tmp_path / "bad_shape.npz"
85 np.savez_compressed(path, **record)63 np.savez_compressed(path, **record)
86 with pytest.raises(ValueError, match=r"shape \(N, 3\)"):64 with pytest.raises(ValueError, match=r"shape \(N, 3\)"):
87 load_points_npz(path)65 segment_points_io.load_points_npz(path)
8866
8967
90def test_load_points_npz_rejects_row_count_mismatch(tmp_path: Path) -> None:68def test_load_points_npz_rejects_row_count_mismatch(tmp_path: pathlib.Path) -> None:
91 record = _make_record(4, seed=3)69 record = _make_record(4, seed=3)
92 record["intensity"] = record["intensity"][:2]70 record["intensity"] = record["intensity"][:2]
93 path = tmp_path / "mismatch.npz"71 path = tmp_path / "mismatch.npz"
94 np.savez_compressed(path, **record)72 np.savez_compressed(path, **record)
95 with pytest.raises(ValueError, match="intensity"):73 with pytest.raises(ValueError, match="intensity"):
96 load_points_npz(path)74 segment_points_io.load_points_npz(path)
9775
9876
99def test_load_points_npz_rejects_scalar_ancillary(tmp_path: Path) -> None:77def test_load_points_npz_rejects_scalar_ancillary(tmp_path: pathlib.Path) -> None:
100 record = _make_record(3, seed=4)78 record = _make_record(3, seed=4)
101 record["red"] = np.array(42, dtype=np.uint16)79 record["red"] = np.array(42, dtype=np.uint16)
102 path = tmp_path / "scalar_ancillary.npz"80 path = tmp_path / "scalar_ancillary.npz"
103 np.savez_compressed(path, **record)81 np.savez_compressed(path, **record)
104 with pytest.raises(ValueError, match=r"'red' must have shape \(N,\), got \(\)"):82 with pytest.raises(ValueError, match=r"'red' must have shape \(N,\), got \(\)"):
105 load_points_npz(path)83 segment_points_io.load_points_npz(path)
10684
10785
108def test_load_points_npz_rejects_column_vector_ancillary(tmp_path: Path) -> None:86def test_load_points_npz_rejects_column_vector_ancillary(tmp_path: pathlib.Path) -> None:
109 record = _make_record(3, seed=5)87 record = _make_record(3, seed=5)
110 record["intensity"] = record["intensity"].reshape(3, 1)88 record["intensity"] = record["intensity"].reshape(3, 1)
111 path = tmp_path / "column_ancillary.npz"89 path = tmp_path / "column_ancillary.npz"
112 np.savez_compressed(path, **record)90 np.savez_compressed(path, **record)
113 with pytest.raises(91 with pytest.raises(
114 ValueError, match=r"'intensity' must have shape \(N,\), got \(3, 1\)"92 ValueError, match=r"'intensity' must have shape \(N,\), got \(3, 1\)"
115 ):93 ):
116 load_points_npz(path)94 segment_points_io.load_points_npz(path)
11795
11896
119def test_save_points_npz_rejects_scalar_ancillary(tmp_path: Path) -> None:97def test_save_points_npz_rejects_scalar_ancillary(tmp_path: pathlib.Path) -> None:
120 record = _make_record(2, seed=6)98 record = _make_record(2, seed=6)
121 record["green"] = np.array(7, dtype=np.uint16)99 record["green"] = np.array(7, dtype=np.uint16)
122 with pytest.raises(ValueError, match=r"'green' must have shape \(N,\), got \(\)"):100 with pytest.raises(ValueError, match=r"'green' must have shape \(N,\), got \(\)"):
123 save_points_npz(tmp_path / "out.npz", record)101 segment_points_io.save_points_npz(tmp_path / "out.npz", record)
124102
125103
126def test_save_points_npz_rejects_column_vector_ancillary(tmp_path: Path) -> None:104def test_save_points_npz_rejects_column_vector_ancillary(tmp_path: pathlib.Path) -> None:
127 record = _make_record(2, seed=7)105 record = _make_record(2, seed=7)
128 record["scan_angle"] = record["scan_angle"].reshape(2, 1)106 record["scan_angle"] = record["scan_angle"].reshape(2, 1)
129 with pytest.raises(107 with pytest.raises(
130 ValueError, match=r"'scan_angle' must have shape \(N,\), got \(2, 1\)"108 ValueError, match=r"'scan_angle' must have shape \(N,\), got \(2, 1\)"
131 ):109 ):
132 save_points_npz(tmp_path / "out.npz", record)110 segment_points_io.save_points_npz(tmp_path / "out.npz", record)
133111
134112
135def test_save_points_npz_rejects_incomplete_record(tmp_path: Path) -> None:113def test_save_points_npz_rejects_incomplete_record(tmp_path: pathlib.Path) -> None:
136 with pytest.raises(ValueError, match="missing required key"):114 with pytest.raises(ValueError, match="missing required key"):
137 save_points_npz(tmp_path / "out.npz", {"points": np.zeros((1, 3))})115 segment_points_io.save_points_npz(tmp_path / "out.npz", {"points": np.zeros((1, 3))})
138116
139117
140def test_number_of_returns_is_part_of_the_written_contract() -> None:118def test_number_of_returns_is_part_of_the_written_contract() -> None:
141 assert NUMBER_OF_RETURNS_KEY in POINT_RECORD_KEYS119 assert segment_points_io.NUMBER_OF_RETURNS_KEY in segment_points_io.POINT_RECORD_KEYS
142 assert NUMBER_OF_RETURNS_KEY not in REQUIRED_POINT_RECORD_KEYS120 assert (
143 assert set(REQUIRED_POINT_RECORD_KEYS) < set(POINT_RECORD_KEYS)121 segment_points_io.NUMBER_OF_RETURNS_KEY
122 not in segment_points_io.REQUIRED_POINT_RECORD_KEYS
123 )
124 assert set(segment_points_io.REQUIRED_POINT_RECORD_KEYS) < set(
125 segment_points_io.POINT_RECORD_KEYS
126 )
144127
145128
146def test_save_points_npz_always_writes_number_of_returns(tmp_path: Path) -> None:129def test_save_points_npz_always_writes_number_of_returns(tmp_path: pathlib.Path) -> None:
147 record = _make_record(6, seed=60)130 record = _make_record(6, seed=60)
148 path = save_points_npz(tmp_path / "with_returns_run3_points.npz", record)131 path = segment_points_io.save_points_npz(tmp_path / "with_returns_run3_points.npz", record)
149132
150 with np.load(path) as data:133 with np.load(path) as data:
151 assert NUMBER_OF_RETURNS_KEY in data.files134 assert segment_points_io.NUMBER_OF_RETURNS_KEY in data.files
152 stored = np.asarray(data[NUMBER_OF_RETURNS_KEY])135 stored = np.asarray(data[segment_points_io.NUMBER_OF_RETURNS_KEY])
153 assert stored.dtype == np.uint8136 assert stored.dtype == np.uint8
154 np.testing.assert_array_equal(stored, record[NUMBER_OF_RETURNS_KEY])137 np.testing.assert_array_equal(stored, record[segment_points_io.NUMBER_OF_RETURNS_KEY])
155138
156139
157def test_save_points_npz_casts_number_of_returns_to_uint8(tmp_path: Path) -> None:140def test_save_points_npz_casts_number_of_returns_to_uint8(tmp_path: pathlib.Path) -> None:
158 record = _make_record(4, seed=61)141 record = _make_record(4, seed=61)
159 record[NUMBER_OF_RETURNS_KEY] = record[NUMBER_OF_RETURNS_KEY].astype(np.int64)142 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = record[
160 path = save_points_npz(tmp_path / "cast_run3_points.npz", record)143 segment_points_io.NUMBER_OF_RETURNS_KEY
144 ].astype(np.int64)
145 path = segment_points_io.save_points_npz(tmp_path / "cast_run3_points.npz", record)
161146
162 loaded = load_points_npz(path)147 loaded = segment_points_io.load_points_npz(path)
163 assert loaded[NUMBER_OF_RETURNS_KEY].dtype == np.uint8148 assert loaded[segment_points_io.NUMBER_OF_RETURNS_KEY].dtype == np.uint8
164149
165150
166def test_save_points_npz_rejects_out_of_range_number_of_returns(tmp_path: Path) -> None:151def test_save_points_npz_rejects_out_of_range_number_of_returns(tmp_path: pathlib.Path) -> None:
167 record = _make_record(3, seed=62)152 record = _make_record(3, seed=62)
168 record[NUMBER_OF_RETURNS_KEY] = np.array([1, -1, 3], dtype=np.int16)153 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([1, -1, 3], dtype=np.int16)
169 with pytest.raises(ValueError, match="number_of_returns"):154 with pytest.raises(ValueError, match="number_of_returns"):
170 save_points_npz(tmp_path / "out.npz", record)155 segment_points_io.save_points_npz(tmp_path / "out.npz", record)
171156
172157
173def test_save_points_npz_rejects_missing_number_of_returns(tmp_path: Path) -> None:158def test_save_points_npz_rejects_missing_number_of_returns(tmp_path: pathlib.Path) -> None:
174 with pytest.raises(ValueError, match="number_of_returns"):159 with pytest.raises(ValueError, match="number_of_returns"):
175 save_points_npz(tmp_path / "out.npz", _make_legacy_record(3, seed=63))160 segment_points_io.save_points_npz(tmp_path / "out.npz", _make_legacy_record(3, seed=63))
176161
177162
178def test_load_points_npz_fills_zeros_for_legacy_records(tmp_path: Path) -> None:163def test_load_points_npz_fills_zeros_for_legacy_records(tmp_path: pathlib.Path) -> None:
179 """Datasets written before AI3D-382 lack the key; 0 means unknown, never 1."""164 """Datasets written before AI3D-382 lack the key; 0 means unknown, never 1."""
180 legacy = _make_legacy_record(5, seed=64)165 legacy = _make_legacy_record(5, seed=64)
181 path = tmp_path / "legacy_run3_points.npz"166 path = tmp_path / "legacy_run3_points.npz"
182 np.savez_compressed(path, **legacy)167 np.savez_compressed(path, **legacy)
183168
184 loaded = load_points_npz(path)169 loaded = segment_points_io.load_points_npz(path)
185170
186 assert list(loaded.keys()) == list(POINT_RECORD_KEYS)171 assert list(loaded.keys()) == list(segment_points_io.POINT_RECORD_KEYS)
187 returns = loaded[NUMBER_OF_RETURNS_KEY]172 returns = loaded[segment_points_io.NUMBER_OF_RETURNS_KEY]
188 assert returns.shape == (5,)173 assert returns.shape == (5,)
189 assert returns.dtype == np.uint8174 assert returns.dtype == np.uint8
190 np.testing.assert_array_equal(returns, np.zeros(5, dtype=np.uint8))175 np.testing.assert_array_equal(returns, np.zeros(5, dtype=np.uint8))
191176
192177
193def test_load_points_npz_rejects_bad_number_of_returns_shape(tmp_path: Path) -> None:178def test_load_points_npz_rejects_bad_number_of_returns_shape(tmp_path: pathlib.Path) -> None:
194 record = _make_record(4, seed=65)179 record = _make_record(4, seed=65)
195 record[NUMBER_OF_RETURNS_KEY] = record[NUMBER_OF_RETURNS_KEY][:2]180 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = record[
181 segment_points_io.NUMBER_OF_RETURNS_KEY
182 ][:2]
196 path = tmp_path / "bad_returns.npz"183 path = tmp_path / "bad_returns.npz"
197 np.savez_compressed(path, **record)184 np.savez_compressed(path, **record)
198 with pytest.raises(185 with pytest.raises(
199 ValueError, match=r"'number_of_returns' must have shape \(N,\), got \(2,\)"186 ValueError, match=r"'number_of_returns' must have shape \(N,\), got \(2,\)"
200 ):187 ):
201 load_points_npz(path)188 segment_points_io.load_points_npz(path)
202189
203190
204def test_save_points_npz_rejects_bool_number_of_returns(tmp_path: Path) -> None:191def test_save_points_npz_rejects_bool_number_of_returns(tmp_path: pathlib.Path) -> None:
205 """A bool mask is not a return count; casting it would fabricate 0/1 counts."""192 """A bool mask is not a return count; casting it would fabricate 0/1 counts."""
206 record = _make_record(3, seed=68)193 record = _make_record(3, seed=68)
207 record[NUMBER_OF_RETURNS_KEY] = np.array([True, False, True])194 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([True, False, True])
208 with pytest.raises(ValueError, match="number_of_returns.*bool"):195 with pytest.raises(ValueError, match="number_of_returns.*bool"):
209 save_points_npz(tmp_path / "out.npz", record)196 segment_points_io.save_points_npz(tmp_path / "out.npz", record)
210197
211198
212def test_load_points_npz_rejects_bool_number_of_returns(tmp_path: Path) -> None:199def test_load_points_npz_rejects_bool_number_of_returns(tmp_path: pathlib.Path) -> None:
213 record = _make_record(3, seed=69)200 record = _make_record(3, seed=69)
214 record[NUMBER_OF_RETURNS_KEY] = np.array([True, False, True])201 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([True, False, True])
215 path = tmp_path / "bool_returns_run3_points.npz"202 path = tmp_path / "bool_returns_run3_points.npz"
216 np.savez_compressed(path, **record)203 np.savez_compressed(path, **record)
217 with pytest.raises(ValueError, match="number_of_returns.*bool"):204 with pytest.raises(ValueError, match="number_of_returns.*bool"):
218 load_points_npz(path)205 segment_points_io.load_points_npz(path)
219206
220207
221def test_load_points_npz_casts_stored_number_of_returns_to_uint8(tmp_path: Path) -> None:208def test_load_points_npz_casts_stored_number_of_returns_to_uint8(tmp_path: pathlib.Path) -> None:
222 """A hand-rolled producer's wider dtype must not leak into merges."""209 """A hand-rolled producer's wider dtype must not leak into merges."""
223 record = _make_record(5, seed=70)210 record = _make_record(5, seed=70)
224 stored = record[NUMBER_OF_RETURNS_KEY].astype(np.int32)211 stored = record[segment_points_io.NUMBER_OF_RETURNS_KEY].astype(np.int32)
225 record[NUMBER_OF_RETURNS_KEY] = stored212 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = stored
226 path = tmp_path / "int32_returns_run3_points.npz"213 path = tmp_path / "int32_returns_run3_points.npz"
227 np.savez_compressed(path, **record)214 np.savez_compressed(path, **record)
228215
229 loaded = load_points_npz(path)216 loaded = segment_points_io.load_points_npz(path)
230217
231 assert loaded[NUMBER_OF_RETURNS_KEY].dtype == np.uint8218 assert loaded[segment_points_io.NUMBER_OF_RETURNS_KEY].dtype == np.uint8
232 np.testing.assert_array_equal(loaded[NUMBER_OF_RETURNS_KEY], stored)219 np.testing.assert_array_equal(loaded[segment_points_io.NUMBER_OF_RETURNS_KEY], stored)
233220
234221
235def test_load_points_npz_rejects_out_of_range_stored_number_of_returns(222def test_load_points_npz_rejects_out_of_range_stored_number_of_returns(
236 tmp_path: Path,223 tmp_path: pathlib.Path,
237) -> None:224) -> None:
238 record = _make_record(3, seed=71)225 record = _make_record(3, seed=71)
239 record[NUMBER_OF_RETURNS_KEY] = np.array([1, 300, 3], dtype=np.int32)226 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([1, 300, 3], dtype=np.int32)
240 path = tmp_path / "out_of_range_returns_run3_points.npz"227 path = tmp_path / "out_of_range_returns_run3_points.npz"
241 np.savez_compressed(path, **record)228 np.savez_compressed(path, **record)
242 with pytest.raises(ValueError, match="number_of_returns.*fit in uint8"):229 with pytest.raises(ValueError, match="number_of_returns.*fit in uint8"):
243 load_points_npz(path)230 segment_points_io.load_points_npz(path)
244231
245232
246def test_load_points_npz_rejects_float_stored_number_of_returns(tmp_path: Path) -> None:233def test_load_points_npz_rejects_float_stored_number_of_returns(tmp_path: pathlib.Path) -> None:
247 record = _make_record(3, seed=72)234 record = _make_record(3, seed=72)
248 record[NUMBER_OF_RETURNS_KEY] = np.array([1.0, 2.0, 3.0], dtype=np.float32)235 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([1.0, 2.0, 3.0], dtype=np.float32)
249 path = tmp_path / "float_returns_run3_points.npz"236 path = tmp_path / "float_returns_run3_points.npz"
250 np.savez_compressed(path, **record)237 np.savez_compressed(path, **record)
251 with pytest.raises(ValueError, match="number_of_returns.*integer array"):238 with pytest.raises(ValueError, match="number_of_returns.*integer array"):
252 load_points_npz(path)239 segment_points_io.load_points_npz(path)
253240
254241
255def test_concat_points_npz_merges_mixed_stored_return_dtypes(tmp_path: Path) -> None:242def test_concat_points_npz_merges_mixed_stored_return_dtypes(tmp_path: pathlib.Path) -> None:
256 """int32-stored and uint8-stored records concatenate after the load coercion."""243 """int32-stored and uint8-stored records concatenate after the load coercion."""
257 wide = _make_record(3, seed=73)244 wide = _make_record(3, seed=73)
258 wide[NUMBER_OF_RETURNS_KEY] = wide[NUMBER_OF_RETURNS_KEY].astype(np.int32)245 wide[segment_points_io.NUMBER_OF_RETURNS_KEY] = wide[
246 segment_points_io.NUMBER_OF_RETURNS_KEY
247 ].astype(np.int32)
259 wide_path = tmp_path / "alpha_run3_points.npz"248 wide_path = tmp_path / "alpha_run3_points.npz"
260 np.savez_compressed(wide_path, **wide)249 np.savez_compressed(wide_path, **wide)
261 narrow_path = save_points_npz(tmp_path / "beta_run3_points.npz", _make_record(2, seed=74))250 narrow_path = segment_points_io.save_points_npz(
251 tmp_path / "beta_run3_points.npz", _make_record(2, seed=74)
252 )
262253
263 merged, _ = concat_points_npz([wide_path, narrow_path])254 merged, _ = segment_points_io.concat_points_npz([wide_path, narrow_path])
264255
265 assert merged[NUMBER_OF_RETURNS_KEY].dtype == np.uint8256 assert merged[segment_points_io.NUMBER_OF_RETURNS_KEY].dtype == np.uint8
266 assert merged[NUMBER_OF_RETURNS_KEY].shape == (5,)257 assert merged[segment_points_io.NUMBER_OF_RETURNS_KEY].shape == (5,)
267258
268259
269def test_load_segment_points_merges_legacy_and_new_records(tmp_path: Path) -> None:260def test_load_segment_points_merges_legacy_and_new_records(tmp_path: pathlib.Path) -> None:
270 legacy = _make_legacy_record(3, seed=66)261 legacy = _make_legacy_record(3, seed=66)
271 modern = _make_record(4, seed=67)262 modern = _make_record(4, seed=67)
272 legacy_path = tmp_path / "alpha_run3_points.npz"263 legacy_path = tmp_path / "alpha_run3_points.npz"
273 np.savez_compressed(legacy_path, **legacy)264 np.savez_compressed(legacy_path, **legacy)
274 modern_path = tmp_path / "beta_run3_points.npz"265 modern_path = tmp_path / "beta_run3_points.npz"
275 save_points_npz(modern_path, modern)266 segment_points_io.save_points_npz(modern_path, modern)
276267
277 merged, _, _ = load_segment_points([legacy_path, modern_path])268 merged, _, _ = segment_points_io.load_segment_points([legacy_path, modern_path])
278269
279 np.testing.assert_array_equal(270 np.testing.assert_array_equal(
280 merged[NUMBER_OF_RETURNS_KEY],271 merged[segment_points_io.NUMBER_OF_RETURNS_KEY],
281 np.concatenate(272 np.concatenate(
282 [np.zeros(3, dtype=np.uint8), modern[NUMBER_OF_RETURNS_KEY]], axis=0273 [np.zeros(3, dtype=np.uint8), modern[segment_points_io.NUMBER_OF_RETURNS_KEY]], axis=0
283 ),274 ),
284 )275 )
285276
286277
287def test_concat_points_npz_carries_number_of_returns(tmp_path: Path) -> None:278def test_concat_points_npz_carries_number_of_returns(tmp_path: pathlib.Path) -> None:
288 first = _make_record(3, seed=68)279 first = _make_record(3, seed=68)
289 second = _make_legacy_record(2, seed=69)280 second = _make_legacy_record(2, seed=69)
290 save_points_npz(tmp_path / "a_run3_points.npz", first)281 segment_points_io.save_points_npz(tmp_path / "a_run3_points.npz", first)
291 np.savez_compressed(tmp_path / "b_run3_points.npz", **second)282 np.savez_compressed(tmp_path / "b_run3_points.npz", **second)
292283
293 merged, spans = concat_points_npz(284 merged, spans = segment_points_io.concat_points_npz(
294 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]285 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]
295 )286 )
296287
297 assert [span.count for span in spans] == [3, 2]288 assert [span.count for span in spans] == [3, 2]
298 assert merged[NUMBER_OF_RETURNS_KEY].dtype == np.uint8289 assert merged[segment_points_io.NUMBER_OF_RETURNS_KEY].dtype == np.uint8
299 np.testing.assert_array_equal(290 np.testing.assert_array_equal(
300 merged[NUMBER_OF_RETURNS_KEY],291 merged[segment_points_io.NUMBER_OF_RETURNS_KEY],
301 np.concatenate(292 np.concatenate(
302 [first[NUMBER_OF_RETURNS_KEY], np.zeros(2, dtype=np.uint8)], axis=0293 [first[segment_points_io.NUMBER_OF_RETURNS_KEY], np.zeros(2, dtype=np.uint8)], axis=0
303 ),294 ),
304 )295 )
305296
306297
307def test_load_run3_segment_carries_number_of_returns(tmp_path: Path) -> None:298def test_load_run3_segment_carries_number_of_returns(tmp_path: pathlib.Path) -> None:
308 seg_dir = tmp_path / "segment_011"299 seg_dir = tmp_path / "segment_011"
309 seg_dir.mkdir()300 seg_dir.mkdir()
310 first = _make_record(2, seed=70)301 first = _make_record(2, seed=70)
311 second = _make_record(3, seed=71)302 second = _make_record(3, seed=71)
312 save_points_npz(seg_dir / "a_run3_points.npz", first)303 segment_points_io.save_points_npz(seg_dir / "a_run3_points.npz", first)
313 save_points_npz(seg_dir / "b_run3_points.npz", second)304 segment_points_io.save_points_npz(seg_dir / "b_run3_points.npz", second)
314305
315 merged, _ = load_run3_segment(seg_dir)306 merged, _ = segment_points_io.load_run3_segment(seg_dir)
316307
317 np.testing.assert_array_equal(308 np.testing.assert_array_equal(
318 merged[NUMBER_OF_RETURNS_KEY],309 merged[segment_points_io.NUMBER_OF_RETURNS_KEY],
319 np.concatenate(310 np.concatenate(
320 [first[NUMBER_OF_RETURNS_KEY], second[NUMBER_OF_RETURNS_KEY]], axis=0311 [
312 first[segment_points_io.NUMBER_OF_RETURNS_KEY],
313 second[segment_points_io.NUMBER_OF_RETURNS_KEY],
314 ],
315 axis=0,
321 ),316 ),
322 )317 )
323318
324319
325def test_load_segment_points_merges_and_tracks_file_ids(tmp_path: Path) -> None:320def test_load_segment_points_merges_and_tracks_file_ids(tmp_path: pathlib.Path) -> None:
326 records = [_make_record(3, seed=10), _make_record(5, seed=11)]321 records = [_make_record(3, seed=10), _make_record(5, seed=11)]
327 paths = [322 paths = [
328 tmp_path / "alpha_run3_points.npz",323 tmp_path / "alpha_run3_points.npz",
329 tmp_path / "beta_run3_points.npz",324 tmp_path / "beta_run3_points.npz",
330 ]325 ]
331 for path, record in zip(paths, records, strict=True):326 for path, record in zip(paths, records, strict=True):
332 save_points_npz(path, record)327 segment_points_io.save_points_npz(path, record)
333328
334 merged, point_file_ids, file_stems = load_segment_points(paths)329 merged, point_file_ids, file_stems = segment_points_io.load_segment_points(paths)
335330
336 assert file_stems == ["alpha_run3_points", "beta_run3_points"]331 assert file_stems == ["alpha_run3_points", "beta_run3_points"]
337 assert merged["points"].shape == (8, 3)332 assert merged["points"].shape == (8, 3)
338 assert point_file_ids.dtype == np.int32333 assert point_file_ids.dtype == np.int32
Importance #50: tests/test_segment_points_io.py @@ -351,45 +346,45 @@
351346
352347
353def test_load_segment_points_empty_raises() -> None:348def test_load_segment_points_empty_raises() -> None:
354 with pytest.raises(FileNotFoundError, match="No \\*_points.npz"):349 with pytest.raises(FileNotFoundError, match="No \\*_points.npz"):
355 load_segment_points([])350 segment_points_io.load_segment_points([])
356351
357352
358def test_load_geoshift_parses_xyz_json(tmp_path: Path) -> None:353def test_load_geoshift_parses_xyz_json(tmp_path: pathlib.Path) -> None:
359 path = tmp_path / "run3_geoshift.json"354 path = tmp_path / "run3_geoshift.json"
360 path.write_text(json.dumps({"x": 725883.5, "y": 5422097.8, "z": 390.8}), encoding="utf-8")355 path.write_text(json.dumps({"x": 725883.5, "y": 5422097.8, "z": 390.8}), encoding="utf-8")
361 geoshift = load_geoshift(path)356 geoshift = segment_points_io.load_geoshift(path)
362 assert geoshift.shape == (3,)357 assert geoshift.shape == (3,)
363 assert geoshift.dtype == np.float64358 assert geoshift.dtype == np.float64
364 np.testing.assert_allclose(geoshift, [725883.5, 5422097.8, 390.8])359 np.testing.assert_allclose(geoshift, [725883.5, 5422097.8, 390.8])
365360
366361
367def test_load_geoshift_rejects_missing_keys(tmp_path: Path) -> None:362def test_load_geoshift_rejects_missing_keys(tmp_path: pathlib.Path) -> None:
368 path = tmp_path / "run3_geoshift.json"363 path = tmp_path / "run3_geoshift.json"
369 path.write_text(json.dumps({"x": 1.0, "y": 2.0}), encoding="utf-8")364 path.write_text(json.dumps({"x": 1.0, "y": 2.0}), encoding="utf-8")
370 with pytest.raises(ValueError, match="missing geoshift key"):365 with pytest.raises(ValueError, match="missing geoshift key"):
371 load_geoshift(path)366 segment_points_io.load_geoshift(path)
372367
373368
374def test_parse_segment_key_accepts_int_and_prefixed_forms() -> None:369def test_parse_segment_key_accepts_int_and_prefixed_forms() -> None:
375 assert parse_segment_key(32) == 32370 assert segment_points_io.parse_segment_key(32) == 32
376 assert parse_segment_key("32") == 32371 assert segment_points_io.parse_segment_key("32") == 32
377 assert parse_segment_key("segment_33") == 33372 assert segment_points_io.parse_segment_key("segment_33") == 33
378 assert parse_segment_key("segment_066") == 66373 assert segment_points_io.parse_segment_key("segment_066") == 66
379374
380375
381def test_parse_segment_key_rejects_invalid() -> None:376def test_parse_segment_key_rejects_invalid() -> None:
382 with pytest.raises(ValueError, match="Segment key must not be empty"):377 with pytest.raises(ValueError, match="Segment key must not be empty"):
383 parse_segment_key("segment_")378 segment_points_io.parse_segment_key("segment_")
384 with pytest.raises(ValueError, match="integer or segment_<idx>"):379 with pytest.raises(ValueError, match="integer or segment_<idx>"):
385 parse_segment_key("lane_a")380 segment_points_io.parse_segment_key("lane_a")
386 with pytest.raises(ValueError, match="integer or segment_<idx>"):381 with pytest.raises(ValueError, match="integer or segment_<idx>"):
387 parse_segment_key(True)382 segment_points_io.parse_segment_key(True)
388383
389384
390def test_normalize_segment_file_blacklist_accepts_multiple_key_formats() -> None:385def test_normalize_segment_file_blacklist_accepts_multiple_key_formats() -> None:
391 blacklist = normalize_segment_file_blacklist(386 blacklist = segment_points_io.normalize_segment_file_blacklist(
392 {387 {
393 "32": ["scan_a_run3_points.npz", "nested/scan_b_run3_points.npz"],388 "32": ["scan_a_run3_points.npz", "nested/scan_b_run3_points.npz"],
394 "segment_33": "scan_c_run3_points.npz",389 "segment_33": "scan_c_run3_points.npz",
395 33: ["scan_d_run3_points.npz"],390 33: ["scan_d_run3_points.npz"],
Importance #51: tests/test_segment_points_io.py @@ -402,23 +397,23 @@
402397
403398
404def test_normalize_segment_file_blacklist_rejects_invalid_values() -> None:399def test_normalize_segment_file_blacklist_rejects_invalid_values() -> None:
405 with pytest.raises(ValueError, match="mapping of segment to files"):400 with pytest.raises(ValueError, match="mapping of segment to files"):
406 normalize_segment_file_blacklist(["32:scan_a_run3_points.npz"]) # type: ignore[arg-type]401 segment_points_io.normalize_segment_file_blacklist(["32:scan_a_run3_points.npz"]) # type: ignore[arg-type]
407 with pytest.raises(ValueError, match="at least one non-empty"):402 with pytest.raises(ValueError, match="at least one non-empty"):
408 normalize_segment_file_blacklist({"32": [" ", ""]})403 segment_points_io.normalize_segment_file_blacklist({"32": [" ", ""]})
409 assert normalize_segment_file_blacklist(None) == {}404 assert segment_points_io.normalize_segment_file_blacklist(None) == {}
410405
411406
412def test_filter_segment_files_respects_segment_specific_exact_and_glob_rules(407def test_filter_segment_files_respects_segment_specific_exact_and_glob_rules(
413 caplog: pytest.LogCaptureFixture,408 caplog: pytest.LogCaptureFixture,
414) -> None:409) -> None:
415 segment_32_files = [410 segment_32_files = [
416 Path("/tmp/lane_points/segment_32/scan_a_run3_points.npz"),411 pathlib.Path("/tmp/lane_points/segment_32/scan_a_run3_points.npz"),
417 Path("/tmp/lane_points/segment_32/nested/scan_b_run3_points.npz"),412 pathlib.Path("/tmp/lane_points/segment_32/nested/scan_b_run3_points.npz"),
418 Path("/tmp/lane_points/segment_32/scan_c_run3_points.npz"),413 pathlib.Path("/tmp/lane_points/segment_32/scan_c_run3_points.npz"),
419 ]414 ]
420 blacklist = normalize_segment_file_blacklist(415 blacklist = segment_points_io.normalize_segment_file_blacklist(
421 {416 {
422 "32": [417 "32": [
423 "scan_a_run3_points.npz",418 "scan_a_run3_points.npz",
424 "*/nested/scan_b_run3_points.npz",419 "*/nested/scan_b_run3_points.npz",
Importance #52: tests/test_segment_points_io.py @@ -427,14 +422,14 @@
427 }422 }
428 )423 )
429424
430 with caplog.at_level(logging.INFO, logger="iolabs.common.segment_points_io"):425 with caplog.at_level(logging.INFO, logger="iolabs.common.segment_points_io"):
431 kept_32, blacklisted_32 = filter_segment_files(426 kept_32, blacklisted_32 = segment_points_io.filter_segment_files(
432 segment_32_files,427 segment_32_files,
433 segment_index=32,428 segment_index=32,
434 blacklist=blacklist,429 blacklist=blacklist,
435 )430 )
436 kept_33, blacklisted_33 = filter_segment_files(431 kept_33, blacklisted_33 = segment_points_io.filter_segment_files(
437 segment_32_files,432 segment_32_files,
438 segment_index=33,433 segment_index=33,
439 blacklist=blacklist,434 blacklist=blacklist,
440 )435 )
Importance #53: tests/test_segment_points_io.py @@ -451,81 +446,81 @@
451 assert [path.name for path in blacklisted_33] == ["scan_c_run3_points.npz"]446 assert [path.name for path in blacklisted_33] == ["scan_c_run3_points.npz"]
452 assert any("excluding 2 input NPZ files" in record.message for record in caplog.records)447 assert any("excluding 2 input NPZ files" in record.message for record in caplog.records)
453448
454449
455def _write_geoshift(path: Path, xyz: tuple[float, float, float]) -> Path:450def _write_geoshift(path: pathlib.Path, xyz: tuple[float, float, float]) -> pathlib.Path:
456 path.parent.mkdir(parents=True, exist_ok=True)451 path.parent.mkdir(parents=True, exist_ok=True)
457 path.write_text(452 path.write_text(
458 json.dumps({"x": xyz[0], "y": xyz[1], "z": xyz[2]}), encoding="utf-8"453 json.dumps({"x": xyz[0], "y": xyz[1], "z": xyz[2]}), encoding="utf-8"
459 )454 )
460 return path455 return path
461456
462457
463def _save_stored_npz(path: Path, record: dict[str, np.ndarray]) -> Path:458def _save_stored_npz(path: pathlib.Path, record: dict[str, np.ndarray]) -> pathlib.Path:
464 """Write an uncompressed (ZIP_STORED) npz, the chunk-streamable layout."""459 """Write an uncompressed (ZIP_STORED) npz, the chunk-streamable layout."""
465 np.savez(path, **record)460 np.savez(path, **record)
466 return path461 return path
467462
468463
469def test_geoshift_candidate_paths_cover_the_three_conventions(tmp_path: Path) -> None:464def test_geoshift_candidate_paths_cover_the_three_conventions(tmp_path: pathlib.Path) -> None:
470 candidates = geoshift_candidate_paths(tmp_path / "dataset" / "lane_points")465 candidates = segment_points_io.geoshift_candidate_paths(tmp_path / "dataset" / "lane_points")
471 assert candidates == [466 assert candidates == [
472 tmp_path / "dataset" / "lane_points" / GEOSHIFT_NAME,467 tmp_path / "dataset" / "lane_points" / segment_points_io.GEOSHIFT_NAME,
473 tmp_path / "dataset" / "lane_points" / "lane_points" / GEOSHIFT_NAME,468 tmp_path / "dataset" / "lane_points" / "lane_points" / segment_points_io.GEOSHIFT_NAME,
474 tmp_path / "dataset" / GEOSHIFT_NAME,469 tmp_path / "dataset" / segment_points_io.GEOSHIFT_NAME,
475 ]470 ]
476471
477472
478def test_find_geoshift_lane_points_dir_convention(tmp_path: Path) -> None:473def test_find_geoshift_lane_points_dir_convention(tmp_path: pathlib.Path) -> None:
479 lane_points = tmp_path / "lane_points"474 lane_points = tmp_path / "lane_points"
480 _write_geoshift(lane_points / GEOSHIFT_NAME, (1.0, 2.0, 3.0))475 _write_geoshift(lane_points / segment_points_io.GEOSHIFT_NAME, (1.0, 2.0, 3.0))
481 np.testing.assert_allclose(find_geoshift(lane_points), [1.0, 2.0, 3.0])476 np.testing.assert_allclose(segment_points_io.find_geoshift(lane_points), [1.0, 2.0, 3.0])
482477
483478
484def test_find_geoshift_dataset_root_convention(tmp_path: Path) -> None:479def test_find_geoshift_dataset_root_convention(tmp_path: pathlib.Path) -> None:
485 _write_geoshift(tmp_path / "lane_points" / GEOSHIFT_NAME, (4.0, 5.0, 6.0))480 _write_geoshift(tmp_path / "lane_points" / segment_points_io.GEOSHIFT_NAME, (4.0, 5.0, 6.0))
486 np.testing.assert_allclose(find_geoshift(tmp_path), [4.0, 5.0, 6.0])481 np.testing.assert_allclose(segment_points_io.find_geoshift(tmp_path), [4.0, 5.0, 6.0])
487482
488483
489def test_find_geoshift_segment_dir_parent_convention(tmp_path: Path) -> None:484def test_find_geoshift_segment_dir_parent_convention(tmp_path: pathlib.Path) -> None:
490 lane_points = tmp_path / "lane_points"485 lane_points = tmp_path / "lane_points"
491 seg_dir = lane_points / "segment_032"486 seg_dir = lane_points / "segment_032"
492 seg_dir.mkdir(parents=True)487 seg_dir.mkdir(parents=True)
493 _write_geoshift(lane_points / GEOSHIFT_NAME, (7.0, 8.0, 9.0))488 _write_geoshift(lane_points / segment_points_io.GEOSHIFT_NAME, (7.0, 8.0, 9.0))
494 np.testing.assert_allclose(find_geoshift(seg_dir), [7.0, 8.0, 9.0])489 np.testing.assert_allclose(segment_points_io.find_geoshift(seg_dir), [7.0, 8.0, 9.0])
495490
496491
497def test_find_geoshift_prefers_directory_over_parent(tmp_path: Path) -> None:492def test_find_geoshift_prefers_directory_over_parent(tmp_path: pathlib.Path) -> None:
498 lane_points = tmp_path / "lane_points"493 lane_points = tmp_path / "lane_points"
499 seg_dir = lane_points / "segment_032"494 seg_dir = lane_points / "segment_032"
500 seg_dir.mkdir(parents=True)495 seg_dir.mkdir(parents=True)
501 _write_geoshift(seg_dir / GEOSHIFT_NAME, (1.0, 1.0, 1.0))496 _write_geoshift(seg_dir / segment_points_io.GEOSHIFT_NAME, (1.0, 1.0, 1.0))
502 _write_geoshift(lane_points / GEOSHIFT_NAME, (2.0, 2.0, 2.0))497 _write_geoshift(lane_points / segment_points_io.GEOSHIFT_NAME, (2.0, 2.0, 2.0))
503 np.testing.assert_allclose(find_geoshift(seg_dir), [1.0, 1.0, 1.0])498 np.testing.assert_allclose(segment_points_io.find_geoshift(seg_dir), [1.0, 1.0, 1.0])
504499
505500
506def test_find_geoshift_or_none_returns_none_when_absent(tmp_path: Path) -> None:501def test_find_geoshift_or_none_returns_none_when_absent(tmp_path: pathlib.Path) -> None:
507 seg_dir = tmp_path / "lane_points" / "segment_000"502 seg_dir = tmp_path / "lane_points" / "segment_000"
508 seg_dir.mkdir(parents=True)503 seg_dir.mkdir(parents=True)
509 assert find_geoshift_or_none(seg_dir) is None504 assert segment_points_io.find_geoshift_or_none(seg_dir) is None
510505
511506
512def test_find_geoshift_raises_and_lists_searched_paths(tmp_path: Path) -> None:507def test_find_geoshift_raises_and_lists_searched_paths(tmp_path: pathlib.Path) -> None:
513 with pytest.raises(FileNotFoundError, match="searched:"):508 with pytest.raises(FileNotFoundError, match="searched:"):
514 find_geoshift(tmp_path)509 segment_points_io.find_geoshift(tmp_path)
515510
516511
517def test_find_geoshift_returns_shift_unsigned(tmp_path: Path) -> None:512def test_find_geoshift_returns_shift_unsigned(tmp_path: pathlib.Path) -> None:
518 """The recorded shift is returned verbatim; no sign is baked in."""513 """The recorded shift is returned verbatim; no sign is baked in."""
519 _write_geoshift(tmp_path / GEOSHIFT_NAME, (725883.5, -5422097.8, 390.8))514 _write_geoshift(tmp_path / segment_points_io.GEOSHIFT_NAME, (725883.5, -5422097.8, 390.8))
520 np.testing.assert_allclose(515 np.testing.assert_allclose(
521 find_geoshift(tmp_path), [725883.5, -5422097.8, 390.8]516 segment_points_io.find_geoshift(tmp_path), [725883.5, -5422097.8, 390.8]
522 )517 )
523518
524519
525def test_geoshift_from_mapping_bare_and_nested() -> None:520def test_geoshift_from_mapping_bare_and_nested() -> None:
526 bare = geoshift_from_mapping({"x": 1.0, "y": 2.0, "z": 3.0})521 bare = segment_points_io.geoshift_from_mapping({"x": 1.0, "y": 2.0, "z": 3.0})
527 nested = geoshift_from_mapping(522 nested = segment_points_io.geoshift_from_mapping(
528 {"pixels_per_meter": 10, "geoshift": {"x": 1.0, "y": 2.0, "z": 3.0}}523 {"pixels_per_meter": 10, "geoshift": {"x": 1.0, "y": 2.0, "z": 3.0}}
529 )524 )
530 assert bare.dtype == np.float64525 assert bare.dtype == np.float64
531 np.testing.assert_allclose(bare, [1.0, 2.0, 3.0])526 np.testing.assert_allclose(bare, [1.0, 2.0, 3.0])
Importance #54: tests/test_segment_points_io.py @@ -533,113 +528,114 @@
533528
534529
535def test_geoshift_from_mapping_rejects_bad_input() -> None:530def test_geoshift_from_mapping_rejects_bad_input() -> None:
536 with pytest.raises(ValueError, match="missing geoshift key"):531 with pytest.raises(ValueError, match="missing geoshift key"):
537 geoshift_from_mapping({"x": 1.0, "y": 2.0})532 segment_points_io.geoshift_from_mapping({"x": 1.0, "y": 2.0})
538 with pytest.raises(ValueError, match="must be an object"):533 with pytest.raises(ValueError, match="must be an object"):
539 geoshift_from_mapping([1.0, 2.0, 3.0]) # type: ignore[arg-type]534 segment_points_io.geoshift_from_mapping([1.0, 2.0, 3.0]) # type: ignore[arg-type]
540 with pytest.raises(ValueError, match="must be numbers"):535 with pytest.raises(ValueError, match="must be numbers"):
541 geoshift_from_mapping({"x": 1.0, "y": "north", "z": 3.0})536 segment_points_io.geoshift_from_mapping({"x": 1.0, "y": "north", "z": 3.0})
542537
543538
544def test_read_points_header_reports_stored_and_compressed(tmp_path: Path) -> None:539def test_read_points_header_reports_stored_and_compressed(tmp_path: pathlib.Path) -> None:
545 record = _make_record(5, seed=20)540 record = _make_record(5, seed=20)
546 stored = _save_stored_npz(tmp_path / "stored.npz", record)541 stored = _save_stored_npz(tmp_path / "stored.npz", record)
547 compressed = tmp_path / "compressed.npz"542 compressed = tmp_path / "compressed.npz"
548 np.savez_compressed(compressed, **record)543 np.savez_compressed(compressed, **record)
549544
550 assert read_points_header(stored) == (5, True)545 assert segment_points_io.read_points_header(stored) == (5, True)
551 assert read_points_header(compressed) == (5, False)546 assert segment_points_io.read_points_header(compressed) == (5, False)
552547
553548
554def test_read_points_header_unreadable_file(tmp_path: Path) -> None:549def test_read_points_header_unreadable_file(tmp_path: pathlib.Path) -> None:
555 broken = tmp_path / "broken.npz"550 broken = tmp_path / "broken.npz"
556 broken.write_bytes(b"not a zip archive")551 broken.write_bytes(b"not a zip archive")
557 assert read_points_header(broken) == (-1, False)552 assert segment_points_io.read_points_header(broken) == (-1, False)
558553
559554
560def test_iter_points_chunks_streams_tail_chunk_and_is_writeable(tmp_path: Path) -> None:555def test_iter_points_chunks_streams_tail_chunk_and_is_writeable(tmp_path: pathlib.Path) -> None:
561 record = _make_record(7, seed=21)556 record = _make_record(7, seed=21)
562 path = _save_stored_npz(tmp_path / "big_run3_points.npz", record)557 path = _save_stored_npz(tmp_path / "big_run3_points.npz", record)
563558
564 chunks = list(iter_points_chunks(path, 3))559 chunks = list(segment_points_io.iter_points_chunks(path, 3))
565560
566 assert [len(chunk) for chunk in chunks] == [3, 3, 1]561 assert [len(chunk) for chunk in chunks] == [3, 3, 1]
567 for chunk in chunks:562 for chunk in chunks:
568 assert chunk.flags.writeable563 assert chunk.flags.writeable
569 assert chunk.flags.c_contiguous564 assert chunk.flags.c_contiguous
570 chunk[:] = 0.0 # must not raise: chunks are owned copies565 chunk[:] = 0.0 # must not raise: chunks are owned copies
571 np.testing.assert_array_equal(566 np.testing.assert_array_equal(
572 np.concatenate(list(iter_points_chunks(path, 3)), axis=0), record["points"]567 np.concatenate(list(segment_points_io.iter_points_chunks(path, 3)), axis=0),
568 record["points"],
573 )569 )
574570
575571
576def test_iter_points_chunks_yields_whole_record_when_not_oversized(tmp_path: Path) -> None:572def test_iter_points_chunks_yields_whole_record_when_not_oversized(tmp_path: pathlib.Path) -> None:
577 record = _make_record(4, seed=22)573 record = _make_record(4, seed=22)
578 path = _save_stored_npz(tmp_path / "small_run3_points.npz", record)574 path = _save_stored_npz(tmp_path / "small_run3_points.npz", record)
579575
580 chunks = list(iter_points_chunks(path, 4))576 chunks = list(segment_points_io.iter_points_chunks(path, 4))
581577
582 assert len(chunks) == 1578 assert len(chunks) == 1
583 assert chunks[0].flags.writeable579 assert chunks[0].flags.writeable
584 np.testing.assert_array_equal(chunks[0], record["points"])580 np.testing.assert_array_equal(chunks[0], record["points"])
585581
586582
587def test_iter_points_chunks_falls_back_for_compressed_records(tmp_path: Path) -> None:583def test_iter_points_chunks_falls_back_for_compressed_records(tmp_path: pathlib.Path) -> None:
588 record = _make_record(9, seed=23)584 record = _make_record(9, seed=23)
589 path = tmp_path / "compressed_run3_points.npz"585 path = tmp_path / "compressed_run3_points.npz"
590 np.savez_compressed(path, **record)586 np.savez_compressed(path, **record)
591587
592 chunks = list(iter_points_chunks(path, 2))588 chunks = list(segment_points_io.iter_points_chunks(path, 2))
593589
594 assert len(chunks) == 1590 assert len(chunks) == 1
595 np.testing.assert_array_equal(chunks[0], record["points"])591 np.testing.assert_array_equal(chunks[0], record["points"])
596592
597593
598def test_iter_points_chunks_disabled_by_non_positive_chunk_size(tmp_path: Path) -> None:594def test_iter_points_chunks_disabled_by_non_positive_chunk_size(tmp_path: pathlib.Path) -> None:
599 record = _make_record(6, seed=24)595 record = _make_record(6, seed=24)
600 path = _save_stored_npz(tmp_path / "run3_points.npz", record)596 path = _save_stored_npz(tmp_path / "run3_points.npz", record)
601597
602 chunks = list(iter_points_chunks(path, 0))598 chunks = list(segment_points_io.iter_points_chunks(path, 0))
603599
604 assert len(chunks) == 1600 assert len(chunks) == 1
605 np.testing.assert_array_equal(chunks[0], record["points"])601 np.testing.assert_array_equal(chunks[0], record["points"])
606602
607603
608def test_discover_run3_files_skips_part_files(tmp_path: Path) -> None:604def test_discover_run3_files_skips_part_files(tmp_path: pathlib.Path) -> None:
609 seg_dir = tmp_path / "segment_003"605 seg_dir = tmp_path / "segment_003"
610 seg_dir.mkdir()606 seg_dir.mkdir()
611 save_points_npz(seg_dir / "beta_run3_points.npz", _make_record(2, seed=30))607 segment_points_io.save_points_npz(seg_dir / "beta_run3_points.npz", _make_record(2, seed=30))
612 save_points_npz(seg_dir / "alpha_run3_points.npz", _make_record(2, seed=31))608 segment_points_io.save_points_npz(seg_dir / "alpha_run3_points.npz", _make_record(2, seed=31))
613 (seg_dir / "gamma_run3_points.npz.part").write_bytes(b"partial write")609 (seg_dir / "gamma_run3_points.npz.part").write_bytes(b"partial write")
614 (seg_dir / "delta_run4_road_surface.npz").write_bytes(b"other artifact")610 (seg_dir / "delta_run4_road_surface.npz").write_bytes(b"other artifact")
615611
616 found = discover_run3_files(seg_dir)612 found = segment_points_io.discover_run3_files(seg_dir)
617613
618 assert [path.name for path in found] == [614 assert [path.name for path in found] == [
619 "alpha_run3_points.npz",615 "alpha_run3_points.npz",
620 "beta_run3_points.npz",616 "beta_run3_points.npz",
621 ]617 ]
622618
623619
624def test_discover_run3_files_missing_dir_is_empty(tmp_path: Path) -> None:620def test_discover_run3_files_missing_dir_is_empty(tmp_path: pathlib.Path) -> None:
625 assert discover_run3_files(tmp_path / "segment_999") == []621 assert segment_points_io.discover_run3_files(tmp_path / "segment_999") == []
626622
627623
628def test_load_run3_segment_boundary_table(tmp_path: Path) -> None:624def test_load_run3_segment_boundary_table(tmp_path: pathlib.Path) -> None:
629 seg_dir = tmp_path / "segment_007"625 seg_dir = tmp_path / "segment_007"
630 seg_dir.mkdir()626 seg_dir.mkdir()
631 first = _make_record(3, seed=40)627 first = _make_record(3, seed=40)
632 second = _make_record(5, seed=41)628 second = _make_record(5, seed=41)
633 save_points_npz(seg_dir / "a_run3_points.npz", first)629 segment_points_io.save_points_npz(seg_dir / "a_run3_points.npz", first)
634 save_points_npz(seg_dir / "b_run3_points.npz", second)630 segment_points_io.save_points_npz(seg_dir / "b_run3_points.npz", second)
635 (seg_dir / "c_run3_points.npz.part").write_bytes(b"partial write")631 (seg_dir / "c_run3_points.npz.part").write_bytes(b"partial write")
636632
637 merged, spans = load_run3_segment(seg_dir)633 merged, spans = segment_points_io.load_run3_segment(seg_dir)
638634
639 assert spans == [635 assert spans == [
640 RecordSpan(name="a_run3_points.npz", offset=0, count=3),636 segment_points_io.RecordSpan(name="a_run3_points.npz", offset=0, count=3),
641 RecordSpan(name="b_run3_points.npz", offset=3, count=5),637 segment_points_io.RecordSpan(name="b_run3_points.npz", offset=3, count=5),
642 ]638 ]
643 assert spans[1].end == 8639 assert spans[1].end == 8
644 assert merged["points"].shape == (8, 3)640 assert merged["points"].shape == (8, 3)
645 np.testing.assert_array_equal(641 np.testing.assert_array_equal(
Importance #55: tests/test_segment_points_io.py @@ -653,58 +649,58 @@
653 np.concatenate([first["intensity"], second["intensity"]], axis=0),649 np.concatenate([first["intensity"], second["intensity"]], axis=0),
654 )650 )
655651
656652
657def test_load_run3_segment_requires_records(tmp_path: Path) -> None:653def test_load_run3_segment_requires_records(tmp_path: pathlib.Path) -> None:
658 seg_dir = tmp_path / "segment_008"654 seg_dir = tmp_path / "segment_008"
659 seg_dir.mkdir()655 seg_dir.mkdir()
660 (seg_dir / "a_run3_points.npz.part").write_bytes(b"partial write")656 (seg_dir / "a_run3_points.npz.part").write_bytes(b"partial write")
661 with pytest.raises(FileNotFoundError, match=r"No \*_run3_points.npz"):657 with pytest.raises(FileNotFoundError, match=r"No \*_run3_points.npz"):
662 load_run3_segment(seg_dir)658 segment_points_io.load_run3_segment(seg_dir)
663659
664660
665def test_concat_points_npz_rejects_mixed_dtypes(tmp_path: Path) -> None:661def test_concat_points_npz_rejects_mixed_dtypes(tmp_path: pathlib.Path) -> None:
666 first = _make_record(3, seed=50)662 first = _make_record(3, seed=50)
667 second = _make_record(3, seed=51)663 second = _make_record(3, seed=51)
668 second["points"] = second["points"].astype(np.float64)664 second["points"] = second["points"].astype(np.float64)
669 save_points_npz(tmp_path / "a_run3_points.npz", first)665 segment_points_io.save_points_npz(tmp_path / "a_run3_points.npz", first)
670 save_points_npz(tmp_path / "b_run3_points.npz", second)666 segment_points_io.save_points_npz(tmp_path / "b_run3_points.npz", second)
671667
672 with pytest.raises(ValueError, match="mixed dtypes"):668 with pytest.raises(ValueError, match="mixed dtypes"):
673 concat_points_npz(669 segment_points_io.concat_points_npz(
674 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]670 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]
675 )671 )
676672
677673
678def test_concat_points_npz_rejects_mixed_ancillary_dtypes(tmp_path: Path) -> None:674def test_concat_points_npz_rejects_mixed_ancillary_dtypes(tmp_path: pathlib.Path) -> None:
679 first = _make_record(2, seed=52)675 first = _make_record(2, seed=52)
680 second = _make_record(2, seed=53)676 second = _make_record(2, seed=53)
681 second["intensity"] = second["intensity"].astype(np.uint8)677 second["intensity"] = second["intensity"].astype(np.uint8)
682 save_points_npz(tmp_path / "a_run3_points.npz", first)678 segment_points_io.save_points_npz(tmp_path / "a_run3_points.npz", first)
683 save_points_npz(tmp_path / "b_run3_points.npz", second)679 segment_points_io.save_points_npz(tmp_path / "b_run3_points.npz", second)
684680
685 with pytest.raises(ValueError, match="'intensity' dtype uint8"):681 with pytest.raises(ValueError, match="'intensity' dtype uint8"):
686 concat_points_npz(682 segment_points_io.concat_points_npz(
687 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]683 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]
688 )684 )
689685
690686
691def test_concat_points_npz_empty_raises() -> None:687def test_concat_points_npz_empty_raises() -> None:
692 with pytest.raises(FileNotFoundError, match="No \\*_points.npz"):688 with pytest.raises(FileNotFoundError, match="No \\*_points.npz"):
693 concat_points_npz([])689 segment_points_io.concat_points_npz([])
694690
695691
696def test_concat_points_npz_target_dtypes_casts_mixed_records(tmp_path: Path) -> None:692def test_concat_points_npz_target_dtypes_casts_mixed_records(tmp_path: pathlib.Path) -> None:
697 # seg3d-style normalization: historical records with different storage693 # seg3d-style normalization: historical records with different storage
698 # dtypes (uint8 vs uint16 intensity) are cast to the target instead of694 # dtypes (uint8 vs uint16 intensity) are cast to the target instead of
699 # rejected.695 # rejected.
700 first = _make_record(2, seed=54)696 first = _make_record(2, seed=54)
701 second = _make_record(2, seed=55)697 second = _make_record(2, seed=55)
702 second["intensity"] = second["intensity"].astype(np.uint8)698 second["intensity"] = second["intensity"].astype(np.uint8)
703 save_points_npz(tmp_path / "a_run3_points.npz", first)699 segment_points_io.save_points_npz(tmp_path / "a_run3_points.npz", first)
704 save_points_npz(tmp_path / "b_run3_points.npz", second)700 segment_points_io.save_points_npz(tmp_path / "b_run3_points.npz", second)
705701
706 merged, spans = concat_points_npz(702 merged, spans = segment_points_io.concat_points_npz(
707 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"],703 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"],
708 target_dtypes={"points": np.dtype(np.float64), "intensity": np.dtype(np.uint16)},704 target_dtypes={"points": np.dtype(np.float64), "intensity": np.dtype(np.uint16)},
709 )705 )
710 assert merged["points"].dtype == np.float64706 assert merged["points"].dtype == np.float64
Importance #56: tests/test_segment_points_io.py @@ -718,9 +714,9 @@
718 )714 )
719715
720716
721def test_find_geoshift_warns_on_multiple_candidates(717def test_find_geoshift_warns_on_multiple_candidates(
722 tmp_path: Path, caplog: pytest.LogCaptureFixture718 tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
723) -> None:719) -> None:
724 # A stale segment-local file shadowing the dataset-level one is legal but720 # A stale segment-local file shadowing the dataset-level one is legal but
725 # suspicious; the lookup must say so out loud.721 # suspicious; the lookup must say so out loud.
726 seg_dir = tmp_path / "lane_points" / "segment_032"722 seg_dir = tmp_path / "lane_points" / "segment_032"
Importance #57: tests/test_segment_points_io.py @@ -728,54 +724,61 @@
728 _write_geoshift(seg_dir / "run3_geoshift.json", (1.0, 1.0, 1.0))724 _write_geoshift(seg_dir / "run3_geoshift.json", (1.0, 1.0, 1.0))
729 _write_geoshift(tmp_path / "lane_points" / "run3_geoshift.json", (2.0, 2.0, 2.0))725 _write_geoshift(tmp_path / "lane_points" / "run3_geoshift.json", (2.0, 2.0, 2.0))
730726
731 with caplog.at_level(logging.WARNING, logger="iolabs.common.segment_points_io"):727 with caplog.at_level(logging.WARNING, logger="iolabs.common.segment_points_io"):
732 shift = find_geoshift(seg_dir)728 shift = segment_points_io.find_geoshift(seg_dir)
733729
734 np.testing.assert_array_equal(shift, np.array([1.0, 1.0, 1.0]))730 np.testing.assert_array_equal(shift, np.array([1.0, 1.0, 1.0]))
735 assert any("Multiple" in message for message in caplog.messages)731 assert any("Multiple" in message for message in caplog.messages)
736732
737733
738def test_public_key_tuples_are_derived_from_the_schema() -> None:734def test_public_key_tuples_are_derived_from_the_schema() -> None:
739 assert POINT_RECORD_KEYS == tuple(POINT_RECORD_SCHEMA)735 assert segment_points_io.POINT_RECORD_KEYS == tuple(segment_points_io.POINT_RECORD_SCHEMA)
740 assert REQUIRED_POINT_RECORD_KEYS == (736 assert segment_points_io.REQUIRED_POINT_RECORD_KEYS == (
741 "points",737 "points",
742 "red",738 "red",
743 "green",739 "green",
744 "blue",740 "blue",
745 "intensity",741 "intensity",
746 "scan_angle",742 "scan_angle",
747 )743 )
748 assert OPTIONAL_POINT_RECORD_KEYS == (NUMBER_OF_RETURNS_KEY,)744 assert segment_points_io.OPTIONAL_POINT_RECORD_KEYS == (
749 assert all(POINT_RECORD_SCHEMA[key].required for key in REQUIRED_POINT_RECORD_KEYS)745 segment_points_io.NUMBER_OF_RETURNS_KEY,
750 assert POINT_RECORD_SCHEMA[NUMBER_OF_RETURNS_KEY].storage_dtype == np.dtype(np.uint8)746 )
751 assert POINT_RECORD_SCHEMA["points"].columns == 3747 assert all(
748 segment_points_io.POINT_RECORD_SCHEMA[key].required
749 for key in segment_points_io.REQUIRED_POINT_RECORD_KEYS
750 )
751 assert segment_points_io.POINT_RECORD_SCHEMA[
752 segment_points_io.NUMBER_OF_RETURNS_KEY
753 ].storage_dtype == np.dtype(np.uint8)
754 assert segment_points_io.POINT_RECORD_SCHEMA["points"].columns == 3
752755
753756
754def test_point_field_spec_rejects_an_optional_key_without_a_fill() -> None:757def test_point_field_spec_rejects_an_optional_key_without_a_fill() -> None:
755 """An optional key with no fill would make pre-existing records unloadable."""758 """An optional key with no fill would make pre-existing records unloadable."""
756 with pytest.raises(ValueError, match="needs a fill factory"):759 with pytest.raises(ValueError, match="needs a fill factory"):
757 PointFieldSpec(required=False)760 segment_points_io.PointFieldSpec(required=False)
758761
759762
760def test_mask_record_masks_points_rows_and_ancillary_elements() -> None:763def test_mask_record_masks_points_rows_and_ancillary_elements() -> None:
761 record = _make_record(5, seed=80)764 record = _make_record(5, seed=80)
762 mask = np.array([True, False, True, False, True])765 mask = np.array([True, False, True, False, True])
763766
764 masked = mask_record(record, mask)767 masked = segment_points_io.mask_record(record, mask)
765768
766 assert list(masked) == list(record)769 assert list(masked) == list(record)
767 assert masked["points"].shape == (3, 3)770 assert masked["points"].shape == (3, 3)
768 np.testing.assert_array_equal(masked["points"], record["points"][mask])771 np.testing.assert_array_equal(masked["points"], record["points"][mask])
769 for key in POINT_RECORD_KEYS:772 for key in segment_points_io.POINT_RECORD_KEYS:
770 np.testing.assert_array_equal(masked[key], record[key][mask])773 np.testing.assert_array_equal(masked[key], record[key][mask])
771774
772775
773def test_mask_record_accepts_an_integer_index_array() -> None:776def test_mask_record_accepts_an_integer_index_array() -> None:
774 record = _make_record(4, seed=81)777 record = _make_record(4, seed=81)
775 index = np.array([3, 0])778 index = np.array([3, 0])
776779
777 masked = mask_record(record, index)780 masked = segment_points_io.mask_record(record, index)
778781
779 np.testing.assert_array_equal(masked["points"], record["points"][index])782 np.testing.assert_array_equal(masked["points"], record["points"][index])
780 np.testing.assert_array_equal(masked["intensity"], record["intensity"][index])783 np.testing.assert_array_equal(masked["intensity"], record["intensity"][index])
781784
Importance #58: tests/test_segment_points_io.py @@ -783,59 +786,61 @@
783def test_mask_record_rejects_misaligned_members() -> None:786def test_mask_record_rejects_misaligned_members() -> None:
784 record = _make_record(4, seed=82)787 record = _make_record(4, seed=82)
785 record["red"] = record["red"][:2]788 record["red"] = record["red"][:2]
786 with pytest.raises(ValueError, match="disagree on point count"):789 with pytest.raises(ValueError, match="disagree on point count"):
787 mask_record(record, np.array([True, False, True, False]))790 segment_points_io.mask_record(record, np.array([True, False, True, False]))
788791
789792
790def test_mask_record_rejects_an_empty_record() -> None:793def test_mask_record_rejects_an_empty_record() -> None:
791 with pytest.raises(ValueError, match="empty point record"):794 with pytest.raises(ValueError, match="empty point record"):
792 mask_record({}, np.array([True]))795 segment_points_io.mask_record({}, np.array([True]))
793796
794797
795def test_concat_records_joins_every_member_on_axis_zero() -> None:798def test_concat_records_joins_every_member_on_axis_zero() -> None:
796 first = _make_record(3, seed=83)799 first = _make_record(3, seed=83)
797 second = _make_record(2, seed=84)800 second = _make_record(2, seed=84)
798801
799 merged = concat_records([first, second])802 merged = segment_points_io.concat_records([first, second])
800803
801 assert list(merged) == list(POINT_RECORD_KEYS)804 assert list(merged) == list(segment_points_io.POINT_RECORD_KEYS)
802 assert merged["points"].shape == (5, 3)805 assert merged["points"].shape == (5, 3)
803 for key in POINT_RECORD_KEYS:806 for key in segment_points_io.POINT_RECORD_KEYS:
804 np.testing.assert_array_equal(807 np.testing.assert_array_equal(
805 merged[key], np.concatenate([first[key], second[key]], axis=0)808 merged[key], np.concatenate([first[key], second[key]], axis=0)
806 )809 )
807810
808811
809def test_concat_records_rejects_mismatched_key_sets() -> None:812def test_concat_records_rejects_mismatched_key_sets() -> None:
810 with pytest.raises(ValueError, match="different keys"):813 with pytest.raises(ValueError, match="different keys"):
811 concat_records([_make_record(2, seed=85), _make_legacy_record(2, seed=86)])814 segment_points_io.concat_records(
815 [_make_record(2, seed=85), _make_legacy_record(2, seed=86)]
816 )
812817
813818
814def test_concat_records_rejects_an_empty_sequence() -> None:819def test_concat_records_rejects_an_empty_sequence() -> None:
815 with pytest.raises(ValueError, match="empty sequence"):820 with pytest.raises(ValueError, match="empty sequence"):
816 concat_records([])821 segment_points_io.concat_records([])
817822
818823
819def test_concat_records_rejects_records_without_members() -> None:824def test_concat_records_rejects_records_without_members() -> None:
820 """Memberless records must raise, not silently concatenate to ``{}``."""825 """Memberless records must raise, not silently concatenate to ``{}``."""
821 with pytest.raises(ValueError, match="no members"):826 with pytest.raises(ValueError, match="no members"):
822 concat_records([{}, {}])827 segment_points_io.concat_records([{}, {}])
823828
824829
825def test_mask_record_rejects_zero_dimensional_members() -> None:830def test_mask_record_rejects_zero_dimensional_members() -> None:
826 """A 0-D member is not row-indexable: raise ValueError, not a raw IndexError."""831 """A 0-D member is not row-indexable: raise ValueError, not a raw IndexError."""
827 record = {key: np.asarray(1, dtype=np.uint8) for key in POINT_RECORD_KEYS}832 record = {key: np.asarray(1, dtype=np.uint8) for key in segment_points_io.POINT_RECORD_KEYS}
828833
829 with pytest.raises(ValueError, match="not 1-D or 2-D"):834 with pytest.raises(ValueError, match="not 1-D or 2-D"):
830 mask_record(record, np.array([True]))835 segment_points_io.mask_record(record, np.array([True]))
831836
832837
833# --- Adding a field to the contract must be a registry entry and nothing else ---838# --- Adding a field to the contract must be a registry entry and nothing else ---
834839
835EXTRA_KEY = "point_source_id"840EXTRA_KEY = "point_source_id"
836841
837EXTRA_SPEC = PointFieldSpec(842EXTRA_SPEC = segment_points_io.PointFieldSpec(
838 required=False,843 required=False,
839 storage_dtype=np.dtype(np.int16),844 storage_dtype=np.dtype(np.int16),
840 fill=functools.partial(np.full, fill_value=-1, dtype=np.int16),845 fill=functools.partial(np.full, fill_value=-1, dtype=np.int16),
841 noun="source id",846 noun="source id",
Importance #59: tests/test_segment_points_io.py @@ -847,11 +852,11 @@
847 """Register one extra optional key, exactly as a real schema addition would."""852 """Register one extra optional key, exactly as a real schema addition would."""
848 monkeypatch.setattr(853 monkeypatch.setattr(
849 segment_points_io,854 segment_points_io,
850 "POINT_RECORD_SCHEMA",855 "POINT_RECORD_SCHEMA",
851 MappingProxyType({**POINT_RECORD_SCHEMA, EXTRA_KEY: EXTRA_SPEC}),856 types.MappingProxyType({**segment_points_io.POINT_RECORD_SCHEMA, EXTRA_KEY: EXTRA_SPEC}),
852 )857 )
853 return (*POINT_RECORD_KEYS, EXTRA_KEY)858 return (*segment_points_io.POINT_RECORD_KEYS, EXTRA_KEY)
854859
855860
856def _make_extended_record(n: int, *, seed: int) -> dict[str, np.ndarray]:861def _make_extended_record(n: int, *, seed: int) -> dict[str, np.ndarray]:
857 record = _make_record(n, seed=seed)862 record = _make_record(n, seed=seed)
Importance #60: tests/test_segment_points_io.py @@ -859,77 +864,79 @@
859 return record864 return record
860865
861866
862def test_registry_entry_alone_round_trips_a_new_field(867def test_registry_entry_alone_round_trips_a_new_field(
863 tmp_path: Path, extended_schema: tuple[str, ...]868 tmp_path: pathlib.Path, extended_schema: tuple[str, ...]
864) -> None:869) -> None:
865 record = _make_extended_record(4, seed=90)870 record = _make_extended_record(4, seed=90)
866871
867 path = save_points_npz(tmp_path / "extended_run3_points.npz", record)872 path = segment_points_io.save_points_npz(tmp_path / "extended_run3_points.npz", record)
868873
869 with np.load(path) as data:874 with np.load(path) as data:
870 assert EXTRA_KEY in data.files875 assert EXTRA_KEY in data.files
871 loaded = load_points_npz(path)876 loaded = segment_points_io.load_points_npz(path)
872 assert list(loaded) == list(extended_schema)877 assert list(loaded) == list(extended_schema)
873 assert loaded[EXTRA_KEY].dtype == np.int16878 assert loaded[EXTRA_KEY].dtype == np.int16
874 np.testing.assert_array_equal(loaded[EXTRA_KEY], record[EXTRA_KEY])879 np.testing.assert_array_equal(loaded[EXTRA_KEY], record[EXTRA_KEY])
875880
876881
877def test_registry_entry_alone_coerces_and_validates_a_new_field(882def test_registry_entry_alone_coerces_and_validates_a_new_field(
878 tmp_path: Path, extended_schema: tuple[str, ...]883 tmp_path: pathlib.Path, extended_schema: tuple[str, ...]
879) -> None:884) -> None:
880 record = _make_extended_record(3, seed=91)885 record = _make_extended_record(3, seed=91)
881 record[EXTRA_KEY] = record[EXTRA_KEY].astype(np.int64)886 record[EXTRA_KEY] = record[EXTRA_KEY].astype(np.int64)
882 loaded = load_points_npz(save_points_npz(tmp_path / "cast_run3_points.npz", record))887 loaded = segment_points_io.load_points_npz(
888 segment_points_io.save_points_npz(tmp_path / "cast_run3_points.npz", record)
889 )
883 assert loaded[EXTRA_KEY].dtype == np.int16890 assert loaded[EXTRA_KEY].dtype == np.int16
884891
885 out_of_range = _make_extended_record(3, seed=92)892 out_of_range = _make_extended_record(3, seed=92)
886 out_of_range[EXTRA_KEY] = np.array([1, 40_000, 3], dtype=np.int32)893 out_of_range[EXTRA_KEY] = np.array([1, 40_000, 3], dtype=np.int32)
887 with pytest.raises(ValueError, match=f"{EXTRA_KEY}.*fit in int16"):894 with pytest.raises(ValueError, match=f"{EXTRA_KEY}.*fit in int16"):
888 save_points_npz(tmp_path / "range_run3_points.npz", out_of_range)895 segment_points_io.save_points_npz(tmp_path / "range_run3_points.npz", out_of_range)
889896
890 boolean = _make_extended_record(3, seed=93)897 boolean = _make_extended_record(3, seed=93)
891 boolean[EXTRA_KEY] = np.array([True, False, True])898 boolean[EXTRA_KEY] = np.array([True, False, True])
892 with pytest.raises(ValueError, match=f"{EXTRA_KEY}.*bool"):899 with pytest.raises(ValueError, match=f"{EXTRA_KEY}.*bool"):
893 save_points_npz(tmp_path / "bool_run3_points.npz", boolean)900 segment_points_io.save_points_npz(tmp_path / "bool_run3_points.npz", boolean)
894901
895 misshaped = _make_extended_record(3, seed=94)902 misshaped = _make_extended_record(3, seed=94)
896 misshaped[EXTRA_KEY] = misshaped[EXTRA_KEY].reshape(3, 1)903 misshaped[EXTRA_KEY] = misshaped[EXTRA_KEY].reshape(3, 1)
897 with pytest.raises(ValueError, match=rf"'{EXTRA_KEY}' must have shape \(N,\)"):904 with pytest.raises(ValueError, match=rf"'{EXTRA_KEY}' must have shape \(N,\)"):
898 save_points_npz(tmp_path / "shape_run3_points.npz", misshaped)905 segment_points_io.save_points_npz(tmp_path / "shape_run3_points.npz", misshaped)
899906
900907
901def test_registry_entry_alone_fills_and_requires_a_new_field(908def test_registry_entry_alone_fills_and_requires_a_new_field(
902 tmp_path: Path, extended_schema: tuple[str, ...]909 tmp_path: pathlib.Path, extended_schema: tuple[str, ...]
903) -> None:910) -> None:
904 older = _make_record(5, seed=95)911 older = _make_record(5, seed=95)
905 path = tmp_path / "older_run3_points.npz"912 path = tmp_path / "older_run3_points.npz"
906 np.savez_compressed(path, **older)913 np.savez_compressed(path, **older)
907914
908 loaded = load_points_npz(path)915 loaded = segment_points_io.load_points_npz(path)
909916
910 assert list(loaded) == list(extended_schema)917 assert list(loaded) == list(extended_schema)
911 np.testing.assert_array_equal(loaded[EXTRA_KEY], np.full(5, -1, dtype=np.int16))918 np.testing.assert_array_equal(loaded[EXTRA_KEY], np.full(5, -1, dtype=np.int16))
912 with pytest.raises(ValueError, match=EXTRA_KEY):919 with pytest.raises(ValueError, match=EXTRA_KEY):
913 save_points_npz(tmp_path / "incomplete_run3_points.npz", older)920 segment_points_io.save_points_npz(tmp_path / "incomplete_run3_points.npz", older)
914921
915922
916def test_registry_entry_alone_flows_through_merge_mask_and_concat(923def test_registry_entry_alone_flows_through_merge_mask_and_concat(
917 tmp_path: Path, extended_schema: tuple[str, ...]924 tmp_path: pathlib.Path, extended_schema: tuple[str, ...]
918) -> None:925) -> None:
919 seg_dir = tmp_path / "segment_042"926 seg_dir = tmp_path / "segment_042"
920 seg_dir.mkdir()927 seg_dir.mkdir()
921 first = _make_extended_record(3, seed=96)928 first = _make_extended_record(3, seed=96)
922 second = _make_extended_record(2, seed=97)929 second = _make_extended_record(2, seed=97)
923 save_points_npz(seg_dir / "a_run3_points.npz", first)930 segment_points_io.save_points_npz(seg_dir / "a_run3_points.npz", first)
924 save_points_npz(seg_dir / "b_run3_points.npz", second)931 segment_points_io.save_points_npz(seg_dir / "b_run3_points.npz", second)
925932
926 merged, spans = load_run3_segment(seg_dir)933 merged, spans = segment_points_io.load_run3_segment(seg_dir)
927 segment_merged, point_file_ids, _ = load_segment_points(934 segment_merged, point_file_ids, _ = segment_points_io.load_segment_points(
928 [seg_dir / "a_run3_points.npz", seg_dir / "b_run3_points.npz"]935 [seg_dir / "a_run3_points.npz", seg_dir / "b_run3_points.npz"]
929 )936 )
930 masked = mask_record(merged, np.array([True, False, True, False, True]))937 masked = segment_points_io.mask_record(merged, np.array([True, False, True, False, True]))
931 joined = concat_records([first, second])938 joined = segment_points_io.concat_records([first, second])
932939
933 expected = np.concatenate([first[EXTRA_KEY], second[EXTRA_KEY]], axis=0)940 expected = np.concatenate([first[EXTRA_KEY], second[EXTRA_KEY]], axis=0)
934 assert [span.count for span in spans] == [3, 2]941 assert [span.count for span in spans] == [3, 2]
935 assert point_file_ids.shape == (5,)942 assert point_file_ids.shape == (5,)
Importance #61: tests/test_segment_points_io.py @@ -940,7 +947,7 @@
940947
941948
942def test_extended_schema_does_not_leak_into_the_real_contract() -> None:949def test_extended_schema_does_not_leak_into_the_real_contract() -> None:
943 """The monkeypatched registry above must not pollute the shipped schema."""950 """The monkeypatched registry above must not pollute the shipped schema."""
944 assert EXTRA_KEY not in POINT_RECORD_SCHEMA951 assert EXTRA_KEY not in _POINT_RECORD_SCHEMA_AT_IMPORT
945 assert EXTRA_KEY not in segment_points_io.POINT_RECORD_SCHEMA952 assert EXTRA_KEY not in segment_points_io.POINT_RECORD_SCHEMA
946 assert tuple(segment_points_io.POINT_RECORD_SCHEMA) == POINT_RECORD_KEYS953 assert tuple(segment_points_io.POINT_RECORD_SCHEMA) == _POINT_RECORD_KEYS_AT_IMPORT
Importance #62: src/iolabs/common/segment_points_io.py @@ -34,26 +34,26 @@
34and consumers working in the local frame ignore it. This module never34and consumers working in the local frame ignore it. This module never
35applies, negates, or bakes in a sign.35applies, negates, or bakes in a sign.
36"""36"""
3737
38import dataclasses
39import fnmatch
38import functools40import functools
39import json41import json
40import logging42import logging
43import pathlib
44import types
41import zipfile45import zipfile
42from collections.abc import Callable, Iterator, Mapping, Sequence46from collections import abc
43from dataclasses import dataclass
44from fnmatch import fnmatch
45from pathlib import Path
46from types import MappingProxyType
4747
48import numpy as np48import numpy as np
4949
50from . import _dtype_coercion50from . import _dtype_coercion
5151
52logger = logging.getLogger(__name__)52logger = logging.getLogger(__name__)
5353
54#: Builds a member for a record that predates its key, given the point count.54#: Builds a member for a record that predates its key, given the point count.
55FillFactory = Callable[[int], np.ndarray]55FillFactory = abc.Callable[[int], np.ndarray]
5656
57#: The point-record key whose row count defines ``N`` for every other member.57#: The point-record key whose row count defines ``N`` for every other member.
58POINTS_KEY = "points"58POINTS_KEY = "points"
5959
Importance #63: src/iolabs/common/segment_points_io.py @@ -63,9 +63,9 @@
63#: Storage dtype of :data:`NUMBER_OF_RETURNS_KEY` (LAS carries 3 bits, values 1-7).63#: Storage dtype of :data:`NUMBER_OF_RETURNS_KEY` (LAS carries 3 bits, values 1-7).
64NUMBER_OF_RETURNS_DTYPE = np.uint864NUMBER_OF_RETURNS_DTYPE = np.uint8
6565
6666
67@dataclass(frozen=True)67@dataclasses.dataclass(frozen=True)
68class PointFieldSpec:68class PointFieldSpec:
69 """How one member of the point record is validated, stored and synthesised.69 """How one member of the point record is validated, stored and synthesised.
7070
71 Attributes:71 Attributes:
Importance #64: src/iolabs/common/segment_points_io.py @@ -108,9 +108,9 @@
108108
109#: The point-record contract: ordered key -> spec. Adding a field to the NPZ109#: The point-record contract: ordered key -> spec. Adding a field to the NPZ
110#: schema means adding an entry here (plus teaching producers to emit it); the110#: schema means adding an entry here (plus teaching producers to emit it); the
111#: load/save/merge machinery below is entirely registry-driven.111#: load/save/merge machinery below is entirely registry-driven.
112POINT_RECORD_SCHEMA: Mapping[str, PointFieldSpec] = MappingProxyType(112POINT_RECORD_SCHEMA: abc.Mapping[str, PointFieldSpec] = types.MappingProxyType(
113 {113 {
114 POINTS_KEY: PointFieldSpec(required=True, columns=3, noun="coordinate"),114 POINTS_KEY: PointFieldSpec(required=True, columns=3, noun="coordinate"),
115 "red": PointFieldSpec(required=True),115 "red": PointFieldSpec(required=True),
116 "green": PointFieldSpec(required=True),116 "green": PointFieldSpec(required=True),
Importance #65: src/iolabs/common/segment_points_io.py @@ -127,9 +127,9 @@
127)127)
128128
129129
130def _schema_keys(130def _schema_keys(
131 schema: Mapping[str, PointFieldSpec], *, required: bool | None = None131 schema: abc.Mapping[str, PointFieldSpec], *, required: bool | None = None
132) -> tuple[str, ...]:132) -> tuple[str, ...]:
133 """List the schema's keys in registry order, optionally by required-ness."""133 """List the schema's keys in registry order, optionally by required-ness."""
134 return tuple(134 return tuple(
135 key for key, spec in schema.items() if required is None or spec.required is required135 key for key, spec in schema.items() if required is None or spec.required is required
Importance #66: src/iolabs/common/segment_points_io.py @@ -190,10 +190,10 @@
190 return "(N,)" if spec.columns is None else f"(N, {spec.columns})"190 return "(N,)" if spec.columns is None else f"(N, {spec.columns})"
191191
192192
193def _validate_record_shapes(193def _validate_record_shapes(
194 record: Mapping[str, np.ndarray],194 record: abc.Mapping[str, np.ndarray],
195 schema: Mapping[str, PointFieldSpec],195 schema: abc.Mapping[str, PointFieldSpec],
196 *,196 *,
197 source: str,197 source: str,
198) -> int:198) -> int:
199 """Check every present member against its spec's shape and return ``N``.199 """Check every present member against its spec's shape and return ``N``.
Importance #67: src/iolabs/common/segment_points_io.py @@ -227,9 +227,9 @@
227 )227 )
228 return n_points228 return n_points
229229
230230
231def load_points_npz(path: str | Path) -> PointRecord:231def load_points_npz(path: str | pathlib.Path) -> PointRecord:
232 """Load one ``*_run3_points.npz``-style file and validate the schema.232 """Load one ``*_run3_points.npz``-style file and validate the schema.
233233
234 Every required key of :data:`POINT_RECORD_SCHEMA` must be stored; ``points``234 Every required key of :data:`POINT_RECORD_SCHEMA` must be stored; ``points``
235 must be shape ``(N, 3)`` and every ancillary array (``red``, ``green``,235 must be shape ``(N, 3)`` and every ancillary array (``red``, ``green``,
Importance #68: src/iolabs/common/segment_points_io.py @@ -254,9 +254,9 @@
254 ValueError: A required key is missing, an array has the wrong shape, or254 ValueError: A required key is missing, an array has the wrong shape, or
255 a member with a declared storage dtype is stored with a255 a member with a declared storage dtype is stored with a
256 non-integer/bool dtype or values outside that dtype's range.256 non-integer/bool dtype or values outside that dtype's range.
257 """257 """
258 npz_path = Path(path)258 npz_path = pathlib.Path(path)
259 schema = POINT_RECORD_SCHEMA259 schema = POINT_RECORD_SCHEMA
260 required_keys = _schema_keys(schema, required=True)260 required_keys = _schema_keys(schema, required=True)
261 with np.load(npz_path) as data:261 with np.load(npz_path) as data:
262 missing = [key for key in required_keys if key not in data.files]262 missing = [key for key in required_keys if key not in data.files]
Importance #69: src/iolabs/common/segment_points_io.py @@ -285,9 +285,9 @@
285 record[key] = spec.fill(n_points)285 record[key] = spec.fill(n_points)
286 return {key: record[key] for key in schema}286 return {key: record[key] for key in schema}
287287
288288
289def save_points_npz(path: str | Path, record: Mapping[str, np.ndarray]) -> Path:289def save_points_npz(path: str | pathlib.Path, record: abc.Mapping[str, np.ndarray]) -> pathlib.Path:
290 """Write a point record as a compressed NPZ (inverse of :func:`load_points_npz`).290 """Write a point record as a compressed NPZ (inverse of :func:`load_points_npz`).
291291
292 Every key in :data:`POINT_RECORD_KEYS` must be present, ``number_of_returns``292 Every key in :data:`POINT_RECORD_KEYS` must be present, ``number_of_returns``
293 included: it is optional on load only, to read datasets that predate it.293 included: it is optional on load only, to read datasets that predate it.
Importance #70: src/iolabs/common/segment_points_io.py @@ -303,9 +303,9 @@
303 Raises:303 Raises:
304 ValueError: A key is missing, an array has the wrong shape, or a member304 ValueError: A key is missing, an array has the wrong shape, or a member
305 with a declared storage dtype cannot be cast to it losslessly.305 with a declared storage dtype cannot be cast to it losslessly.
306 """306 """
307 npz_path = Path(path)307 npz_path = pathlib.Path(path)
308 schema = POINT_RECORD_SCHEMA308 schema = POINT_RECORD_SCHEMA
309 keys = _schema_keys(schema)309 keys = _schema_keys(schema)
310 missing = [key for key in keys if key not in record]310 missing = [key for key in keys if key not in record]
311 if missing:311 if missing:
Importance #71: src/iolabs/common/segment_points_io.py @@ -323,9 +323,9 @@
323 np.savez_compressed(npz_path, **payload)323 np.savez_compressed(npz_path, **payload)
324 return npz_path324 return npz_path
325325
326326
327def mask_record(record: Mapping[str, np.ndarray], mask: np.ndarray) -> PointRecord:327def mask_record(record: abc.Mapping[str, np.ndarray], mask: np.ndarray) -> PointRecord:
328 """Select the same points from every member of a point record.328 """Select the same points from every member of a point record.
329329
330 Schema-agnostic: whatever keys the record carries are all indexed with330 Schema-agnostic: whatever keys the record carries are all indexed with
331 *mask* along axis 0, so ``points`` keeps its ``(n, 3)`` rows while the331 *mask* along axis 0, so ``points`` keeps its ``(n, 3)`` rows while the
Importance #72: src/iolabs/common/segment_points_io.py @@ -367,9 +367,9 @@
367 )367 )
368 return {key: array[mask] for key, array in arrays.items()}368 return {key: array[mask] for key, array in arrays.items()}
369369
370370
371def concat_records(records: Sequence[Mapping[str, np.ndarray]]) -> PointRecord:371def concat_records(records: abc.Sequence[abc.Mapping[str, np.ndarray]]) -> PointRecord:
372 """Concatenate point records member-by-member along axis 0.372 """Concatenate point records member-by-member along axis 0.
373373
374 Schema-agnostic: every key of the first record is concatenated across all374 Schema-agnostic: every key of the first record is concatenated across all
375 records, so ``points`` grows by rows and the ancillary members by elements.375 records, so ``points`` grows by rows and the ancillary members by elements.
Importance #73: src/iolabs/common/segment_points_io.py @@ -408,17 +408,17 @@
408 }408 }
409409
410410
411def load_segment_points(411def load_segment_points(
412 files: Sequence[str | Path],412 files: abc.Sequence[str | pathlib.Path],
413) -> tuple[PointRecord, np.ndarray, list[str]]:413) -> tuple[PointRecord, np.ndarray, list[str]]:
414 """Load and concatenate multiple point NPZs for one segment.414 """Load and concatenate multiple point NPZs for one segment.
415415
416 Returns ``(merged_record, point_file_ids, file_stems)`` where416 Returns ``(merged_record, point_file_ids, file_stems)`` where
417 ``point_file_ids[i]`` is the index into *files* for merged point ``i``,417 ``point_file_ids[i]`` is the index into *files* for merged point ``i``,
418 and ``file_stems`` holds ``Path(f).stem`` for each input in order.418 and ``file_stems`` holds ``Path(f).stem`` for each input in order.
419 """419 """
420 npz_files = [Path(path) for path in files]420 npz_files = [pathlib.Path(path) for path in files]
421 if not npz_files:421 if not npz_files:
422 raise FileNotFoundError("No *_points.npz files provided for segment load")422 raise FileNotFoundError("No *_points.npz files provided for segment load")
423423
424 records: list[PointRecord] = []424 records: list[PointRecord] = []
Importance #74: src/iolabs/common/segment_points_io.py @@ -441,9 +441,9 @@
441 return merged, point_file_ids, file_stems441 return merged, point_file_ids, file_stems
442442
443443
444def geoshift_from_mapping(444def geoshift_from_mapping(
445 mapping: Mapping[str, object],445 mapping: abc.Mapping[str, object],
446 *,446 *,
447 source: str = "geoshift mapping",447 source: str = "geoshift mapping",
448) -> np.ndarray:448) -> np.ndarray:
449 """Convert an already-parsed geoshift mapping to a shape ``(3,)`` float64 array.449 """Convert an already-parsed geoshift mapping to a shape ``(3,)`` float64 array.
Importance #75: src/iolabs/common/segment_points_io.py @@ -464,16 +464,16 @@
464 Raises:464 Raises:
465 ValueError: If *mapping* is not an object, a key is missing, or a465 ValueError: If *mapping* is not an object, a key is missing, or a
466 value is not numeric.466 value is not numeric.
467 """467 """
468 if not isinstance(mapping, Mapping):468 if not isinstance(mapping, abc.Mapping):
469 raise ValueError(469 raise ValueError(
470 f"{source}: geoshift JSON must be an object with keys x/y/z, "470 f"{source}: geoshift JSON must be an object with keys x/y/z, "
471 f"got {type(mapping).__name__}"471 f"got {type(mapping).__name__}"
472 )472 )
473 values: Mapping[str, object] = mapping473 values: abc.Mapping[str, object] = mapping
474 nested = mapping.get("geoshift")474 nested = mapping.get("geoshift")
475 if isinstance(nested, Mapping):475 if isinstance(nested, abc.Mapping):
476 values = nested476 values = nested
477477
478 missing = [key for key in ("x", "y", "z") if key not in values]478 missing = [key for key in ("x", "y", "z") if key not in values]
479 if missing:479 if missing:
Importance #76: src/iolabs/common/segment_points_io.py @@ -489,24 +489,24 @@
489 f"{[values['x'], values['y'], values['z']]!r}"489 f"{[values['x'], values['y'], values['z']]!r}"
490 ) from exc490 ) from exc
491491
492492
493def load_geoshift(path: str | Path) -> np.ndarray:493def load_geoshift(path: str | pathlib.Path) -> np.ndarray:
494 """Load a Step-3 ``run3_geoshift.json`` as a shape ``(3,)`` float64 array.494 """Load a Step-3 ``run3_geoshift.json`` as a shape ``(3,)`` float64 array.
495495
496 Expected JSON layout (written by segmentation-trajectory SegmentMapper)::496 Expected JSON layout (written by segmentation-trajectory SegmentMapper)::
497497
498 {"x": float, "y": float, "z": float}498 {"x": float, "y": float, "z": float}
499499
500 The value is returned as recorded; no sign is applied (see module docstring).500 The value is returned as recorded; no sign is applied (see module docstring).
501 """501 """
502 geoshift_path = Path(path)502 geoshift_path = pathlib.Path(path)
503 with geoshift_path.open(encoding="utf-8") as handle:503 with geoshift_path.open(encoding="utf-8") as handle:
504 data = json.load(handle)504 data = json.load(handle)
505 return geoshift_from_mapping(data, source=str(geoshift_path))505 return geoshift_from_mapping(data, source=str(geoshift_path))
506506
507507
508def geoshift_candidate_paths(directory: str | Path) -> list[Path]:508def geoshift_candidate_paths(directory: str | pathlib.Path) -> list[pathlib.Path]:
509 """List the ``run3_geoshift.json`` paths the fleet's three conventions use.509 """List the ``run3_geoshift.json`` paths the fleet's three conventions use.
510510
511 In lookup order:511 In lookup order:
512512
Importance #77: src/iolabs/common/segment_points_io.py @@ -523,17 +523,17 @@
523523
524 Returns:524 Returns:
525 The candidate paths in lookup order (existence is not checked).525 The candidate paths in lookup order (existence is not checked).
526 """526 """
527 base = Path(directory)527 base = pathlib.Path(directory)
528 return [528 return [
529 base / GEOSHIFT_NAME,529 base / GEOSHIFT_NAME,
530 base / LANE_POINTS_DIR_NAME / GEOSHIFT_NAME,530 base / LANE_POINTS_DIR_NAME / GEOSHIFT_NAME,
531 base.parent / GEOSHIFT_NAME,531 base.parent / GEOSHIFT_NAME,
532 ]532 ]
533533
534534
535def find_geoshift_or_none(directory: str | Path) -> np.ndarray | None:535def find_geoshift_or_none(directory: str | pathlib.Path) -> np.ndarray | None:
536 """Find and load a geoshift near *directory*, or return ``None``.536 """Find and load a geoshift near *directory*, or return ``None``.
537537
538 Searches :func:`geoshift_candidate_paths` in order and loads the first538 Searches :func:`geoshift_candidate_paths` in order and loads the first
539 existing file. Datasets processed without a geoshift have no such file and539 existing file. Datasets processed without a geoshift have no such file and
Importance #78: src/iolabs/common/segment_points_io.py @@ -561,9 +561,9 @@
561 logger.debug("Using geoshift %s for %s", existing[0], directory)561 logger.debug("Using geoshift %s for %s", existing[0], directory)
562 return load_geoshift(existing[0])562 return load_geoshift(existing[0])
563563
564564
565def find_geoshift(directory: str | Path) -> np.ndarray:565def find_geoshift(directory: str | pathlib.Path) -> np.ndarray:
566 """Find and load a geoshift near *directory*, raising when absent.566 """Find and load a geoshift near *directory*, raising when absent.
567567
568 Args:568 Args:
569 directory: Dataset root, ``lane_points`` directory, or segment directory.569 directory: Dataset root, ``lane_points`` directory, or segment directory.
Importance #79: src/iolabs/common/segment_points_io.py @@ -603,12 +603,12 @@
603 ) from exc603 ) from exc
604604
605605
606def _normalize_file_patterns(file_patterns: object, *, source: str) -> list[str]:606def _normalize_file_patterns(file_patterns: object, *, source: str) -> list[str]:
607 raw_patterns: Sequence[object]607 raw_patterns: abc.Sequence[object]
608 if isinstance(file_patterns, str):608 if isinstance(file_patterns, str):
609 raw_patterns = [file_patterns]609 raw_patterns = [file_patterns]
610 elif isinstance(file_patterns, Sequence):610 elif isinstance(file_patterns, abc.Sequence):
611 raw_patterns = file_patterns611 raw_patterns = file_patterns
612 else:612 else:
613 raise ValueError(613 raise ValueError(
614 f"{source} must be a string or a sequence of strings, got "614 f"{source} must be a string or a sequence of strings, got "
Importance #80: src/iolabs/common/segment_points_io.py @@ -623,9 +623,9 @@
623 return normalized623 return normalized
624624
625625
626def normalize_segment_file_blacklist(626def normalize_segment_file_blacklist(
627 mapping: Mapping[object, object] | None,627 mapping: abc.Mapping[object, object] | None,
628) -> dict[int, list[str]]:628) -> dict[int, list[str]]:
629 """Normalize a per-segment fnmatch blacklist mapping.629 """Normalize a per-segment fnmatch blacklist mapping.
630630
631 Keys may be ints or ``segment_<idx>`` strings. Values are a string or a631 Keys may be ints or ``segment_<idx>`` strings. Values are a string or a
Importance #81: src/iolabs/common/segment_points_io.py @@ -633,9 +633,9 @@
633 the same segment are merged and sorted.633 the same segment are merged and sorted.
634 """634 """
635 if mapping is None:635 if mapping is None:
636 return {}636 return {}
637 if not isinstance(mapping, Mapping):637 if not isinstance(mapping, abc.Mapping):
638 raise ValueError("npz_blacklist_by_segment must be a mapping of segment to files")638 raise ValueError("npz_blacklist_by_segment must be a mapping of segment to files")
639639
640 normalized: dict[int, set[str]] = {}640 normalized: dict[int, set[str]] = {}
641 for segment_key, file_patterns in mapping.items():641 for segment_key, file_patterns in mapping.items():
Importance #82: src/iolabs/common/segment_points_io.py @@ -653,27 +653,27 @@
653 }653 }
654654
655655
656def filter_segment_files(656def filter_segment_files(
657 files: Sequence[str | Path],657 files: abc.Sequence[str | pathlib.Path],
658 segment_index: int,658 segment_index: int,
659 blacklist: Mapping[int, Sequence[str]],659 blacklist: abc.Mapping[int, abc.Sequence[str]],
660) -> tuple[list[Path], list[Path]]:660) -> tuple[list[pathlib.Path], list[pathlib.Path]]:
661 """Split input files into kept and blacklisted buckets via fnmatch.661 """Split input files into kept and blacklisted buckets via fnmatch.
662662
663 Patterns from ``blacklist[segment_index]`` are matched against both the663 Patterns from ``blacklist[segment_index]`` are matched against both the
664 file name and the full POSIX path. Returns ``(kept_files, excluded_files)``.664 file name and the full POSIX path. Returns ``(kept_files, excluded_files)``.
665 """665 """
666 kept_files: list[Path] = []666 kept_files: list[pathlib.Path] = []
667 excluded_files: list[Path] = []667 excluded_files: list[pathlib.Path] = []
668 file_patterns = list(blacklist.get(segment_index, []))668 file_patterns = list(blacklist.get(segment_index, []))
669669
670 for raw_path in files:670 for raw_path in files:
671 npz_path = Path(raw_path)671 npz_path = pathlib.Path(raw_path)
672 path_text = npz_path.as_posix()672 path_text = npz_path.as_posix()
673 file_name = npz_path.name673 file_name = npz_path.name
674 is_blacklisted = any(674 is_blacklisted = any(
675 fnmatch(file_name, pattern) or fnmatch(path_text, pattern)675 fnmatch.fnmatch(file_name, pattern) or fnmatch.fnmatch(path_text, pattern)
676 for pattern in file_patterns676 for pattern in file_patterns
677 )677 )
678 if is_blacklisted:678 if is_blacklisted:
679 excluded_files.append(npz_path)679 excluded_files.append(npz_path)
Importance #83: src/iolabs/common/segment_points_io.py @@ -695,9 +695,9 @@
695 )695 )
696 return kept_files, excluded_files696 return kept_files, excluded_files
697697
698698
699def read_points_header(path: str | Path) -> tuple[int, bool]:699def read_points_header(path: str | pathlib.Path) -> tuple[int, bool]:
700 """Return ``(row_count, chunk_streamable)`` for a record's ``points.npy`` member.700 """Return ``(row_count, chunk_streamable)`` for a record's ``points.npy`` member.
701701
702 Reads only the NPY header inside the NPZ ZIP container, so the point data702 Reads only the NPY header inside the NPZ ZIP container, so the point data
703 is never materialised. Streaming needs an uncompressed (``ZIP_STORED``)703 is never materialised. Streaming needs an uncompressed (``ZIP_STORED``)
Importance #84: src/iolabs/common/segment_points_io.py @@ -710,9 +710,9 @@
710 Returns:710 Returns:
711 ``(rows, streamable)``. ``rows`` is ``-1`` when the header is711 ``(rows, streamable)``. ``rows`` is ``-1`` when the header is
712 unreadable (missing member, corrupt archive, unsupported NPY version).712 unreadable (missing member, corrupt archive, unsupported NPY version).
713 """713 """
714 npz_path = Path(path)714 npz_path = pathlib.Path(path)
715 try:715 try:
716 with zipfile.ZipFile(npz_path) as archive:716 with zipfile.ZipFile(npz_path) as archive:
717 info = archive.getinfo("points.npy")717 info = archive.getinfo("points.npy")
718 stored = info.compress_type == zipfile.ZIP_STORED718 stored = info.compress_type == zipfile.ZIP_STORED
Importance #85: src/iolabs/common/segment_points_io.py @@ -733,9 +733,9 @@
733 # caught here so a partial write degrades to "unreadable" instead.733 # caught here so a partial write degrades to "unreadable" instead.
734 return -1, False734 return -1, False
735735
736736
737def _iter_points_chunks_streamed(path: Path, chunk_points: int) -> Iterator[np.ndarray]:737def _iter_points_chunks_streamed(path: pathlib.Path, chunk_points: int) -> abc.Iterator[np.ndarray]:
738 """Stream the ``points.npy`` member of a ZIP_STORED npz in row chunks."""738 """Stream the ``points.npy`` member of a ZIP_STORED npz in row chunks."""
739 with zipfile.ZipFile(path) as archive, archive.open("points.npy") as handle:739 with zipfile.ZipFile(path) as archive, archive.open("points.npy") as handle:
740 version = np.lib.format.read_magic(handle)740 version = np.lib.format.read_magic(handle)
741 if version == (1, 0):741 if version == (1, 0):
Importance #86: src/iolabs/common/segment_points_io.py @@ -753,9 +753,9 @@
753 buffer = handle.read(count * row_bytes)753 buffer = handle.read(count * row_bytes)
754 yield np.frombuffer(buffer, dtype=dtype).reshape(count, cols).copy()754 yield np.frombuffer(buffer, dtype=dtype).reshape(count, cols).copy()
755755
756756
757def iter_points_chunks(path: str | Path, chunk_points: int) -> Iterator[np.ndarray]:757def iter_points_chunks(path: str | pathlib.Path, chunk_points: int) -> abc.Iterator[np.ndarray]:
758 """Yield a record's ``points`` rows, chunked when the member is streamable.758 """Yield a record's ``points`` rows, chunked when the member is streamable.
759759
760 Records at or below *chunk_points* rows -- and any record whose760 Records at or below *chunk_points* rows -- and any record whose
761 ``points.npy`` member is compressed, Fortran-ordered or otherwise761 ``points.npy`` member is compressed, Fortran-ordered or otherwise
Importance #87: src/iolabs/common/segment_points_io.py @@ -774,9 +774,9 @@
774 Yields:774 Yields:
775 ``(rows_i, 3)`` point chunks in file order; the final chunk holds the775 ``(rows_i, 3)`` point chunks in file order; the final chunk holds the
776 remainder and may be shorter.776 remainder and may be shorter.
777 """777 """
778 npz_path = Path(path)778 npz_path = pathlib.Path(path)
779 rows, streamable = read_points_header(npz_path)779 rows, streamable = read_points_header(npz_path)
780 if streamable and chunk_points > 0 and rows > chunk_points:780 if streamable and chunk_points > 0 and rows > chunk_points:
781 logger.info(781 logger.info(
782 "%s: streaming %d points in chunks of %d",782 "%s: streaming %d points in chunks of %d",
Importance #88: src/iolabs/common/segment_points_io.py @@ -789,9 +789,9 @@
789 with np.load(npz_path) as data:789 with np.load(npz_path) as data:
790 yield np.asarray(data["points"])790 yield np.asarray(data["points"])
791791
792792
793@dataclass(frozen=True)793@dataclasses.dataclass(frozen=True)
794class RecordSpan:794class RecordSpan:
795 """One input record's identity and row range within a concatenated cloud.795 """One input record's identity and row range within a concatenated cloud.
796796
797 Attributes:797 Attributes:
Importance #89: src/iolabs/common/segment_points_io.py @@ -809,9 +809,9 @@
809 """One past the last global row index contributed by this file."""809 """One past the last global row index contributed by this file."""
810 return self.offset + self.count810 return self.offset + self.count
811811
812812
813def discover_run3_files(segment_dir: str | Path) -> list[Path]:813def discover_run3_files(segment_dir: str | pathlib.Path) -> list[pathlib.Path]:
814 """List a segment directory's ``*_run3_points.npz`` records in load order.814 """List a segment directory's ``*_run3_points.npz`` records in load order.
815815
816 Partial writes carry a trailing marker suffix (``*.npz.part`` and friends);816 Partial writes carry a trailing marker suffix (``*.npz.part`` and friends);
817 those are skipped rather than handed to a loader that would crash on them.817 those are skipped rather than handed to a loader that would crash on them.
Importance #90: src/iolabs/common/segment_points_io.py @@ -822,14 +822,14 @@
822 Returns:822 Returns:
823 Sorted, complete record paths. Empty when the directory is missing or823 Sorted, complete record paths. Empty when the directory is missing or
824 holds no records.824 holds no records.
825 """825 """
826 seg_dir = Path(segment_dir)826 seg_dir = pathlib.Path(segment_dir)
827 if not seg_dir.is_dir():827 if not seg_dir.is_dir():
828 logger.warning("Segment directory missing: %s", seg_dir)828 logger.warning("Segment directory missing: %s", seg_dir)
829 return []829 return []
830830
831 found: list[Path] = []831 found: list[pathlib.Path] = []
832 for path in sorted(seg_dir.glob(f"{RUN3_POINTS_GLOB}*")):832 for path in sorted(seg_dir.glob(f"{RUN3_POINTS_GLOB}*")):
833 if not path.name.endswith(RUN3_POINTS_SUFFIX):833 if not path.name.endswith(RUN3_POINTS_SUFFIX):
834 logger.info("Skipping incomplete/partial run3 record: %s", path.name)834 logger.info("Skipping incomplete/partial run3 record: %s", path.name)
835 continue835 continue
Importance #91: src/iolabs/common/segment_points_io.py @@ -837,11 +837,11 @@
837 return found837 return found
838838
839839
840def concat_points_npz(840def concat_points_npz(
841 files: Sequence[str | Path],841 files: abc.Sequence[str | pathlib.Path],
842 *,842 *,
843 target_dtypes: Mapping[str, np.dtype] | None = None,843 target_dtypes: abc.Mapping[str, np.dtype] | None = None,
844) -> tuple[PointRecord, list[RecordSpan]]:844) -> tuple[PointRecord, list[RecordSpan]]:
845 """Concatenate point records after validating a consistent schema.845 """Concatenate point records after validating a consistent schema.
846846
847 Each file must satisfy the :func:`load_points_npz` contract. By default847 Each file must satisfy the :func:`load_points_npz` contract. By default
Importance #92: src/iolabs/common/segment_points_io.py @@ -872,9 +872,9 @@
872 FileNotFoundError: If *files* is empty.872 FileNotFoundError: If *files* is empty.
873 ValueError: If a file violates the point-record contract or its dtypes873 ValueError: If a file violates the point-record contract or its dtypes
874 disagree with the first file's (for keys without a target dtype).874 disagree with the first file's (for keys without a target dtype).
875 """875 """
876 npz_files = [Path(path) for path in files]876 npz_files = [pathlib.Path(path) for path in files]
877 if not npz_files:877 if not npz_files:
878 raise FileNotFoundError("No *_points.npz files provided for concatenation")878 raise FileNotFoundError("No *_points.npz files provided for concatenation")
879879
880 casts: dict[str, np.dtype] = {880 casts: dict[str, np.dtype] = {
Importance #93: src/iolabs/common/segment_points_io.py @@ -911,11 +911,11 @@
911 return concat_records(records), spans911 return concat_records(records), spans
912912
913913
914def load_run3_segment(914def load_run3_segment(
915 segment_dir: str | Path,915 segment_dir: str | pathlib.Path,
916 *,916 *,
917 target_dtypes: Mapping[str, np.dtype] | None = None,917 target_dtypes: abc.Mapping[str, np.dtype] | None = None,
918) -> tuple[PointRecord, list[RecordSpan]]:918) -> tuple[PointRecord, list[RecordSpan]]:
919 """Discover and concatenate one segment's run3 records.919 """Discover and concatenate one segment's run3 records.
920920
921 Combines :func:`discover_run3_files` (sorted glob, ``.part`` skipped) with921 Combines :func:`discover_run3_files` (sorted glob, ``.part`` skipped) with
Importance #94: src/iolabs/common/segment_points_io.py @@ -931,9 +931,9 @@
931 Raises:931 Raises:
932 FileNotFoundError: If the directory holds no complete run3 record.932 FileNotFoundError: If the directory holds no complete run3 record.
933 ValueError: If the records' schema or dtypes disagree.933 ValueError: If the records' schema or dtypes disagree.
934 """934 """
935 seg_dir = Path(segment_dir)935 seg_dir = pathlib.Path(segment_dir)
936 files = discover_run3_files(seg_dir)936 files = discover_run3_files(seg_dir)
937 if not files:937 if not files:
938 raise FileNotFoundError(f"No {RUN3_POINTS_GLOB} in {seg_dir}")938 raise FileNotFoundError(f"No {RUN3_POINTS_GLOB} in {seg_dir}")
939 return concat_points_npz(files, target_dtypes=target_dtypes)939 return concat_points_npz(files, target_dtypes=target_dtypes)
Importance #95: tests/test_color_intensity_data.py @@ -1,16 +1,16 @@
1"""Tests for ColorIntensityData selection and concatenation operations."""1"""Tests for ColorIntensityData selection and concatenation operations."""
2from dataclasses import dataclass, field2import dataclasses
33
4import numpy as np4import numpy as np
5import pytest5import pytest
66
7from iolabs.common.color_intensity_data import ColorIntensityData7from iolabs.common import color_intensity_data
88
99
10def _make_sample(n: int = 5, offset: int = 0) -> ColorIntensityData:10def _make_sample(n: int = 5, offset: int = 0) -> color_intensity_data.ColorIntensityData:
11 """Create a sample ColorIntensityData with n points and optional array offset."""11 """Create a sample ColorIntensityData with n points and optional array offset."""
12 return ColorIntensityData(12 return color_intensity_data.ColorIntensityData(
13 red=np.arange(offset, offset + n, dtype=np.uint8),13 red=np.arange(offset, offset + n, dtype=np.uint8),
14 green=np.arange(offset + 10, offset + 10 + n, dtype=np.uint8),14 green=np.arange(offset + 10, offset + 10 + n, dtype=np.uint8),
15 blue=np.arange(offset + 20, offset + 20 + n, dtype=np.uint8),15 blue=np.arange(offset + 20, offset + 20 + n, dtype=np.uint8),
16 intensity=np.arange(offset + 100, offset + 100 + n, dtype=np.float64),16 intensity=np.arange(offset + 100, offset + 100 + n, dtype=np.float64),
Importance #96: tests/test_color_intensity_data.py @@ -85,9 +85,9 @@
85 """Tests for the AI3D-382 number_of_returns field."""85 """Tests for the AI3D-382 number_of_returns field."""
8686
87 def test_defaults_to_zeros_when_omitted(self):87 def test_defaults_to_zeros_when_omitted(self):
88 """Callers predating AI3D-382 get 0 (unknown) per point, never 1."""88 """Callers predating AI3D-382 get 0 (unknown) per point, never 1."""
89 data = ColorIntensityData(89 data = color_intensity_data.ColorIntensityData(
90 red=np.zeros(4, dtype=np.uint8),90 red=np.zeros(4, dtype=np.uint8),
91 green=np.zeros(4, dtype=np.uint8),91 green=np.zeros(4, dtype=np.uint8),
92 blue=np.zeros(4, dtype=np.uint8),92 blue=np.zeros(4, dtype=np.uint8),
93 intensity=np.zeros(4, dtype=np.float64),93 intensity=np.zeros(4, dtype=np.float64),
Importance #97: tests/test_color_intensity_data.py @@ -99,9 +99,9 @@
99 np.testing.assert_array_equal(data.number_of_returns, np.zeros(4, dtype=np.uint8))99 np.testing.assert_array_equal(data.number_of_returns, np.zeros(4, dtype=np.uint8))
100100
101 def test_zero_fill_survives_mask_and_append(self):101 def test_zero_fill_survives_mask_and_append(self):
102 """A defaulted field stays aligned through the mask/concat paths."""102 """A defaulted field stays aligned through the mask/concat paths."""
103 legacy = ColorIntensityData(103 legacy = color_intensity_data.ColorIntensityData(
104 red=np.zeros(3, dtype=np.uint8),104 red=np.zeros(3, dtype=np.uint8),
105 green=np.zeros(3, dtype=np.uint8),105 green=np.zeros(3, dtype=np.uint8),
106 blue=np.zeros(3, dtype=np.uint8),106 blue=np.zeros(3, dtype=np.uint8),
107 intensity=np.zeros(3, dtype=np.float64),107 intensity=np.zeros(3, dtype=np.float64),
Importance #98: tests/test_color_intensity_data.py @@ -123,10 +123,10 @@
123123
124 def test_extra_field_flows_through_mask_and_append(self):124 def test_extra_field_flows_through_mask_and_append(self):
125 """A subclass field is masked and concatenated by the generic transforms."""125 """A subclass field is masked and concatenated by the generic transforms."""
126126
127 @dataclass127 @dataclasses.dataclass
128 class WithClassification(ColorIntensityData):128 class WithClassification(color_intensity_data.ColorIntensityData):
129 classification: np.ndarray | None = None129 classification: np.ndarray | None = None
130130
131 def _make(n: int, offset: int) -> WithClassification:131 def _make(n: int, offset: int) -> WithClassification:
132 base = _make_sample(n, offset)132 base = _make_sample(n, offset)
Importance #99: tests/test_color_intensity_data.py @@ -160,11 +160,11 @@
160160
161 def test_non_init_subclass_field_is_not_passed_to_the_constructor(self):161 def test_non_init_subclass_field_is_not_passed_to_the_constructor(self):
162 """A derived ``init=False`` field must not break the generic transforms."""162 """A derived ``init=False`` field must not break the generic transforms."""
163163
164 @dataclass164 @dataclasses.dataclass
165 class WithPointCount(ColorIntensityData):165 class WithPointCount(color_intensity_data.ColorIntensityData):
166 point_count: int = field(init=False, default=0)166 point_count: int = dataclasses.field(init=False, default=0)
167167
168 def __post_init__(self) -> None:168 def __post_init__(self) -> None:
169 super().__post_init__()169 super().__post_init__()
170 self.point_count = len(self.red)170 self.point_count = len(self.red)
Importance #100: tests/test_color_intensity_data.py @@ -195,10 +195,10 @@
195195
196 def test_subclass_may_declare_a_required_field(self):196 def test_subclass_may_declare_a_required_field(self):
197 """A trailing defaulted field would make a required subclass field a TypeError."""197 """A trailing defaulted field would make a required subclass field a TypeError."""
198198
199 @dataclass199 @dataclasses.dataclass
200 class WithRequiredClassification(ColorIntensityData):200 class WithRequiredClassification(color_intensity_data.ColorIntensityData):
201 classification: np.ndarray201 classification: np.ndarray
202202
203 data = WithRequiredClassification(203 data = WithRequiredClassification(
204 red=np.zeros(3, dtype=np.uint8),204 red=np.zeros(3, dtype=np.uint8),
Importance #101: tests/test_color_intensity_data.py @@ -217,9 +217,9 @@
217 assert len(merged.classification) == 5217 assert len(merged.classification) == 5
218218
219 def test_pre_ai3d_382_fields_still_take_positional_args(self):219 def test_pre_ai3d_382_fields_still_take_positional_args(self):
220 """The five original fields keep their positional order for old call sites."""220 """The five original fields keep their positional order for old call sites."""
221 data = ColorIntensityData(221 data = color_intensity_data.ColorIntensityData(
222 np.zeros(2, dtype=np.uint8),222 np.zeros(2, dtype=np.uint8),
223 np.zeros(2, dtype=np.uint8),223 np.zeros(2, dtype=np.uint8),
224 np.zeros(2, dtype=np.uint8),224 np.zeros(2, dtype=np.uint8),
225 np.zeros(2, dtype=np.float64),225 np.zeros(2, dtype=np.float64),
Importance #102: tests/test_color_intensity_data.py @@ -230,9 +230,9 @@
230230
231 def test_number_of_returns_is_not_positional(self):231 def test_number_of_returns_is_not_positional(self):
232 """Passing it as a sixth positional arg is a TypeError, not a silent mismatch."""232 """Passing it as a sixth positional arg is a TypeError, not a silent mismatch."""
233 with pytest.raises(TypeError):233 with pytest.raises(TypeError):
234 ColorIntensityData(234 color_intensity_data.ColorIntensityData(
235 np.zeros(2, dtype=np.uint8),235 np.zeros(2, dtype=np.uint8),
236 np.zeros(2, dtype=np.uint8),236 np.zeros(2, dtype=np.uint8),
237 np.zeros(2, dtype=np.uint8),237 np.zeros(2, dtype=np.uint8),
238 np.zeros(2, dtype=np.float64),238 np.zeros(2, dtype=np.float64),
Importance #103: tests/test_color_intensity_data.py @@ -244,11 +244,11 @@
244class TestNumberOfReturnsDtypeContract:244class TestNumberOfReturnsDtypeContract:
245 """The constructor enforces the same uint8 contract as segment_points_io."""245 """The constructor enforces the same uint8 contract as segment_points_io."""
246246
247 @staticmethod247 @staticmethod
248 def _make(number_of_returns: np.ndarray) -> ColorIntensityData:248 def _make(number_of_returns: np.ndarray) -> color_intensity_data.ColorIntensityData:
249 n = len(number_of_returns)249 n = len(number_of_returns)
250 return ColorIntensityData(250 return color_intensity_data.ColorIntensityData(
251 red=np.zeros(n, dtype=np.uint8),251 red=np.zeros(n, dtype=np.uint8),
252 green=np.zeros(n, dtype=np.uint8),252 green=np.zeros(n, dtype=np.uint8),
253 blue=np.zeros(n, dtype=np.uint8),253 blue=np.zeros(n, dtype=np.uint8),
254 intensity=np.zeros(n, dtype=np.float64),254 intensity=np.zeros(n, dtype=np.float64),
Importance #104: tests/test_segment_points_io.py @@ -2,43 +2,21 @@
22
3import functools3import functools
4import json4import json
5import logging5import logging
6from pathlib import Path6import pathlib
7from types import MappingProxyType7import types
88
9import numpy as np9import numpy as np
10import pytest10import pytest
1111
12from iolabs.common import segment_points_io12from iolabs.common import segment_points_io
13from iolabs.common.segment_points_io import (13
14 GEOSHIFT_NAME,14# Import-time snapshot of the shipped registry object. Monkeypatching the
15 NUMBER_OF_RETURNS_KEY,15# module attribute must not mutate this binding; the leak test below checks
16 OPTIONAL_POINT_RECORD_KEYS,16# both this snapshot and the live ``segment_points_io.POINT_RECORD_SCHEMA``.
17 POINT_RECORD_KEYS,17_POINT_RECORD_SCHEMA_AT_IMPORT = segment_points_io.POINT_RECORD_SCHEMA
18 POINT_RECORD_SCHEMA,18_POINT_RECORD_KEYS_AT_IMPORT = segment_points_io.POINT_RECORD_KEYS
19 REQUIRED_POINT_RECORD_KEYS,
20 PointFieldSpec,
21 RecordSpan,
22 concat_points_npz,
23 concat_records,
24 discover_run3_files,
25 filter_segment_files,
26 find_geoshift,
27 find_geoshift_or_none,
28 geoshift_candidate_paths,
29 geoshift_from_mapping,
30 iter_points_chunks,
31 load_geoshift,
32 load_points_npz,
33 load_run3_segment,
34 load_segment_points,
35 mask_record,
36 normalize_segment_file_blacklist,
37 parse_segment_key,
38 read_points_header,
39 save_points_npz,
40)
4119
4220
43def _make_record(n: int, *, seed: int = 0) -> dict[str, np.ndarray]:21def _make_record(n: int, *, seed: int = 0) -> dict[str, np.ndarray]:
44 rng = np.random.default_rng(seed)22 rng = np.random.default_rng(seed)
Importance #105: tests/test_segment_points_io.py @@ -55,284 +33,301 @@
5533
56def _make_legacy_record(n: int, *, seed: int = 0) -> dict[str, np.ndarray]:34def _make_legacy_record(n: int, *, seed: int = 0) -> dict[str, np.ndarray]:
57 """Build a pre-AI3D-382 record: every key except ``number_of_returns``."""35 """Build a pre-AI3D-382 record: every key except ``number_of_returns``."""
58 record = _make_record(n, seed=seed)36 record = _make_record(n, seed=seed)
59 del record[NUMBER_OF_RETURNS_KEY]37 del record[segment_points_io.NUMBER_OF_RETURNS_KEY]
60 return record38 return record
6139
6240
63def test_save_load_points_npz_round_trip(tmp_path: Path) -> None:41def test_save_load_points_npz_round_trip(tmp_path: pathlib.Path) -> None:
64 record = _make_record(7, seed=1)42 record = _make_record(7, seed=1)
65 path = tmp_path / "scan_a_run3_points.npz"43 path = tmp_path / "scan_a_run3_points.npz"
66 save_points_npz(path, record)44 segment_points_io.save_points_npz(path, record)
6745
68 loaded = load_points_npz(path)46 loaded = segment_points_io.load_points_npz(path)
69 assert list(loaded.keys()) == list(POINT_RECORD_KEYS)47 assert list(loaded.keys()) == list(segment_points_io.POINT_RECORD_KEYS)
70 for key in POINT_RECORD_KEYS:48 for key in segment_points_io.POINT_RECORD_KEYS:
71 np.testing.assert_array_equal(loaded[key], record[key])49 np.testing.assert_array_equal(loaded[key], record[key])
7250
7351
74def test_load_points_npz_rejects_missing_keys(tmp_path: Path) -> None:52def test_load_points_npz_rejects_missing_keys(tmp_path: pathlib.Path) -> None:
75 path = tmp_path / "broken.npz"53 path = tmp_path / "broken.npz"
76 np.savez_compressed(path, points=np.zeros((2, 3), dtype=np.float32), red=np.zeros(2))54 np.savez_compressed(path, points=np.zeros((2, 3), dtype=np.float32), red=np.zeros(2))
77 with pytest.raises(ValueError, match="missing required key"):55 with pytest.raises(ValueError, match="missing required key"):
78 load_points_npz(path)56 segment_points_io.load_points_npz(path)
7957
8058
81def test_load_points_npz_rejects_bad_points_shape(tmp_path: Path) -> None:59def test_load_points_npz_rejects_bad_points_shape(tmp_path: pathlib.Path) -> None:
82 record = _make_record(3, seed=2)60 record = _make_record(3, seed=2)
83 record["points"] = np.zeros((3, 2), dtype=np.float32)61 record["points"] = np.zeros((3, 2), dtype=np.float32)
84 path = tmp_path / "bad_shape.npz"62 path = tmp_path / "bad_shape.npz"
85 np.savez_compressed(path, **record)63 np.savez_compressed(path, **record)
86 with pytest.raises(ValueError, match=r"shape \(N, 3\)"):64 with pytest.raises(ValueError, match=r"shape \(N, 3\)"):
87 load_points_npz(path)65 segment_points_io.load_points_npz(path)
8866
8967
90def test_load_points_npz_rejects_row_count_mismatch(tmp_path: Path) -> None:68def test_load_points_npz_rejects_row_count_mismatch(tmp_path: pathlib.Path) -> None:
91 record = _make_record(4, seed=3)69 record = _make_record(4, seed=3)
92 record["intensity"] = record["intensity"][:2]70 record["intensity"] = record["intensity"][:2]
93 path = tmp_path / "mismatch.npz"71 path = tmp_path / "mismatch.npz"
94 np.savez_compressed(path, **record)72 np.savez_compressed(path, **record)
95 with pytest.raises(ValueError, match="intensity"):73 with pytest.raises(ValueError, match="intensity"):
96 load_points_npz(path)74 segment_points_io.load_points_npz(path)
9775
9876
99def test_load_points_npz_rejects_scalar_ancillary(tmp_path: Path) -> None:77def test_load_points_npz_rejects_scalar_ancillary(tmp_path: pathlib.Path) -> None:
100 record = _make_record(3, seed=4)78 record = _make_record(3, seed=4)
101 record["red"] = np.array(42, dtype=np.uint16)79 record["red"] = np.array(42, dtype=np.uint16)
102 path = tmp_path / "scalar_ancillary.npz"80 path = tmp_path / "scalar_ancillary.npz"
103 np.savez_compressed(path, **record)81 np.savez_compressed(path, **record)
104 with pytest.raises(ValueError, match=r"'red' must have shape \(N,\), got \(\)"):82 with pytest.raises(ValueError, match=r"'red' must have shape \(N,\), got \(\)"):
105 load_points_npz(path)83 segment_points_io.load_points_npz(path)
10684
10785
108def test_load_points_npz_rejects_column_vector_ancillary(tmp_path: Path) -> None:86def test_load_points_npz_rejects_column_vector_ancillary(tmp_path: pathlib.Path) -> None:
109 record = _make_record(3, seed=5)87 record = _make_record(3, seed=5)
110 record["intensity"] = record["intensity"].reshape(3, 1)88 record["intensity"] = record["intensity"].reshape(3, 1)
111 path = tmp_path / "column_ancillary.npz"89 path = tmp_path / "column_ancillary.npz"
112 np.savez_compressed(path, **record)90 np.savez_compressed(path, **record)
113 with pytest.raises(91 with pytest.raises(
114 ValueError, match=r"'intensity' must have shape \(N,\), got \(3, 1\)"92 ValueError, match=r"'intensity' must have shape \(N,\), got \(3, 1\)"
115 ):93 ):
116 load_points_npz(path)94 segment_points_io.load_points_npz(path)
11795
11896
119def test_save_points_npz_rejects_scalar_ancillary(tmp_path: Path) -> None:97def test_save_points_npz_rejects_scalar_ancillary(tmp_path: pathlib.Path) -> None:
120 record = _make_record(2, seed=6)98 record = _make_record(2, seed=6)
121 record["green"] = np.array(7, dtype=np.uint16)99 record["green"] = np.array(7, dtype=np.uint16)
122 with pytest.raises(ValueError, match=r"'green' must have shape \(N,\), got \(\)"):100 with pytest.raises(ValueError, match=r"'green' must have shape \(N,\), got \(\)"):
123 save_points_npz(tmp_path / "out.npz", record)101 segment_points_io.save_points_npz(tmp_path / "out.npz", record)
124102
125103
126def test_save_points_npz_rejects_column_vector_ancillary(tmp_path: Path) -> None:104def test_save_points_npz_rejects_column_vector_ancillary(tmp_path: pathlib.Path) -> None:
127 record = _make_record(2, seed=7)105 record = _make_record(2, seed=7)
128 record["scan_angle"] = record["scan_angle"].reshape(2, 1)106 record["scan_angle"] = record["scan_angle"].reshape(2, 1)
129 with pytest.raises(107 with pytest.raises(
130 ValueError, match=r"'scan_angle' must have shape \(N,\), got \(2, 1\)"108 ValueError, match=r"'scan_angle' must have shape \(N,\), got \(2, 1\)"
131 ):109 ):
132 save_points_npz(tmp_path / "out.npz", record)110 segment_points_io.save_points_npz(tmp_path / "out.npz", record)
133111
134112
135def test_save_points_npz_rejects_incomplete_record(tmp_path: Path) -> None:113def test_save_points_npz_rejects_incomplete_record(tmp_path: pathlib.Path) -> None:
136 with pytest.raises(ValueError, match="missing required key"):114 with pytest.raises(ValueError, match="missing required key"):
137 save_points_npz(tmp_path / "out.npz", {"points": np.zeros((1, 3))})115 segment_points_io.save_points_npz(tmp_path / "out.npz", {"points": np.zeros((1, 3))})
138116
139117
140def test_number_of_returns_is_part_of_the_written_contract() -> None:118def test_number_of_returns_is_part_of_the_written_contract() -> None:
141 assert NUMBER_OF_RETURNS_KEY in POINT_RECORD_KEYS119 assert segment_points_io.NUMBER_OF_RETURNS_KEY in segment_points_io.POINT_RECORD_KEYS
142 assert NUMBER_OF_RETURNS_KEY not in REQUIRED_POINT_RECORD_KEYS120 assert (
143 assert set(REQUIRED_POINT_RECORD_KEYS) < set(POINT_RECORD_KEYS)121 segment_points_io.NUMBER_OF_RETURNS_KEY
122 not in segment_points_io.REQUIRED_POINT_RECORD_KEYS
123 )
124 assert set(segment_points_io.REQUIRED_POINT_RECORD_KEYS) < set(
125 segment_points_io.POINT_RECORD_KEYS
126 )
144127
145128
146def test_save_points_npz_always_writes_number_of_returns(tmp_path: Path) -> None:129def test_save_points_npz_always_writes_number_of_returns(tmp_path: pathlib.Path) -> None:
147 record = _make_record(6, seed=60)130 record = _make_record(6, seed=60)
148 path = save_points_npz(tmp_path / "with_returns_run3_points.npz", record)131 path = segment_points_io.save_points_npz(tmp_path / "with_returns_run3_points.npz", record)
149132
150 with np.load(path) as data:133 with np.load(path) as data:
151 assert NUMBER_OF_RETURNS_KEY in data.files134 assert segment_points_io.NUMBER_OF_RETURNS_KEY in data.files
152 stored = np.asarray(data[NUMBER_OF_RETURNS_KEY])135 stored = np.asarray(data[segment_points_io.NUMBER_OF_RETURNS_KEY])
153 assert stored.dtype == np.uint8136 assert stored.dtype == np.uint8
154 np.testing.assert_array_equal(stored, record[NUMBER_OF_RETURNS_KEY])137 np.testing.assert_array_equal(stored, record[segment_points_io.NUMBER_OF_RETURNS_KEY])
155138
156139
157def test_save_points_npz_casts_number_of_returns_to_uint8(tmp_path: Path) -> None:140def test_save_points_npz_casts_number_of_returns_to_uint8(tmp_path: pathlib.Path) -> None:
158 record = _make_record(4, seed=61)141 record = _make_record(4, seed=61)
159 record[NUMBER_OF_RETURNS_KEY] = record[NUMBER_OF_RETURNS_KEY].astype(np.int64)142 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = record[
160 path = save_points_npz(tmp_path / "cast_run3_points.npz", record)143 segment_points_io.NUMBER_OF_RETURNS_KEY
144 ].astype(np.int64)
145 path = segment_points_io.save_points_npz(tmp_path / "cast_run3_points.npz", record)
161146
162 loaded = load_points_npz(path)147 loaded = segment_points_io.load_points_npz(path)
163 assert loaded[NUMBER_OF_RETURNS_KEY].dtype == np.uint8148 assert loaded[segment_points_io.NUMBER_OF_RETURNS_KEY].dtype == np.uint8
164149
165150
166def test_save_points_npz_rejects_out_of_range_number_of_returns(tmp_path: Path) -> None:151def test_save_points_npz_rejects_out_of_range_number_of_returns(tmp_path: pathlib.Path) -> None:
167 record = _make_record(3, seed=62)152 record = _make_record(3, seed=62)
168 record[NUMBER_OF_RETURNS_KEY] = np.array([1, -1, 3], dtype=np.int16)153 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([1, -1, 3], dtype=np.int16)
169 with pytest.raises(ValueError, match="number_of_returns"):154 with pytest.raises(ValueError, match="number_of_returns"):
170 save_points_npz(tmp_path / "out.npz", record)155 segment_points_io.save_points_npz(tmp_path / "out.npz", record)
171156
172157
173def test_save_points_npz_rejects_missing_number_of_returns(tmp_path: Path) -> None:158def test_save_points_npz_rejects_missing_number_of_returns(tmp_path: pathlib.Path) -> None:
174 with pytest.raises(ValueError, match="number_of_returns"):159 with pytest.raises(ValueError, match="number_of_returns"):
175 save_points_npz(tmp_path / "out.npz", _make_legacy_record(3, seed=63))160 segment_points_io.save_points_npz(tmp_path / "out.npz", _make_legacy_record(3, seed=63))
176161
177162
178def test_load_points_npz_fills_zeros_for_legacy_records(tmp_path: Path) -> None:163def test_load_points_npz_fills_zeros_for_legacy_records(tmp_path: pathlib.Path) -> None:
179 """Datasets written before AI3D-382 lack the key; 0 means unknown, never 1."""164 """Datasets written before AI3D-382 lack the key; 0 means unknown, never 1."""
180 legacy = _make_legacy_record(5, seed=64)165 legacy = _make_legacy_record(5, seed=64)
181 path = tmp_path / "legacy_run3_points.npz"166 path = tmp_path / "legacy_run3_points.npz"
182 np.savez_compressed(path, **legacy)167 np.savez_compressed(path, **legacy)
183168
184 loaded = load_points_npz(path)169 loaded = segment_points_io.load_points_npz(path)
185170
186 assert list(loaded.keys()) == list(POINT_RECORD_KEYS)171 assert list(loaded.keys()) == list(segment_points_io.POINT_RECORD_KEYS)
187 returns = loaded[NUMBER_OF_RETURNS_KEY]172 returns = loaded[segment_points_io.NUMBER_OF_RETURNS_KEY]
188 assert returns.shape == (5,)173 assert returns.shape == (5,)
189 assert returns.dtype == np.uint8174 assert returns.dtype == np.uint8
190 np.testing.assert_array_equal(returns, np.zeros(5, dtype=np.uint8))175 np.testing.assert_array_equal(returns, np.zeros(5, dtype=np.uint8))
191176
192177
193def test_load_points_npz_rejects_bad_number_of_returns_shape(tmp_path: Path) -> None:178def test_load_points_npz_rejects_bad_number_of_returns_shape(tmp_path: pathlib.Path) -> None:
194 record = _make_record(4, seed=65)179 record = _make_record(4, seed=65)
195 record[NUMBER_OF_RETURNS_KEY] = record[NUMBER_OF_RETURNS_KEY][:2]180 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = record[
181 segment_points_io.NUMBER_OF_RETURNS_KEY
182 ][:2]
196 path = tmp_path / "bad_returns.npz"183 path = tmp_path / "bad_returns.npz"
197 np.savez_compressed(path, **record)184 np.savez_compressed(path, **record)
198 with pytest.raises(185 with pytest.raises(
199 ValueError, match=r"'number_of_returns' must have shape \(N,\), got \(2,\)"186 ValueError, match=r"'number_of_returns' must have shape \(N,\), got \(2,\)"
200 ):187 ):
201 load_points_npz(path)188 segment_points_io.load_points_npz(path)
202189
203190
204def test_save_points_npz_rejects_bool_number_of_returns(tmp_path: Path) -> None:191def test_save_points_npz_rejects_bool_number_of_returns(tmp_path: pathlib.Path) -> None:
205 """A bool mask is not a return count; casting it would fabricate 0/1 counts."""192 """A bool mask is not a return count; casting it would fabricate 0/1 counts."""
206 record = _make_record(3, seed=68)193 record = _make_record(3, seed=68)
207 record[NUMBER_OF_RETURNS_KEY] = np.array([True, False, True])194 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([True, False, True])
208 with pytest.raises(ValueError, match="number_of_returns.*bool"):195 with pytest.raises(ValueError, match="number_of_returns.*bool"):
209 save_points_npz(tmp_path / "out.npz", record)196 segment_points_io.save_points_npz(tmp_path / "out.npz", record)
210197
211198
212def test_load_points_npz_rejects_bool_number_of_returns(tmp_path: Path) -> None:199def test_load_points_npz_rejects_bool_number_of_returns(tmp_path: pathlib.Path) -> None:
213 record = _make_record(3, seed=69)200 record = _make_record(3, seed=69)
214 record[NUMBER_OF_RETURNS_KEY] = np.array([True, False, True])201 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([True, False, True])
215 path = tmp_path / "bool_returns_run3_points.npz"202 path = tmp_path / "bool_returns_run3_points.npz"
216 np.savez_compressed(path, **record)203 np.savez_compressed(path, **record)
217 with pytest.raises(ValueError, match="number_of_returns.*bool"):204 with pytest.raises(ValueError, match="number_of_returns.*bool"):
218 load_points_npz(path)205 segment_points_io.load_points_npz(path)
219206
220207
221def test_load_points_npz_casts_stored_number_of_returns_to_uint8(tmp_path: Path) -> None:208def test_load_points_npz_casts_stored_number_of_returns_to_uint8(tmp_path: pathlib.Path) -> None:
222 """A hand-rolled producer's wider dtype must not leak into merges."""209 """A hand-rolled producer's wider dtype must not leak into merges."""
223 record = _make_record(5, seed=70)210 record = _make_record(5, seed=70)
224 stored = record[NUMBER_OF_RETURNS_KEY].astype(np.int32)211 stored = record[segment_points_io.NUMBER_OF_RETURNS_KEY].astype(np.int32)
225 record[NUMBER_OF_RETURNS_KEY] = stored212 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = stored
226 path = tmp_path / "int32_returns_run3_points.npz"213 path = tmp_path / "int32_returns_run3_points.npz"
227 np.savez_compressed(path, **record)214 np.savez_compressed(path, **record)
228215
229 loaded = load_points_npz(path)216 loaded = segment_points_io.load_points_npz(path)
230217
231 assert loaded[NUMBER_OF_RETURNS_KEY].dtype == np.uint8218 assert loaded[segment_points_io.NUMBER_OF_RETURNS_KEY].dtype == np.uint8
232 np.testing.assert_array_equal(loaded[NUMBER_OF_RETURNS_KEY], stored)219 np.testing.assert_array_equal(loaded[segment_points_io.NUMBER_OF_RETURNS_KEY], stored)
233220
234221
235def test_load_points_npz_rejects_out_of_range_stored_number_of_returns(222def test_load_points_npz_rejects_out_of_range_stored_number_of_returns(
236 tmp_path: Path,223 tmp_path: pathlib.Path,
237) -> None:224) -> None:
238 record = _make_record(3, seed=71)225 record = _make_record(3, seed=71)
239 record[NUMBER_OF_RETURNS_KEY] = np.array([1, 300, 3], dtype=np.int32)226 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([1, 300, 3], dtype=np.int32)
240 path = tmp_path / "out_of_range_returns_run3_points.npz"227 path = tmp_path / "out_of_range_returns_run3_points.npz"
241 np.savez_compressed(path, **record)228 np.savez_compressed(path, **record)
242 with pytest.raises(ValueError, match="number_of_returns.*fit in uint8"):229 with pytest.raises(ValueError, match="number_of_returns.*fit in uint8"):
243 load_points_npz(path)230 segment_points_io.load_points_npz(path)
244231
245232
246def test_load_points_npz_rejects_float_stored_number_of_returns(tmp_path: Path) -> None:233def test_load_points_npz_rejects_float_stored_number_of_returns(tmp_path: pathlib.Path) -> None:
247 record = _make_record(3, seed=72)234 record = _make_record(3, seed=72)
248 record[NUMBER_OF_RETURNS_KEY] = np.array([1.0, 2.0, 3.0], dtype=np.float32)235 record[segment_points_io.NUMBER_OF_RETURNS_KEY] = np.array([1.0, 2.0, 3.0], dtype=np.float32)
249 path = tmp_path / "float_returns_run3_points.npz"236 path = tmp_path / "float_returns_run3_points.npz"
250 np.savez_compressed(path, **record)237 np.savez_compressed(path, **record)
251 with pytest.raises(ValueError, match="number_of_returns.*integer array"):238 with pytest.raises(ValueError, match="number_of_returns.*integer array"):
252 load_points_npz(path)239 segment_points_io.load_points_npz(path)
253240
254241
255def test_concat_points_npz_merges_mixed_stored_return_dtypes(tmp_path: Path) -> None:242def test_concat_points_npz_merges_mixed_stored_return_dtypes(tmp_path: pathlib.Path) -> None:
256 """int32-stored and uint8-stored records concatenate after the load coercion."""243 """int32-stored and uint8-stored records concatenate after the load coercion."""
257 wide = _make_record(3, seed=73)244 wide = _make_record(3, seed=73)
258 wide[NUMBER_OF_RETURNS_KEY] = wide[NUMBER_OF_RETURNS_KEY].astype(np.int32)245 wide[segment_points_io.NUMBER_OF_RETURNS_KEY] = wide[
246 segment_points_io.NUMBER_OF_RETURNS_KEY
247 ].astype(np.int32)
259 wide_path = tmp_path / "alpha_run3_points.npz"248 wide_path = tmp_path / "alpha_run3_points.npz"
260 np.savez_compressed(wide_path, **wide)249 np.savez_compressed(wide_path, **wide)
261 narrow_path = save_points_npz(tmp_path / "beta_run3_points.npz", _make_record(2, seed=74))250 narrow_path = segment_points_io.save_points_npz(
251 tmp_path / "beta_run3_points.npz", _make_record(2, seed=74)
252 )
262253
263 merged, _ = concat_points_npz([wide_path, narrow_path])254 merged, _ = segment_points_io.concat_points_npz([wide_path, narrow_path])
264255
265 assert merged[NUMBER_OF_RETURNS_KEY].dtype == np.uint8256 assert merged[segment_points_io.NUMBER_OF_RETURNS_KEY].dtype == np.uint8
266 assert merged[NUMBER_OF_RETURNS_KEY].shape == (5,)257 assert merged[segment_points_io.NUMBER_OF_RETURNS_KEY].shape == (5,)
267258
268259
269def test_load_segment_points_merges_legacy_and_new_records(tmp_path: Path) -> None:260def test_load_segment_points_merges_legacy_and_new_records(tmp_path: pathlib.Path) -> None:
270 legacy = _make_legacy_record(3, seed=66)261 legacy = _make_legacy_record(3, seed=66)
271 modern = _make_record(4, seed=67)262 modern = _make_record(4, seed=67)
272 legacy_path = tmp_path / "alpha_run3_points.npz"263 legacy_path = tmp_path / "alpha_run3_points.npz"
273 np.savez_compressed(legacy_path, **legacy)264 np.savez_compressed(legacy_path, **legacy)
274 modern_path = tmp_path / "beta_run3_points.npz"265 modern_path = tmp_path / "beta_run3_points.npz"
275 save_points_npz(modern_path, modern)266 segment_points_io.save_points_npz(modern_path, modern)
276267
277 merged, _, _ = load_segment_points([legacy_path, modern_path])268 merged, _, _ = segment_points_io.load_segment_points([legacy_path, modern_path])
278269
279 np.testing.assert_array_equal(270 np.testing.assert_array_equal(
280 merged[NUMBER_OF_RETURNS_KEY],271 merged[segment_points_io.NUMBER_OF_RETURNS_KEY],
281 np.concatenate(272 np.concatenate(
282 [np.zeros(3, dtype=np.uint8), modern[NUMBER_OF_RETURNS_KEY]], axis=0273 [np.zeros(3, dtype=np.uint8), modern[segment_points_io.NUMBER_OF_RETURNS_KEY]], axis=0
283 ),274 ),
284 )275 )
285276
286277
287def test_concat_points_npz_carries_number_of_returns(tmp_path: Path) -> None:278def test_concat_points_npz_carries_number_of_returns(tmp_path: pathlib.Path) -> None:
288 first = _make_record(3, seed=68)279 first = _make_record(3, seed=68)
289 second = _make_legacy_record(2, seed=69)280 second = _make_legacy_record(2, seed=69)
290 save_points_npz(tmp_path / "a_run3_points.npz", first)281 segment_points_io.save_points_npz(tmp_path / "a_run3_points.npz", first)
291 np.savez_compressed(tmp_path / "b_run3_points.npz", **second)282 np.savez_compressed(tmp_path / "b_run3_points.npz", **second)
292283
293 merged, spans = concat_points_npz(284 merged, spans = segment_points_io.concat_points_npz(
294 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]285 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]
295 )286 )
296287
297 assert [span.count for span in spans] == [3, 2]288 assert [span.count for span in spans] == [3, 2]
298 assert merged[NUMBER_OF_RETURNS_KEY].dtype == np.uint8289 assert merged[segment_points_io.NUMBER_OF_RETURNS_KEY].dtype == np.uint8
299 np.testing.assert_array_equal(290 np.testing.assert_array_equal(
300 merged[NUMBER_OF_RETURNS_KEY],291 merged[segment_points_io.NUMBER_OF_RETURNS_KEY],
301 np.concatenate(292 np.concatenate(
302 [first[NUMBER_OF_RETURNS_KEY], np.zeros(2, dtype=np.uint8)], axis=0293 [first[segment_points_io.NUMBER_OF_RETURNS_KEY], np.zeros(2, dtype=np.uint8)], axis=0
303 ),294 ),
304 )295 )
305296
306297
307def test_load_run3_segment_carries_number_of_returns(tmp_path: Path) -> None:298def test_load_run3_segment_carries_number_of_returns(tmp_path: pathlib.Path) -> None:
308 seg_dir = tmp_path / "segment_011"299 seg_dir = tmp_path / "segment_011"
309 seg_dir.mkdir()300 seg_dir.mkdir()
310 first = _make_record(2, seed=70)301 first = _make_record(2, seed=70)
311 second = _make_record(3, seed=71)302 second = _make_record(3, seed=71)
312 save_points_npz(seg_dir / "a_run3_points.npz", first)303 segment_points_io.save_points_npz(seg_dir / "a_run3_points.npz", first)
313 save_points_npz(seg_dir / "b_run3_points.npz", second)304 segment_points_io.save_points_npz(seg_dir / "b_run3_points.npz", second)
314305
315 merged, _ = load_run3_segment(seg_dir)306 merged, _ = segment_points_io.load_run3_segment(seg_dir)
316307
317 np.testing.assert_array_equal(308 np.testing.assert_array_equal(
318 merged[NUMBER_OF_RETURNS_KEY],309 merged[segment_points_io.NUMBER_OF_RETURNS_KEY],
319 np.concatenate(310 np.concatenate(
320 [first[NUMBER_OF_RETURNS_KEY], second[NUMBER_OF_RETURNS_KEY]], axis=0311 [
312 first[segment_points_io.NUMBER_OF_RETURNS_KEY],
313 second[segment_points_io.NUMBER_OF_RETURNS_KEY],
314 ],
315 axis=0,
321 ),316 ),
322 )317 )
323318
324319
325def test_load_segment_points_merges_and_tracks_file_ids(tmp_path: Path) -> None:320def test_load_segment_points_merges_and_tracks_file_ids(tmp_path: pathlib.Path) -> None:
326 records = [_make_record(3, seed=10), _make_record(5, seed=11)]321 records = [_make_record(3, seed=10), _make_record(5, seed=11)]
327 paths = [322 paths = [
328 tmp_path / "alpha_run3_points.npz",323 tmp_path / "alpha_run3_points.npz",
329 tmp_path / "beta_run3_points.npz",324 tmp_path / "beta_run3_points.npz",
330 ]325 ]
331 for path, record in zip(paths, records, strict=True):326 for path, record in zip(paths, records, strict=True):
332 save_points_npz(path, record)327 segment_points_io.save_points_npz(path, record)
333328
334 merged, point_file_ids, file_stems = load_segment_points(paths)329 merged, point_file_ids, file_stems = segment_points_io.load_segment_points(paths)
335330
336 assert file_stems == ["alpha_run3_points", "beta_run3_points"]331 assert file_stems == ["alpha_run3_points", "beta_run3_points"]
337 assert merged["points"].shape == (8, 3)332 assert merged["points"].shape == (8, 3)
338 assert point_file_ids.dtype == np.int32333 assert point_file_ids.dtype == np.int32
Importance #106: tests/test_segment_points_io.py @@ -351,45 +346,45 @@
351346
352347
353def test_load_segment_points_empty_raises() -> None:348def test_load_segment_points_empty_raises() -> None:
354 with pytest.raises(FileNotFoundError, match="No \\*_points.npz"):349 with pytest.raises(FileNotFoundError, match="No \\*_points.npz"):
355 load_segment_points([])350 segment_points_io.load_segment_points([])
356351
357352
358def test_load_geoshift_parses_xyz_json(tmp_path: Path) -> None:353def test_load_geoshift_parses_xyz_json(tmp_path: pathlib.Path) -> None:
359 path = tmp_path / "run3_geoshift.json"354 path = tmp_path / "run3_geoshift.json"
360 path.write_text(json.dumps({"x": 725883.5, "y": 5422097.8, "z": 390.8}), encoding="utf-8")355 path.write_text(json.dumps({"x": 725883.5, "y": 5422097.8, "z": 390.8}), encoding="utf-8")
361 geoshift = load_geoshift(path)356 geoshift = segment_points_io.load_geoshift(path)
362 assert geoshift.shape == (3,)357 assert geoshift.shape == (3,)
363 assert geoshift.dtype == np.float64358 assert geoshift.dtype == np.float64
364 np.testing.assert_allclose(geoshift, [725883.5, 5422097.8, 390.8])359 np.testing.assert_allclose(geoshift, [725883.5, 5422097.8, 390.8])
365360
366361
367def test_load_geoshift_rejects_missing_keys(tmp_path: Path) -> None:362def test_load_geoshift_rejects_missing_keys(tmp_path: pathlib.Path) -> None:
368 path = tmp_path / "run3_geoshift.json"363 path = tmp_path / "run3_geoshift.json"
369 path.write_text(json.dumps({"x": 1.0, "y": 2.0}), encoding="utf-8")364 path.write_text(json.dumps({"x": 1.0, "y": 2.0}), encoding="utf-8")
370 with pytest.raises(ValueError, match="missing geoshift key"):365 with pytest.raises(ValueError, match="missing geoshift key"):
371 load_geoshift(path)366 segment_points_io.load_geoshift(path)
372367
373368
374def test_parse_segment_key_accepts_int_and_prefixed_forms() -> None:369def test_parse_segment_key_accepts_int_and_prefixed_forms() -> None:
375 assert parse_segment_key(32) == 32370 assert segment_points_io.parse_segment_key(32) == 32
376 assert parse_segment_key("32") == 32371 assert segment_points_io.parse_segment_key("32") == 32
377 assert parse_segment_key("segment_33") == 33372 assert segment_points_io.parse_segment_key("segment_33") == 33
378 assert parse_segment_key("segment_066") == 66373 assert segment_points_io.parse_segment_key("segment_066") == 66
379374
380375
381def test_parse_segment_key_rejects_invalid() -> None:376def test_parse_segment_key_rejects_invalid() -> None:
382 with pytest.raises(ValueError, match="Segment key must not be empty"):377 with pytest.raises(ValueError, match="Segment key must not be empty"):
383 parse_segment_key("segment_")378 segment_points_io.parse_segment_key("segment_")
384 with pytest.raises(ValueError, match="integer or segment_<idx>"):379 with pytest.raises(ValueError, match="integer or segment_<idx>"):
385 parse_segment_key("lane_a")380 segment_points_io.parse_segment_key("lane_a")
386 with pytest.raises(ValueError, match="integer or segment_<idx>"):381 with pytest.raises(ValueError, match="integer or segment_<idx>"):
387 parse_segment_key(True)382 segment_points_io.parse_segment_key(True)
388383
389384
390def test_normalize_segment_file_blacklist_accepts_multiple_key_formats() -> None:385def test_normalize_segment_file_blacklist_accepts_multiple_key_formats() -> None:
391 blacklist = normalize_segment_file_blacklist(386 blacklist = segment_points_io.normalize_segment_file_blacklist(
392 {387 {
393 "32": ["scan_a_run3_points.npz", "nested/scan_b_run3_points.npz"],388 "32": ["scan_a_run3_points.npz", "nested/scan_b_run3_points.npz"],
394 "segment_33": "scan_c_run3_points.npz",389 "segment_33": "scan_c_run3_points.npz",
395 33: ["scan_d_run3_points.npz"],390 33: ["scan_d_run3_points.npz"],
Importance #107: tests/test_segment_points_io.py @@ -402,23 +397,23 @@
402397
403398
404def test_normalize_segment_file_blacklist_rejects_invalid_values() -> None:399def test_normalize_segment_file_blacklist_rejects_invalid_values() -> None:
405 with pytest.raises(ValueError, match="mapping of segment to files"):400 with pytest.raises(ValueError, match="mapping of segment to files"):
406 normalize_segment_file_blacklist(["32:scan_a_run3_points.npz"]) # type: ignore[arg-type]401 segment_points_io.normalize_segment_file_blacklist(["32:scan_a_run3_points.npz"]) # type: ignore[arg-type]
407 with pytest.raises(ValueError, match="at least one non-empty"):402 with pytest.raises(ValueError, match="at least one non-empty"):
408 normalize_segment_file_blacklist({"32": [" ", ""]})403 segment_points_io.normalize_segment_file_blacklist({"32": [" ", ""]})
409 assert normalize_segment_file_blacklist(None) == {}404 assert segment_points_io.normalize_segment_file_blacklist(None) == {}
410405
411406
412def test_filter_segment_files_respects_segment_specific_exact_and_glob_rules(407def test_filter_segment_files_respects_segment_specific_exact_and_glob_rules(
413 caplog: pytest.LogCaptureFixture,408 caplog: pytest.LogCaptureFixture,
414) -> None:409) -> None:
415 segment_32_files = [410 segment_32_files = [
416 Path("/tmp/lane_points/segment_32/scan_a_run3_points.npz"),411 pathlib.Path("/tmp/lane_points/segment_32/scan_a_run3_points.npz"),
417 Path("/tmp/lane_points/segment_32/nested/scan_b_run3_points.npz"),412 pathlib.Path("/tmp/lane_points/segment_32/nested/scan_b_run3_points.npz"),
418 Path("/tmp/lane_points/segment_32/scan_c_run3_points.npz"),413 pathlib.Path("/tmp/lane_points/segment_32/scan_c_run3_points.npz"),
419 ]414 ]
420 blacklist = normalize_segment_file_blacklist(415 blacklist = segment_points_io.normalize_segment_file_blacklist(
421 {416 {
422 "32": [417 "32": [
423 "scan_a_run3_points.npz",418 "scan_a_run3_points.npz",
424 "*/nested/scan_b_run3_points.npz",419 "*/nested/scan_b_run3_points.npz",
Importance #108: tests/test_segment_points_io.py @@ -427,14 +422,14 @@
427 }422 }
428 )423 )
429424
430 with caplog.at_level(logging.INFO, logger="iolabs.common.segment_points_io"):425 with caplog.at_level(logging.INFO, logger="iolabs.common.segment_points_io"):
431 kept_32, blacklisted_32 = filter_segment_files(426 kept_32, blacklisted_32 = segment_points_io.filter_segment_files(
432 segment_32_files,427 segment_32_files,
433 segment_index=32,428 segment_index=32,
434 blacklist=blacklist,429 blacklist=blacklist,
435 )430 )
436 kept_33, blacklisted_33 = filter_segment_files(431 kept_33, blacklisted_33 = segment_points_io.filter_segment_files(
437 segment_32_files,432 segment_32_files,
438 segment_index=33,433 segment_index=33,
439 blacklist=blacklist,434 blacklist=blacklist,
440 )435 )
Importance #109: tests/test_segment_points_io.py @@ -451,81 +446,81 @@
451 assert [path.name for path in blacklisted_33] == ["scan_c_run3_points.npz"]446 assert [path.name for path in blacklisted_33] == ["scan_c_run3_points.npz"]
452 assert any("excluding 2 input NPZ files" in record.message for record in caplog.records)447 assert any("excluding 2 input NPZ files" in record.message for record in caplog.records)
453448
454449
455def _write_geoshift(path: Path, xyz: tuple[float, float, float]) -> Path:450def _write_geoshift(path: pathlib.Path, xyz: tuple[float, float, float]) -> pathlib.Path:
456 path.parent.mkdir(parents=True, exist_ok=True)451 path.parent.mkdir(parents=True, exist_ok=True)
457 path.write_text(452 path.write_text(
458 json.dumps({"x": xyz[0], "y": xyz[1], "z": xyz[2]}), encoding="utf-8"453 json.dumps({"x": xyz[0], "y": xyz[1], "z": xyz[2]}), encoding="utf-8"
459 )454 )
460 return path455 return path
461456
462457
463def _save_stored_npz(path: Path, record: dict[str, np.ndarray]) -> Path:458def _save_stored_npz(path: pathlib.Path, record: dict[str, np.ndarray]) -> pathlib.Path:
464 """Write an uncompressed (ZIP_STORED) npz, the chunk-streamable layout."""459 """Write an uncompressed (ZIP_STORED) npz, the chunk-streamable layout."""
465 np.savez(path, **record)460 np.savez(path, **record)
466 return path461 return path
467462
468463
469def test_geoshift_candidate_paths_cover_the_three_conventions(tmp_path: Path) -> None:464def test_geoshift_candidate_paths_cover_the_three_conventions(tmp_path: pathlib.Path) -> None:
470 candidates = geoshift_candidate_paths(tmp_path / "dataset" / "lane_points")465 candidates = segment_points_io.geoshift_candidate_paths(tmp_path / "dataset" / "lane_points")
471 assert candidates == [466 assert candidates == [
472 tmp_path / "dataset" / "lane_points" / GEOSHIFT_NAME,467 tmp_path / "dataset" / "lane_points" / segment_points_io.GEOSHIFT_NAME,
473 tmp_path / "dataset" / "lane_points" / "lane_points" / GEOSHIFT_NAME,468 tmp_path / "dataset" / "lane_points" / "lane_points" / segment_points_io.GEOSHIFT_NAME,
474 tmp_path / "dataset" / GEOSHIFT_NAME,469 tmp_path / "dataset" / segment_points_io.GEOSHIFT_NAME,
475 ]470 ]
476471
477472
478def test_find_geoshift_lane_points_dir_convention(tmp_path: Path) -> None:473def test_find_geoshift_lane_points_dir_convention(tmp_path: pathlib.Path) -> None:
479 lane_points = tmp_path / "lane_points"474 lane_points = tmp_path / "lane_points"
480 _write_geoshift(lane_points / GEOSHIFT_NAME, (1.0, 2.0, 3.0))475 _write_geoshift(lane_points / segment_points_io.GEOSHIFT_NAME, (1.0, 2.0, 3.0))
481 np.testing.assert_allclose(find_geoshift(lane_points), [1.0, 2.0, 3.0])476 np.testing.assert_allclose(segment_points_io.find_geoshift(lane_points), [1.0, 2.0, 3.0])
482477
483478
484def test_find_geoshift_dataset_root_convention(tmp_path: Path) -> None:479def test_find_geoshift_dataset_root_convention(tmp_path: pathlib.Path) -> None:
485 _write_geoshift(tmp_path / "lane_points" / GEOSHIFT_NAME, (4.0, 5.0, 6.0))480 _write_geoshift(tmp_path / "lane_points" / segment_points_io.GEOSHIFT_NAME, (4.0, 5.0, 6.0))
486 np.testing.assert_allclose(find_geoshift(tmp_path), [4.0, 5.0, 6.0])481 np.testing.assert_allclose(segment_points_io.find_geoshift(tmp_path), [4.0, 5.0, 6.0])
487482
488483
489def test_find_geoshift_segment_dir_parent_convention(tmp_path: Path) -> None:484def test_find_geoshift_segment_dir_parent_convention(tmp_path: pathlib.Path) -> None:
490 lane_points = tmp_path / "lane_points"485 lane_points = tmp_path / "lane_points"
491 seg_dir = lane_points / "segment_032"486 seg_dir = lane_points / "segment_032"
492 seg_dir.mkdir(parents=True)487 seg_dir.mkdir(parents=True)
493 _write_geoshift(lane_points / GEOSHIFT_NAME, (7.0, 8.0, 9.0))488 _write_geoshift(lane_points / segment_points_io.GEOSHIFT_NAME, (7.0, 8.0, 9.0))
494 np.testing.assert_allclose(find_geoshift(seg_dir), [7.0, 8.0, 9.0])489 np.testing.assert_allclose(segment_points_io.find_geoshift(seg_dir), [7.0, 8.0, 9.0])
495490
496491
497def test_find_geoshift_prefers_directory_over_parent(tmp_path: Path) -> None:492def test_find_geoshift_prefers_directory_over_parent(tmp_path: pathlib.Path) -> None:
498 lane_points = tmp_path / "lane_points"493 lane_points = tmp_path / "lane_points"
499 seg_dir = lane_points / "segment_032"494 seg_dir = lane_points / "segment_032"
500 seg_dir.mkdir(parents=True)495 seg_dir.mkdir(parents=True)
501 _write_geoshift(seg_dir / GEOSHIFT_NAME, (1.0, 1.0, 1.0))496 _write_geoshift(seg_dir / segment_points_io.GEOSHIFT_NAME, (1.0, 1.0, 1.0))
502 _write_geoshift(lane_points / GEOSHIFT_NAME, (2.0, 2.0, 2.0))497 _write_geoshift(lane_points / segment_points_io.GEOSHIFT_NAME, (2.0, 2.0, 2.0))
503 np.testing.assert_allclose(find_geoshift(seg_dir), [1.0, 1.0, 1.0])498 np.testing.assert_allclose(segment_points_io.find_geoshift(seg_dir), [1.0, 1.0, 1.0])
504499
505500
506def test_find_geoshift_or_none_returns_none_when_absent(tmp_path: Path) -> None:501def test_find_geoshift_or_none_returns_none_when_absent(tmp_path: pathlib.Path) -> None:
507 seg_dir = tmp_path / "lane_points" / "segment_000"502 seg_dir = tmp_path / "lane_points" / "segment_000"
508 seg_dir.mkdir(parents=True)503 seg_dir.mkdir(parents=True)
509 assert find_geoshift_or_none(seg_dir) is None504 assert segment_points_io.find_geoshift_or_none(seg_dir) is None
510505
511506
512def test_find_geoshift_raises_and_lists_searched_paths(tmp_path: Path) -> None:507def test_find_geoshift_raises_and_lists_searched_paths(tmp_path: pathlib.Path) -> None:
513 with pytest.raises(FileNotFoundError, match="searched:"):508 with pytest.raises(FileNotFoundError, match="searched:"):
514 find_geoshift(tmp_path)509 segment_points_io.find_geoshift(tmp_path)
515510
516511
517def test_find_geoshift_returns_shift_unsigned(tmp_path: Path) -> None:512def test_find_geoshift_returns_shift_unsigned(tmp_path: pathlib.Path) -> None:
518 """The recorded shift is returned verbatim; no sign is baked in."""513 """The recorded shift is returned verbatim; no sign is baked in."""
519 _write_geoshift(tmp_path / GEOSHIFT_NAME, (725883.5, -5422097.8, 390.8))514 _write_geoshift(tmp_path / segment_points_io.GEOSHIFT_NAME, (725883.5, -5422097.8, 390.8))
520 np.testing.assert_allclose(515 np.testing.assert_allclose(
521 find_geoshift(tmp_path), [725883.5, -5422097.8, 390.8]516 segment_points_io.find_geoshift(tmp_path), [725883.5, -5422097.8, 390.8]
522 )517 )
523518
524519
525def test_geoshift_from_mapping_bare_and_nested() -> None:520def test_geoshift_from_mapping_bare_and_nested() -> None:
526 bare = geoshift_from_mapping({"x": 1.0, "y": 2.0, "z": 3.0})521 bare = segment_points_io.geoshift_from_mapping({"x": 1.0, "y": 2.0, "z": 3.0})
527 nested = geoshift_from_mapping(522 nested = segment_points_io.geoshift_from_mapping(
528 {"pixels_per_meter": 10, "geoshift": {"x": 1.0, "y": 2.0, "z": 3.0}}523 {"pixels_per_meter": 10, "geoshift": {"x": 1.0, "y": 2.0, "z": 3.0}}
529 )524 )
530 assert bare.dtype == np.float64525 assert bare.dtype == np.float64
531 np.testing.assert_allclose(bare, [1.0, 2.0, 3.0])526 np.testing.assert_allclose(bare, [1.0, 2.0, 3.0])
Importance #110: tests/test_segment_points_io.py @@ -533,113 +528,114 @@
533528
534529
535def test_geoshift_from_mapping_rejects_bad_input() -> None:530def test_geoshift_from_mapping_rejects_bad_input() -> None:
536 with pytest.raises(ValueError, match="missing geoshift key"):531 with pytest.raises(ValueError, match="missing geoshift key"):
537 geoshift_from_mapping({"x": 1.0, "y": 2.0})532 segment_points_io.geoshift_from_mapping({"x": 1.0, "y": 2.0})
538 with pytest.raises(ValueError, match="must be an object"):533 with pytest.raises(ValueError, match="must be an object"):
539 geoshift_from_mapping([1.0, 2.0, 3.0]) # type: ignore[arg-type]534 segment_points_io.geoshift_from_mapping([1.0, 2.0, 3.0]) # type: ignore[arg-type]
540 with pytest.raises(ValueError, match="must be numbers"):535 with pytest.raises(ValueError, match="must be numbers"):
541 geoshift_from_mapping({"x": 1.0, "y": "north", "z": 3.0})536 segment_points_io.geoshift_from_mapping({"x": 1.0, "y": "north", "z": 3.0})
542537
543538
544def test_read_points_header_reports_stored_and_compressed(tmp_path: Path) -> None:539def test_read_points_header_reports_stored_and_compressed(tmp_path: pathlib.Path) -> None:
545 record = _make_record(5, seed=20)540 record = _make_record(5, seed=20)
546 stored = _save_stored_npz(tmp_path / "stored.npz", record)541 stored = _save_stored_npz(tmp_path / "stored.npz", record)
547 compressed = tmp_path / "compressed.npz"542 compressed = tmp_path / "compressed.npz"
548 np.savez_compressed(compressed, **record)543 np.savez_compressed(compressed, **record)
549544
550 assert read_points_header(stored) == (5, True)545 assert segment_points_io.read_points_header(stored) == (5, True)
551 assert read_points_header(compressed) == (5, False)546 assert segment_points_io.read_points_header(compressed) == (5, False)
552547
553548
554def test_read_points_header_unreadable_file(tmp_path: Path) -> None:549def test_read_points_header_unreadable_file(tmp_path: pathlib.Path) -> None:
555 broken = tmp_path / "broken.npz"550 broken = tmp_path / "broken.npz"
556 broken.write_bytes(b"not a zip archive")551 broken.write_bytes(b"not a zip archive")
557 assert read_points_header(broken) == (-1, False)552 assert segment_points_io.read_points_header(broken) == (-1, False)
558553
559554
560def test_iter_points_chunks_streams_tail_chunk_and_is_writeable(tmp_path: Path) -> None:555def test_iter_points_chunks_streams_tail_chunk_and_is_writeable(tmp_path: pathlib.Path) -> None:
561 record = _make_record(7, seed=21)556 record = _make_record(7, seed=21)
562 path = _save_stored_npz(tmp_path / "big_run3_points.npz", record)557 path = _save_stored_npz(tmp_path / "big_run3_points.npz", record)
563558
564 chunks = list(iter_points_chunks(path, 3))559 chunks = list(segment_points_io.iter_points_chunks(path, 3))
565560
566 assert [len(chunk) for chunk in chunks] == [3, 3, 1]561 assert [len(chunk) for chunk in chunks] == [3, 3, 1]
567 for chunk in chunks:562 for chunk in chunks:
568 assert chunk.flags.writeable563 assert chunk.flags.writeable
569 assert chunk.flags.c_contiguous564 assert chunk.flags.c_contiguous
570 chunk[:] = 0.0 # must not raise: chunks are owned copies565 chunk[:] = 0.0 # must not raise: chunks are owned copies
571 np.testing.assert_array_equal(566 np.testing.assert_array_equal(
572 np.concatenate(list(iter_points_chunks(path, 3)), axis=0), record["points"]567 np.concatenate(list(segment_points_io.iter_points_chunks(path, 3)), axis=0),
568 record["points"],
573 )569 )
574570
575571
576def test_iter_points_chunks_yields_whole_record_when_not_oversized(tmp_path: Path) -> None:572def test_iter_points_chunks_yields_whole_record_when_not_oversized(tmp_path: pathlib.Path) -> None:
577 record = _make_record(4, seed=22)573 record = _make_record(4, seed=22)
578 path = _save_stored_npz(tmp_path / "small_run3_points.npz", record)574 path = _save_stored_npz(tmp_path / "small_run3_points.npz", record)
579575
580 chunks = list(iter_points_chunks(path, 4))576 chunks = list(segment_points_io.iter_points_chunks(path, 4))
581577
582 assert len(chunks) == 1578 assert len(chunks) == 1
583 assert chunks[0].flags.writeable579 assert chunks[0].flags.writeable
584 np.testing.assert_array_equal(chunks[0], record["points"])580 np.testing.assert_array_equal(chunks[0], record["points"])
585581
586582
587def test_iter_points_chunks_falls_back_for_compressed_records(tmp_path: Path) -> None:583def test_iter_points_chunks_falls_back_for_compressed_records(tmp_path: pathlib.Path) -> None:
588 record = _make_record(9, seed=23)584 record = _make_record(9, seed=23)
589 path = tmp_path / "compressed_run3_points.npz"585 path = tmp_path / "compressed_run3_points.npz"
590 np.savez_compressed(path, **record)586 np.savez_compressed(path, **record)
591587
592 chunks = list(iter_points_chunks(path, 2))588 chunks = list(segment_points_io.iter_points_chunks(path, 2))
593589
594 assert len(chunks) == 1590 assert len(chunks) == 1
595 np.testing.assert_array_equal(chunks[0], record["points"])591 np.testing.assert_array_equal(chunks[0], record["points"])
596592
597593
598def test_iter_points_chunks_disabled_by_non_positive_chunk_size(tmp_path: Path) -> None:594def test_iter_points_chunks_disabled_by_non_positive_chunk_size(tmp_path: pathlib.Path) -> None:
599 record = _make_record(6, seed=24)595 record = _make_record(6, seed=24)
600 path = _save_stored_npz(tmp_path / "run3_points.npz", record)596 path = _save_stored_npz(tmp_path / "run3_points.npz", record)
601597
602 chunks = list(iter_points_chunks(path, 0))598 chunks = list(segment_points_io.iter_points_chunks(path, 0))
603599
604 assert len(chunks) == 1600 assert len(chunks) == 1
605 np.testing.assert_array_equal(chunks[0], record["points"])601 np.testing.assert_array_equal(chunks[0], record["points"])
606602
607603
608def test_discover_run3_files_skips_part_files(tmp_path: Path) -> None:604def test_discover_run3_files_skips_part_files(tmp_path: pathlib.Path) -> None:
609 seg_dir = tmp_path / "segment_003"605 seg_dir = tmp_path / "segment_003"
610 seg_dir.mkdir()606 seg_dir.mkdir()
611 save_points_npz(seg_dir / "beta_run3_points.npz", _make_record(2, seed=30))607 segment_points_io.save_points_npz(seg_dir / "beta_run3_points.npz", _make_record(2, seed=30))
612 save_points_npz(seg_dir / "alpha_run3_points.npz", _make_record(2, seed=31))608 segment_points_io.save_points_npz(seg_dir / "alpha_run3_points.npz", _make_record(2, seed=31))
613 (seg_dir / "gamma_run3_points.npz.part").write_bytes(b"partial write")609 (seg_dir / "gamma_run3_points.npz.part").write_bytes(b"partial write")
614 (seg_dir / "delta_run4_road_surface.npz").write_bytes(b"other artifact")610 (seg_dir / "delta_run4_road_surface.npz").write_bytes(b"other artifact")
615611
616 found = discover_run3_files(seg_dir)612 found = segment_points_io.discover_run3_files(seg_dir)
617613
618 assert [path.name for path in found] == [614 assert [path.name for path in found] == [
619 "alpha_run3_points.npz",615 "alpha_run3_points.npz",
620 "beta_run3_points.npz",616 "beta_run3_points.npz",
621 ]617 ]
622618
623619
624def test_discover_run3_files_missing_dir_is_empty(tmp_path: Path) -> None:620def test_discover_run3_files_missing_dir_is_empty(tmp_path: pathlib.Path) -> None:
625 assert discover_run3_files(tmp_path / "segment_999") == []621 assert segment_points_io.discover_run3_files(tmp_path / "segment_999") == []
626622
627623
628def test_load_run3_segment_boundary_table(tmp_path: Path) -> None:624def test_load_run3_segment_boundary_table(tmp_path: pathlib.Path) -> None:
629 seg_dir = tmp_path / "segment_007"625 seg_dir = tmp_path / "segment_007"
630 seg_dir.mkdir()626 seg_dir.mkdir()
631 first = _make_record(3, seed=40)627 first = _make_record(3, seed=40)
632 second = _make_record(5, seed=41)628 second = _make_record(5, seed=41)
633 save_points_npz(seg_dir / "a_run3_points.npz", first)629 segment_points_io.save_points_npz(seg_dir / "a_run3_points.npz", first)
634 save_points_npz(seg_dir / "b_run3_points.npz", second)630 segment_points_io.save_points_npz(seg_dir / "b_run3_points.npz", second)
635 (seg_dir / "c_run3_points.npz.part").write_bytes(b"partial write")631 (seg_dir / "c_run3_points.npz.part").write_bytes(b"partial write")
636632
637 merged, spans = load_run3_segment(seg_dir)633 merged, spans = segment_points_io.load_run3_segment(seg_dir)
638634
639 assert spans == [635 assert spans == [
640 RecordSpan(name="a_run3_points.npz", offset=0, count=3),636 segment_points_io.RecordSpan(name="a_run3_points.npz", offset=0, count=3),
641 RecordSpan(name="b_run3_points.npz", offset=3, count=5),637 segment_points_io.RecordSpan(name="b_run3_points.npz", offset=3, count=5),
642 ]638 ]
643 assert spans[1].end == 8639 assert spans[1].end == 8
644 assert merged["points"].shape == (8, 3)640 assert merged["points"].shape == (8, 3)
645 np.testing.assert_array_equal(641 np.testing.assert_array_equal(
Importance #111: tests/test_segment_points_io.py @@ -653,58 +649,58 @@
653 np.concatenate([first["intensity"], second["intensity"]], axis=0),649 np.concatenate([first["intensity"], second["intensity"]], axis=0),
654 )650 )
655651
656652
657def test_load_run3_segment_requires_records(tmp_path: Path) -> None:653def test_load_run3_segment_requires_records(tmp_path: pathlib.Path) -> None:
658 seg_dir = tmp_path / "segment_008"654 seg_dir = tmp_path / "segment_008"
659 seg_dir.mkdir()655 seg_dir.mkdir()
660 (seg_dir / "a_run3_points.npz.part").write_bytes(b"partial write")656 (seg_dir / "a_run3_points.npz.part").write_bytes(b"partial write")
661 with pytest.raises(FileNotFoundError, match=r"No \*_run3_points.npz"):657 with pytest.raises(FileNotFoundError, match=r"No \*_run3_points.npz"):
662 load_run3_segment(seg_dir)658 segment_points_io.load_run3_segment(seg_dir)
663659
664660
665def test_concat_points_npz_rejects_mixed_dtypes(tmp_path: Path) -> None:661def test_concat_points_npz_rejects_mixed_dtypes(tmp_path: pathlib.Path) -> None:
666 first = _make_record(3, seed=50)662 first = _make_record(3, seed=50)
667 second = _make_record(3, seed=51)663 second = _make_record(3, seed=51)
668 second["points"] = second["points"].astype(np.float64)664 second["points"] = second["points"].astype(np.float64)
669 save_points_npz(tmp_path / "a_run3_points.npz", first)665 segment_points_io.save_points_npz(tmp_path / "a_run3_points.npz", first)
670 save_points_npz(tmp_path / "b_run3_points.npz", second)666 segment_points_io.save_points_npz(tmp_path / "b_run3_points.npz", second)
671667
672 with pytest.raises(ValueError, match="mixed dtypes"):668 with pytest.raises(ValueError, match="mixed dtypes"):
673 concat_points_npz(669 segment_points_io.concat_points_npz(
674 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]670 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]
675 )671 )
676672
677673
678def test_concat_points_npz_rejects_mixed_ancillary_dtypes(tmp_path: Path) -> None:674def test_concat_points_npz_rejects_mixed_ancillary_dtypes(tmp_path: pathlib.Path) -> None:
679 first = _make_record(2, seed=52)675 first = _make_record(2, seed=52)
680 second = _make_record(2, seed=53)676 second = _make_record(2, seed=53)
681 second["intensity"] = second["intensity"].astype(np.uint8)677 second["intensity"] = second["intensity"].astype(np.uint8)
682 save_points_npz(tmp_path / "a_run3_points.npz", first)678 segment_points_io.save_points_npz(tmp_path / "a_run3_points.npz", first)
683 save_points_npz(tmp_path / "b_run3_points.npz", second)679 segment_points_io.save_points_npz(tmp_path / "b_run3_points.npz", second)
684680
685 with pytest.raises(ValueError, match="'intensity' dtype uint8"):681 with pytest.raises(ValueError, match="'intensity' dtype uint8"):
686 concat_points_npz(682 segment_points_io.concat_points_npz(
687 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]683 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"]
688 )684 )
689685
690686
691def test_concat_points_npz_empty_raises() -> None:687def test_concat_points_npz_empty_raises() -> None:
692 with pytest.raises(FileNotFoundError, match="No \\*_points.npz"):688 with pytest.raises(FileNotFoundError, match="No \\*_points.npz"):
693 concat_points_npz([])689 segment_points_io.concat_points_npz([])
694690
695691
696def test_concat_points_npz_target_dtypes_casts_mixed_records(tmp_path: Path) -> None:692def test_concat_points_npz_target_dtypes_casts_mixed_records(tmp_path: pathlib.Path) -> None:
697 # seg3d-style normalization: historical records with different storage693 # seg3d-style normalization: historical records with different storage
698 # dtypes (uint8 vs uint16 intensity) are cast to the target instead of694 # dtypes (uint8 vs uint16 intensity) are cast to the target instead of
699 # rejected.695 # rejected.
700 first = _make_record(2, seed=54)696 first = _make_record(2, seed=54)
701 second = _make_record(2, seed=55)697 second = _make_record(2, seed=55)
702 second["intensity"] = second["intensity"].astype(np.uint8)698 second["intensity"] = second["intensity"].astype(np.uint8)
703 save_points_npz(tmp_path / "a_run3_points.npz", first)699 segment_points_io.save_points_npz(tmp_path / "a_run3_points.npz", first)
704 save_points_npz(tmp_path / "b_run3_points.npz", second)700 segment_points_io.save_points_npz(tmp_path / "b_run3_points.npz", second)
705701
706 merged, spans = concat_points_npz(702 merged, spans = segment_points_io.concat_points_npz(
707 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"],703 [tmp_path / "a_run3_points.npz", tmp_path / "b_run3_points.npz"],
708 target_dtypes={"points": np.dtype(np.float64), "intensity": np.dtype(np.uint16)},704 target_dtypes={"points": np.dtype(np.float64), "intensity": np.dtype(np.uint16)},
709 )705 )
710 assert merged["points"].dtype == np.float64706 assert merged["points"].dtype == np.float64
Importance #112: tests/test_segment_points_io.py @@ -718,9 +714,9 @@
718 )714 )
719715
720716
721def test_find_geoshift_warns_on_multiple_candidates(717def test_find_geoshift_warns_on_multiple_candidates(
722 tmp_path: Path, caplog: pytest.LogCaptureFixture718 tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
723) -> None:719) -> None:
724 # A stale segment-local file shadowing the dataset-level one is legal but720 # A stale segment-local file shadowing the dataset-level one is legal but
725 # suspicious; the lookup must say so out loud.721 # suspicious; the lookup must say so out loud.
726 seg_dir = tmp_path / "lane_points" / "segment_032"722 seg_dir = tmp_path / "lane_points" / "segment_032"
Importance #113: tests/test_segment_points_io.py @@ -728,54 +724,61 @@
728 _write_geoshift(seg_dir / "run3_geoshift.json", (1.0, 1.0, 1.0))724 _write_geoshift(seg_dir / "run3_geoshift.json", (1.0, 1.0, 1.0))
729 _write_geoshift(tmp_path / "lane_points" / "run3_geoshift.json", (2.0, 2.0, 2.0))725 _write_geoshift(tmp_path / "lane_points" / "run3_geoshift.json", (2.0, 2.0, 2.0))
730726
731 with caplog.at_level(logging.WARNING, logger="iolabs.common.segment_points_io"):727 with caplog.at_level(logging.WARNING, logger="iolabs.common.segment_points_io"):
732 shift = find_geoshift(seg_dir)728 shift = segment_points_io.find_geoshift(seg_dir)
733729
734 np.testing.assert_array_equal(shift, np.array([1.0, 1.0, 1.0]))730 np.testing.assert_array_equal(shift, np.array([1.0, 1.0, 1.0]))
735 assert any("Multiple" in message for message in caplog.messages)731 assert any("Multiple" in message for message in caplog.messages)
736732
737733
738def test_public_key_tuples_are_derived_from_the_schema() -> None:734def test_public_key_tuples_are_derived_from_the_schema() -> None:
739 assert POINT_RECORD_KEYS == tuple(POINT_RECORD_SCHEMA)735 assert segment_points_io.POINT_RECORD_KEYS == tuple(segment_points_io.POINT_RECORD_SCHEMA)
740 assert REQUIRED_POINT_RECORD_KEYS == (736 assert segment_points_io.REQUIRED_POINT_RECORD_KEYS == (
741 "points",737 "points",
742 "red",738 "red",
743 "green",739 "green",
744 "blue",740 "blue",
745 "intensity",741 "intensity",
746 "scan_angle",742 "scan_angle",
747 )743 )
748 assert OPTIONAL_POINT_RECORD_KEYS == (NUMBER_OF_RETURNS_KEY,)744 assert segment_points_io.OPTIONAL_POINT_RECORD_KEYS == (
749 assert all(POINT_RECORD_SCHEMA[key].required for key in REQUIRED_POINT_RECORD_KEYS)745 segment_points_io.NUMBER_OF_RETURNS_KEY,
750 assert POINT_RECORD_SCHEMA[NUMBER_OF_RETURNS_KEY].storage_dtype == np.dtype(np.uint8)746 )
751 assert POINT_RECORD_SCHEMA["points"].columns == 3747 assert all(
748 segment_points_io.POINT_RECORD_SCHEMA[key].required
749 for key in segment_points_io.REQUIRED_POINT_RECORD_KEYS
750 )
751 assert segment_points_io.POINT_RECORD_SCHEMA[
752 segment_points_io.NUMBER_OF_RETURNS_KEY
753 ].storage_dtype == np.dtype(np.uint8)
754 assert segment_points_io.POINT_RECORD_SCHEMA["points"].columns == 3
752755
753756
754def test_point_field_spec_rejects_an_optional_key_without_a_fill() -> None:757def test_point_field_spec_rejects_an_optional_key_without_a_fill() -> None:
755 """An optional key with no fill would make pre-existing records unloadable."""758 """An optional key with no fill would make pre-existing records unloadable."""
756 with pytest.raises(ValueError, match="needs a fill factory"):759 with pytest.raises(ValueError, match="needs a fill factory"):
757 PointFieldSpec(required=False)760 segment_points_io.PointFieldSpec(required=False)
758761
759762
760def test_mask_record_masks_points_rows_and_ancillary_elements() -> None:763def test_mask_record_masks_points_rows_and_ancillary_elements() -> None:
761 record = _make_record(5, seed=80)764 record = _make_record(5, seed=80)
762 mask = np.array([True, False, True, False, True])765 mask = np.array([True, False, True, False, True])
763766
764 masked = mask_record(record, mask)767 masked = segment_points_io.mask_record(record, mask)
765768
766 assert list(masked) == list(record)769 assert list(masked) == list(record)
767 assert masked["points"].shape == (3, 3)770 assert masked["points"].shape == (3, 3)
768 np.testing.assert_array_equal(masked["points"], record["points"][mask])771 np.testing.assert_array_equal(masked["points"], record["points"][mask])
769 for key in POINT_RECORD_KEYS:772 for key in segment_points_io.POINT_RECORD_KEYS:
770 np.testing.assert_array_equal(masked[key], record[key][mask])773 np.testing.assert_array_equal(masked[key], record[key][mask])
771774
772775
773def test_mask_record_accepts_an_integer_index_array() -> None:776def test_mask_record_accepts_an_integer_index_array() -> None:
774 record = _make_record(4, seed=81)777 record = _make_record(4, seed=81)
775 index = np.array([3, 0])778 index = np.array([3, 0])
776779
777 masked = mask_record(record, index)780 masked = segment_points_io.mask_record(record, index)
778781
779 np.testing.assert_array_equal(masked["points"], record["points"][index])782 np.testing.assert_array_equal(masked["points"], record["points"][index])
780 np.testing.assert_array_equal(masked["intensity"], record["intensity"][index])783 np.testing.assert_array_equal(masked["intensity"], record["intensity"][index])
781784
Importance #114: tests/test_segment_points_io.py @@ -783,59 +786,61 @@
783def test_mask_record_rejects_misaligned_members() -> None:786def test_mask_record_rejects_misaligned_members() -> None:
784 record = _make_record(4, seed=82)787 record = _make_record(4, seed=82)
785 record["red"] = record["red"][:2]788 record["red"] = record["red"][:2]
786 with pytest.raises(ValueError, match="disagree on point count"):789 with pytest.raises(ValueError, match="disagree on point count"):
787 mask_record(record, np.array([True, False, True, False]))790 segment_points_io.mask_record(record, np.array([True, False, True, False]))
788791
789792
790def test_mask_record_rejects_an_empty_record() -> None:793def test_mask_record_rejects_an_empty_record() -> None:
791 with pytest.raises(ValueError, match="empty point record"):794 with pytest.raises(ValueError, match="empty point record"):
792 mask_record({}, np.array([True]))795 segment_points_io.mask_record({}, np.array([True]))
793796
794797
795def test_concat_records_joins_every_member_on_axis_zero() -> None:798def test_concat_records_joins_every_member_on_axis_zero() -> None:
796 first = _make_record(3, seed=83)799 first = _make_record(3, seed=83)
797 second = _make_record(2, seed=84)800 second = _make_record(2, seed=84)
798801
799 merged = concat_records([first, second])802 merged = segment_points_io.concat_records([first, second])
800803
801 assert list(merged) == list(POINT_RECORD_KEYS)804 assert list(merged) == list(segment_points_io.POINT_RECORD_KEYS)
802 assert merged["points"].shape == (5, 3)805 assert merged["points"].shape == (5, 3)
803 for key in POINT_RECORD_KEYS:806 for key in segment_points_io.POINT_RECORD_KEYS:
804 np.testing.assert_array_equal(807 np.testing.assert_array_equal(
805 merged[key], np.concatenate([first[key], second[key]], axis=0)808 merged[key], np.concatenate([first[key], second[key]], axis=0)
806 )809 )
807810
808811
809def test_concat_records_rejects_mismatched_key_sets() -> None:812def test_concat_records_rejects_mismatched_key_sets() -> None:
810 with pytest.raises(ValueError, match="different keys"):813 with pytest.raises(ValueError, match="different keys"):
811 concat_records([_make_record(2, seed=85), _make_legacy_record(2, seed=86)])814 segment_points_io.concat_records(
815 [_make_record(2, seed=85), _make_legacy_record(2, seed=86)]
816 )
812817
813818
814def test_concat_records_rejects_an_empty_sequence() -> None:819def test_concat_records_rejects_an_empty_sequence() -> None:
815 with pytest.raises(ValueError, match="empty sequence"):820 with pytest.raises(ValueError, match="empty sequence"):
816 concat_records([])821 segment_points_io.concat_records([])
817822
818823
819def test_concat_records_rejects_records_without_members() -> None:824def test_concat_records_rejects_records_without_members() -> None:
820 """Memberless records must raise, not silently concatenate to ``{}``."""825 """Memberless records must raise, not silently concatenate to ``{}``."""
821 with pytest.raises(ValueError, match="no members"):826 with pytest.raises(ValueError, match="no members"):
822 concat_records([{}, {}])827 segment_points_io.concat_records([{}, {}])
823828
824829
825def test_mask_record_rejects_zero_dimensional_members() -> None:830def test_mask_record_rejects_zero_dimensional_members() -> None:
826 """A 0-D member is not row-indexable: raise ValueError, not a raw IndexError."""831 """A 0-D member is not row-indexable: raise ValueError, not a raw IndexError."""
827 record = {key: np.asarray(1, dtype=np.uint8) for key in POINT_RECORD_KEYS}832 record = {key: np.asarray(1, dtype=np.uint8) for key in segment_points_io.POINT_RECORD_KEYS}
828833
829 with pytest.raises(ValueError, match="not 1-D or 2-D"):834 with pytest.raises(ValueError, match="not 1-D or 2-D"):
830 mask_record(record, np.array([True]))835 segment_points_io.mask_record(record, np.array([True]))
831836
832837
833# --- Adding a field to the contract must be a registry entry and nothing else ---838# --- Adding a field to the contract must be a registry entry and nothing else ---
834839
835EXTRA_KEY = "point_source_id"840EXTRA_KEY = "point_source_id"
836841
837EXTRA_SPEC = PointFieldSpec(842EXTRA_SPEC = segment_points_io.PointFieldSpec(
838 required=False,843 required=False,
839 storage_dtype=np.dtype(np.int16),844 storage_dtype=np.dtype(np.int16),
840 fill=functools.partial(np.full, fill_value=-1, dtype=np.int16),845 fill=functools.partial(np.full, fill_value=-1, dtype=np.int16),
841 noun="source id",846 noun="source id",
Importance #115: tests/test_segment_points_io.py @@ -847,11 +852,11 @@
847 """Register one extra optional key, exactly as a real schema addition would."""852 """Register one extra optional key, exactly as a real schema addition would."""
848 monkeypatch.setattr(853 monkeypatch.setattr(
849 segment_points_io,854 segment_points_io,
850 "POINT_RECORD_SCHEMA",855 "POINT_RECORD_SCHEMA",
851 MappingProxyType({**POINT_RECORD_SCHEMA, EXTRA_KEY: EXTRA_SPEC}),856 types.MappingProxyType({**segment_points_io.POINT_RECORD_SCHEMA, EXTRA_KEY: EXTRA_SPEC}),
852 )857 )
853 return (*POINT_RECORD_KEYS, EXTRA_KEY)858 return (*segment_points_io.POINT_RECORD_KEYS, EXTRA_KEY)
854859
855860
856def _make_extended_record(n: int, *, seed: int) -> dict[str, np.ndarray]:861def _make_extended_record(n: int, *, seed: int) -> dict[str, np.ndarray]:
857 record = _make_record(n, seed=seed)862 record = _make_record(n, seed=seed)
Importance #116: tests/test_segment_points_io.py @@ -859,77 +864,79 @@
859 return record864 return record
860865
861866
862def test_registry_entry_alone_round_trips_a_new_field(867def test_registry_entry_alone_round_trips_a_new_field(
863 tmp_path: Path, extended_schema: tuple[str, ...]868 tmp_path: pathlib.Path, extended_schema: tuple[str, ...]
864) -> None:869) -> None:
865 record = _make_extended_record(4, seed=90)870 record = _make_extended_record(4, seed=90)
866871
867 path = save_points_npz(tmp_path / "extended_run3_points.npz", record)872 path = segment_points_io.save_points_npz(tmp_path / "extended_run3_points.npz", record)
868873
869 with np.load(path) as data:874 with np.load(path) as data:
870 assert EXTRA_KEY in data.files875 assert EXTRA_KEY in data.files
871 loaded = load_points_npz(path)876 loaded = segment_points_io.load_points_npz(path)
872 assert list(loaded) == list(extended_schema)877 assert list(loaded) == list(extended_schema)
873 assert loaded[EXTRA_KEY].dtype == np.int16878 assert loaded[EXTRA_KEY].dtype == np.int16
874 np.testing.assert_array_equal(loaded[EXTRA_KEY], record[EXTRA_KEY])879 np.testing.assert_array_equal(loaded[EXTRA_KEY], record[EXTRA_KEY])
875880
876881
877def test_registry_entry_alone_coerces_and_validates_a_new_field(882def test_registry_entry_alone_coerces_and_validates_a_new_field(
878 tmp_path: Path, extended_schema: tuple[str, ...]883 tmp_path: pathlib.Path, extended_schema: tuple[str, ...]
879) -> None:884) -> None:
880 record = _make_extended_record(3, seed=91)885 record = _make_extended_record(3, seed=91)
881 record[EXTRA_KEY] = record[EXTRA_KEY].astype(np.int64)886 record[EXTRA_KEY] = record[EXTRA_KEY].astype(np.int64)
882 loaded = load_points_npz(save_points_npz(tmp_path / "cast_run3_points.npz", record))887 loaded = segment_points_io.load_points_npz(
888 segment_points_io.save_points_npz(tmp_path / "cast_run3_points.npz", record)
889 )
883 assert loaded[EXTRA_KEY].dtype == np.int16890 assert loaded[EXTRA_KEY].dtype == np.int16
884891
885 out_of_range = _make_extended_record(3, seed=92)892 out_of_range = _make_extended_record(3, seed=92)
886 out_of_range[EXTRA_KEY] = np.array([1, 40_000, 3], dtype=np.int32)893 out_of_range[EXTRA_KEY] = np.array([1, 40_000, 3], dtype=np.int32)
887 with pytest.raises(ValueError, match=f"{EXTRA_KEY}.*fit in int16"):894 with pytest.raises(ValueError, match=f"{EXTRA_KEY}.*fit in int16"):
888 save_points_npz(tmp_path / "range_run3_points.npz", out_of_range)895 segment_points_io.save_points_npz(tmp_path / "range_run3_points.npz", out_of_range)
889896
890 boolean = _make_extended_record(3, seed=93)897 boolean = _make_extended_record(3, seed=93)
891 boolean[EXTRA_KEY] = np.array([True, False, True])898 boolean[EXTRA_KEY] = np.array([True, False, True])
892 with pytest.raises(ValueError, match=f"{EXTRA_KEY}.*bool"):899 with pytest.raises(ValueError, match=f"{EXTRA_KEY}.*bool"):
893 save_points_npz(tmp_path / "bool_run3_points.npz", boolean)900 segment_points_io.save_points_npz(tmp_path / "bool_run3_points.npz", boolean)
894901
895 misshaped = _make_extended_record(3, seed=94)902 misshaped = _make_extended_record(3, seed=94)
896 misshaped[EXTRA_KEY] = misshaped[EXTRA_KEY].reshape(3, 1)903 misshaped[EXTRA_KEY] = misshaped[EXTRA_KEY].reshape(3, 1)
897 with pytest.raises(ValueError, match=rf"'{EXTRA_KEY}' must have shape \(N,\)"):904 with pytest.raises(ValueError, match=rf"'{EXTRA_KEY}' must have shape \(N,\)"):
898 save_points_npz(tmp_path / "shape_run3_points.npz", misshaped)905 segment_points_io.save_points_npz(tmp_path / "shape_run3_points.npz", misshaped)
899906
900907
901def test_registry_entry_alone_fills_and_requires_a_new_field(908def test_registry_entry_alone_fills_and_requires_a_new_field(
902 tmp_path: Path, extended_schema: tuple[str, ...]909 tmp_path: pathlib.Path, extended_schema: tuple[str, ...]
903) -> None:910) -> None:
904 older = _make_record(5, seed=95)911 older = _make_record(5, seed=95)
905 path = tmp_path / "older_run3_points.npz"912 path = tmp_path / "older_run3_points.npz"
906 np.savez_compressed(path, **older)913 np.savez_compressed(path, **older)
907914
908 loaded = load_points_npz(path)915 loaded = segment_points_io.load_points_npz(path)
909916
910 assert list(loaded) == list(extended_schema)917 assert list(loaded) == list(extended_schema)
911 np.testing.assert_array_equal(loaded[EXTRA_KEY], np.full(5, -1, dtype=np.int16))918 np.testing.assert_array_equal(loaded[EXTRA_KEY], np.full(5, -1, dtype=np.int16))
912 with pytest.raises(ValueError, match=EXTRA_KEY):919 with pytest.raises(ValueError, match=EXTRA_KEY):
913 save_points_npz(tmp_path / "incomplete_run3_points.npz", older)920 segment_points_io.save_points_npz(tmp_path / "incomplete_run3_points.npz", older)
914921
915922
916def test_registry_entry_alone_flows_through_merge_mask_and_concat(923def test_registry_entry_alone_flows_through_merge_mask_and_concat(
917 tmp_path: Path, extended_schema: tuple[str, ...]924 tmp_path: pathlib.Path, extended_schema: tuple[str, ...]
918) -> None:925) -> None:
919 seg_dir = tmp_path / "segment_042"926 seg_dir = tmp_path / "segment_042"
920 seg_dir.mkdir()927 seg_dir.mkdir()
921 first = _make_extended_record(3, seed=96)928 first = _make_extended_record(3, seed=96)
922 second = _make_extended_record(2, seed=97)929 second = _make_extended_record(2, seed=97)
923 save_points_npz(seg_dir / "a_run3_points.npz", first)930 segment_points_io.save_points_npz(seg_dir / "a_run3_points.npz", first)
924 save_points_npz(seg_dir / "b_run3_points.npz", second)931 segment_points_io.save_points_npz(seg_dir / "b_run3_points.npz", second)
925932
926 merged, spans = load_run3_segment(seg_dir)933 merged, spans = segment_points_io.load_run3_segment(seg_dir)
927 segment_merged, point_file_ids, _ = load_segment_points(934 segment_merged, point_file_ids, _ = segment_points_io.load_segment_points(
928 [seg_dir / "a_run3_points.npz", seg_dir / "b_run3_points.npz"]935 [seg_dir / "a_run3_points.npz", seg_dir / "b_run3_points.npz"]
929 )936 )
930 masked = mask_record(merged, np.array([True, False, True, False, True]))937 masked = segment_points_io.mask_record(merged, np.array([True, False, True, False, True]))
931 joined = concat_records([first, second])938 joined = segment_points_io.concat_records([first, second])
932939
933 expected = np.concatenate([first[EXTRA_KEY], second[EXTRA_KEY]], axis=0)940 expected = np.concatenate([first[EXTRA_KEY], second[EXTRA_KEY]], axis=0)
934 assert [span.count for span in spans] == [3, 2]941 assert [span.count for span in spans] == [3, 2]
935 assert point_file_ids.shape == (5,)942 assert point_file_ids.shape == (5,)
Importance #117: tests/test_segment_points_io.py @@ -940,7 +947,7 @@
940947
941948
942def test_extended_schema_does_not_leak_into_the_real_contract() -> None:949def test_extended_schema_does_not_leak_into_the_real_contract() -> None:
943 """The monkeypatched registry above must not pollute the shipped schema."""950 """The monkeypatched registry above must not pollute the shipped schema."""
944 assert EXTRA_KEY not in POINT_RECORD_SCHEMA951 assert EXTRA_KEY not in _POINT_RECORD_SCHEMA_AT_IMPORT
945 assert EXTRA_KEY not in segment_points_io.POINT_RECORD_SCHEMA952 assert EXTRA_KEY not in segment_points_io.POINT_RECORD_SCHEMA
946 assert tuple(segment_points_io.POINT_RECORD_SCHEMA) == POINT_RECORD_KEYS953 assert tuple(segment_points_io.POINT_RECORD_SCHEMA) == _POINT_RECORD_KEYS_AT_IMPORT