Source code for pycsamt.jones.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 (
    Callable,
    Union,
)

import numpy as np
import pandas as pd

from ..log.logger import get_logger
from .cbase import JCBBase, JCoreParser, JParseMixin
from .j import JFile

logger = get_logger(__name__)

__all__ = ["JCollectionMixin", "JCollection"]

Pathish = Union[str, Path]


[docs] class JCollectionMixin(JParseMixin): r""" Mixin that provides folder/glob expansion and robust parsing orchestration for Jones J-format collections. The mixin augments a stateful base (e.g., :class:`JCBBase`) with helpers to normalize user input paths, expand folders and wildcards, deduplicate candidates, and parse items using a tolerant strategy. Implementations typically use :class:`~pycsamt.jones.j.JFile` as the per-item reader, but the mixin is agnostic to the concrete class as long as it exposes a compatible ``from_file`` constructor. Notes ----- The design keeps discovery and parsing separate. First the mixin expands and filters path candidates; then the owning class decides how to instantiate items and how to record failures. This separation avoids tight coupling and keeps error handling clear. The mixin favors **permissive** heuristics while still catching obvious mistakes (non-files, unreadable paths, extensions that are unlikely to be J-format, etc.). Typical Methods --------------- from_paths(paths, *, strict=False, verbose=0) Expand and parse ``paths`` into items. Returns the created items (usually a list). _iter_paths(paths) Yield :class:`pathlib.Path` objects from strings or paths, ignoring duplicates. _iter_j_files(paths, *, recursive=True) Yield J-candidates by expanding folders and glob patterns (e.g., ``"*.j"``, ``"**/*.txt"``). _is_j_candidate(path) Light heuristic that recognizes J-format candidates. Examples -------- Expand a folder and parse all candidates: >>> mix = JCollectionMixin() >>> # usually inherited together with JCBBase >>> list(mix._iter_j_files(["data/j"]))[:1] # doctest: +ELLIPSIS [PosixPath('...kb0-s001.txt')] See Also -------- JCBBase Minimal stateful base for collections (stores items). JCollection High-level collection that mixes in this helper and provides user-facing APIs. pycsamt.jones.j.JFile High-level J reader used as default item type. References ---------- .. [1] A. G. Jones (1994). *J-format v2.0*. MTNet notes. """
[docs] def add_from( self, sources: Pathish | Sequence[Pathish], *, recursive: bool = True, strict: bool = False, on_dup: str = "replace", verbose: int | None = None, ) -> JCollectionMixin: v = self.verbose if verbose is None else int(verbose) pr = JCoreParser( recursive=recursive, strict=strict, on_dup=on_dup, verbose=v, ) items = pr.parse(sources) for jf in items: self.add(jf) # 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]) -> JCollection: keep = {str(s) for s in stations} out = JCollection(verbose=self.verbose) for jf in self: # type: ignore[operator] sid = getattr(jf, "site", None) or "-" if sid in keep: out.add(jf) return out
[docs] def where(self, fn: Callable[[JFile], bool]) -> JCollection: out = JCollection(verbose=self.verbose) for jf in self: # type: ignore[operator] if fn(jf): out.add(jf) return out
[docs] def sort( self, key: str | Callable[[JFile], object] = "station", *, reverse: bool = False, ) -> JCollection: def _key(jf: JFile) -> object: if callable(key): return key(jf) if key in {"station", "site", "name"}: return ( getattr(jf, "site", None) or getattr(jf, "name", None) or "" ) if key == "n_freq": f = getattr(jf, "freq", None) return int(getattr(f, "size", len(f or []))) if key == "path": return str(jf.path) if jf.path else "" if key == "lat": return float(getattr(jf, "lat", 9e9) or 9e9) if key == "lon": return float(getattr(jf, "lon", 9e9) or 9e9) return getattr(jf, key, None) out = JCollection( 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 jf in self: # type: ignore[operator] p.append(str(jf.path) if jf.path else "-") return p
[docs] def nf_stats(self) -> dict: vals: list[int] = [] for jf in self: # type: ignore[operator] f = getattr(jf, "freq", None) vals.append(int(getattr(f, "size", 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 JCollection(JCBBase, JCollectionMixin): r""" High-level collection of Jones J-format files. Combines :class:`JCBBase` (state container) with :class:`JCollectionMixin` (discovery/orchestration) to offer a convenient interface for batch loading, quick metadata browsing, and simple filtering over many J files. The default per-item object is :class:`~pycsamt.jones.j.JFile`, which exposes parsed headers (:class:`~pycsamt.jones.heads.Heads`) and data blocks (:class:`~pycsamt.jones.blocks.JBlocks`) as well as MT objects (:class:`pycsamt.z.z.Z`, :class:`pycsamt.z.tipper.Tipper`, :class:`pycsamt.z.resphase.ResPhase`) when available. Parameters ---------- verbose : int, default 0 Verbosity for logging and warnings during discovery and parsing. Attributes ---------- items : list of JFile Parsed items in insertion order. The class follows Python's container protocol (``__len__``, ``__iter__``). n : int Number of items in the collection (``len(self)``). paths : list of Path Convenience view of the associated file paths. stations : list of str Station codes when available on items (best-effort). Methods ------- parse(paths, *, strict=False, verbose=None) Expand ``paths`` (file, folder, glob), parse J files, and append items to the collection. Returns the new items. where(**query) Optional, return a filtered view (e.g., by ``station`` or by component family ``has_Z/has_R/has_T``). summary() Optional, return a compact text or dataframe summary. write_index(path, *, overwrite=True) Optional, serialize a CSV/TSV inventory for the collection. Notes ----- The collection is intentionally lightweight. It does not enforce a database schema, and it avoids implicitly reading large arrays until needed. This makes it suitable for quick exploration and CI tests. Error handling is conservative: unreadable paths or parse errors are usually skipped with a warning (unless ``strict=True`` is requested). Examples -------- Parse a folder and browse basic metadata: >>> col = JCollection(verbose=0) >>> _ = col.parse(["data/j"]) >>> len(col) >= 1 True >>> sorted(set(getattr(x, "site", None) for x in col))[:3] # doctest: +ELLIPSIS [...] Filter by station (if ``where`` is provided): >>> sub = getattr(col, "where", lambda **k: col)(station="KB0001") >>> len(sub) >= 1 True See Also -------- JCBBase Underlying state container. JCollectionMixin Path expansion and tolerant orchestration. pycsamt.jones.j.JFile Per-item high-level J reader. References ---------- .. [1] A. G. Jones (1994). *J-format v2.0*. MTNet notes. """ def __init__( self, items: Iterable[JFile] | 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, ) -> JCollection: pr = JCoreParser( 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: JCollection, *, on_dup: str = "replace", ) -> JCollection: out = JCollection( items=list(self), verbose=self.verbose, ) if on_dup not in {"replace", "keep"}: raise ValueError("on_dup must be keep|replace") for jf in other: sid = getattr(jf, "site", None) sid = sid or (str(jf.path) if jf.path else None) if sid in out._index and on_dup == "keep": continue out.add(jf) return out
def _resolve(self, site: str) -> JFile: """Find item by key, site/station, stem, or filename (case-insensitive).""" site_upper = str(site).upper() # Case-insensitive check on the # index dictionary first for speed for key, idx in self._index.items(): if str(key).upper() == site_upper: return self._items[idx] # Fallback to iterating through all items if not in index for jf in self: sid = getattr(jf, "site", None) if sid and str(sid).upper() == site_upper: return jf # Fallback to path matching (case-insensitive) site_lower = str(site).lower() for jf in self: p = getattr(jf, "path", None) if p is None: continue if ( p.stem.lower() == site_lower or p.name.lower() == site_lower or str(p).lower() == site_lower ): return jf raise KeyError(f"site not found: {site!r}") def _heads(self, jf: JFile): return getattr(jf, "heads", None)
[docs] def get( self, site: str, what: str, default: object | None = None, ) -> object | None: """ Quick extractor. 'what' supports: - 'freq' - 'z','zxx','zxy','zyx','zyy' - 'tip','tx','ty' - 'rxy','ryx','rxx','ryy' (rho) - 'phixy','phiyx','phixx','phiyy' - 'station'/'site','lat','lon','elev','az','name' - 'path','filename' """ try: jf = self._resolve(site) except KeyError: return default w = str(what).lower() # --------------- freq --------------- if w == "freq": return getattr(jf, "freq", None) # --------------- Z ------------------ if w == "z": return getattr(getattr(jf, "Z", None), "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(getattr(jf, "Z", None), "z", None) if z is None: return default a = np.asarray(z) return a[:, m[0], m[1]] # ------------- tipper --------------- if w in {"tip", "tx", "ty"}: tp = getattr(jf, "Tip", None) if tp is None: return default arr = getattr(tp, "tipper", None) if arr is None: return default a = np.asarray(arr) if a.ndim == 3 and a.shape[1:] == (1, 2): a = a[:, 0, :] if w == "tip": return a return a[:, 0] if w == "tx" else a[:, 1] # -------- resistivity/phase -------- if w in { "rxx", "rxy", "ryx", "ryy", "phixx", "phixy", "phiyx", "phiyy", }: rp = getattr(jf, "Res", None) if rp is None: return default if w.startswith("phi"): key = "phase_" + w[3:] else: key = "res_" + w return getattr(rp, key, None) # ----------- headers/site ---------- if w in {"station", "site"}: return getattr(jf, "site", None) if w == "name": return getattr(jf, "name", None) if w == "lat": return getattr(jf, "lat", None) if w == "lon": return getattr(jf, "lon", None) if w == "elev": return getattr(jf, "elev", None) if w == "az": return getattr(jf, "azimuth", None) # -------------- path-ish ------------ if w in {"path", "filename"}: p = getattr(jf, "path", None) if p is None: return default return str(p) if w == "path" else p.name return default
[docs] def set( self, site: str, *, jfile: JFile | None = None, update: dict[str, object] | None = None, ) -> JFile: """ Replace or mutate a site's :class:`JFile`. - If 'jfile' is given, replace the stored object. - Else apply 'update' keys on the existing object: 'station'/'site','lat','lon','elev','az', 'freq','z','tip','resphase' """ jf = self._resolve(site) if jfile is not None: try: self.remove(site) # optional on CBBase except Exception: pass try: self.add(jfile) except Exception: idx = getattr(self, "_index", None) if isinstance(idx, dict): idx[site] = jfile return jfile upd = update or {} heads = self._heads(jf) info = getattr(heads, "info", None) if "station" in upd or "site" in upd: sid = str(upd.get("station", upd.get("site"))) try: # prefer JFile mutable name jf.station = sid # type: ignore[attr-defined] except Exception: # fallback: set on head if present try: heads.head.station = sid # type: ignore[attr-defined] except Exception: pass def _set_info(key: str, val: object) -> None: if info is None: return try: info.items[key] = str(val) # drop cached parsed site if any info._site_cache = None except Exception: pass if "lat" in upd: _set_info("LATITUDE", upd["lat"]) if "lon" in upd: _set_info("LONGITUDE", upd["lon"]) if "elev" in upd: _set_info("ELEVATION", upd["elev"]) if "az" in upd: _set_info("AZIMUTH", upd["az"]) if "freq" in upd and getattr(jf, "Z", None) is not None: jf.Z._freq = np.asarray(upd["freq"], float) if "z" in upd and getattr(jf, "Z", None) is not None: jf.Z._z = np.asarray(upd["z"], complex) if "tip" in upd and getattr(jf, "Tip", None) is not None: jf.Tip._tipper = np.asarray(upd["tip"], complex) if "resphase" in upd and getattr(jf, "Res", None) is not None: rp = np.asarray(upd["resphase"], float) try: jf.Res.resistivity = rp # type: ignore[attr-defined] except Exception: pass return jf
[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, ) -> JFile: """ Shift or set position and/or rename station. """ jf = self._resolve(site) heads = self._heads(jf) info = getattr(heads, "info", None) if info is None: raise ValueError("no >INFO to adjust") if rename: try: jf.station = str(rename) # type: ignore[attr-defined] except Exception: try: heads.head.station = str(rename) # type: ignore[attr-defined] except Exception: pass def _getf(k: str, default: float) -> float: v = info.items.get(k) try: return float(v) if v is not None else default except Exception: return default if lat is not None: info.items["LATITUDE"] = str(float(lat)) if lon is not None: info.items["LONGITUDE"] = str(float(lon)) if elev is not None: info.items["ELEVATION"] = str(float(elev)) if dlat is not None: cur = _getf("LATITUDE", 0.0) info.items["LATITUDE"] = str(cur + float(dlat)) if dlon is not None: cur = _getf("LONGITUDE", 0.0) info.items["LONGITUDE"] = str(cur + float(dlon)) info._site_cache = None return jf
# ----------------- summary / repr ----------------
[docs] def summary( self, fields: Sequence[str] = ( "station", "n_freq", "has_z", "has_r", "has_t", "lat", "lon", "az", ), ) -> list[dict]: rows: list[dict] = [] for jf in self: f = getattr(jf, "freq", None) n = int(getattr(f, "size", 0)) row = { "station": getattr(jf, "site", None) or getattr(jf, "name", None) or "-", "n_freq": n, "has_z": bool(getattr(jf, "Z", None)), "has_r": bool(getattr(jf, "Res", None)), "has_t": bool(getattr(jf, "Tip", None)), "lat": getattr(jf, "lat", None), "lon": getattr(jf, "lon", None), "az": getattr(jf, "azimuth", None), } rows.append({k: row.get(k) for k in fields}) return rows
@staticmethod def _site_of(it) -> str | None: # Prefer common single-file attributes for nm in ("site", "station", "name"): v = getattr(it, nm, None) if isinstance(v, str) and v.strip(): return v # Fallback to nested headers if present hd = getattr(it, "heads", None) sta = getattr(getattr(hd, "head", None), "station", None) if isinstance(sta, str) and sta.strip(): return sta return None @staticmethod def _lat_of(it) -> float | None: # JFile exposes .lat; some objects use .latitude v = getattr(it, "lat", None) if v is None: v = getattr(it, "latitude", None) if v is None: hd = getattr(it, "heads", None) v = getattr(hd, "latitude", None) return float(v) if isinstance(v, (int, float)) else None @staticmethod def _lon_of(it) -> float | None: # JFile exposes .lon; some objects use .longitude v = getattr(it, "lon", None) if v is None: v = getattr(it, "longitude", None) if v is None: hd = getattr(it, "heads", None) v = getattr(hd, "longitude", None) return float(v) if isinstance(v, (int, float)) else None
[docs] @property def sites(self) -> list[str]: """Best-effort station names (one per 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: """Vector of latitudes (np.nan for missing).""" 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: """Vector of longitudes (np.nan for missing).""" 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)
[docs] def export( self, output_dir: Pathish, *, file_pattern: str = "{station}.j", export_summary: bool = False, summary_filename: str = "summary.csv", **jfile_write_kwargs, ) -> dict: """ Exports all JFile items to a directory with advanced options. This method orchestrates the writing of each JFile in the collection to a specified directory, with flexible naming, error handling, and an optional summary CSV file. Parameters ---------- output_dir : Pathish The path to the directory where files will be saved. It will be created if it does not exist. file_pattern : str, default="{station}.j" A format string for output filenames. Can use attributes of JFile like `{station}`, `{name}`, `{site}`. export_summary : bool, default=False If True, a summary of the collection will also be saved as a CSV file in the output directory. summary_filename : str, default="summary.csv" The name of the summary file if `export_summary` is True. **jfile_write_kwargs Keyword arguments to be passed directly to each `JFile.write()` call (e.g., `datatype="ZRT"`, `overwrite=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. """ out_dir = Path(str(output_dir)).expanduser().resolve() out_dir.mkdir(parents=True, exist_ok=True) successful_paths = [] failed_items = [] # Use tqdm for a progress bar if available items_iterator = self._items try: from tqdm import tqdm items_iterator = tqdm(self._items, desc="Exporting J-Files") except (NameError, ImportError): pass # tqdm not installed for jf in items_iterator: sid = jf.site or "unknown_station" try: filename = file_pattern.format( station=sid, site=sid, name=jf.name ) output_path = out_dir / filename # Delegate the actual writing to the JFile instance written_path = jf.write( new_jfn=str(output_path), **jfile_write_kwargs ) successful_paths.append(written_path) except Exception as e: failed_items.append((sid, e)) logger.error(f"Failed to write J-file for station {sid}: {e}") # Export the summary CSV if requested if export_summary: try: summary_df = pd.DataFrame(self.summary()) summary_path = out_dir / summary_filename summary_df.to_csv(summary_path, index=False) successful_paths.append(str(summary_path)) except Exception as e: failed_items.append(("summary.csv", e)) logger.error(f"Failed to write summary CSV: {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, **kwargs, ) -> JFile | list[JFile] | None: """ Fetches JFile objects from the collection based on specified criteria. This method provides a flexible way to search for J-format files by site name, geographic coordinates, or any other attribute of the JFile object or its nested Heads sections. Parameters ---------- site : str, optional The site or station name to search for. The comparison is case-insensitive. lat : float, optional The latitude to search for, in decimal degrees. lon : float, optional The longitude to search for, in decimal degrees. tol : float, default=0.001 The tolerance in decimal degrees for geographic coordinate searches. A match is found if the absolute difference is within this tolerance. first : bool, default=False If True, returns only the first matching JFile object found, or None if no match is found. If False, returns a list of all matching objects. **kwargs : Any Additional keyword arguments to match against attributes of the JFile object or its nested Heads object (e.g., `acqby='Contractor'`). The attribute name is case-insensitive. Returns ------- JFile or list of JFile or None - If `first=True`, returns the first matching JFile or None. - If `first=False`, returns a list of all matching JFile objects. An empty list is returned if no matches are found. Examples -------- >>> # Fetch a single site by its name >>> jfile_obj = jcollection.fetch(site='S01', first=True) >>> # Fetch all sites where the azimuth is 0 >>> zero_az_files = jcollection.fetch(azimuth=0) >>> # Fetch all sites within a small geographic area >>> area_files = jcollection.fetch( ... lat=26.05, ... lon=-10.33, ... tol=0.1 ... ) """ matches = [] for jf in self: is_match = True # --- Match by site name (case-insensitive) --- if site is not None: jf_site = getattr(jf, "site", None) if not (jf_site and jf_site.upper() == site.upper()): is_match = False # --- Match by geographic coordinates with tolerance --- if lat is not None and is_match: jf_lat = getattr(jf, "lat", None) if jf_lat is None or abs(jf_lat - lat) > tol: is_match = False if lon is not None and is_match: jf_lon = getattr(jf, "lon", None) if jf_lon is None or abs(jf_lon - lon) > tol: is_match = False # --- Match by other arbitrary attributes (case-insensitive) --- for key, value in kwargs.items(): if not is_match: break # Check for attribute directly on JFile object first attr_val = getattr(jf, key.lower(), None) # If not found, check on the nested head and info objects if attr_val is None and jf.heads and jf.heads.head: attr_val = getattr(jf.heads.head, key.lower(), None) if attr_val is None and jf.heads and jf.heads.info: attr_val = getattr(jf.heads.info, key.lower(), None) # Perform comparison if isinstance(attr_val, str) and isinstance(value, str): if attr_val.upper() != value.upper(): is_match = False elif attr_val != value: is_match = False if is_match: matches.append(jf) if first: return matches[0] if matches else None return matches
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_z = sum(1 for r in summary_data if r.get("has_z")) with_r = sum(1 for r in summary_data if r.get("has_r")) with_t = sum(1 for r in summary_data if r.get("has_t")) # Get all frequencies from all files to find the true min/max all_freqs = ( np.concatenate( [ jf.freq for jf in self if jf.freq is not None and jf.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 = [ " " + "-" * 68, " Statistical Summary:", f" Total Sites: {total_files}", f" Component Counts: Z={with_z}, R={with_r}, T={with_t}", f" Frequency Range (Hz): {freq_range}", f" Latitude Range: {lat_range}", f" Longitude Range: {lon_range}", " " + "-" * 68, ] 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 "JCollection(n=0, stations=[])" # --- Header --- title = f" JCollection Summary (Total Sites: {len(summary_data)}) " width = 72 header = ["=" * width, title.center(width), "=" * width] # --- Per-Site Details --- details = ["\nSite Details:"] # Calculate max station length, ensuring # it's at least as wide as the header max_station_len = max( [len(r["station"]) for r in summary_data] + [len("Station")] ) table_header = ( f" {'Station'.ljust(max_station_len)} | Freqs" " | Z | R | T | Lat | Lon | Az " ) table_width = len(table_header) - 2 details.append(table_header) details.append(" " + "-" * table_width) for r in summary_data: tip = "Y" if r["has_t"] else "N" zz = "Y" if r["has_z"] else "N" rr = "Y" if r["has_r"] else "N" lat_str = f"{r['lat']:.4f}" if r["lat"] is not None else "N/A" lon_str = f"{r['lon']:.4f}" if r["lon"] is not None else "N/A" az_str = f"{r['az']:.1f}" if r["az"] is not None else "N/A" details.append( f" {r['station'].ljust(max_station_len)} | " f"{r['n_freq']:<5} | {zz:^1} | {rr:^1} | {tip:^1} | " f"{lat_str:<9} | {lon_str:<10} | {az_str}" ) 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 __repr__(self) -> str: # pragma: no cover return f"JCollection(n={len(self)}, stations={self.stations()!r})"