# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Spreadsheet (``.xlsx``) importer for PCBH 0.1.
Field borehole logs almost never arrive in the tidy one-interval-per-row
shape that :func:`pycsamt.format.borehole.boreholes_from_csv` expects: a
typical workbook has a multi-row merged header, a *Rock name* column, a
*From*/*To* depth pair, bilingual labels, and **no collar coordinates**
at all. This module turns such a sheet into a canonical
:class:`~pycsamt.format.borehole.schema.PCBHDocument` by
1. flattening one chosen sheet to a header row + value rows,
2. resolving canonical PCBH fields from that header (the same alias
machinery as the CSV importer, plus spreadsheet-specific hints),
3. injecting an external collar position (per hole, or one shared
collar) supplied out-of-band, and
4. delegating to
:func:`pycsamt.format.borehole.csvio.assemble_interval_document`.
No pandas dependency: ``openpyxl`` is imported lazily, read-only.
"""
from __future__ import annotations
import datetime as _dt
import hashlib
import io
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from ...api.property import PyCSAMTObject
from .csvio import (
DEFAULT_CSV_MAX_BYTES,
DEFAULT_CSV_MAX_ROWS,
assemble_interval_document,
)
from .mapping import (
CANONICAL_CSV_FIELDS,
ImportReport,
PCBHCSVImportError,
resolve_csv_columns,
)
from .schema import PCBHDocument
__all__ = [
"SheetOutline",
"WorkbookOutline",
"inspect_workbook",
"boreholes_from_xlsx",
"sheet_rows",
]
_PREVIEW_ROWS = 15
_CONSTANT_FIELDS = set(CANONICAL_CSV_FIELDS) | {
"units.depth",
"units.resistivity",
}
_MISSING = {"", "na", "n/a", "nan", "none", "null"}
# ---------------------------------------------------------------------------
# workbook inspection
# ---------------------------------------------------------------------------
[docs]
@dataclass(repr=False)
class SheetOutline(PyCSAMTObject):
"""Lightweight description of one worksheet."""
name: str
n_rows: int
n_cols: int
preview: list[list[str]] = field(default_factory=list)
header_candidates: list[int] = field(default_factory=list)
[docs]
@dataclass(repr=False)
class WorkbookOutline(PyCSAMTObject):
"""All sheets in a workbook, previews only — no bulk data read."""
source: str
sheets: list[SheetOutline] = field(default_factory=list)
[docs]
def sheet(self, key: str | int) -> SheetOutline:
"""Return one sheet by name or positional index."""
if isinstance(key, int):
return self.sheets[key]
for outline in self.sheets:
if outline.name == key:
return outline
raise KeyError(f"no sheet named {key!r}")
[docs]
def inspect_workbook(source: str | Path | bytes) -> WorkbookOutline:
"""Return sheet names, shapes, previews, and header-row guesses.
Parameters
----------
source : path-like or bytes
An ``.xlsx`` workbook. Bytes are accepted so an application can
pass an uploaded payload without touching disk.
Returns
-------
WorkbookOutline
One :class:`SheetOutline` per worksheet.
"""
raw, name = _read_bytes(source, DEFAULT_CSV_MAX_BYTES)
workbook = _load_workbook(raw)
try:
outlines: list[SheetOutline] = []
for worksheet in workbook.worksheets:
rows = _trimmed_rows(worksheet)
preview = [
[_cell_text(cell) for cell in row]
for row in rows[:_PREVIEW_ROWS]
]
outlines.append(
SheetOutline(
name=worksheet.title,
n_rows=len(rows),
n_cols=max((len(row) for row in rows), default=0),
preview=preview,
header_candidates=_header_candidates(rows),
)
)
finally:
workbook.close()
return WorkbookOutline(source=name, sheets=outlines)
[docs]
def sheet_rows(
source: str | Path | bytes,
*,
sheet: str | int | None = None,
header_row: int | None = None,
) -> tuple[str, list[str], list[list[str]]]:
"""Flatten one worksheet to ``(sheet_name, headers, data_rows)``.
A thin public wrapper around the workbook flattening used by
:func:`boreholes_from_xlsx`, for callers that resolve their own
canonical fields (e.g. :mod:`pycsamt.format.pointset`). ``header_row``
is 0-based; auto-detected when omitted.
"""
raw, _ = _read_bytes(source, DEFAULT_CSV_MAX_BYTES)
workbook = _load_workbook(raw)
try:
worksheet = _resolve_sheet(workbook, sheet)
name = worksheet.title
rows = _trimmed_rows(worksheet)
finally:
workbook.close()
if not rows:
return name, [], []
if header_row is None:
header_row = next(
(i for i, r in enumerate(rows) if _looks_like_header(r)), 0
)
header_row = max(0, min(int(header_row), len(rows) - 1))
headers = _header_names(rows[header_row])
data = [
_normalise_row(r, len(headers))
for r in rows[header_row + 1 :]
]
return name, headers, data
# ---------------------------------------------------------------------------
# import
# ---------------------------------------------------------------------------
[docs]
def boreholes_from_xlsx(
source: str | Path | bytes,
*,
sheet: str | int | None = None,
header_row: int | None = None,
columns: dict[str, Any] | None = None,
constants: dict[str, Any] | None = None,
collars: dict[str, Any] | str | Path | None = None,
default_borehole_id: str | None = None,
strict: bool = True,
document_id: str | None = None,
created_by: str = "pycsamt XLSX importer",
max_bytes: int = DEFAULT_CSV_MAX_BYTES,
max_rows: int = DEFAULT_CSV_MAX_ROWS,
) -> tuple[PCBHDocument, ImportReport]:
"""Import one worksheet of a borehole workbook as a PCBH document.
Parameters
----------
source : path-like or bytes
The ``.xlsx`` workbook.
sheet : str or int, optional
Worksheet name or 0-based index. Defaults to the first sheet.
header_row : int, optional
0-based row index of the header. Auto-detected when omitted
(the first row with at least two text cells).
columns : dict, optional
Explicit ``{canonical_field: source}`` mapping. ``source`` may be
a header string, a 0-based column index, or an Excel column
letter. Canonical names use dotted paths such as ``interval.from_md``.
constants : dict, optional
Constant canonical values (commonly ``borehole.id`` and
``crs.horizontal`` for a single-hole sheet).
collars : dict or path-like, optional
External collar positions, keyed by borehole id
(``{"ZK2203": {"x": .., "y": .., "z": .., "crs": "EPSG:32648"}}``),
or a single ``{"x": .., "y": .., "z": ..}`` applied to every hole,
or a small CSV with ``id,x,y,z[,crs]`` columns. Spreadsheets rarely
carry coordinates, so this is how they enter the document.
strict : bool, default=True
Raise :class:`PCBHCSVImportError` on any recorded error. In
permissive mode a missing collar/CRS becomes a flagged placeholder
and invalid rows are dropped.
document_id : str, optional
Defaults to ``xlsx:<file stem>``.
created_by : str, default='pycsamt XLSX importer'
Provenance name stored on the document.
max_bytes, max_rows : int
Resource limits, shared with the CSV importer.
Returns
-------
document : PCBHDocument
report : ImportReport
Includes the resolved sheet, header row, column mapping, injected
collars, and every rejected row.
"""
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")
raw, name = _read_bytes(source, max_bytes)
supplied_constants = dict(constants or {})
report = ImportReport(
source=name,
source_sha256=hashlib.sha256(raw).hexdigest(),
delimiter="xlsx",
strict=strict,
constants=supplied_constants,
)
for key in supplied_constants:
if key not in _CONSTANT_FIELDS:
report.add(
"error",
"xlsx.constant_field",
f"unknown constant field {key!r}",
)
workbook = _load_workbook(raw)
try:
worksheet = _resolve_sheet(workbook, sheet)
report.source_files["sheet"] = worksheet.title
rows = _trimmed_rows(worksheet)
finally:
workbook.close()
if not rows:
report.add("error", "xlsx.empty", "worksheet has no rows")
raise PCBHCSVImportError(report)
header_index = _resolve_header_row(rows, header_row, report)
headers = _header_names(rows[header_index])
report.inferred_values.append(
f"header row = {header_index + 1} (1-based)"
)
if len(headers) < 2:
report.add(
"error",
"xlsx.header",
"header row needs at least two columns",
row=header_index + 1,
)
data_rows = [
_normalise_row(row, len(headers))
for row in rows[header_index + 1 :]
]
explicit = _resolve_column_tokens(columns, headers, report)
# Preflight the alias resolver on a throwaway report so collar
# injection can key on the borehole-id column even when it was
# matched by alias rather than named explicitly.
scratch = ImportReport(
source=name, source_sha256="", delimiter="xlsx", strict=False
)
preflight = resolve_csv_columns(
headers,
explicit=explicit,
constants=supplied_constants,
report=scratch,
)
if (
default_borehole_id
and "borehole.id" not in preflight
and "borehole.id" not in explicit
and "borehole.id" not in supplied_constants
):
supplied_constants["borehole.id"] = str(default_borehole_id)
report.inferred_values.append(
f"borehole.id = {default_borehole_id!r} (no id column found)"
)
headers, data_rows, explicit = _inject_collars(
headers,
data_rows,
explicit,
collars=collars,
constants=supplied_constants,
id_source=explicit.get("borehole.id") or preflight.get("borehole.id"),
report=report,
strict=strict,
)
if strict:
_guard_required_present(explicit, supplied_constants, report)
mapping = resolve_csv_columns(
headers,
explicit=explicit,
constants=supplied_constants,
report=report,
)
if report.errors:
raise PCBHCSVImportError(report)
return assemble_interval_document(
headers,
iter(data_rows),
mapping=mapping,
constants=supplied_constants,
report=report,
strict=strict,
document_id=document_id or f"xlsx:{Path(name).stem}",
created_by=created_by,
source_str=name,
max_rows=max_rows,
row_offset=header_index + 2,
source_kind="xlsx",
)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _read_bytes(source: Any, max_bytes: int) -> tuple[bytes, str]:
if isinstance(source, (bytes, bytearray)):
raw = bytes(source)
name = "workbook.xlsx"
elif hasattr(source, "read"):
raw = source.read()
name = getattr(source, "name", "workbook.xlsx")
else:
path = Path(source)
raw = path.read_bytes()
name = str(path)
if len(raw) > max_bytes:
raise ValueError(
f"workbook is {len(raw)} bytes; limit is {max_bytes} bytes"
)
return raw, name
def _load_workbook(raw: bytes):
try:
import openpyxl
except ModuleNotFoundError as error: # pragma: no cover - packaging
raise ModuleNotFoundError(
"reading .xlsx workbooks requires 'openpyxl'"
) from error
try:
return openpyxl.load_workbook(
io.BytesIO(raw), read_only=True, data_only=True
)
except Exception as error: # noqa: BLE001 - surface a clean message
raise ValueError(f"could not open workbook: {error}") from error
def _resolve_sheet(workbook, sheet):
names = workbook.sheetnames
if sheet is None:
return workbook[names[0]]
if isinstance(sheet, int):
if not 0 <= sheet < len(names):
raise ValueError(
f"sheet index {sheet} out of range (0..{len(names) - 1})"
)
return workbook[names[sheet]]
if sheet not in names:
raise ValueError(f"no sheet named {sheet!r}; have {names!r}")
return workbook[sheet]
def _trimmed_rows(worksheet) -> list[list[Any]]:
rows = [list(row) for row in worksheet.iter_rows(values_only=True)]
while rows and all(_is_blank(cell) for cell in rows[-1]):
rows.pop()
return rows
def _is_blank(cell: Any) -> bool:
return cell is None or (isinstance(cell, str) and not cell.strip())
def _cell_text(cell: Any) -> str:
if cell is None:
return ""
if isinstance(cell, str):
return cell.strip()
if isinstance(cell, bool):
return "true" if cell else "false"
if isinstance(cell, float):
if cell.is_integer():
return str(int(cell))
return repr(cell)
if isinstance(cell, (_dt.datetime, _dt.date)):
return cell.isoformat()
return str(cell).strip()
def _normalise_row(row: list[Any], width: int) -> list[str]:
values = [_cell_text(cell) for cell in row]
if len(values) < width:
values.extend([""] * (width - len(values)))
elif len(values) > width:
values = values[:width]
return values
def _header_names(row: list[Any]) -> list[str]:
names: list[str] = []
seen: dict[str, int] = {}
for index, cell in enumerate(row, start=1):
text = _cell_text(cell) or f"column_{index}"
if text in seen:
seen[text] += 1
text = f"{text}_{seen[text]}"
else:
seen[text] = 1
names.append(text)
return names
def _numeric_count(row: list[Any]) -> int:
count = 0
for cell in row:
if isinstance(cell, (int, float)) and not isinstance(cell, bool):
count += 1
elif isinstance(cell, str):
try:
float(cell.strip())
count += 1
except ValueError:
pass
return count
def _text_count(row: list[Any]) -> int:
return sum(
1
for cell in row
if isinstance(cell, str)
and cell.strip()
and not _is_number_text(cell)
)
def _is_number_text(cell: str) -> bool:
try:
float(cell.strip())
return True
except ValueError:
return False
def _looks_like_header(row: list[Any]) -> bool:
return _text_count(row) >= 2 and _numeric_count(row) == 0
def _header_candidates(rows: list[list[Any]]) -> list[int]:
candidates: list[int] = []
for index, row in enumerate(rows[:_PREVIEW_ROWS]):
if not _looks_like_header(row):
continue
following = rows[index + 1 : index + 6]
if any(_numeric_count(later) >= 2 for later in following):
candidates.append(index)
return candidates
def _resolve_header_row(
rows: list[list[Any]], header_row: int | None, report: ImportReport
) -> int:
if header_row is not None:
if not 0 <= header_row < len(rows):
report.add(
"error",
"xlsx.header_row",
f"header_row {header_row} is outside 0..{len(rows) - 1}",
)
raise PCBHCSVImportError(report)
return header_row
for index, row in enumerate(rows):
if _looks_like_header(row):
return index
return 0
def _resolve_column_tokens(
columns: dict[str, Any] | None,
headers: list[str],
report: ImportReport,
) -> dict[str, str]:
resolved: dict[str, str] = {}
for canonical, token in (columns or {}).items():
header = _token_to_header(token, headers)
if header is None:
report.add(
"error",
"xlsx.column_token",
f"cannot resolve column {token!r} for {canonical!r}",
)
continue
resolved[canonical] = header
return resolved
def _token_to_header(token: Any, headers: list[str]) -> str | None:
if isinstance(token, int) and not isinstance(token, bool):
return headers[token] if 0 <= token < len(headers) else None
text = str(token).strip()
if text in headers:
return text
if text.isalpha():
index = 0
for char in text.upper():
index = index * 26 + (ord(char) - ord("A") + 1)
index -= 1
return headers[index] if 0 <= index < len(headers) else None
return None
def _normalise_collars(
collars: dict[str, Any] | str | Path | None,
) -> tuple[dict[str, dict[str, Any]], dict[str, Any] | None]:
"""Return ``(per_id, shared)`` collar tables."""
if collars is None:
return {}, None
if isinstance(collars, (str, Path)):
import csv as _csv
per_id: dict[str, dict[str, Any]] = {}
with open(collars, newline="", encoding="utf-8-sig") as handle:
for row in _csv.DictReader(handle):
key = str(
row.get("id") or row.get("borehole_id") or ""
).strip()
if not key:
continue
per_id[key] = {
"x": row.get("x"),
"y": row.get("y"),
"z": row.get("z"),
"crs": row.get("crs"),
}
return per_id, None
if not isinstance(collars, dict):
raise TypeError("collars must be a dict, path, or None")
lowered = {str(k).lower() for k in collars}
if {"x", "y", "z"} <= lowered:
return {}, {str(k).lower(): v for k, v in collars.items()}
return {str(k): dict(v) for k, v in collars.items()}, None
def _inject_collars(
headers: list[str],
data_rows: list[list[str]],
explicit: dict[str, str],
*,
collars: dict[str, Any] | str | Path | None,
constants: dict[str, Any],
id_source: str | None,
report: ImportReport,
strict: bool,
) -> tuple[list[str], list[list[str]], dict[str, str]]:
per_id, shared = _normalise_collars(collars)
have_collar = per_id or shared
id_index = headers.index(id_source) if id_source in headers else None
id_constant = str(constants.get("borehole.id") or "").strip()
synth = {
"collar.x": "__pcbh_collar_x",
"collar.y": "__pcbh_collar_y",
"collar.z": "__pcbh_collar_z",
}
crs_synth = "__pcbh_crs"
add_crs = have_collar and (
shared and shared.get("crs")
or any(entry.get("crs") for entry in per_id.values())
)
if not have_collar:
if strict:
return headers, data_rows, explicit
# permissive: placeholder collar + CRS so a preview still renders
for canonical, value in (
("collar.x", 0.0),
("collar.y", 0.0),
("collar.z", 0.0),
):
if canonical not in explicit and canonical not in constants:
constants[canonical] = value
if (
"crs.horizontal" not in explicit
and "crs.horizontal" not in constants
):
constants["crs.horizontal"] = "LOCAL:unknown"
report.add(
"warning",
"xlsx.placeholder_collar",
"no collar coordinates supplied; using a (0, 0, 0) "
"LOCAL:unknown placeholder — set real collars before use",
)
return headers, data_rows, explicit
new_headers = list(headers) + list(synth.values())
if add_crs:
new_headers.append(crs_synth)
injected: set[str] = set()
for row in data_rows:
key = ""
if id_index is not None and id_index < len(row):
key = row[id_index].strip()
key = key or id_constant
entry = per_id.get(key) if per_id else shared
if entry is None and shared is not None:
entry = shared
if entry:
injected.add(key or "<all>")
row.append(_cell_or_blank(entry.get("x")))
row.append(_cell_or_blank(entry.get("y")))
row.append(_cell_or_blank(entry.get("z")))
if add_crs:
row.append(_cell_or_blank(entry.get("crs")))
else:
row.extend(["", "", ""])
if add_crs:
row.append("")
explicit = dict(explicit)
explicit.update(synth)
if add_crs and "crs.horizontal" not in constants:
explicit["crs.horizontal"] = crs_synth
report.inferred_values.append(
f"injected external collars for: {sorted(injected)}"
)
return new_headers, data_rows, explicit
def _cell_or_blank(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _guard_required_present(
explicit: dict[str, str],
constants: dict[str, Any],
report: ImportReport,
) -> None:
for canonical in ("collar.x", "collar.y", "collar.z", "crs.horizontal"):
if canonical in explicit or canonical in constants:
continue
report.add(
"error",
"xlsx.missing_collar",
f"{canonical!r} has no column, constant, or collar entry; "
"pass collars= or constants=, or use strict=False",
)
if report.errors:
raise PCBHCSVImportError(report)