Source code for pycsamt.format.borehole.csvio

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Combined interval-CSV importer for PCBH 0.1."""

from __future__ import annotations

import csv
import hashlib
import io
import math
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from .mapping import (
    CANONICAL_CSV_FIELDS,
    ImportReport,
    PCBHCSVImportError,
    resolve_csv_columns,
)
from .schema import (
    BOREHOLE_KINDS,
    BOREHOLE_STATUSES,
    Collar,
    CoordinateReferenceSystem,
    LogInterval,
    PCBHBorehole,
    PCBHDocument,
    PCBHValidationError,
    Trajectory,
    UnitSystem,
    VocabularyEntry,
)

__all__ = [
    "DEFAULT_CSV_MAX_BYTES",
    "DEFAULT_CSV_MAX_ROWS",
    "boreholes_from_csv",
]

DEFAULT_CSV_MAX_BYTES = 10 * 1024 * 1024
DEFAULT_CSV_MAX_ROWS = 250_000
_DELIMITERS = (",", ";", "\t", "|")
_MISSING = {"", "na", "n/a", "nan", "none", "null"}
_CONSTANT_FIELDS = set(CANONICAL_CSV_FIELDS) | {
    "units.depth",
    "units.resistivity",
}


@dataclass
class _HoleRows:
    borehole_id: str
    name: str
    x: float
    y: float
    z: float
    crs: str
    kind: str
    status: str
    total_depth_md: float | None
    intervals: list[LogInterval] = field(default_factory=list)


[docs] def boreholes_from_csv( path: str | Path, *, columns: dict[str, str] | None = None, constants: dict[str, Any] | None = None, strict: bool = True, delimiter: str | None = None, document_id: str | None = None, created_by: str = "pycsamt CSV importer", max_bytes: int = DEFAULT_CSV_MAX_BYTES, max_rows: int = DEFAULT_CSV_MAX_ROWS, ) -> tuple[PCBHDocument, ImportReport]: """Import a combined collar-and-interval CSV as a PCBH document. Parameters ---------- path : path-like UTF-8 CSV containing repeated collar fields and interval rows. columns : dict, optional Explicit ``{canonical_field: source_header}`` mapping. Canonical names use dotted paths such as ``borehole.id`` and ``interval.from_md``. constants : dict, optional Constant canonical values, commonly ``crs.horizontal``. Constants take precedence over mapped row values. strict : bool, default=True Raise :class:`PCBHCSVImportError` if any error is recorded. In permissive mode, invalid rows are rejected and valid rows returned. delimiter : {',', ';', '\\t', '|'}, optional Explicit delimiter. If omitted, detection is restricted to this set. document_id : str, optional PCBH document identifier. Defaults to ``csv:<file stem>``. created_by : str, default='pycsamt CSV importer' Provenance name stored on the document. max_bytes : int, default=10485760 Maximum source size in bytes. max_rows : int, default=250000 Maximum number of data rows. Returns ------- document : PCBHDocument Valid document containing every accepted row. report : ImportReport Source checksum, mappings, inferences, counts, and diagnostics. Raises ------ PCBHCSVImportError If the file structure is unusable, no valid boreholes remain, or strict mode records an error. The exception exposes ``report``. ValueError If a resource limit or delimiter parameter is invalid. Notes ----- Missing tokens are normalized before conversion and never stringified. Repeated collar and total-depth values must agree within a borehole. """ if columns is not None and not isinstance(columns, dict): raise TypeError("columns must be a dict or None") if constants is not None and not isinstance(constants, dict): raise TypeError("constants must be a dict or None") if not isinstance(strict, bool): raise TypeError("strict must be a boolean") if not isinstance(created_by, str) or not created_by.strip(): raise ValueError("created_by must be a non-empty string") source = Path(path) if isinstance(max_bytes, bool) or not isinstance(max_bytes, int): raise TypeError("max_bytes must be an integer") if max_bytes <= 0: raise ValueError("max_bytes must be greater than zero") if isinstance(max_rows, bool) or not isinstance(max_rows, int): raise TypeError("max_rows must be an integer") if max_rows <= 0: raise ValueError("max_rows must be greater than zero") raw = source.read_bytes() if len(raw) > max_bytes: raise ValueError( f"CSV input is {len(raw)} bytes; limit is {max_bytes} bytes" ) try: text = raw.decode("utf-8-sig") except UnicodeDecodeError as error: raise ValueError("PCBH CSV input must be UTF-8") from error chosen_delimiter = _delimiter(text, delimiter) supplied_constants = dict(constants or {}) report = ImportReport( source=str(source), source_sha256=hashlib.sha256(raw).hexdigest(), delimiter=chosen_delimiter, strict=strict, constants=supplied_constants, ) for key in supplied_constants: if key not in _CONSTANT_FIELDS: report.add( "error", "csv.constant_field", f"unknown constant field {key!r}", ) reader = csv.reader(io.StringIO(text), delimiter=chosen_delimiter) try: headers = next(reader) except StopIteration: report.add("error", "csv.empty", "CSV file is empty") raise PCBHCSVImportError(report) headers = [header.strip() for header in headers] if len(headers) < 2 or any(not header for header in headers): report.add( "error", "csv.header", "CSV requires a non-empty header row with at least two columns", row=1, ) mapping = resolve_csv_columns( headers, explicit=columns, constants=supplied_constants, report=report, ) if report.errors: raise PCBHCSVImportError(report) groups: dict[str, _HoleRows] = {} vocabulary: dict[str, VocabularyEntry] = {} label_codes: dict[str, str] = {} global_crs: str | None = None for row_number, values in enumerate(reader, start=2): report.rows_read += 1 if report.rows_read > max_rows: report.add( "error", "csv.row_limit", f"CSV exceeds the {max_rows}-row limit", row=row_number, ) break if not values or all(_missing(value) for value in values): report.rows_skipped += 1 continue errors_before = len(report.errors) if len(values) != len(headers): report.add( "error", "csv.row_width", f"expected {len(headers)} columns, found {len(values)}", row=row_number, ) report.rows_rejected += 1 continue row = dict(zip(headers, values)) parsed = _parse_row( row, row_number, mapping, supplied_constants, report, ) if parsed is None: report.rows_rejected += 1 continue hole_id = parsed["borehole.id"] group = groups.get(hole_id) if group is None: group = _new_group(parsed, report) groups[hole_id] = group elif not _consistent_group(group, parsed, row_number, report): report.rows_rejected += 1 continue if global_crs is not None and parsed["crs.horizontal"] != global_crs: report.add( "error", "csv.crs_conflict", f"CRS {parsed['crs.horizontal']!r} conflicts with " f"document CRS {global_crs!r}", row=row_number, column=mapping.get("crs.horizontal"), ) report.rows_rejected += 1 if not group.intervals: groups.pop(hole_id, None) continue interval = parsed["interval"] if _overlaps(interval, group.intervals): report.add( "error", "csv.interval_overlap", f"interval [{interval.from_md}, {interval.to_md}) overlaps " f"another interval for {hole_id!r}", row=row_number, ) report.rows_rejected += 1 continue if ( group.total_depth_md is not None and interval.to_md > group.total_depth_md ): report.add( "error", "csv.interval_beyond_td", "interval extends beyond total_depth_md", row=row_number, column=mapping.get("interval.to_md"), ) report.rows_rejected += 1 continue if len(report.errors) > errors_before: report.rows_rejected += 1 continue if not _register_lithology( interval, vocabulary, label_codes, row_number, report, ): report.rows_rejected += 1 continue if global_crs is None: global_crs = parsed["crs.horizontal"] group.intervals.append(interval) report.rows_accepted += 1 if strict and report.errors: raise PCBHCSVImportError(report) groups = {key: value for key, value in groups.items() if value.intervals} if not groups or global_crs is None: report.add( "error", "csv.no_valid_rows", "CSV import produced no valid boreholes", ) raise PCBHCSVImportError(report) boreholes = [_build_borehole(group, report) for group in groups.values()] units = UnitSystem( depth=str(supplied_constants.get("units.depth", "m")), resistivity=str(supplied_constants.get("units.resistivity", "ohm.m")), ) if units.depth != "m" or units.resistivity != "ohm.m": report.add( "error", "csv.units_unsupported", "combined CSV currently requires metres and ohm metres; " "convert source units before import", ) raise PCBHCSVImportError(report) document = PCBHDocument( document_id=document_id or f"csv:{source.stem}", created_at=datetime.now(timezone.utc) .isoformat() .replace("+00:00", "Z"), created_by=created_by, crs=CoordinateReferenceSystem(global_crs), units=units, boreholes=boreholes, lithologies=list(vocabulary.values()), metadata={ "csv_source": str(source), "csv_source_sha256": report.source_sha256, }, ) try: document.validate() except PCBHValidationError as error: for issue in error.issues: report.add( "error", f"csv.document.{issue.code}", f"{issue.path}: {issue.message}", ) raise PCBHCSVImportError(report) from error return document, report
def _delimiter(text: str, supplied: str | None) -> str: if supplied is not None: if supplied not in _DELIMITERS: raise ValueError(f"delimiter must be one of {_DELIMITERS!r}") return supplied sample = text[:65536] try: return ( csv.Sniffer() .sniff(sample, delimiters="".join(_DELIMITERS)) .delimiter ) except csv.Error as error: raise ValueError( "could not detect CSV delimiter; pass delimiter explicitly" ) from error def _parse_row( row: dict[str, str], row_number: int, mapping: dict[str, str], constants: dict[str, Any], report: ImportReport, ) -> dict[str, Any] | None: def value(canonical: str) -> Any: raw = constants.get(canonical) if canonical not in constants: source = mapping.get(canonical) raw = row.get(source, "") if source else None return None if _missing(raw) else raw hole_id = value("borehole.id") lithology = value("interval.lithology") if hole_id is None: report.add( "error", "csv.missing_id", "borehole ID is missing", row=row_number ) elif not str(hole_id).strip(): report.add( "error", "csv.missing_id", "borehole ID is blank", row=row_number ) if lithology is None: report.add( "error", "csv.missing_lithology", "lithology is missing", row=row_number, column=mapping.get("interval.lithology"), ) elif not str(lithology).strip(): report.add( "error", "csv.missing_lithology", "lithology is blank", row=row_number, column=mapping.get("interval.lithology"), ) numeric: dict[str, float | None] = {} for canonical in ( "collar.x", "collar.y", "collar.z", "borehole.total_depth_md", "interval.from_md", "interval.to_md", "interval.resistivity_ohm_m", ): raw = value(canonical) required = canonical in { "collar.x", "collar.y", "collar.z", "interval.from_md", "interval.to_md", } numeric[canonical] = _number( raw, canonical, row_number, mapping.get(canonical), report, required=required, ) crs = value("crs.horizontal") if crs is None: report.add( "error", "csv.missing_crs", "CRS is missing", row=row_number ) elif not str(crs).strip(): report.add("error", "csv.missing_crs", "CRS is blank", row=row_number) if report.errors and report.errors[-1].row == row_number: return None from_md = numeric["interval.from_md"] to_md = numeric["interval.to_md"] resistivity = numeric["interval.resistivity_ohm_m"] if from_md < 0 or to_md <= from_md: report.add( "error", "csv.interval_bounds", "interval requires 0 <= from_md < to_md", row=row_number, ) return None if resistivity is not None and resistivity <= 0: report.add( "error", "csv.resistivity", "resistivity must be greater than zero", row=row_number, ) return None total_depth = numeric["borehole.total_depth_md"] if total_depth is not None and total_depth <= 0: report.add( "error", "csv.total_depth", "total_depth_md must be greater than zero", row=row_number, ) return None name_value = value("borehole.name") kind_value = value("borehole.kind") status_value = value("borehole.status") inferred = { canonical for canonical, raw in ( ("borehole.name", name_value), ("borehole.kind", kind_value), ("borehole.status", status_value), ) if raw is None } kind = str(kind_value or "unknown").strip() status = str(status_value or "unknown").strip() if not _controlled(kind, BOREHOLE_KINDS): report.add( "error", "csv.borehole_kind", f"unsupported borehole kind {kind!r}", row=row_number, ) return None if not _controlled(status, BOREHOLE_STATUSES): report.add( "error", "csv.borehole_status", f"unsupported borehole status {status!r}", row=row_number, ) return None data_nature = str(value("interval.data_nature") or "observed") code_value = value("interval.code") interval = LogInterval( from_md=from_md, to_md=to_md, code=str(code_value).strip() if code_value else None, label=str(lithology).strip(), description=str(value("interval.description") or "").strip(), resistivity_ohm_m=resistivity, data_nature=data_nature, ) interval_errors = [ issue for issue in interval.collect_issues() if issue.severity == "error" ] if interval_errors: for issue in interval_errors: report.add( "error", f"csv.{issue.code}", issue.message, row=row_number, ) return None return { "borehole.id": str(hole_id).strip(), "borehole.name": str(name_value or hole_id).strip(), "borehole.kind": kind, "borehole.status": status, "borehole.total_depth_md": total_depth, "collar.x": numeric["collar.x"], "collar.y": numeric["collar.y"], "collar.z": numeric["collar.z"], "crs.horizontal": str(crs).strip(), "interval": interval, "_inferred": inferred, } def _number( value: Any, canonical: str, row: int, column: str | None, report: ImportReport, *, required: bool, ) -> float | None: if value is None: if required: report.add( "error", "csv.missing_number", f"{canonical} is missing", row=row, column=column, ) return None if isinstance(value, bool): number = math.nan else: try: number = float(value) except (TypeError, ValueError): number = math.nan if not math.isfinite(number): report.add( "error", "csv.invalid_number", f"{canonical} must be a finite number, got {value!r}", row=row, column=column, ) return None return number def _new_group(parsed: dict[str, Any], report: ImportReport) -> _HoleRows: hole_id = parsed["borehole.id"] for field_name, default in ( ("borehole.name", hole_id), ("borehole.kind", "unknown"), ("borehole.status", "unknown"), ): if field_name in parsed["_inferred"]: text = f"{field_name}={default!r} for {hole_id!r}" if text not in report.inferred_values: report.inferred_values.append(text) return _HoleRows( borehole_id=hole_id, name=parsed["borehole.name"], x=parsed["collar.x"], y=parsed["collar.y"], z=parsed["collar.z"], crs=parsed["crs.horizontal"], kind=parsed["borehole.kind"], status=parsed["borehole.status"], total_depth_md=parsed["borehole.total_depth_md"], ) def _consistent_group( group: _HoleRows, parsed: dict[str, Any], row: int, report: ImportReport, ) -> bool: comparisons = ( ("collar.x", group.x), ("collar.y", group.y), ("collar.z", group.z), ("crs.horizontal", group.crs), ("borehole.name", group.name), ("borehole.kind", group.kind), ("borehole.status", group.status), ("borehole.total_depth_md", group.total_depth_md), ) for canonical, expected in comparisons: actual = parsed[canonical] if canonical == "borehole.total_depth_md" and actual is None: continue if expected is None and canonical == "borehole.total_depth_md": group.total_depth_md = actual continue equal = ( math.isclose(actual, expected, rel_tol=0.0, abs_tol=1e-9) if isinstance(expected, float) else actual == expected ) if not equal: message = ( f"{canonical}={actual!r} conflicts with first value " f"{expected!r} for {group.borehole_id!r}" ) report.add("error", "csv.collar_conflict", message, row=row) report.conflict_resolutions.append( message + "; first value retained and row rejected" ) return False return True def _overlaps(interval: LogInterval, existing: list[LogInterval]) -> bool: return any( interval.from_md < other.to_md and interval.to_md > other.from_md for other in existing ) def _register_lithology( interval: LogInterval, vocabulary: dict[str, VocabularyEntry], label_codes: dict[str, str], row: int, report: ImportReport, ) -> bool: label = interval.label or "unknown" folded = label.casefold() code = interval.code or label_codes.get(folded) if code is None: base = re.sub(r"[^A-Z0-9]+", "_", label.upper()).strip("_") base = base or "UNKNOWN" code = base suffix = 2 while ( code in vocabulary and vocabulary[code].name.casefold() != folded ): code = f"{base}_{suffix}" suffix += 1 report.inferred_values.append( f"interval.code={code!r} for lithology {label!r}" ) current = vocabulary.get(code) if current is not None and current.name.casefold() != folded: report.add( "error", "csv.lithology_code_conflict", f"lithology code {code!r} identifies both {current.name!r} " f"and {label!r}", row=row, ) return False interval.code = code label_codes[folded] = code vocabulary.setdefault(code, VocabularyEntry(code=code, name=label)) return True def _build_borehole(group: _HoleRows, report: ImportReport) -> PCBHBorehole: intervals = sorted(group.intervals, key=lambda item: item.from_md) total_depth = group.total_depth_md if total_depth is None: total_depth = max(item.to_md for item in intervals) report.inferred_values.append( f"borehole.total_depth_md={total_depth!r} for " f"{group.borehole_id!r} from deepest interval" ) return PCBHBorehole( id=group.borehole_id, name=group.name, kind=group.kind, status=group.status, collar=Collar(group.x, group.y, group.z), total_depth_md=total_depth, trajectory=Trajectory(method="vertical"), interval_logs={"lithology": intervals}, ) def _missing(value: Any) -> bool: return value is None or ( isinstance(value, str) and value.strip().casefold() in _MISSING ) def _controlled(value: str, allowed: tuple[str, ...]) -> bool: left, separator, right = value.partition(":") return value in allowed or bool(separator and left and right)