Source code for pycsamt.seg.collection

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0-or-later

from __future__ import annotations

from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import (
    Any,
    Callable,
    Union,
)

import numpy as np

from ..core.config import StationNamePolicy, get_config
from ..exceptions import SiteError
from ..log.logger import get_logger
from ..utils.handlers import columns_manager
from .cbase import CBBase, CoreParser, ParseMixin
from .edi import EDIFile

logger = get_logger(__name__)

__all__ = ["CollectionMixin", "EDICollection"]

Pathish = Union[str, Path]


[docs] class CollectionMixin(ParseMixin): r""" Utilities for working with collections of :class:`EDIFile` objects. The mixin adds non-IO helpers that operate on a set of items already loaded in memory. Typical operations include filtering, mapping, grouping, and summarizing. It is designed to be combined with a small container base, such as :class:`CBBase`. Parameters ---------- None This is a mixin. It does not define its own public constructor parameters. Attributes ---------- items : dict Mapping from collection key (for example the station id) to :class:`EDIFile`. Expected to be provided by the host collection. order : list of str Insertion order of keys. Used to preserve stable iteration and deterministic summaries. Notes ----- The mixin focuses on pure-Python transformations. The typical methods provided by implementations include: * ``select(keys=None, predicate=None)`` : return a new view with items that match the keys or satisfy the boolean predicate. * ``map(func)`` : apply a callable to each :class:`EDIFile` and collect the results in a list. * ``groupby(keyfunc)`` : bucket items by a user-defined key and return a mapping to lists of :class:`EDIFile`. * ``summary(fields=('station', 'n_freq', ...))`` : build a compact table of common attributes for quick inspection. These helpers do not read from disk and do not mutate :class:`EDIFile` content unless explicitly documented by the host class. Examples -------- Filter by station prefix and compute sample counts:: view = coll.select( predicate=lambda ed: ed.station.startswith('A') ) counts = view.map(lambda ed: ed.Z.n_freq) Group by presence of tipper and list stations:: groups = coll.groupby( lambda ed: ed.Tip.tipper is not None ) stations_with_tip = [ed.station for ed in groups.get(True, [])] See Also -------- EDICollection Concrete collection that mixes this class with a container and a loader. CBBase Minimal container used by collections. CoreParser Parser used to build :class:`EDIFile` objects. EDIFile Single-file reader and writer. References ---------- .. [1] SEG EDI MT/EMAP standard (1987), MTNet. https://www.mtnet.info/docs/seg_mt_emap_1987.pdf """
[docs] def add_from( self, sources: Pathish | Sequence[Pathish], *, recursive: bool = True, strict: bool = False, on_dup: str = "replace", verbose: int | None = None, ) -> CollectionMixin: v = self.verbose if verbose is None else int(verbose) pr = CoreParser( recursive=recursive, strict=strict, on_dup=on_dup, verbose=v, ) items = pr.parse(sources) for ed in items: self.add(ed) # type: ignore[attr-defined] errs = pr.errors() if errs: logger.info("Added with %d errors.", len(errs)) return self
[docs] def select(self, stations: Sequence[str]) -> EDICollection: stations = columns_manager(stations, empty_as_none=False) keep = {str(s) for s in stations} out = EDICollection(verbose=self.verbose) for ed in self: # type: ignore[operator] sid = getattr(ed, "station", None) or "-" if sid in keep: out.add(ed) return out
[docs] def where(self, fn: Callable[[EDIFile], bool]) -> EDICollection: out = EDICollection(verbose=self.verbose) for ed in self: # type: ignore[operator] if fn(ed): out.add(ed) return out
[docs] def sort( self, key: str | Callable[[EDIFile], object] = "station", *, reverse: bool = False, ) -> EDICollection: def _key(ed: EDIFile) -> object: if callable(key): return key(ed) if key == "station": return getattr(ed, "station", "") if key == "n_freq": return int(getattr(ed.Z, "n_freq", 0) or 0) if key == "path": return str(ed.path) if ed.path else "" return getattr(ed, key, None) out = EDICollection( items=sorted( list(self), # type: ignore[arg-type] key=_key, reverse=reverse, ), verbose=self.verbose, ) return out
[docs] @property def paths(self) -> list[str]: p: list[str] = [] for ed in self: # type: ignore[operator] p.append(str(ed.path) if ed.path else "-") return p
[docs] def nf_stats(self) -> dict: vals: list[int] = [] for ed in self: # type: ignore[operator] vals.append(int(getattr(ed.Z, "n_freq", 0) or 0)) a = np.asarray(vals, int) if a.size == 0: return {"min": 0, "max": 0, "mean": 0.0} return { "min": int(a.min()), "max": int(a.max()), "mean": float(a.mean()), }
[docs] class EDICollection(CBBase, CollectionMixin): r""" High-level collection of :class:`EDIFile` objects with integrated loading and summarization. The class couples the small container :class:`CBBase` with :class:`CoreParser` to discover, read, and manage many EDI files at once. Items are indexed by station id or by absolute path, with a configurable duplicate policy. Parameters ---------- sources : path-like or sequence of path-like, optional Files, folders, or glob patterns to load. When omitted the collection is created empty. Use :meth:`load` later to populate it. recursive : bool, default ``True`` Recurse into subdirectories when scanning folders. on_dup : {'replace', 'keep'}, default ``'replace'`` Duplicate policy for items that share the same key. With ``'replace'`` the last item wins. With ``'keep'`` the first item is preserved. index_by : {'station', 'path'}, default ``'station'`` Key used to index items. When ``'station'`` the ``EDIFile.station`` is preferred with a fallback to file path if unspecified. strict : bool, default ``False`` If ``True`` read errors are raised. If ``False`` they are captured and available via :meth:`errors`. verbose : int, default ``0`` Verbosity forwarded to the underlying :class:`EDIFile` readers. Attributes ---------- parser : CoreParser Internal parser used by :meth:`load`. Exposed for advanced scenarios or diagnostics. items : dict Mapping from key to :class:`EDIFile`. order : list of str Insertion order of collection keys. meta : dict Free-form metadata that client code can populate. Methods ------- load(sources) Discover and read items. Merges into the current collection while respecting the duplicate policy. select(keys=None, predicate=None) Return a filtered view. Provided by :class:`CollectionMixin`. map(func) Apply a callable to each item and collect results. groupby(keyfunc) Group items by a user function, returning a dict of lists. summary(fields=('station', 'n_freq', 'has_tipper')) Build a compact per-item table suitable for logging or quick inspection. errors() Return a list of ``(path, exception)`` pairs from the last :meth:`load` call. Notes ----- The class aims to be pragmatic. Loading is done with a single pass of :class:`CoreParser`. Summaries are built from lightweight attributes that are cheap to access, such as ``station``, number of frequencies, and section presence. Heavy analysis should be implemented outside the collection to keep the API small. Examples -------- Load a folder, keep first duplicates, and summarize:: coll = EDICollection( sources=['data/edi'], on_dup='keep', recursive=True ) tbl = coll.summary( fields=('station', 'n_freq', 'tipper') ) for row in tbl: print(row) Add more files later and filter by predicate:: coll.load(['more/*.edi']) mt_only = coll.select( predicate=lambda ed: ed.has_section('mtsect') ) stations = mt_only.map(lambda ed: ed.station) See Also -------- CoreParser Multi-source reader used by :meth:`load`. CBBase Base container that stores items and preserves order. ParseMixin Discovery utilities employed by the parser. EDIFile Single EDI reader and writer stored in the collection. References ---------- .. [1] SEG EDI MT/EMAP standard (1987), MTNet. https://www.mtnet.info/docs/seg_mt_emap_1987.pdf """ def __init__( self, items: Iterable[EDIFile] | None = None, *, verbose: int = 0, ) -> None: super().__init__(items=items, verbose=verbose)
[docs] @classmethod def from_sources( cls, sources: Pathish | Sequence[Pathish], *, recursive: bool = True, strict: bool = False, on_dup: str = "replace", verbose: int = 0, ) -> EDICollection: pr = CoreParser( recursive=recursive, strict=strict, on_dup=on_dup, verbose=verbose, ) items = pr.parse(sources) col = cls(items=items, verbose=verbose) errs = pr.errors() if errs: logger.info( "Loaded %d items with %d errors.", len(items), len(errs), ) return col
[docs] def merge( self, other: EDICollection, *, on_dup: str = "replace", ) -> EDICollection: out = EDICollection( items=list(self), verbose=self.verbose, ) if on_dup not in {"replace", "keep"}: raise ValueError("on_dup must be keep|replace") for ed in other: sid = getattr(ed, "station", None) sid = sid or (str(ed.path) if ed.path else None) if sid in out._index and on_dup == "keep": continue out.add(ed) return out
def _resolve(self, site: str): """Find item by key, station, stem or filename (case-insensitive).""" want = str(site).upper() cands = set(_site_candidates(want)) # fast path: index keys (case-insensitive) for key, idx in self._index.items(): if str(key).upper() in cands: return self._items[idx] # scan stations for ed in self: sid = getattr(ed, "station", None) if sid and str(sid).upper() in cands: return ed # scan paths low = want.lower() for ed in self: p = getattr(ed, "path", None) if p is None: continue if ( p.stem.lower() in {low} | {c.lower() for c in cands} or p.name.lower() == low or str(p).lower() == low ): return ed all_sites = ", ".join(self.stations()) raise SiteError( f"site not found: {site!r}. Try one of: " f"{all_sites}\nHint: call " "`<collection>.stations()` to list." ) def _head(self, ed: EDIFile): return ed.get_section("head")
[docs] def get( self, site: str, what: str, default: object | None = None, ) -> object | None: """ Quick extractor for a single field/array. 'what' supports: - 'freq', 'z', 'zxx','zxy','zyx','zyy' - 'tip','tx','ty' - 'station','dataid','lat','lon','elev' - 'path','filename' - 'spectra','timeseries' """ try: ed = self._resolve(site) except KeyError: return default w = str(what).lower() # transfer functions if w == "freq": return getattr(ed.Z, "freq", None) if w == "z": return getattr(ed.Z, "z", None) if w in {"zxx", "zxy", "zyx", "zyy"}: m = {"zxx": (0, 0), "zxy": (0, 1), "zyx": (1, 0), "zyy": (1, 1)}[ w ] z = getattr(ed.Z, "z", None) if z is None: return default return z[:, m[0], m[1]] if w in {"tip", "tx", "ty"}: t = getattr(ed.Tip, "tipper", None) if t is None or getattr(t, "size", 0) == 0: return default if w == "tip": return t return t[:, 0, 0] if w == "tx" else t[:, 0, 1] # headers / identity head = self._head(ed) if w == "station": return getattr(ed, "station", None) if w == "dataid": return getattr(head, "dataid", None) if head else None if w in {"lat", "lon", "elev"}: if not head: return default if w == "lat": return getattr(head, "lat", None) if w == "lon": # support both LONG and LON v = getattr(head, "long", None) return v if v is not None else getattr(head, "lon", None) return getattr(head, "elev", None) # path-ish if w in {"path", "filename"}: p = getattr(ed, "path", None) if p is None: return default return str(p) if w == "path" else p.name # higher-level blocks if w == "spectra": return ed.get_section("spectra") if w in {"ts", "timeseries"}: return ed.get_section("timeseries") return default
[docs] def set( self, site: str, *, edi: EDIFile | None = None, update: dict[str, object] | None = None, ) -> EDIFile: """ Replace or mutate a site's EDI. - If 'edi' is given, replace the stored object. - Else apply 'update' fields on the existing object: keys: 'station','dataid','lat','lon','elev','z', 'freq','tip','spectra','timeseries' """ ed = self._resolve(site) # replace whole object if edi is not None: # try a collection-level replace try: self.remove(site) # optional on CBBase except Exception: pass try: self.add(edi) # CBBase.add except Exception: # fallback: mutate mapping directly idx = getattr(self, "_index", None) if isinstance(idx, dict): idx[site] = edi return edi # mutate in place upd = update or {} head = self._head(ed) if "station" in upd: try: ed.station = str(upd["station"]) except Exception: if head: head.dataid = str(upd["station"]) if "dataid" in upd and head: head.dataid = str(upd["dataid"]) if "lat" in upd and head: try: head.lat = float(upd["lat"]) # type: ignore[arg-type] except Exception: pass if "lon" in upd and head: try: # support LONG or LON if hasattr(head, "long"): head.long = float(upd["lon"]) # type: ignore[arg-type] else: head.lon = float(upd["lon"]) # type: ignore[attr-defined] except Exception: pass if "elev" in upd and head: try: head.elev = float(upd["elev"]) # type: ignore[arg-type] except Exception: pass if "freq" in upd and hasattr(ed.Z, "freq"): ed.Z._freq = np.asarray(upd["freq"], float) if "z" in upd and hasattr(ed.Z, "z"): ed.Z._z = np.asarray(upd["z"], complex) if "tip" in upd and hasattr(ed.Tip, "tipper"): ed.Tip._tipper = np.asarray(upd["tip"], complex) if "spectra" in upd: ed.add_section("spectra", upd["spectra"]) if "timeseries" in upd or "ts" in upd: val = upd.get("timeseries", upd.get("ts")) ed.add_section("timeseries", val) return ed
[docs] def adjust( self, site: str, *, dlat: float | None = None, dlon: float | None = None, lat: float | None = None, lon: float | None = None, elev: float | None = None, rename: str | None = None, ) -> EDIFile: """ Shift or set position and/or rename station. """ ed = self._resolve(site) head = self._head(ed) if head is None: raise ValueError("no >HEAD to adjust") # rename first so index logic can react later if needed if rename: try: ed.station = str(rename) except Exception: head.dataid = str(rename) if lat is not None: try: head.lat = float(lat) except Exception: pass if lon is not None: try: if hasattr(head, "long"): head.long = float(lon) else: head.lon = float(lon) # type: ignore[attr-defined] except Exception: pass if elev is not None: try: head.elev = float(elev) except Exception: pass # relative shifts if dlat is not None: try: head.lat = float(head.lat) + float(dlat) # type: ignore[arg-type] except Exception: pass if dlon is not None: try: if hasattr(head, "long"): head.long = float(head.long) + float(dlon) else: head.lon = float(head.lon) + float(dlon) # type: ignore[attr-defined] except Exception: pass return ed
[docs] def export( self, output_dir: Pathish, *, file_pattern: str = "{station}.edi", **edi_write_kwargs, ) -> dict: """ Exports all EDIFile items to a directory with advanced options. This method orchestrates the writing of each EDIFile in the collection to a specified directory, with flexible naming and clear error reporting. Parameters ---------- output_dir : Pathish The path to the directory where EDI files will be saved. It will be created if it does not exist. file_pattern : str, default="{station}.edi" A format string for output filenames. Can use attributes like `{station}`. **edi_write_kwargs Keyword arguments to be passed directly to each `EDIFile.write()` call (e.g., `datatype="mt"`, `synthesize_spectra=True`). Returns ------- dict A dictionary with two keys: - 'successful': A list of paths to successfully written files. - 'failed': A list of tuples, where each tuple contains the station name and the error that occurred during writing. """ out_dir = Path(str(output_dir)).expanduser().resolve() out_dir.mkdir(parents=True, exist_ok=True) successful_paths = [] failed_items = [] items_iterator = self._items # Optional: Use tqdm for a progress bar if available try: from tqdm import tqdm except ImportError: tqdm = None if tqdm: items_iterator = tqdm(self._items, desc="Exporting EDI Files") for ed in items_iterator: sid = self._site_of(ed) or "unknown_station" try: filename = file_pattern.format(station=sid) output_path = out_dir / filename # Delegate the actual writing to the EDIFile instance written_path = ed.write( new_edifn=str(output_path), **edi_write_kwargs ) successful_paths.append(written_path) except Exception as e: failed_items.append((sid, e)) logger.error(f"Failed to write EDI for station {sid}: {e}") return {"successful": successful_paths, "failed": failed_items}
[docs] def fetch( self, site: str | None = None, lat: float | None = None, lon: float | None = None, tol: float = 0.001, first: bool = False, strict: bool = False, policy: StationNamePolicy | None = None, **kwargs, ) -> EDIFile | list[EDIFile] | None: r""" Fetch EDI files by station, coordinates, or other metadata. This method searches the collection for :class:`~pycsamt.seg.edi.EDIFile` objects by station name, geographic coordinates, or any attribute exposed on the EDI or its ``>HEAD`` section. Parameters ---------- site : str or int or float, optional Station selector. Case-insensitive. Numeric-like values are normalized using the active :class:`~pycsamt.core.config.StationNamePolicy` and station-variant expansion. Variants tried include the raw token, integer form, zero-padded prefix form (e.g. ``S150``), and common ``×10`` slips (``1500`` <-> ``150``). lat : float, optional Latitude in decimal degrees. Compared against the ``>HEAD`` field ``lat`` using the tolerance ``tol``. lon : float, optional Longitude in decimal degrees. Compared against the ``>HEAD`` field ``long`` using the tolerance ``tol``. tol : float, default 0.001 Absolute tolerance (decimal degrees) used when matching ``lat`` and ``lon``. first : bool, default False If ``True``, return only the first matching object or ``None`` if no match (unless ``strict=True``, see *Raises*). If ``False``, return a list of all matches (possibly empty). strict : bool, default False If ``True`` and no match is found, raise :class:`~pycsamt.seg.exceptions.SiteError` with a helpful message suggesting ``<collection>.stations()``. policy : StationNamePolicy, optional Custom naming policy. Defaults to :func:`~pycsamt.core.config.get_config` ``.station_policy``. **kwargs : Any Additional attribute filters. Keys are matched (case-insensitive) first on the EDI object, then on its ``head`` section. String values compare in a case-insensitive way; non-strings compare by equality. Returns ------- EDIFile or list of EDIFile or None If ``first=True``, an :class:`EDIFile` or ``None`` when no match and ``strict=False``. If ``first=False``, a list of matches (possibly empty). Raises ------ SiteError Raised when no match is found and ``strict=True``. The error message lists available stations and suggests calling ``<collection>.stations()``. Notes ----- - Station matching expands candidate variants using the active :class:`StationNamePolicy` and a small set of robust transforms (integer form, zero-padded with prefix, and common ``×10`` slips). - Geographic matching uses absolute tolerance on decimal degrees and compares against ``>HEAD`` fields ``lat`` and ``long``. - Arbitrary filters in ``**kwargs`` are applied after station and coordinate tests. String comparisons are case-insensitive; other types use equality. Examples -------- >>> # List available stations >>> coll.stations() ['S150', 'S200', 'S250', 'S300', ...] >>> # Fetch a single station by numeric label >>> ed = coll.fetch(site=150, first=True) >>> # Enforce a helpful error when not found >>> coll.fetch(site='S999', first=True, strict=True) Traceback (most recent call last): ... SiteError: No station found. Try one of: ... >>> # Fetch by coordinates within tolerance >>> ed = coll.fetch(lat=-22.37, lon=139.19, first=True) >>> # Filter by metadata on >HEAD (case-insensitive) >>> zs = coll.fetch(acqby='Zonge') See Also -------- pycsamt.core.config.StationNamePolicy pycsamt.core.config.ensure_station pycsamt.seg.collection.EDICollection.stations pycsamt.seg.collection.EDICollection._resolve References ---------- .. [1] SEG EDI 1.0 Electromagnetic Data Interchange specification. """ matches: list[EDIFile] = [] site_cands = ( set(_site_candidates(site, policy)) if site is not None else set() ) for edi in self: head = edi.get_section("head") ok = True # --- station match (case-insensitive, with variants) if site is not None: est = getattr(edi, "station", None) if not (est and est.upper() in site_cands): ok = False # --- geo match with tolerance if lat is not None and ok: hlat = getattr(head, "lat", None) if hlat is None or abs(hlat - lat) > tol: ok = False if lon is not None and ok: hlon = getattr(head, "long", None) if hlon is None or abs(hlon - lon) > tol: ok = False # --- arbitrary attribute matches for key, val in kwargs.items(): if not ok: break aval = getattr(edi, key.lower(), None) if aval is None and head is not None: aval = getattr(head, key.lower(), None) if isinstance(aval, str) and isinstance(val, str): if aval.upper() != val.upper(): ok = False elif aval != val: ok = False if ok: matches.append(edi) if first: if matches: return matches[0] if strict: all_sites = ", ".join(self.stations()) raise SiteError( "No station found. Try one of: " f"{all_sites}\nHint: call " "`<collection>.stations()` to list." ) return None # first=False if not matches and strict: all_sites = ", ".join(self.stations()) raise SiteError( "No stations matched your query. Try one of: " f"{all_sites}\nHint: call " "`<collection>.stations()` to list." ) return matches
@staticmethod def _site_of(it: EDIFile) -> str | None: """Safely extract the site/station name from an EDIFile.""" return getattr(it, "station", None) @staticmethod def _lat_of(it: EDIFile) -> float | None: """Safely extract the latitude from an EDIFile's HEAD section.""" head = it.get_section("head") v = getattr(head, "lat", None) return float(v) if isinstance(v, (int, float)) else None @staticmethod def _lon_of(it: EDIFile) -> float | None: """Safely extract the longitude from an EDIFile's HEAD section.""" head = it.get_section("head") v = getattr(head, "long", None) return float(v) if isinstance(v, (int, float)) else None
[docs] @property def sites(self) -> list[str]: """Returns a list of best-effort station names for each item.""" out: list[str] = [] for it in self._items: s = self._site_of(it) out.append(s if s is not None else "") return out
[docs] @property def latitude(self) -> np.ndarray: """Returns a numpy array of latitudes (np.nan for missing values).""" vals: list[float] = [] for it in self._items: v = self._lat_of(it) vals.append(np.nan if v is None else float(v)) return np.asarray(vals, dtype=float)
[docs] @property def longitude(self) -> np.ndarray: """Returns a numpy array of longitudes (np.nan for missing values).""" vals: list[float] = [] for it in self._items: v = self._lon_of(it) vals.append(np.nan if v is None else float(v)) return np.asarray(vals, dtype=float)
def __repr__(self) -> str: # pragma: no cover return f"EDICollection(n={len(self)}, stations={self.stations()!r})" def _summary_stats(self, summary_data: list[dict]) -> str: """Creates a statistical summary block from summary data.""" if not summary_data: return " (No statistics available for an empty collection)\n" total_files = len(summary_data) with_tipper = sum(1 for r in summary_data if r.get("tipper")) with_spectra = sum(1 for r in summary_data if r.get("spectra")) with_ts = sum(1 for r in summary_data if r.get("ts")) # Get all frequencies from all files to find the true min/max all_freqs = ( np.concatenate( [ edi.Z.freq for edi in self if edi.Z.freq is not None and edi.Z.freq.size > 0 ] ) if total_files > 0 else np.array([]) ) lats = self.latitude[~np.isnan(self.latitude)] lons = self.longitude[~np.isnan(self.longitude)] freq_range = ( f"Min={np.min(all_freqs):.2E}, Max={np.max(all_freqs):.2E}" if all_freqs.size > 0 else "N/A" ) lat_range = ( f"{min(lats):.4f} to {max(lats):.4f}" if len(lats) > 0 else "N/A" ) lon_range = ( f"{min(lons):.4f} to {max(lons):.4f}" if len(lons) > 0 else "N/A" ) lines = [ " " + "-" * 65, " Statistical Summary:", f" Total Sites: {total_files}", f" Content Counts: Tipper={with_tipper}," f" Spectra={with_spectra}, TimeSeries={with_ts}", f" Frequency Range (Hz): {freq_range}", f" Latitude Range: {lat_range}", f" Longitude Range: {lon_range}", " " + "-" * 65, ] return "\n".join(lines) def __str__(self) -> str: # pragma: no cover """Provides a detailed and statistical summary of the collection.""" summary_data = self.summary() if not summary_data: return "EDICollection(n=0, stations=[])" # --- Header --- title = f" EDICollection Summary (Total Sites: {len(summary_data)}) " width = 70 header = ["=" * width, title.center(width), "=" * width] # --- Per-Site Details --- details = ["\nSite Details:"] max_station_len = max( [len(r["station"]) for r in summary_data] + [len("Station")] ) table_header = ( f" {'Station'.ljust(max_station_len)} | Freqs" " | Tipper | Spectra | TimeSeries" ) table_width = len(table_header) - 2 details.append(table_header) details.append(" " + "-" * table_width) for r in summary_data: tip = "Y" if r["tipper"] else "N" sp = "Y" if r["spectra"] else "N" ts = "Y" if r["ts"] else "N" details.append( f" {r['station'].ljust(max_station_len)} | " f"{r['n_freq']:<5} | {tip:^6} | {sp:^7} | {ts:^10}" ) details.append(" " + "-" * table_width) details.append( " *Y = Yes (data present); *N = No (data not present)" ) # --- Statistical Summary --- stats_str = self._summary_stats(summary_data) # --- Combine and Return --- return "\n".join(header + [stats_str] + details)
def _site_candidates( site: Any, policy: StationNamePolicy | None = None, ) -> list[str]: pol = policy or get_config().station_policy def _try_float(v): try: return float(v) except Exception: return None s = str(site).strip() cands = {s.upper()} x = _try_float(s) if x is not None: n = int(round(x)) cands.add(str(n).upper()) cands.add(f"{pol.prefix}{n:0{pol.pad}d}".upper()) # common “×10” slip: try n*10 and n/10 cands.add(str(n * 10).upper()) cands.add(f"{pol.prefix}{n * 10:0{pol.pad}d}".upper()) if n % 10 == 0: m = n // 10 cands.add(str(m).upper()) cands.add(f"{pol.prefix}{m:0{pol.pad}d}".upper()) return list(cands)