Source code for pycsamt.format.borehole.lasio

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Loss-explicit LAS 2.0 subset import and export for PCBH."""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path

from ...api.property import PyCSAMTObject
from .mapping import ImportReport
from .schema import (
    Collar,
    CoordinateReferenceSystem,
    LogInterval,
    PCBHBorehole,
    PCBHDocument,
    Trajectory,
    VocabularyEntry,
)

__all__ = ["LASExportReport", "borehole_from_las", "write_las_subset"]


[docs] @dataclass(repr=False) class LASExportReport(PyCSAMTObject): """Preservation and loss summary for a LAS subset export.""" path: str curves_written: list[str] = field(default_factory=list) losses: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
[docs] def borehole_from_las( path: str | Path, *, collar: Collar, crs_horizontal: str, kind: str = "unknown", status: str = "unknown", depth_curve: str = "DEPT", resistivity_curve: str = "RESD", lithology_curve: str | None = "LITH", max_samples: int = 250_000, ) -> tuple[PCBHDocument, ImportReport]: """Import a LAS 2.0 curve subset while retaining curve metadata.""" if not isinstance(collar, Collar): raise TypeError("collar must be a PCBH Collar") collar.validate() if not isinstance(crs_horizontal, str) or not crs_horizontal.strip(): raise ValueError("crs_horizontal must be a non-empty string") if isinstance(max_samples, bool) or not isinstance(max_samples, int): raise TypeError("max_samples must be an integer") if max_samples <= 0: raise ValueError("max_samples must be greater than zero") source = Path(path) sections, curves, rows = _read_las(source, max_samples=max_samples) if _well_text(sections, "WRAP").upper() not in {"", "NO"}: raise ValueError("wrapped LAS data is not supported; require WRAP.NO") depth_key = depth_curve.upper() if depth_key not in curves: raise ValueError(f"depth curve {depth_curve!r} is absent") depth_unit = curves[depth_key]["unit"].upper() depth_factor = {"M": 1.0, "METRE": 1.0, "METER": 1.0, "FT": 0.3048}.get( depth_unit ) if depth_factor is None: raise ValueError(f"unsupported LAS depth unit {depth_unit!r}") index = [value * depth_factor for value in curves[depth_key]["index"]] null = _well_float(sections, "NULL", -9999.25) samples = [] for mnemonic, curve in curves.items(): if mnemonic == depth_key: continue values = [] for depth, row in zip(index, rows): raw = row[curve["column"]] value = None if math.isclose(raw, null, abs_tol=1e-12) else raw values.append([depth, value]) samples.append( { "mnemonic": mnemonic, "name": curve["description"], "unit": curve["unit"], "index": "md", "samples": values, "null": None, } ) raw_step = _well_float( sections, "STEP", _median_step(index) / depth_factor ) step = abs(raw_step * depth_factor) if any(second <= first for first, second in zip(index, index[1:])): raise ValueError("LAS depth curve must increase strictly") total_depth = max(index) + step if index else 0.0 if total_depth <= 0: raise ValueError( "LAS depth curve does not define positive total depth" ) well_name = _well_text(sections, "WELL") or source.stem intervals, vocabulary = _las_intervals( index, rows, curves, lithology_curve, resistivity_curve, null, step, ) hole = PCBHBorehole( id=well_name, name=well_name, kind=kind, status=status, collar=collar, total_depth_md=total_depth, trajectory=Trajectory(method="vertical"), interval_logs={"lithology": intervals} if intervals else {}, extensions={ "pcbh:continuous_curves": samples, "pcbh:las_metadata": { "version": _well_text(sections, "VERS"), "wrap": _well_text(sections, "WRAP"), "null_value": null, "well": dict(sections.get("W", {})), }, }, ) document = PCBHDocument( document_id=f"las:{source.stem}", created_at=datetime.now(timezone.utc) .isoformat() .replace("+00:00", "Z"), created_by="pycsamt LAS importer", crs=CoordinateReferenceSystem(crs_horizontal), boreholes=[hole], lithologies=vocabulary, ) document.validate() report = ImportReport( source=str(source), source_sha256=__import__("hashlib") .sha256(source.read_bytes()) .hexdigest(), delimiter="whitespace", strict=True, rows_read=len(rows), rows_accepted=len(rows), ) report.source_files[source.name] = report.source_sha256 report.inferred_values.append( f"total_depth_md={total_depth!r} from last depth plus STEP" ) if depth_factor != 1.0: report.unit_conversions.append( f"LAS depth {depth_unit} converted to PCBH metres" ) return document, report
[docs] def write_las_subset( borehole: PCBHBorehole, path: str | Path, *, null_value: float = -9999.25, company: str = "pycsamt", ) -> tuple[Path, LASExportReport]: """Write inline PCBH curves, or an interval-derived LAS subset.""" borehole.validate() if not math.isfinite(null_value): raise ValueError("null_value must be finite") if not isinstance(company, str) or not company.strip(): raise ValueError("company must be a non-empty string") curves = borehole.extensions.get("pcbh:continuous_curves", []) losses = [ "CRS and absolute collar coordinates", "structures and non-lithology interval families", "samples, assays, construction, and PCBH provenance", "original LAS headers not represented by the subset writer", ] if borehole.trajectory.method != "vertical": losses.append("deviated trajectory survey") if curves: depth, definitions, columns = _extension_columns(curves, null_value) else: depth, definitions, columns = _interval_columns(borehole, null_value) losses.append( "continuous curves unavailable; values derived from intervals" ) output = Path(path) output.parent.mkdir(parents=True, exist_ok=True) step = _median_step(depth) lines = [ "~VERSION INFORMATION", " VERS. 2.0: CWLS LOG ASCII STANDARD", " WRAP. NO: ONE LINE PER DEPTH STEP", "~WELL INFORMATION", f" STRT.M {depth[0]:.6g}: START DEPTH", f" STOP.M {depth[-1]:.6g}: STOP DEPTH", f" STEP.M {step:.6g}: STEP", f" NULL. {null_value:.12g}: NULL VALUE", f" COMP. {company}: COMPANY", f" WELL. {borehole.name}: WELL", "~CURVE INFORMATION", ] lines.extend( f" {name}.{unit}: {description}" for name, unit, description in definitions ) lines.append("~A " + " ".join(name for name, _, _ in definitions)) for index in range(len(depth)): lines.append(" ".join(f"{column[index]:.12g}" for column in columns)) output.write_text("\n".join(lines) + "\n", encoding="utf-8") return output, LASExportReport( path=str(output), curves_written=[item[0] for item in definitions], losses=losses, )
def _read_las(path: Path, *, max_samples: int): sections: dict[str, dict[str, str]] = {} curve_defs, data = [], [] section = "" for raw in path.read_text(encoding="utf-8-sig").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue if line.startswith("~"): section = line[1:2].upper() continue if section == "A": values = [float(value) for value in line.split()] if not all(math.isfinite(value) for value in values): raise ValueError("LAS ASCII data values must be finite") data.append(values) if len(data) > max_samples: raise ValueError("LAS sample limit exceeded") elif section == "C": mnemonic, unit, value, description = _las_item(line) curve_defs.append((mnemonic, unit, description)) else: mnemonic, _, value, _ = _las_item(line) sections.setdefault(section, {})[mnemonic] = value if not curve_defs or not data: raise ValueError("LAS file requires curve definitions and ASCII data") if any(len(row) != len(curve_defs) for row in data): raise ValueError("LAS data width does not match curve definitions") if len({item[0] for item in curve_defs}) != len(curve_defs): raise ValueError("LAS curve mnemonics must be unique") curves = { name: { "unit": unit, "description": description, "column": index, "index": [row[index] for row in data], } for index, (name, unit, description) in enumerate(curve_defs) } return sections, curves, data def _las_item(line: str): left, _, description = line.partition(":") mnemonic_unit, _, value = left.partition(" ") mnemonic, _, unit = mnemonic_unit.partition(".") return ( mnemonic.strip().upper(), unit.strip(), value.strip(), description.strip(), ) def _well_text(sections, key): for section in ("W", "V"): if key in sections.get(section, {}): return sections[section][key] return "" def _well_float(sections, key, default): try: return float(_well_text(sections, key)) except (TypeError, ValueError): return default def _median_step(depth): if len(depth) < 2: return 1.0 differences = sorted(abs(b - a) for a, b in zip(depth, depth[1:])) return differences[len(differences) // 2] def _las_intervals(index, rows, curves, lithology, resistivity, null, step): if not lithology or lithology.upper() not in curves: return [], [] lith_col = curves[lithology.upper()]["column"] resistivity_definition = curves.get(resistivity.upper(), {}) resistivity_unit = resistivity_definition.get("unit", "").upper() res_col = ( resistivity_definition.get("column") if resistivity_unit in {"OHMM", "OHM.M", "OHM-M"} else None ) intervals, vocabulary = [], {} start = 0 for end in range(1, len(rows) + 1): if end < len(rows) and rows[end][lith_col] == rows[start][lith_col]: continue code = f"{rows[start][lith_col]:g}" values = ( [] if res_col is None else [ row[res_col] for row in rows[start:end] if not math.isclose(row[res_col], null) ] ) intervals.append( LogInterval( index[start], index[end - 1] + step, code=code, label=code, resistivity_ohm_m=sum(values) / len(values) if values else None, data_nature="observed", ) ) vocabulary.setdefault(code, VocabularyEntry(code, code)) start = end return intervals, list(vocabulary.values()) def _extension_columns(curves, null): depth = [float(item[0]) for item in curves[0]["samples"]] definitions = [("DEPT", "M", "MEASURED DEPTH")] columns = [depth] for curve in curves: if [float(item[0]) for item in curve["samples"]] != depth: raise ValueError( "LAS export requires curves on one shared depth index" ) definitions.append( (curve["mnemonic"], curve.get("unit", ""), curve.get("name", "")) ) columns.append( [ null if item[1] is None else float(item[1]) for item in curve["samples"] ] ) return depth, definitions, columns def _interval_columns(borehole, null): intervals = borehole.interval_logs.get("lithology", []) if not intervals: raise ValueError( "LAS export needs inline curves or lithology intervals" ) depth = [item.from_md for item in intervals] definitions = [ ("DEPT", "M", "MEASURED DEPTH"), ("RESD", "OHMM", "RESISTIVITY"), ("LITH", "", "LITHOLOGY CODE"), ] codes = { item.code or item.label: index + 1 for index, item in enumerate(intervals) } return ( depth, definitions, [ depth, [item.resistivity_ohm_m or null for item in intervals], [codes[item.code or item.label] for item in intervals], ], )