Source code for pycsamt.format.borehole.schema

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""In-memory schema and semantic validation for PCBH 0.1.

PCBH represents one or more boreholes independently of an inversion model.
This module contains no JSON, CSV, LAS, trajectory-desurvey, CRS-transform,
PCSF, or rendering code. Those responsibilities are intentionally deferred
to later implementation phases.
"""

from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime
from math import isfinite
from typing import Any

from ...api.property import MetadataMixin, PyCSAMTObject

__all__ = [
    "PCBH_VERSION",
    "SEVERITIES",
    "BOREHOLE_KINDS",
    "BOREHOLE_STATUSES",
    "DATA_NATURES",
    "NORTH_REFERENCES",
    "AZIMUTH_DIRECTIONS",
    "ValidationIssue",
    "PCBHValidationError",
    "CoordinateReferenceSystem",
    "UnitSystem",
    "Collar",
    "VocabularyEntry",
    "SurveyStation",
    "Trajectory",
    "LogInterval",
    "StructureObservation",
    "PCBHBorehole",
    "PCBHDocument",
]

PCBH_VERSION = "0.1.0"
SEVERITIES = ("error", "warning", "info")
BOREHOLE_KINDS = (
    "water",
    "mining_exploration",
    "mining_production",
    "geotechnical",
    "environmental",
    "petroleum",
    "geothermal",
    "scientific",
    "monitoring",
    "unknown",
)
BOREHOLE_STATUSES = (
    "planned",
    "drilling",
    "completed",
    "suspended",
    "abandoned",
    "decommissioned",
    "unknown",
)
DATA_NATURES = ("observed", "interpreted", "derived", "unknown")
NORTH_REFERENCES = ("true", "grid", "magnetic", "unknown")
AZIMUTH_DIRECTIONS = ("clockwise",)
TRAJECTORY_METHODS = ("vertical", "survey")
DESURVEY_METHODS = ("minimum_curvature",)
ORIENTATION_REPRESENTATIONS = (
    "none",
    "global_plane",
    "global_line",
    "core_alpha_beta",
)


def _join_path(base: str, field_name: str) -> str:
    return f"{base}.{field_name}" if base else field_name


def _is_namespaced(value: str) -> bool:
    left, sep, right = value.partition(":")
    return bool(sep and left and right)


def _is_allowed_or_namespaced(value: str, allowed: tuple[str, ...]) -> bool:
    return value in allowed or _is_namespaced(value)


def _finite_number(value: Any) -> bool:
    return (
        isinstance(value, (int, float))
        and not isinstance(value, bool)
        and isfinite(float(value))
    )


[docs] @dataclass(frozen=True, repr=False) class ValidationIssue(PyCSAMTObject): """One machine-readable PCBH validation finding.""" severity: str code: str message: str path: str = "" source: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: if self.severity not in SEVERITIES: raise ValueError(f"severity must be one of {SEVERITIES}") if not str(self.code).strip(): raise ValueError("validation issue code must be non-empty") if not str(self.message).strip(): raise ValueError("validation issue message must be non-empty")
[docs] class PCBHValidationError(ValueError): """Raised when error-severity issues remain after validation.""" def __init__(self, issues: Iterable[ValidationIssue]): self.issues = tuple( issue for issue in issues if issue.severity == "error" ) preview = "; ".join( f"{issue.path or '$'}: {issue.message}" for issue in self.issues[:5] ) if len(self.issues) > 5: preview += f"; ... ({len(self.issues) - 5} more)" super().__init__(preview or "PCBH validation failed")
class _Validatable: def collect_issues(self, path: str = "") -> list[ValidationIssue]: raise NotImplementedError def validate(self) -> None: issues = self.collect_issues() if any(issue.severity == "error" for issue in issues): raise PCBHValidationError(issues) def _issue( code: str, message: str, path: str, severity: str = "error" ) -> ValidationIssue: return ValidationIssue( severity=severity, code=code, message=message, path=path ) def _required_text(value: Any, path: str, code: str) -> list[ValidationIssue]: if not isinstance(value, str) or not value.strip(): return [_issue(code, "must be a non-empty string", path)] return [] def _optional_finite( value: Any, path: str, code: str ) -> list[ValidationIssue]: if value is not None and not _finite_number(value): return [_issue(code, "must be a finite number or null", path)] return []
[docs] @dataclass(repr=False) class CoordinateReferenceSystem(_Validatable, PyCSAMTObject): """Document-wide horizontal and vertical coordinate references.""" horizontal: str vertical: str = "unknown" axis_order: str = "xy" coordinate_unit: str = "m"
[docs] def collect_issues(self, path: str = "crs") -> list[ValidationIssue]: issues = _required_text( self.horizontal, _join_path(path, "horizontal"), "crs.horizontal" ) if self.axis_order != "xy": issues.append( _issue( "crs.axis_order", "must be 'xy' in PCBH 0.1", _join_path(path, "axis_order"), ) ) issues += _required_text( self.vertical, _join_path(path, "vertical"), "crs.vertical" ) issues += _required_text( self.coordinate_unit, _join_path(path, "coordinate_unit"), "crs.coordinate_unit", ) return issues
[docs] @dataclass(repr=False) class UnitSystem(_Validatable, PyCSAMTObject): """Units shared by all boreholes in one PCBH 0.1 document.""" depth: str = "m" diameter: str = "m" angle: str = "deg" resistivity: str = "ohm.m"
[docs] def collect_issues(self, path: str = "units") -> list[ValidationIssue]: issues: list[ValidationIssue] = [] for name in ("depth", "diameter", "angle", "resistivity"): issues += _required_text( getattr(self, name), _join_path(path, name), f"units.{name}" ) return issues
[docs] @dataclass(repr=False) class Collar(_Validatable, PyCSAMTObject): """Borehole reference/origin position in the document CRS.""" x: float y: float z: float longitude: float | None = None latitude: float | None = None position_uncertainty_m: float | None = None
[docs] def collect_issues(self, path: str = "collar") -> list[ValidationIssue]: issues: list[ValidationIssue] = [] for name in ("x", "y", "z"): if not _finite_number(getattr(self, name)): issues.append( _issue( f"collar.{name}", "must be a finite number", _join_path(path, name), ) ) issues += _optional_finite( self.longitude, _join_path(path, "longitude"), "collar.longitude" ) issues += _optional_finite( self.latitude, _join_path(path, "latitude"), "collar.latitude" ) if ( self.longitude is not None and _finite_number(self.longitude) and not -180 <= float(self.longitude) <= 180 ): issues.append( _issue( "collar.longitude_range", "must be within [-180, 180]", _join_path(path, "longitude"), ) ) if ( self.latitude is not None and _finite_number(self.latitude) and not -90 <= float(self.latitude) <= 90 ): issues.append( _issue( "collar.latitude_range", "must be within [-90, 90]", _join_path(path, "latitude"), ) ) if (self.longitude is None) != (self.latitude is None): issues.append( _issue( "collar.lonlat_pair", "longitude and latitude must be set together", path, ) ) issues += _optional_finite( self.position_uncertainty_m, _join_path(path, "position_uncertainty_m"), "collar.uncertainty", ) if ( _finite_number(self.position_uncertainty_m) and float(self.position_uncertainty_m) < 0 ): issues.append( _issue( "collar.uncertainty_range", "must be >= 0", _join_path(path, "position_uncertainty_m"), ) ) return issues
[docs] @dataclass(repr=False) class VocabularyEntry(_Validatable, PyCSAMTObject): """Self-contained geological or domain vocabulary entry.""" code: str name: str color: str | None = None description: str = "" external_ids: dict[str, str] = field(default_factory=dict) properties: dict[str, Any] = field(default_factory=dict)
[docs] def collect_issues( self, path: str = "vocabulary" ) -> list[ValidationIssue]: issues = _required_text( self.code, _join_path(path, "code"), "vocabulary.code" ) issues += _required_text( self.name, _join_path(path, "name"), "vocabulary.name" ) if self.color is not None: color = str(self.color) if ( len(color) != 7 or not color.startswith("#") or any(ch not in "0123456789abcdefABCDEF" for ch in color[1:]) ): issues.append( _issue( "vocabulary.color", "must be a #RRGGBB color", _join_path(path, "color"), ) ) if not isinstance(self.external_ids, dict) or not all( isinstance(k, str) and isinstance(v, str) for k, v in self.external_ids.items() ): issues.append( _issue( "vocabulary.external_ids", "must map strings to strings", _join_path(path, "external_ids"), ) ) return issues
[docs] @dataclass(repr=False) class SurveyStation(_Validatable, PyCSAMTObject): """One measured-depth orientation station.""" md: float azimuth_deg: float inclination_deg: float
[docs] def collect_issues( self, path: str = "survey_station" ) -> list[ValidationIssue]: issues: list[ValidationIssue] = [] for name in ("md", "azimuth_deg", "inclination_deg"): if not _finite_number(getattr(self, name)): issues.append( _issue( f"trajectory.{name}", "must be a finite number", _join_path(path, name), ) ) if _finite_number(self.md) and float(self.md) < 0: issues.append( _issue( "trajectory.md_range", "must be >= 0", _join_path(path, "md"), ) ) if ( _finite_number(self.azimuth_deg) and not 0 <= float(self.azimuth_deg) < 360 ): issues.append( _issue( "trajectory.azimuth_range", "must be within [0, 360)", _join_path(path, "azimuth_deg"), ) ) if ( _finite_number(self.inclination_deg) and not 0 <= float(self.inclination_deg) <= 180 ): issues.append( _issue( "trajectory.inclination_range", "must be within [0, 180]", _join_path(path, "inclination_deg"), ) ) if ( _finite_number(self.inclination_deg) and float(self.inclination_deg) > 90 ): issues.append( _issue( "trajectory.reentry", "inclination above 90 degrees represents an " "upward/re-entry segment", _join_path(path, "inclination_deg"), "warning", ) ) return issues
[docs] @dataclass(repr=False) class Trajectory(_Validatable, PyCSAMTObject): """Vertical shorthand or measured survey for a borehole path.""" method: str = "vertical" north_reference: str = "unknown" desurvey_method: str = "minimum_curvature" stations: list[SurveyStation] = field(default_factory=list)
[docs] def collect_issues( self, path: str = "trajectory" ) -> list[ValidationIssue]: issues: list[ValidationIssue] = [] if self.method not in TRAJECTORY_METHODS: issues.append( _issue( "trajectory.method", f"must be one of {TRAJECTORY_METHODS}", _join_path(path, "method"), ) ) if self.north_reference not in NORTH_REFERENCES: issues.append( _issue( "trajectory.north_reference", f"must be one of {NORTH_REFERENCES}", _join_path(path, "north_reference"), ) ) if self.desurvey_method not in DESURVEY_METHODS: issues.append( _issue( "trajectory.desurvey_method", f"must be one of {DESURVEY_METHODS}", _join_path(path, "desurvey_method"), ) ) if self.method == "vertical" and self.stations: issues.append( _issue( "trajectory.vertical_stations", "vertical trajectory must not contain survey stations", _join_path(path, "stations"), ) ) if self.method == "survey" and len(self.stations) < 2: issues.append( _issue( "trajectory.station_count", "survey trajectory needs at least two stations", _join_path(path, "stations"), ) ) previous: float | None = None for index, station in enumerate(self.stations): station_path = f"{path}.stations[{index}]" if not isinstance(station, SurveyStation): issues.append( _issue( "trajectory.station_type", "must be a SurveyStation", station_path, ) ) continue issues.extend(station.collect_issues(station_path)) if _finite_number(station.md): md = float(station.md) if previous is not None and md <= previous: issues.append( _issue( "trajectory.md_order", "station measured depths must increase strictly", _join_path(station_path, "md"), ) ) previous = md if ( self.method == "survey" and self.stations and _finite_number(self.stations[0].md) and float(self.stations[0].md) != 0 ): issues.append( _issue( "trajectory.missing_collar_station", "first survey station should be at md 0", f"{path}.stations[0].md", "warning", ) ) if self.north_reference == "magnetic": issues.append( _issue( "trajectory.magnetic_provenance", "magnetic azimuth requires declination/date/source " "provenance before conversion", _join_path(path, "north_reference"), "warning", ) ) return issues
[docs] @dataclass(repr=False) class LogInterval(_Validatable, PyCSAMTObject): """One measured-depth interval in a named log family.""" from_md: float to_md: float code: str | None = None label: str | None = None description: str = "" resistivity_ohm_m: float | None = None data_nature: str = "unknown" confidence: float | None = None properties: dict[str, Any] = field(default_factory=dict)
[docs] def collect_issues(self, path: str = "interval") -> list[ValidationIssue]: issues: list[ValidationIssue] = [] for name in ("from_md", "to_md"): if not _finite_number(getattr(self, name)): issues.append( _issue( f"interval.{name}", "must be a finite number", _join_path(path, name), ) ) if _finite_number(self.from_md) and float(self.from_md) < 0: issues.append( _issue( "interval.from_range", "must be >= 0", _join_path(path, "from_md"), ) ) if ( _finite_number(self.from_md) and _finite_number(self.to_md) and float(self.to_md) <= float(self.from_md) ): issues.append( _issue( "interval.order", "to_md must be greater than from_md", path, ) ) if not (isinstance(self.code, str) and self.code.strip()) and not ( isinstance(self.label, str) and self.label.strip() ): issues.append( _issue( "interval.identity", "at least one of code or label must be non-empty", path, ) ) if self.data_nature not in DATA_NATURES: issues.append( _issue( "interval.data_nature", f"must be one of {DATA_NATURES}", _join_path(path, "data_nature"), ) ) issues += _optional_finite( self.resistivity_ohm_m, _join_path(path, "resistivity_ohm_m"), "interval.resistivity", ) if ( _finite_number(self.resistivity_ohm_m) and float(self.resistivity_ohm_m) <= 0 ): issues.append( _issue( "interval.resistivity_range", "must be > 0", _join_path(path, "resistivity_ohm_m"), ) ) issues += _optional_finite( self.confidence, _join_path(path, "confidence"), "interval.confidence", ) if ( _finite_number(self.confidence) and not 0 <= float(self.confidence) <= 1 ): issues.append( _issue( "interval.confidence_range", "must be within [0, 1]", _join_path(path, "confidence"), ) ) return issues
[docs] @dataclass(repr=False) class StructureObservation(_Validatable, PyCSAMTObject): """Point or interval structural observation along a borehole.""" kind: str at_md: float | None = None from_md: float | None = None to_md: float | None = None orientation_representation: str = "none" strike_deg: float | None = None dip_deg: float | None = None dip_direction_deg: float | None = None trend_deg: float | None = None plunge_deg: float | None = None alpha_deg: float | None = None beta_deg: float | None = None aperture_m: float | None = None fill: str | None = None data_nature: str = "unknown" confidence: float | None = None
[docs] def collect_issues(self, path: str = "structure") -> list[ValidationIssue]: issues = _required_text( self.kind, _join_path(path, "kind"), "structure.kind" ) point = self.at_md is not None interval = self.from_md is not None or self.to_md is not None if point == interval: issues.append( _issue( "structure.location", "set exactly one of at_md or a from_md/to_md interval", path, ) ) if point: issues += _optional_finite( self.at_md, _join_path(path, "at_md"), "structure.at_md" ) if _finite_number(self.at_md) and float(self.at_md) < 0: issues.append( _issue( "structure.at_md_range", "must be >= 0", _join_path(path, "at_md"), ) ) if interval: if self.from_md is None or self.to_md is None: issues.append( _issue( "structure.interval_pair", "from_md and to_md must be set together", path, ) ) elif ( not _finite_number(self.from_md) or not _finite_number(self.to_md) or float(self.from_md) < 0 or float(self.to_md) <= float(self.from_md) ): issues.append( _issue( "structure.interval_range", "requires finite 0 <= from_md < to_md", path, ) ) if self.orientation_representation not in ORIENTATION_REPRESENTATIONS: issues.append( _issue( "structure.orientation_representation", f"must be one of {ORIENTATION_REPRESENTATIONS}", _join_path(path, "orientation_representation"), ) ) required_by_representation = { "global_plane": ("dip_deg", "dip_direction_deg"), "global_line": ("trend_deg", "plunge_deg"), "core_alpha_beta": ("alpha_deg", "beta_deg"), } for name in required_by_representation.get( self.orientation_representation, () ): if not _finite_number(getattr(self, name)): issues.append( _issue( "structure.orientation_required", f"{name} is required for " f"{self.orientation_representation}", _join_path(path, name), ) ) for name in ( "strike_deg", "dip_direction_deg", "trend_deg", "alpha_deg", "beta_deg", ): value = getattr(self, name) if value is not None and ( not _finite_number(value) or not 0 <= float(value) < 360 ): issues.append( _issue( "structure.bearing_range", "must be within [0, 360)", _join_path(path, name), ) ) for name in ("dip_deg", "plunge_deg"): value = getattr(self, name) if value is not None and ( not _finite_number(value) or not 0 <= float(value) <= 90 ): issues.append( _issue( "structure.angle_range", "must be within [0, 90]", _join_path(path, name), ) ) issues += _optional_finite( self.aperture_m, _join_path(path, "aperture_m"), "structure.aperture", ) if _finite_number(self.aperture_m) and float(self.aperture_m) < 0: issues.append( _issue( "structure.aperture_range", "must be >= 0", _join_path(path, "aperture_m"), ) ) if self.data_nature not in DATA_NATURES: issues.append( _issue( "structure.data_nature", f"must be one of {DATA_NATURES}", _join_path(path, "data_nature"), ) ) issues += _optional_finite( self.confidence, _join_path(path, "confidence"), "structure.confidence", ) if ( _finite_number(self.confidence) and not 0 <= float(self.confidence) <= 1 ): issues.append( _issue( "structure.confidence_range", "must be within [0, 1]", _join_path(path, "confidence"), ) ) return issues
[docs] @dataclass(repr=False) class PCBHBorehole(_Validatable, PyCSAMTObject, MetadataMixin): """One borehole and its trajectory, logs, and structures.""" id: str name: str kind: str status: str collar: Collar total_depth_md: float trajectory: Trajectory = field(default_factory=Trajectory) diameter: float | None = None interval_logs: dict[str, list[LogInterval]] = field(default_factory=dict) structures: list[StructureObservation] = field(default_factory=list) aliases: list[str] = field(default_factory=list) metadata: dict[str, Any] = field(default_factory=dict) extensions: dict[str, Any] = field(default_factory=dict)
[docs] def collect_issues(self, path: str = "borehole") -> list[ValidationIssue]: issues = _required_text(self.id, _join_path(path, "id"), "borehole.id") issues += _required_text( self.name, _join_path(path, "name"), "borehole.name" ) if not _is_allowed_or_namespaced(self.kind, BOREHOLE_KINDS): issues.append( _issue( "borehole.kind", "must be a standard or namespaced kind; standard " f"values are {BOREHOLE_KINDS}", _join_path(path, "kind"), ) ) if not _is_allowed_or_namespaced(self.status, BOREHOLE_STATUSES): issues.append( _issue( "borehole.status", "must be a standard or namespaced status; standard " f"values are {BOREHOLE_STATUSES}", _join_path(path, "status"), ) ) if ( not _finite_number(self.total_depth_md) or float(self.total_depth_md) <= 0 ): issues.append( _issue( "borehole.total_depth", "must be a finite number > 0", _join_path(path, "total_depth_md"), ) ) issues += _optional_finite( self.diameter, _join_path(path, "diameter"), "borehole.diameter" ) if _finite_number(self.diameter) and float(self.diameter) <= 0: issues.append( _issue( "borehole.diameter_range", "must be > 0", _join_path(path, "diameter"), ) ) if not isinstance(self.collar, Collar): issues.append( _issue( "borehole.collar_type", "must be a Collar", _join_path(path, "collar"), ) ) else: issues.extend( self.collar.collect_issues(_join_path(path, "collar")) ) if not isinstance(self.trajectory, Trajectory): issues.append( _issue( "borehole.trajectory_type", "must be a Trajectory", _join_path(path, "trajectory"), ) ) else: issues.extend( self.trajectory.collect_issues(_join_path(path, "trajectory")) ) max_depth = ( float(self.total_depth_md) if _finite_number(self.total_depth_md) else None ) if isinstance(self.trajectory, Trajectory): for index, station in enumerate(self.trajectory.stations): if ( max_depth is not None and isinstance(station, SurveyStation) and _finite_number(station.md) and float(station.md) > max_depth ): issues.append( _issue( "borehole.station_beyond_td", "survey station exceeds total_depth_md", f"{path}.trajectory.stations[{index}].md", ) ) known_families = { "lithology", "formation", "weathering", "alteration", "mineralization", "oxidation", "hydrostratigraphy", "geotechnical", "interpretation", } for family, intervals in self.interval_logs.items(): family_path = f"{path}.interval_logs.{family}" if family not in known_families and not _is_namespaced(family): issues.append( _issue( "interval.family", "custom log family must be namespaced", family_path, ) ) valid_intervals: list[tuple[int, LogInterval]] = [] for index, interval in enumerate(intervals): interval_path = f"{family_path}[{index}]" if not isinstance(interval, LogInterval): issues.append( _issue( "interval.type", "must be a LogInterval", interval_path, ) ) continue issues.extend(interval.collect_issues(interval_path)) valid_intervals.append((index, interval)) if ( max_depth is not None and _finite_number(interval.to_md) and float(interval.to_md) > max_depth ): issues.append( _issue( "borehole.interval_beyond_td", "interval exceeds total_depth_md", _join_path(interval_path, "to_md"), ) ) if interval.code and family == "lithology": pass ordered = sorted( valid_intervals, key=lambda item: ( float(item[1].from_md) if _finite_number(item[1].from_md) else float("inf") ), ) previous_to: float | None = None for index, interval in ordered: if ( previous_to is not None and _finite_number(interval.from_md) and float(interval.from_md) < previous_to ): issues.append( _issue( "interval.overlap", "overlaps another interval in exclusive family " f"{family!r}", f"{family_path}[{index}]", ) ) if _finite_number(interval.to_md): previous_to = max( previous_to or float("-inf"), float(interval.to_md) ) for index, structure in enumerate(self.structures): structure_path = f"{path}.structures[{index}]" if not isinstance(structure, StructureObservation): issues.append( _issue( "structure.type", "must be a StructureObservation", structure_path, ) ) continue issues.extend(structure.collect_issues(structure_path)) depth = ( structure.at_md if structure.at_md is not None else structure.to_md ) if ( max_depth is not None and _finite_number(depth) and float(depth) > max_depth ): issues.append( _issue( "borehole.structure_beyond_td", "structure exceeds total_depth_md", structure_path, ) ) for key in self.extensions: if not isinstance(key, str) or not _is_namespaced(key): issues.append( _issue( "extensions.namespace", "extension keys must be namespaced", f"{path}.extensions.{key}", ) ) return issues
[docs] @dataclass(repr=False) class PCBHDocument(_Validatable, PyCSAMTObject, MetadataMixin): """Root PCBH document containing one or more boreholes.""" document_id: str created_at: str created_by: str crs: CoordinateReferenceSystem boreholes: list[PCBHBorehole] units: UnitSystem = field(default_factory=UnitSystem) title: str = "" description: str = "" lithologies: list[VocabularyEntry] = field(default_factory=list) formations: list[VocabularyEntry] = field(default_factory=list) pcbh_version: str = PCBH_VERSION azimuth_direction: str = "clockwise" inclination_reference: str = "vertical_down" depth_reference: str = "collar" z_positive: str = "up" metadata: dict[str, Any] = field(default_factory=dict) extensions: dict[str, Any] = field(default_factory=dict)
[docs] def collect_issues(self, path: str = "$") -> list[ValidationIssue]: issues: list[ValidationIssue] = [] try: version_parts = tuple( int(part) for part in self.pcbh_version.split(".") ) except (AttributeError, ValueError): version_parts = () if len(version_parts) != 3 or version_parts[0] != 0: issues.append( _issue( "document.version", f"must be compatible with PCBH {PCBH_VERSION!r}", f"{path}.pcbh_version", ) ) issues += _required_text( self.document_id, f"{path}.document_id", "document.id" ) issues += _required_text( self.created_at, f"{path}.created_at", "document.created_at" ) if isinstance(self.created_at, str) and self.created_at.strip(): try: datetime.fromisoformat( self.created_at.strip().replace("Z", "+00:00") ) except ValueError: issues.append( _issue( "document.created_at_format", "must be an ISO-8601 timestamp", f"{path}.created_at", ) ) issues += _required_text( self.created_by, f"{path}.created_by", "document.created_by" ) if self.azimuth_direction not in AZIMUTH_DIRECTIONS: issues.append( _issue( "document.azimuth_direction", "must be 'clockwise'", f"{path}.azimuth_direction", ) ) if self.inclination_reference != "vertical_down": issues.append( _issue( "document.inclination_reference", "must be 'vertical_down'", f"{path}.inclination_reference", ) ) if self.depth_reference != "collar": issues.append( _issue( "document.depth_reference", "must be 'collar'", f"{path}.depth_reference", ) ) if self.z_positive != "up": issues.append( _issue( "document.z_positive", "must be 'up'", f"{path}.z_positive" ) ) if not isinstance(self.crs, CoordinateReferenceSystem): issues.append( _issue( "document.crs_type", "must be a CoordinateReferenceSystem", f"{path}.crs", ) ) else: issues.extend(self.crs.collect_issues(f"{path}.crs")) if not isinstance(self.units, UnitSystem): issues.append( _issue( "document.units_type", "must be a UnitSystem", f"{path}.units", ) ) else: issues.extend(self.units.collect_issues(f"{path}.units")) if not self.boreholes: issues.append( _issue( "document.boreholes_empty", "must contain at least one borehole", f"{path}.boreholes", ) ) seen_ids: set[str] = set() seen_folded: dict[str, str] = {} for index, borehole in enumerate(self.boreholes): borehole_path = f"{path}.boreholes[{index}]" if not isinstance(borehole, PCBHBorehole): issues.append( _issue( "borehole.type", "must be a PCBHBorehole", borehole_path, ) ) continue issues.extend(borehole.collect_issues(borehole_path)) if borehole.id in seen_ids: issues.append( _issue( "borehole.duplicate_id", f"duplicate borehole id {borehole.id!r}", _join_path(borehole_path, "id"), ) ) seen_ids.add(borehole.id) folded = borehole.id.casefold() if folded in seen_folded and seen_folded[folded] != borehole.id: issues.append( _issue( "borehole.case_collision", "id differs only by case from " f"{seen_folded[folded]!r}", _join_path(borehole_path, "id"), "warning", ) ) seen_folded[folded] = borehole.id for vocabulary_name, entries in ( ("lithologies", self.lithologies), ("formations", self.formations), ): codes: set[str] = set() for index, entry in enumerate(entries): entry_path = f"{path}.{vocabulary_name}[{index}]" if not isinstance(entry, VocabularyEntry): issues.append( _issue( "vocabulary.type", "must be a VocabularyEntry", entry_path, ) ) continue issues.extend(entry.collect_issues(entry_path)) if entry.code in codes: issues.append( _issue( "vocabulary.duplicate_code", f"duplicate code {entry.code!r}", _join_path(entry_path, "code"), ) ) codes.add(entry.code) lithology_codes = { entry.code for entry in self.lithologies if isinstance(entry, VocabularyEntry) } formation_codes = { entry.code for entry in self.formations if isinstance(entry, VocabularyEntry) } for bh_index, borehole in enumerate(self.boreholes): if not isinstance(borehole, PCBHBorehole): continue for family, known_codes in ( ("lithology", lithology_codes), ("formation", formation_codes), ): for interval_index, interval in enumerate( borehole.interval_logs.get(family, []) ): if ( isinstance(interval, LogInterval) and interval.code and interval.code not in known_codes ): issues.append( _issue( "interval.unresolved_code", f"code {interval.code!r} is absent from the " f"{family} vocabulary", f"{path}.boreholes[{bh_index}].interval_logs.{family}[{interval_index}].code", ) ) for key in self.extensions: if not isinstance(key, str) or not _is_namespaced(key): issues.append( _issue( "extensions.namespace", "extension keys must be namespaced", f"{path}.extensions.{key}", ) ) return issues