# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""PCGS - pyCSAMT Common Geological Structure.
PCGS is a small, portable document for field structural evidence --
planar measurements (strike/dip), linear measurements (trend/plunge),
and fault traces -- collected along one or several survey lines, so it
can travel between projects and applications on its own and be
recognised and loaded straight into Map View's Geology section.
Like :mod:`pycsamt.format.geology` (PCGL) and
:mod:`pycsamt.format.borehole` (PCBH), PCGS is a thin document envelope
(id, timestamps, provenance, versioning) around an existing pycsamt
class -- here, :class:`pycsamt.geology.structural.StructuralModel` --
rather than a parallel data model.
Canonical encoding is UTF-8 JSON, canonical extension ``.pcgs.json``. A
model can also be assembled from up to three CSV files (one each for
planar measurements, linear measurements, and fault traces), the same
tables :meth:`pycsamt.geology.structural.StructuralModel.from_csv`
already accepts -- see that method for the column layout, including the
optional ``line`` column for a multi-line survey.
Example
-------
>>> from pycsamt.format.structure import StructModel
>>> from pycsamt.geology.structural import FaultTrace, StructuralModel
>>> model = StructuralModel(
... faults=[FaultTrace(x=500.0, dip_deg=70.0, downthrown_side="right")],
... )
>>> doc = StructModel.from_structural_model(model, title="Site A structure")
>>> doc.issues()
[]
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Union
from ..api.property import MetadataMixin, PyCSAMTObject
from ..geology.structural import StructuralModel
__all__ = [
"PCGS_VERSION",
"StructModel",
"StructModelValidationError",
"read_structure",
"write_structure",
"structure_from_csv",
"structure_to_dict",
"structure_from_dict",
]
PCGS_VERSION = "0.1.0"
PathLike = Union[str, Path]
[docs]
class StructModelValidationError(ValueError):
"""Raised when a PCGS document fails validation."""
[docs]
@dataclass(repr=False)
class StructModel(PyCSAMTObject, MetadataMixin):
"""A versioned, portable structural-geology document.
Parameters
----------
document_id : str
created_at : str
ISO-8601 timestamp.
created_by : str
model : StructuralModel
The structural evidence itself. See
:class:`pycsamt.geology.structural.StructuralModel`.
pcgs_version : str
title, description : str
"""
document_id: str
created_at: str
created_by: str
model: StructuralModel
pcgs_version: str = PCGS_VERSION
title: str = ""
description: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
[docs]
@property
def planar(self):
return self.model.planar
[docs]
@property
def linear(self):
return self.model.linear
[docs]
@property
def faults(self):
return self.model.faults
def __len__(self) -> int:
return len(self.model.planar) + len(self.model.linear) + len(
self.model.faults
)
[docs]
@classmethod
def from_structural_model(
cls,
model: StructuralModel | None = None,
*,
document_id: str | None = None,
created_by: str = "pycsamt",
title: str = "",
description: str = "",
) -> StructModel:
doc = cls(
document_id=document_id or f"pcgs:{_now()}",
created_at=_now(),
created_by=created_by,
model=model if model is not None else StructuralModel(),
title=title,
description=description,
)
doc.validate()
return doc
[docs]
@classmethod
def from_csv(
cls,
*,
planar_path: PathLike | None = None,
linear_path: PathLike | None = None,
faults_path: PathLike | None = None,
delimiter: str = ",",
document_id: str | None = None,
created_by: str = "pycsamt CSV importer",
title: str = "",
) -> StructModel:
"""Load a document from up to three CSV files -- see
:meth:`pycsamt.geology.structural.StructuralModel.from_csv`."""
model = StructuralModel.from_csv(
planar_path=planar_path,
linear_path=linear_path,
faults_path=faults_path,
delimiter=delimiter,
)
return cls.from_structural_model(
model,
document_id=document_id,
created_by=created_by,
title=title,
)
[docs]
def issues(self) -> list[str]:
out: list[str] = []
if not str(self.document_id).strip():
out.append("document_id must be non-empty")
try:
parts = tuple(int(p) for p in self.pcgs_version.split("."))
except (AttributeError, ValueError):
parts = ()
if len(parts) != 3 or parts[0] != 0:
out.append(f"pcgs_version must be compatible with {PCGS_VERSION}")
if not isinstance(self.model, StructuralModel):
out.append("model must be a StructuralModel")
return out
[docs]
def validate(self) -> None:
problems = self.issues()
if problems:
raise StructModelValidationError("; ".join(problems[:6]))
# ---------------------------------------------------------------------------
# JSON I/O
# ---------------------------------------------------------------------------
[docs]
def structure_to_dict(doc: StructModel) -> dict[str, Any]:
"""Return a canonical JSON-safe dict for *doc*."""
doc.validate()
payload = doc.model.to_dict()
return {
"pcgs_version": doc.pcgs_version,
"document_id": doc.document_id,
"created_at": doc.created_at,
"created_by": doc.created_by,
"title": doc.title,
"description": doc.description,
"metadata": dict(doc.metadata),
"planar": payload["planar"],
"linear": payload["linear"],
"faults": payload["faults"],
}
[docs]
def structure_from_dict(payload: dict[str, Any]) -> StructModel:
"""Build and validate a :class:`StructModel` from a dict."""
if not isinstance(payload, dict):
raise StructModelValidationError("PCGS payload must be a JSON object")
model = StructuralModel.from_dict(payload)
doc = StructModel(
document_id=str(payload.get("document_id", "pcgs:structure")),
created_at=str(payload.get("created_at") or _now()),
created_by=str(payload.get("created_by", "pycsamt")),
model=model,
pcgs_version=str(payload.get("pcgs_version", PCGS_VERSION)),
title=str(payload.get("title", "")),
description=str(payload.get("description", "")),
metadata=dict(payload.get("metadata", {}) or {}),
)
doc.validate()
return doc
[docs]
def read_structure(path: PathLike) -> StructModel:
"""Read a ``.pcgs.json`` file."""
raw = Path(path).read_text(encoding="utf-8")
return structure_from_dict(json.loads(raw))
[docs]
def write_structure(doc: StructModel, path: PathLike) -> Path:
"""Write *doc* as canonical ``.pcgs.json``."""
target = Path(path)
text = json.dumps(structure_to_dict(doc), ensure_ascii=False, indent=2)
target.write_text(text + "\n", encoding="utf-8")
return target
[docs]
def structure_from_csv(**kwargs: Any) -> StructModel:
"""Module-level convenience alias for :meth:`StructModel.from_csv`."""
return StructModel.from_csv(**kwargs)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")