# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""PCGL - pyCSAMT Common Geology Legend.
PCGL is a small, portable document for the resistivity-to-geology
*interpretation legend* that Map View's Interpretation overlay applies to
a 3-D block / fence / depth-slice / iso-surface view: an ordered table of
named units, each with a resistivity range, a display colour, and an
optional pattern/swatch reference, plus a little document metadata so the
legend can travel between projects and applications on its own.
PCGL is deliberately a *thin wrapper*: the table itself is a
:class:`pycsamt.geology.lithology.RockDatabase` (already the
resistivity-to-rock classification engine used across pycsamt), extended
there with ``pattern_id``/``pattern_source`` fields. This module only adds
the document envelope (id, timestamps, provenance, versioning) and
canonical JSON (de)serialization on top of it -- it introduces no parallel
class hierarchy, the same way :mod:`pycsamt.format.borehole` bridges
:class:`pycsamt.geology.Borehole` rather than reimplementing it.
Canonical encoding is UTF-8 JSON, canonical extension ``.pcgl.json``. A
plain CSV with columns ``name, rho_min, rho_max, color, description, code,
source, pattern_id, pattern_source`` (only ``name, rho_min, rho_max``
required) round-trips through :meth:`GeologyLegend.from_csv` /
:meth:`GeologyLegend.to_csv` -- the same columns
:meth:`pycsamt.geology.lithology.RockDatabase.from_csv` accepts, so an
existing rock-property CSV already loads as a legend with no pattern
assigned.
Example
-------
>>> from pycsamt.format.geology import GeologyLegend
>>> legend = GeologyLegend.from_rock_database(title="Site A interpretation")
>>> legend.db.classify(250.0).name
'Granite (weathered)'
>>> legend.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.lithology import RockDatabase, RockEntry
__all__ = [
"PCGL_VERSION",
"GeologyLegend",
"GeologyLegendValidationError",
"read_legend",
"write_legend",
"legend_from_csv",
"legend_to_dict",
"legend_from_dict",
]
PCGL_VERSION = "0.1.0"
PathLike = Union[str, Path]
[docs]
class GeologyLegendValidationError(ValueError):
"""Raised when a PCGL document fails validation."""
[docs]
@dataclass(repr=False)
class GeologyLegend(PyCSAMTObject, MetadataMixin):
"""A versioned, portable resistivity-to-geology legend document.
Parameters
----------
document_id : str
created_at : str
ISO-8601 timestamp.
created_by : str
Creating application, workflow step, or person
(e.g. ``"auto-suggest"``, ``"manual"``, a user name).
db : RockDatabase
The legend table itself -- resistivity ranges, colours, and
pattern references. See :class:`pycsamt.geology.lithology.
RockDatabase`.
pcgl_version : str
title, description : str
rho_unit : str
Resistivity unit the ranges are expressed in. PCGL 0.1 only
defines ``"ohm.m"`` (linear ohm-metres); readers MUST reject any
other value rather than silently reinterpreting the ranges.
"""
document_id: str
created_at: str
created_by: str
db: RockDatabase
pcgl_version: str = PCGL_VERSION
title: str = ""
description: str = ""
rho_unit: str = "ohm.m"
metadata: dict[str, Any] = field(default_factory=dict)
# -- convenience passthrough -----------------------------------
[docs]
@property
def entries(self) -> tuple[RockEntry, ...]:
return self.db.entries
[docs]
def classify(self, rho_ohm_m: float, method: str = "nearest") -> RockEntry:
return self.db.classify(rho_ohm_m, method=method)
# -- construction -------------------------------------------------
[docs]
@classmethod
def from_rock_database(
cls,
db: RockDatabase | None = None,
*,
document_id: str | None = None,
created_by: str = "pycsamt",
title: str = "",
description: str = "",
) -> GeologyLegend:
"""Wrap an existing (or the built-in default) rock database as a
PCGL document, ready to write or hand to Map View."""
legend = cls(
document_id=document_id or f"pcgl:{_now()}",
created_at=_now(),
created_by=created_by,
db=db if db is not None else RockDatabase.default(),
title=title,
description=description,
)
legend.validate()
return legend
[docs]
@classmethod
def from_csv(
cls,
path: PathLike,
*,
document_id: str | None = None,
created_by: str = "pycsamt CSV importer",
title: str = "",
) -> GeologyLegend:
"""Load a legend from a CSV (see module docstring for columns)."""
p = Path(path)
db = RockDatabase.from_csv(p)
return cls.from_rock_database(
db,
document_id=document_id or f"pcgl:{p.stem}",
created_by=created_by,
title=title or p.stem,
)
[docs]
def to_csv(self, path: PathLike) -> Path:
"""Write the legend table as CSV (document metadata is dropped;
use :func:`write_legend` to keep it)."""
return self.db.to_csv(path)
# -- validation -----------------------------------------------------
[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.pcgl_version.split("."))
except (AttributeError, ValueError):
parts = ()
if len(parts) != 3 or parts[0] != 0:
out.append(f"pcgl_version must be compatible with {PCGL_VERSION}")
if self.rho_unit != "ohm.m":
out.append(f"unsupported rho_unit {self.rho_unit!r} (only 'ohm.m')")
if not isinstance(self.db, RockDatabase):
out.append("db must be a RockDatabase")
return out
if len(self.db) == 0:
out.append("legend must contain at least one entry")
seen: set[str] = set()
for e in self.db.entries:
if not str(e.name).strip():
out.append("every entry must have a non-empty name")
if not (e.rho_min >= 0 and e.rho_max > e.rho_min):
out.append(
f"entry {e.name!r}: rho_min must be >= 0 and < rho_max"
)
if e.pattern_id and not e.pattern_source:
out.append(
f"entry {e.name!r}: pattern_id set without pattern_source"
)
key = e.name.strip().casefold()
if key in seen:
out.append(f"duplicate entry name {e.name!r}")
seen.add(key)
return out
[docs]
def validate(self) -> None:
problems = self.issues()
if problems:
raise GeologyLegendValidationError("; ".join(problems[:6]))
# ---------------------------------------------------------------------------
# JSON I/O
# ---------------------------------------------------------------------------
[docs]
def legend_to_dict(legend: GeologyLegend) -> dict[str, Any]:
"""Return a canonical JSON-safe dict for *legend*."""
legend.validate()
payload = legend.db.to_dict()
return {
"pcgl_version": legend.pcgl_version,
"document_id": legend.document_id,
"created_at": legend.created_at,
"created_by": legend.created_by,
"title": legend.title,
"description": legend.description,
"rho_unit": legend.rho_unit,
"metadata": dict(legend.metadata),
"entries": payload["entries"],
}
[docs]
def legend_from_dict(payload: dict[str, Any]) -> GeologyLegend:
"""Build and validate a :class:`GeologyLegend` from a dict."""
if not isinstance(payload, dict):
raise GeologyLegendValidationError("PCGL payload must be a JSON object")
db = RockDatabase.from_dict(
{"entries": payload.get("entries", [])},
metadata={"origin": "pcgl"},
)
legend = GeologyLegend(
document_id=str(payload.get("document_id", "pcgl:legend")),
created_at=str(payload.get("created_at") or _now()),
created_by=str(payload.get("created_by", "pycsamt")),
db=db,
pcgl_version=str(payload.get("pcgl_version", PCGL_VERSION)),
title=str(payload.get("title", "")),
description=str(payload.get("description", "")),
rho_unit=str(payload.get("rho_unit", "ohm.m")),
metadata=dict(payload.get("metadata", {}) or {}),
)
legend.validate()
return legend
[docs]
def read_legend(path: PathLike) -> GeologyLegend:
"""Read a ``.pcgl.json`` file."""
raw = Path(path).read_text(encoding="utf-8")
return legend_from_dict(json.loads(raw))
[docs]
def write_legend(legend: GeologyLegend, path: PathLike) -> Path:
"""Write *legend* as canonical ``.pcgl.json``."""
target = Path(path)
text = json.dumps(legend_to_dict(legend), ensure_ascii=False, indent=2)
target.write_text(text + "\n", encoding="utf-8")
return target
[docs]
def legend_from_csv(path: PathLike, **kwargs: Any) -> GeologyLegend:
"""Module-level convenience alias for :meth:`GeologyLegend.from_csv`."""
return GeologyLegend.from_csv(path, **kwargs)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _now() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")