Source code for pycsamt.map.loader

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
# ruff: noqa: E501
"""Multi-line EDI ingestion for :mod:`pycsamt.map`.

A map-view survey is usually made of several profile lines, each
stored as its own EDI folder.  The renderers in :mod:`pycsamt.map`
already understand the per-station ``line`` attribute, but the
normalizer :func:`pycsamt.map._core.ensure_map_data` only consumes a
single source.  This module lifts the multi-line grouping logic out
of the GUI layer so scripts and notebooks can build one combined
:class:`~pycsamt.map.MapData` from many lines.

Accepted ``source`` forms
-------------------------
mapping
    ``{line_name: dir | iterable_of_edis}`` — explicit line layout.
sequence of directories
    ``[dir_a, dir_b, ...]`` — one line per directory (named after it).
sequence of EDI-like items / files
    Treated as a single ``"line"``.
single directory
    Grouped according to *detect* (see :func:`load_lines`).
single file
    A one-station line named after the file stem.
"""

from __future__ import annotations

import re
from collections.abc import Iterable, Mapping
from dataclasses import replace
from pathlib import Path
from typing import Any

from ._core import MapData, ensure_map_data

__all__ = ["load_lines", "resolve_line_groups"]

_EDI_GLOBS = ("*.edi", "*.EDI")
_ID_PREFIX = re.compile(r"^([A-Za-z]*\d+)")


[docs] def load_lines( source: Any, *, detect: str = "folder", recursive: bool = True, line_map: dict[str, Iterable[str]] | None = None, verbose: int = 0, ) -> MapData: """Build one combined :class:`MapData` from multiple lines. Parameters ---------- source : Mapping, sequence, directory, or file (see module docstring). A :class:`MapData` is returned unchanged. detect : {"folder", "auto", "flat"}, default "folder" Line-grouping rule applied only when *source* is a single directory: ``folder`` One line per immediate sub-folder holding EDI files (a flat folder becomes one line named after it). ``auto`` Group by the station-ID prefix, e.g. ``22-001`` -> ``L22``. ``flat`` Treat the whole directory as a single line. recursive : Recurse into sub-directories when discovering EDI files and when calling :func:`ensure_map_data`. line_map : Optional ``line -> station names`` mapping forwarded to :func:`ensure_map_data` for sources without embedded line metadata. verbose : Verbosity forwarded to :func:`ensure_map_data`. Returns ------- MapData Stations from every line, re-indexed and tagged with their resolved line name; profiles are rebuilt per line. """ if isinstance(source, MapData): return source groups = resolve_line_groups( source, detect=detect, recursive=recursive, ) stations: list[Any] = [] edis: list[Any] = [] index = 0 for line, line_source in groups.items(): if not _has_items(line_source): continue line_data = ensure_map_data( line_source, recursive=recursive, line_map=line_map, verbose=verbose, ) for station in line_data.stations: stations.append(replace(station, line=line, index=index)) index += 1 edis.extend(line_data.iter_edis()) metadata = { "n_stations": len(stations), "n_lines": len({s.line for s in stations}), "lines": tuple(groups), } return MapData( sites=tuple(edis), stations=tuple(stations), profiles=(), metadata=metadata, )
[docs] def resolve_line_groups( source: Any, *, detect: str = "folder", recursive: bool = True, ) -> dict[str, Any]: """Return a ``{line_name: source}`` mapping for *source*. The values are passed straight to :func:`ensure_map_data`, so they may be directories, lists of file paths, or EDI-like iterables. This mirrors the ``groups`` store used by the GUI loader, making the two paths share one grouping contract. """ if isinstance(source, Mapping): return {str(k): v for k, v in source.items()} if isinstance(source, (str, Path)): path = Path(source) if path.is_file(): return {path.stem: [str(path)]} if not path.exists(): raise FileNotFoundError(f"EDI source path not found: {path}") return _detect_dir_groups( path, detect=detect, recursive=recursive, ) if isinstance(source, Iterable) and not isinstance( source, (str, bytes), ): items = list(source) if items and all(_is_dir(it) for it in items): return {Path(str(it)).name: str(it) for it in items} return {"line": items} raise TypeError( f"Unsupported EDI source for load_lines: {type(source)!r}." )
# ── directory grouping helpers ───────────────────────── def _detect_dir_groups( path: Path, *, detect: str, recursive: bool, ) -> dict[str, Any]: mode = (detect or "folder").lower() if mode == "auto": return _group_by_id_prefix(path, recursive) if mode == "flat": return {path.name: _discover_edis(path, recursive)} if mode == "folder": return _group_by_subfolder(path, recursive) raise ValueError( f"Unknown detect mode {detect!r}. " "Expected 'folder', 'auto', or 'flat'." ) def _group_by_subfolder( path: Path, recursive: bool, ) -> dict[str, list[str]]: groups: dict[str, list[str]] = {} for edi in _discover_edis(path, recursive): parent = Path(edi).parent line = parent.name if parent != path else path.name groups.setdefault(line, []).append(edi) return groups or {path.name: []} def _group_by_id_prefix( path: Path, recursive: bool, ) -> dict[str, list[str]]: groups: dict[str, list[str]] = {} for edi in _discover_edis(path, recursive): stem = Path(edi).stem match = _ID_PREFIX.match(stem) prefix = match.group(1) if match else stem[:4] if prefix.isdigit(): prefix = f"L{prefix}" groups.setdefault(prefix, []).append(edi) return groups or {path.name: []} def _discover_edis(path: Path, recursive: bool) -> list[str]: found: list[str] = [] for pattern in _EDI_GLOBS: matches = path.rglob(pattern) if recursive else path.glob(pattern) found.extend(str(p) for p in matches) # de-duplicate (case-insensitive globs can overlap) and sort return sorted(dict.fromkeys(found)) def _is_dir(item: Any) -> bool: try: return Path(str(item)).is_dir() except (TypeError, ValueError): return False def _has_items(line_source: Any) -> bool: if isinstance(line_source, (str, Path)): return True if isinstance(line_source, Iterable): return bool(list(line_source)) return line_source is not None