Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions dascore/core/_spool_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
VALID_COORDINATE_LABELS,
Inventory,
ResolvedContext,
axis_columns,
)
from dascore.core.inventory_loader import BLESSED_NAME, find_inventory
from dascore.exceptions import (
Expand Down Expand Up @@ -665,14 +666,27 @@ def _get_geometry_coord(inventory, path, label, distances):
index = crs.axis_index(label)
except InvalidInventoryError:
return None
if not path.geometry:
return None
coords = path.coordinates_at(distances)
if index >= coords.shape[1]:
# Geometry which places nothing defines no axis, so the caller's
# on_missing policy rules rather than a column of nan.
if not any(axis_columns(x, crs) for x in path.geometry):
return None
# axis_index refuses a label this CRS has no axis for, and the array is
# one column per axis, so the index is always one of its columns.
coords = path.coordinates_at(distances, crs)
return get_coord(data=coords[:, index], units=crs.units[index])


def _get_geometry_column_coord(path, name, distances):
"""Return one geometry column which is not a position, with its units."""
values = path.column_at(name, distances)
if values is None:
return None
# The path has already refused two segments measuring one column in two
# units, so whichever states it states the one they agree on.
units = next((x.units[name] for x in path.geometry if name in x.units), None)
return get_coord(data=values, units=units)


def get_coord_values(inventory, path, name, distances):
"""Return the values of one requested coordinate, or None if undefined."""
if name == "distance":
Expand All @@ -687,7 +701,13 @@ def get_coord_values(inventory, path, name, distances):
path, track, field or TRACK_IDENTITY_FIELDS[track], distances
)
if name in VALID_COORDINATE_LABELS:
return _get_geometry_coord(inventory, path, name, distances)
# A coordinate label this CRS does not declare is not an axis here,
# and is free to be a geometry column of its own -- depth, where the
# CRS is spent on easting, northing, and elevation.
if (coord := _get_geometry_coord(inventory, path, name, distances)) is not None:
return coord
if (coord := _get_geometry_column_coord(path, name, distances)) is not None:
return coord
return _get_annotation_coord(path, name, distances)


Expand Down
383 changes: 303 additions & 80 deletions dascore/core/inventory.py

Large diffs are not rendered by default.

135 changes: 93 additions & 42 deletions dascore/core/inventory_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,9 @@ class _Table(NamedTuple):
# field, so nothing else records where a row sits and it must place
# each row unambiguously.
places: bool = False
# The field every column but the order gathers into, keyed by header.
# None where each column names a field of the object directly.
columns: str | None = None


# Keyed by CSV stem, which is the attribute the table fills. Held as a
Expand All @@ -487,7 +490,9 @@ class _Table(NamedTuple):
"optical_components": _Table(order="sequence", places=True),
"coupling": _Table(),
"annotations": _Table(),
"geometry": _Table(points=True, group="segment", order="distance"),
"geometry": _Table(
points=True, group="segment", order="distance", columns="coordinates"
),
"distance_map": _Table(points=True, order="distance"),
}

Expand Down Expand Up @@ -535,14 +540,18 @@ def _object_rows(frame: pd.DataFrame, table: _Table, path: Path) -> list[dict]:
return out


def _point_rows(frame: pd.DataFrame, table: _Table, path: Path, axes) -> list[dict]:
def _point_rows(
frame: pd.DataFrame, table: _Table, path: Path, units: Mapping[str, str]
) -> list[dict]:
"""
Read a table whose every row is one control point.

Points gather into objects holding parallel arrays: one object per
value of the grouping column, or a single object when the attribute
is one. Coordinate columns are named by the CRS and are stored on the
canonical axes, so the frame decides which column is which.
is one. Where the table gathers its columns (``geometry``), every
column but the ordering one becomes an entry of that mapping keyed by
its header, so a name the file never states is a column the segment
does not have.
"""
require_columns(frame, [table.order, table.group], path)
require_stated(frame, [table.order, table.group], path)
Expand All @@ -558,8 +567,9 @@ def _point_rows(frame: pd.DataFrame, table: _Table, path: Path, axes) -> list[di
out = []
for name, rows in groups:
point: dict[str, Any] = {} if name is None else {"name": str(name)}
gathered: dict[str, tuple] = {}
for column in rows.columns:
if column == table.group or column in axes:
if column == table.group:
continue
stated = rows[column].notna()
if not stated.any():
Expand All @@ -584,35 +594,18 @@ def _point_rows(frame: pd.DataFrame, table: _Table, path: Path, axes) -> list[di
"is stated by every point or by none."
)
raise InvalidInventoryError(msg)
point[column] = tuple(rows[column])
if axes:
point["coordinates"] = _coordinates(rows, axes, path)
if table.columns is not None and column != table.order:
gathered[column] = tuple(rows[column])
else:
point[column] = tuple(rows[column])
if table.columns is not None:
point[table.columns] = gathered
if stated := {x: units[x] for x in gathered if x in units}:
point["units"] = stated
out.append(point)
return out


def _coordinates(rows: pd.DataFrame, axes: Mapping[str, int], path: Path):
"""
Gather a geometry table's labelled columns onto the canonical axes.

The CRS names the axes and states their order, so a header is read by
which axis it names rather than by where it sits in the file.
"""
ordered = sorted(axes, key=lambda label: axes[label])
out = []
for _, row in rows.iterrows():
stated = [row[label] for label in ordered]
if any(pd.isnull(x) for x in stated):
missing = [x for x, v in zip(ordered, stated, strict=True) if pd.isnull(v)]
msg = (
f"{_quote(path)} leaves {', '.join(missing)} empty for a point; "
"a coordinate states every axis its frame declares."
)
raise InvalidInventoryError(msg)
out.append(tuple(stated))
return tuple(out)


def _is_path_dir(child: Path) -> bool:
"""Return True if a directory name claims to be an optical path epoch."""
# is_dir() follows a link, and the stray walk steps over one, so a
Expand Down Expand Up @@ -835,35 +828,93 @@ def _read_track_table(path: Path, table: _Table, stem: str, crs):
if frame.empty:
msg = f"{_quote(path)} states no rows, so it describes no {stem}."
raise InvalidInventoryError(msg)
axes = _geometry_axes(frame, crs, path) if stem == "geometry" else {}
units: Mapping[str, str] = {}
if stem == "geometry":
frame, units = _geometry_columns(frame, crs, path)
if not table.points:
rows = _object_rows(frame, table, path)
if stem == "annotations":
_parse_annotations(rows, path)
return rows
built = _point_rows(frame, table, path, axes)
built = _point_rows(frame, table, path, units)
# A single object rather than a collection: the table has no grouping
# column because every point belongs to the one map it describes.
return built if table.group is not None else built[0]


def _geometry_axes(frame: pd.DataFrame, crs, path: Path) -> dict[str, int]:
# A scalar column may state its units in its header, `depth (m)`. The axes
# take theirs from the CRS, so a unit on one of those is refused below.
_UNIT_SUFFIX = re.compile(r"^(?P<name>.*?)\s*\((?P<units>[^()]*)\)$")


def _geometry_columns(frame: pd.DataFrame, crs, path: Path):
"""
Return which column names which canonical axis.
Read a geometry table's headers, and refuse what cannot be a column.

Coordinates are stored on the canonical axes while a geometry table
names them the way its frame does, so the CRS decides both which
headers are legal and what each one means.
A header naming an axis the CRS declares is that axis and takes the
CRS's units; every other one is a numeric column in its own right, and
may carry its units in parentheses. The axes are all stated or none
are, since guessing the missing one is not a reader's job. Text is
refused: a value which varies along the fiber without being a number
is what annotations are for.
"""

def is_axis(name: str) -> bool:
"""Whether the CRS reads this header as one of its own axes."""
try:
crs.axis_index(name)
except InvalidInventoryError:
return False
return True

labels = tuple(crs.coordinate_labels)
stated = {x for x in frame.columns} - {"segment", "distance"}
if stated != set(labels):
renamed, units = {}, {}
for header in frame.columns:
if header in {"segment", "distance"}:
continue
name, unit = header, ""
if (match := _UNIT_SUFFIX.match(header)) is not None:
name, unit = match.group("name"), match.group("units").strip()
renamed[header] = name
Comment on lines +875 to +878

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unit suffixes on structural geometry headers

If a CSV spells the required optical-distance header as distance (ft), this normalization renames it to distance, allowing _point_rows() to use those values as the geometry's meter-based optical distances while the parsed ft unit is discarded because ordering columns are not gathered into units. The inventory then loads successfully with every geometry interval misplaced by the unit conversion factor. Refuse suffixes that normalize to distance or segment rather than treating them as ordinary numeric columns.

Useful? React with 👍 / 👎.

if unit and is_axis(name):
msg = (
f"{_quote(path)} states units for {name!r}, which is a "
"position axis; the CRS states the units of its own axes."
)
raise InvalidInventoryError(msg)
if unit:
units[name] = unit
# Counted against the structural columns as well: `distance (m)` renames
# to a column the table already has, and two of them would reach pandas
# rather than this message.
written = [*renamed.values(), "segment", "distance"]
if repeated := sorted({x for x in written if written.count(x) > 1}):
msg = (
f"{_quote(path)} states the coordinate columns {sorted(stated)}, "
f"but its frame declares {list(labels)}."
f"{_quote(path)} names the column(s) {repeated} more than once; "
"one column states one thing."
)
raise InvalidInventoryError(msg)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return {label: index for index, label in enumerate(labels)}
axes = {x for x in renamed.values() if is_axis(x)}
if axes and len(axes) != len(labels):
msg = (
f"{_quote(path)} states the axis column(s) {sorted(axes)}, but "
f"its frame declares {list(labels)}; a segment states every axis "
"or none of them."
)
raise InvalidInventoryError(msg)
frame = frame.rename(columns=renamed).copy()
for column in sorted(set(renamed.values())):
values = pd.to_numeric(frame[column], errors="coerce")
if (bad := frame[column].notna() & values.isna()).any():
msg = (
f"{_quote(path)} states {frame.loc[bad, column].iloc[0]!r} in "
f"column {column!r}, which is not a number. A geometry column "
"is numeric; text which varies along the fiber belongs in "
"annotations.csv."
)
raise InvalidInventoryError(msg)
frame[column] = values
return frame, units


def _parse_annotations(rows: list[dict], path: Path) -> None:
Expand Down
8 changes: 7 additions & 1 deletion dascore/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,13 @@ def inventory_patch_pair():
Geometry(
name="trench",
distance=(100.0, 400.0),
coordinates=((-117.0, 40.0, 1500.0), (-117.0, 40.1, 1500.0)),
# The canonical axis names, so the segment states the CRS's
# axes whatever this inventory's CRS happens to call them.
coordinates={
"x": (-117.0, -117.0),
"y": (40.0, 40.1),
"z": (1500.0, 1500.0),
},
),
),
coupling=(
Expand Down
18 changes: 14 additions & 4 deletions dascore/proc/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@
validate_enrich_selection,
)
from dascore.core.coords import BaseCoord, get_coord
from dascore.core.inventory import Interrogator, Inventory, ResolvedContext
from dascore.core.inventory import (
Interrogator,
Inventory,
ResolvedContext,
axis_columns,
)
from dascore.exceptions import (
InvalidInventoryError,
ParameterError,
Expand Down Expand Up @@ -321,13 +326,18 @@ def _get_blanket_coord_names(inventory, path) -> list[str]:
"""
Return the coordinate names a blanket request copies.

The geometry axes and the annotation groups: what the path says about
The geometry columns and the annotation groups: what the path says about
each channel. Optical distance and the typed-track fields are asked for
by name, since they restate what the patch's own axis and the inventory
already record.
"""
labels = inventory.coordinate_reference_system.coordinate_labels
out = ["x", "y", "z"][: len(labels)] if path.geometry else []
crs = inventory.coordinate_reference_system
labels = crs.coordinate_labels
# The axes are copied under their canonical names, and only where some
# segment actually places the fiber; the rest come under their own.
axes = {x for segment in path.geometry for x in axis_columns(segment, crs)}
out = ["x", "y", "z"][: len(labels)] if axes else []
out += [x for x in path.geometry_columns() if x not in axes]
seen = dict.fromkeys(x.group for x in path.annotations)
return out + [x for x in seen if x]

Expand Down
2 changes: 1 addition & 1 deletion docs/recipes/tunnel_inventory.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ print("borehole 3 sensing fiber:", at["borehole 3 down"][0], "to", at["borehole

# Where the fiber is

Geometry places optical distance in space, one row per control point, grouped into segments. The columns after `segment` and `distance` are the axes the coordinate reference system declared, and naming any others is an error rather than an extra.
Geometry holds the curves measured along the fiber, one row per control point, grouped into segments. A column whose name the coordinate reference system declares is that position axis, and a segment stating one states them all; any other column is a number in its own right, which this deployment has no use for but a borehole survey would.

The survey points are the ones lettered in @fig-tunnel. Each straight run of fiber goes from one to the next, so the table is those points paired with the component that runs between them.

Expand Down
Loading
Loading