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
2 changes: 1 addition & 1 deletion dascore/core/attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
DataType,
max_lens,
)
from dascore.models import DascoreBaseModel, UnitQuantity
from dascore.utils.misc import (
to_str,
validate_acquisition_key,
)
from dascore.utils.models import DascoreBaseModel, UnitQuantity

str_validator = PlainValidator(to_str)

Expand Down
12 changes: 6 additions & 6 deletions dascore/core/coordmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@
ParameterError,
PatchBroadcastError,
)
from dascore.models import (
ArrayLike,
DascoreBaseModel,
frozen_dict_serializer,
frozen_dict_validator,
)
from dascore.utils.docs import compose_docstring
from dascore.utils.mapping import FrozenDict
from dascore.utils.misc import (
Expand All @@ -75,12 +81,6 @@
cached_method,
iterate,
)
from dascore.utils.models import (
ArrayLike,
DascoreBaseModel,
frozen_dict_serializer,
frozen_dict_validator,
)

MaybeArray = ArrayLike | np.ndarray | None

Expand Down
10 changes: 5 additions & 5 deletions dascore/core/coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
from dascore.compat import array, is_array
from dascore.constants import _AGG_FUNCS, DIM_REDUCE_DOCS, dascore_styles
from dascore.exceptions import CoordError, ParameterError
from dascore.models import (
ArrayLike,
DascoreBaseModel,
UnitQuantity,
)
from dascore.units import (
Quantity,
Unit,
Expand Down Expand Up @@ -63,11 +68,6 @@
iterate,
sanitize_range_param,
)
from dascore.utils.models import (
ArrayLike,
DascoreBaseModel,
UnitQuantity,
)
from dascore.utils.time import (
dtype_time_like,
is_datetime64,
Expand Down
56 changes: 29 additions & 27 deletions dascore/core/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,21 @@

from dascore.constants import DataCategory, DataType
from dascore.exceptions import InvalidInventoryError, ParameterError
from dascore.models import (
DateTime64,
FiniteFloat,
FrozenDictType,
InventoryModel,
TimeRangedModel,
UnitQuantity,
)
from dascore.utils.mapping import FrozenDict
from dascore.utils.misc import (
check_code,
is_strictly_monotonic,
optional_import,
validate_acquisition_key,
)
from dascore.utils.models import (
DateTime64,
FrozenDictType,
InventoryModel,
TimeRangedModel,
UnitQuantity,
)

CouplingType = Literal[
"conduit",
Expand Down Expand Up @@ -90,8 +91,6 @@
# The token rule is shared with PatchAttrs.acquisition_key so a code legal
# in one is legal in the other.
CodeStr = Annotated[str, AfterValidator(check_code)]
# A float which must be finite; nan/inf silently poison downstream math.
FiniteFloat = Annotated[float, Field(allow_inf_nan=False)]
# Sensor orientation, in the ranges seismology already uses.
Azimuth = Annotated[float, Field(ge=0, lt=360, allow_inf_nan=False)]
Dip = Annotated[float, Field(ge=-90, le=90, allow_inf_nan=False)]
Expand Down Expand Up @@ -129,14 +128,17 @@ def _annotation_value(value):
]


def _type_tag(name: str):
def _object_type_tag(name: str):
"""
Return the serialization-only type tag field for a tagged model class.
Return the serialization-only ``object_type`` field of a union member.

Every model states its class in a json document (see
[dascore.models.registry](`dascore.models.registry`)), but pydantic must
pick a union member's class before an object exists, so these models
declare the tag as a real field and the base class leaves them alone.

The tag drives union dispatch and the authoring format's type
declaration in serialized YAML/JSON. Users never set it: it defaults to
the class name, the Literal annotation rejects any other value, and it
is hidden from repr.
Users never set it: it defaults to the class name, the Literal
annotation rejects any other value, and it is hidden from repr.
"""
return Field(default=name, repr=False)

Expand Down Expand Up @@ -254,7 +256,7 @@ def axis_index(self, label: str) -> int:
class ExternalResource(InventoryModel):
"""External resource identified but not otherwise modeled by DASCore."""

type: Literal["ExternalResource"] = _type_tag("ExternalResource")
object_type: Literal["ExternalResource"] = _object_type_tag("ExternalResource")
resource_id: ResourceIdStr
uri: str = Field(default="", description="URI or identifier for the resource.")
name: str = Field(default="", description="Human-readable resource name.")
Expand All @@ -270,7 +272,7 @@ class OpticalMeasurement(InventoryModel):
datasheet claim is a legitimate record (method="datasheet").
"""

type: Literal["OpticalMeasurement"] = _type_tag("OpticalMeasurement")
object_type: Literal["OpticalMeasurement"] = _object_type_tag("OpticalMeasurement")
resource_id: ResourceIdStr
name: str = Field(default="", description="Human-readable measurement name.")
method: str = Field(
Expand Down Expand Up @@ -301,7 +303,7 @@ class OpticalMeasurement(InventoryModel):
class Interrogator(InventoryModel):
"""DFOS interrogator unit used for data collection."""

type: Literal["Interrogator"] = _type_tag("Interrogator")
object_type: Literal["Interrogator"] = _object_type_tag("Interrogator")
resource_id: ResourceIdStr
name: str = Field(default="", description="Human-readable resource name.")
manufacturer: str = Field(default="", description="Manufacturer name.")
Expand All @@ -315,7 +317,7 @@ class Interrogator(InventoryModel):
class Enclosure(InventoryModel):
"""Physical housing, pipe, duct, conduit, or carrier resource."""

type: Literal["Enclosure"] = _type_tag("Enclosure")
object_type: Literal["Enclosure"] = _object_type_tag("Enclosure")
resource_id: ResourceIdStr
name: str = Field(default="", description="Human-readable resource name.")
enclosure_type: str = Field(
Expand All @@ -340,7 +342,7 @@ class Enclosure(InventoryModel):
class Cable(InventoryModel):
"""Physical cable containing one or more fiber segments."""

type: Literal["Cable"] = _type_tag("Cable")
object_type: Literal["Cable"] = _object_type_tag("Cable")
resource_id: ResourceIdStr
name: str = Field(default="", description="Human-readable resource name.")
manufacturer: str = Field(default="", description="Manufacturer name.")
Expand All @@ -361,7 +363,7 @@ class Cable(InventoryModel):

_Resource: TypeAlias = Annotated[
Interrogator | Cable | Enclosure | ExternalResource | OpticalMeasurement,
Field(discriminator="type"),
Field(discriminator="object_type"),
]


Expand Down Expand Up @@ -432,7 +434,7 @@ def _check_measurement_pairing(self) -> Self:
class FiberSegment(_OpticalComponentBase):
"""Length of optical fiber within a cable, patch cord, or other run."""

type: Literal["FiberSegment"] = _type_tag("FiberSegment")
object_type: Literal["FiberSegment"] = _object_type_tag("FiberSegment")
container: Cable | str | None = Field(
default=None, description="Cable containing this fiber."
)
Expand Down Expand Up @@ -471,7 +473,7 @@ def attenuation_db_per_km(self) -> float | tuple[float, ...] | None:
class Connector(_OpticalComponentBase):
"""Optical connector in an optical path."""

type: Literal["Connector"] = _type_tag("Connector")
object_type: Literal["Connector"] = _object_type_tag("Connector")
container: Enclosure | str | None = Field(
default=None, description="Enclosure housing this connector."
)
Expand All @@ -481,7 +483,7 @@ class Connector(_OpticalComponentBase):
class Splice(_OpticalComponentBase):
"""Optical splice in an optical path."""

type: Literal["Splice"] = _type_tag("Splice")
object_type: Literal["Splice"] = _object_type_tag("Splice")
container: Enclosure | str | None = Field(
default=None, description="Enclosure housing this splice."
)
Expand All @@ -491,7 +493,7 @@ class Splice(_OpticalComponentBase):
class Terminator(_OpticalComponentBase):
"""Optical path terminator."""

type: Literal["Terminator"] = _type_tag("Terminator")
object_type: Literal["Terminator"] = _object_type_tag("Terminator")
container: Enclosure | str | None = Field(
default=None, description="Enclosure housing this terminator."
)
Expand All @@ -502,7 +504,7 @@ class Terminator(_OpticalComponentBase):

OpticalComponent: TypeAlias = Annotated[
FiberSegment | Connector | Splice | Terminator,
Field(discriminator="type"),
Field(discriminator="object_type"),
]


Expand Down Expand Up @@ -1687,7 +1689,7 @@ class InventoryNames(NamedTuple):
# Fields which name or tag a record rather than describe it: the type
# discriminator and resource id a shareable record carries, and the codes
# locating an acquisition (a patch's acquisition_key already states them).
_IDENTITY_FIELDS = frozenset({"type", "resource_id", "code", "location_code"})
_IDENTITY_FIELDS = frozenset({"object_type", "resource_id", "code", "location_code"})

# What a distance-ranged track carries to place itself, which is its extent
# rather than a value the channels inside it take.
Expand Down
31 changes: 16 additions & 15 deletions dascore/core/inventory_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
skipped, so a directory cannot load as an entity silently missing the
tracks its own files state.

The contract, in one line: **file declares type, container agrees, name
The contract, in one line: **file declares object_type, container agrees, name
implies identity, envelope implies version.** Every object file states what
it is, its container checks that statement rather than supplying it, its
name decides which entity it is, and the top-level ``inventory.yaml``
Expand Down Expand Up @@ -56,8 +56,9 @@
InvalidInventoryError,
MissingOptionalDependencyError,
)
from dascore.models import InventoryModel, TimeRangedModel
from dascore.models.registry import TAG_FIELD
from dascore.utils.misc import check_code, optional_import
from dascore.utils.models import InventoryModel, TimeRangedModel
from dascore.utils.time import to_datetime64

# One data model stands behind all three spellings, so they are accepted
Expand Down Expand Up @@ -229,7 +230,7 @@ def _declared_type(path: Path) -> str | None:
data = _read_object(path)
except (InvalidInventoryError, MissingOptionalDependencyError):
return None
declared = data.get("type")
declared = data.get(TAG_FIELD)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Continue accepting the previous type discriminator

Existing inventory directories written for schema version 1 declare every object with type, but _declared_type now checks only object_type; consequently dc.inventory(path) treats those files as untyped and fails with “declares no object_type.” Since this commit neither bumps the inventory schema version nor provides a migration path, accept type as a legacy alias while emitting object_type for new documents.

Useful? React with 👍 / 👎.

return declared if isinstance(declared, str) else None


Expand Down Expand Up @@ -317,27 +318,26 @@ def _pick_model(data: dict, container: _Container, source: Path):
The container never supplies the type: a file which does not say what
it is has not participated in the format, and one which says something
its container cannot hold is misfiled rather than reinterpreted.

The tag is left in the data. Every model reads its own, so the file's
statement is checked a second time by the object it builds.
"""
declared = data.get("type")
declared = data.get(TAG_FIELD)
legal = tuple(x.__name__ for x in container.models)
# Not merely absent: a type which is not a name at all -- a list, a
# mapping -- names no model either, and must not reach the lookups below.
if not isinstance(declared, str):
msg = (
f"{_quote(source)} declares no type. Every object file states "
f"what it is, e.g. 'type: {legal[0]}'."
f"{_quote(source)} declares no {TAG_FIELD}. Every object file "
f"states what it is, e.g. '{TAG_FIELD}: {legal[0]}'."
)
raise InvalidInventoryError(msg)
for model in container.models:
if model.__name__ == declared:
# The tag is a real, discriminating field only on the models
# which share a union; everywhere else it belongs to the format.
if "type" not in model.model_fields:
data.pop("type")
return model
known = "an inventory model" if declared in _model_names() else "unknown"
msg = (
f"{_quote(source)} declares type {declared!r} ({known}), which "
f"{_quote(source)} declares {TAG_FIELD} {declared!r} ({known}), which "
f"{source.parent.name} cannot hold. Expected one of {legal}."
)
raise InvalidInventoryError(msg)
Expand Down Expand Up @@ -793,10 +793,10 @@ def _load_envelope(root: Path) -> dict[str, Any] | None:
raise InvalidInventoryError(msg)
source = found[0]
data = _read_object(source)
if (declared := data.pop("type", None)) != Inventory.__name__:
if (declared := data.get(TAG_FIELD)) != Inventory.__name__:
msg = (
f"{_quote(source)} declares type {declared!r}; the envelope "
f"declares 'type: {Inventory.__name__}'."
f"{_quote(source)} declares {TAG_FIELD} {declared!r}; the envelope "
f"declares '{TAG_FIELD}: {Inventory.__name__}'."
)
raise InvalidInventoryError(msg)
_refuse_supplied(data, _ENVELOPE_COLLECTIONS, source, "the envelope")
Expand Down Expand Up @@ -837,7 +837,8 @@ def check(path: Path):
elif _object_suffix(path) is not None:
if (declared := _declared_type(path)) in known:
msg = (
f"{path.relative_to(root)} declares type {declared!r} but "
f"{path.relative_to(root)} declares {TAG_FIELD} "
f"{declared!r} but "
"nothing contains it. An inventory object lives in one of "
f"{tuple(_CONTAINERS)}, named for the entity it is."
)
Expand Down
2 changes: 1 addition & 1 deletion dascore/core/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@
from dascore.core.attrs import PatchAttrs
from dascore.core.coordmanager import CoordManager, get_coord_manager
from dascore.core.summary import PatchSummary
from dascore.models import ArrayLike
from dascore.utils.array import (
PatchUFunc,
apply_ufunc,
patch_array_function,
patch_array_ufunc,
)
from dascore.utils.display import array_to_text, attrs_to_text, get_dascore_text
from dascore.utils.models import ArrayLike
from dascore.utils.namespace import NamespaceOwner
from dascore.utils.patch import check_patch_attrs, check_patch_coords, get_patch_names
from dascore.utils.time import to_float
Expand Down
6 changes: 3 additions & 3 deletions dascore/core/summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@

import numpy as np
import pandas as pd
from pydantic import ConfigDict, Field, model_validator
from pydantic import ConfigDict, Field, SerializeAsAny, model_validator

import dascore as dc
from dascore.constants import path_types
from dascore.core.attrs import PatchAttrs
from dascore.core.coords import CoordSummary
from dascore.utils.models import DascoreBaseModel
from dascore.models import DascoreBaseModel
from dascore.utils.paths import coerce_to_upath, is_pathlike


Expand Down Expand Up @@ -202,7 +202,7 @@ class PatchSummary(DascoreBaseModel):

model_config = ConfigDict(title="Patch Summary", extra="ignore", frozen=True)

attrs: PatchAttrs = Field(default_factory=PatchAttrs)
attrs: SerializeAsAny[PatchAttrs] = Field(default_factory=PatchAttrs)

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 Resolve tagged attrs when loading a PatchSummary

When PatchSummary.attrs is a format-specific subclass, SerializeAsAny now writes its fields and object_type, but a JSON round trip still passes the nested mapping through PatchAttrs.from_dict in _normalize_input. That validator only accepts and removes a subclass tag; it does not instantiate the named subclass, so PatchSummary.model_validate_json(summary.model_dump_json()).attrs becomes plain PatchAttrs and loses the subclass's typed validation. Resolve the nested tag before constructing the attrs value.

Useful? React with 👍 / 👎.

coords: dict[str, CoordSummary] = Field(default_factory=dict)
dims: tuple[str, ...] = ()
shape: tuple[int, ...] = ()
Expand Down
4 changes: 4 additions & 0 deletions dascore/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,7 @@ class RemoteCacheError(IOError, DASCoreError):

class InvalidInventoryError(ValueError, DASCoreError):
"""Raised when inventory metadata violates the DASDAE inventory model."""


class InvalidModelTagError(ValueError, DASCoreError):
"""Raised when a serialized document names its model class illegally."""
12 changes: 6 additions & 6 deletions dascore/io/ai4eps/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
import dascore as dc
from dascore.constants import opt_timeable_types
from dascore.io import FiberIO, ScanPayload, make_scan_payload
from dascore.models import DateTime64, OptionalFiniteFloat
from dascore.utils.hdf5 import H5Reader
from dascore.utils.models import DateTime64

from .utils import _get_attrs_dict, _get_coords, _get_patches, _is_ai4eps

Expand All @@ -19,12 +19,12 @@ class AI4EPSPatchAttrs(dc.PatchAttrs):
"""Patch attributes for AI4EPS event files."""

event_id: str = ""
event_time: DateTime64 = np.datetime64("NaT")
magnitude: float = np.nan
event_time: DateTime64 = np.datetime64("NaT", "ns")
magnitude: OptionalFiniteFloat = None
magnitude_type: str = ""
event_latitude: float = np.nan
event_longitude: float = np.nan
event_depth_km: float = np.nan
event_latitude: OptionalFiniteFloat = None
event_longitude: OptionalFiniteFloat = None
event_depth_km: OptionalFiniteFloat = None


class AI4EPSV1(FiberIO):
Expand Down
Loading
Loading