# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0-or-later
"""
Survey layout.
- Station: robust line-geometry container that understands modern/legacy
AVG frames, exposes unique station positions, IDs, and handy header
($Stn.*) derivations for round-trips.
- Topography: robust container for .stn station location files.
"""
from __future__ import annotations
import warnings
from collections.abc import Mapping, Sequence
from dataclasses import field
from pathlib import Path
from typing import (
Any,
Literal,
)
import numpy as np
import pandas as pd
from scipy.interpolate import griddata
from ..compat.python import dc
from ..exceptions import ProcessingError, StationError
from ..gis.utils import (
assert_xy_coordinate_system,
calculate_azimuth,
get_elevation_from_api,
get_elevation_from_utm,
normalize_lat_lon,
to_ll,
to_utm,
)
from ..log.logger import get_logger
from ..utils.deps import import_optional_dependency
from ..utils.validation import ensure_n_items
from .base import AVGComponentBase
from .utils import (
find_and_rename_column,
number_stations,
read_stn,
)
__all__ = ["Station", "Topography"]
logger = get_logger(__name__)
[docs]
class Topography(AVGComponentBase):
r"""A container for station topography and location data.
This class is designed to read, manage, and process spatial
information from Zonge ``.stn`` files, as described in the
ASTATIC manual [1]_. It handles both legacy (space-delimited)
and modern (comma-delimited) formats and provides a suite of
tools for coordinate conversion, regularization, and gridding.
Parameters
----------
data : pandas.DataFrame, optional
A pre-loaded DataFrame containing station location data.
If provided, it will be standardized upon initialization.
meta : mapping, optional
An optional dictionary for metadata, consistent with the
:class:`~.base.AVGComponentBase` API.
verbose : bool, default False
Controls the level of detail in logging output.
Attributes
----------
stations : numpy.ndarray
An array of the station numbers or identifiers.
eastings, northings : numpy.ndarray
Arrays of the station UTM coordinates.
elevations : numpy.ndarray
An array of the station elevations.
Methods
-------
read(source)
Reads and parses a ``.stn`` file or a DataFrame.
generate(...)
A static method to create a synthetic survey line.
correct_coords(...)
Regularizes station locations to a best-fit straight line.
convert_coords(...)
Converts coordinates between UTM and geographic (lat/lon).
to_grid(...)
Interpolates the scattered station data onto a regular 2D
grid for contouring.
get_step()
Calculates the distance between consecutive stations.
Notes
-----
The `read` method is designed to be robust, automatically
detecting the delimiter and header row of ``.stn`` files.
Upon reading, it standardizes column names to a canonical
schema (e.g., 'easting', 'northing', 'elevation'), making
the data consistent for all subsequent processing steps.
Examples
--------
>>> from pycsamt.zonge.survey import Topography
>>> # Load topography from a .stn file
>>> topo = Topography().read('data/avg/K1.stn')
>>>
>>> # Generate a synthetic survey line
>>> synthetic_topo = Topography.generate(
... start_coord=(500000, 4000000),
... n_stations=20,
... step=50,
... azimuth=45
... )
>>> # Get the average station spacing
>>> avg_step = synthetic_topo.get_step().mean()
References
----------
.. [1] Zonge International, Inc. (2014). *ASTATIC v3.70
User Manual*, "STN Files" section, p. 36.
"""
def __init__(
self,
data: pd.DataFrame | None = None,
meta: Mapping[str, Any] | None = None,
*,
utm_zone: str = None,
epsg: int = None,
name: str | None = None,
verbose: bool = False,
) -> None:
super().__init__(
data=data, meta=meta, name=name or "Topography", verbose=verbose
)
self.utm_zone = utm_zone
self.epsg = epsg
self._azimuths: np.ndarray | None = None
[docs]
def read(
self,
source: str | Path | pd.DataFrame,
meta: Mapping[str, Any] | None = None,
**kws: Any,
) -> Topography:
r"""Read topography data from a file path or DataFrame.
This is the primary data ingestion method for the Topography
class. It is designed to be robust, handling various ``.stn``
file formats and standardizing the data into a consistent
internal structure.
Parameters
----------
source : str, pathlib.Path, or pandas.DataFrame
The data source to load. This can be:
- A string or `pathlib.Path` pointing to a Zonge ``.stn``
file.
- A `pandas.DataFrame` containing station location data.
meta : mapping, optional
An optional dictionary for metadata, consistent with the
:class:`~.base.AVGComponentBase` API. This is typically
not used for ``.stn`` files.
Returns
-------
self : Topography
The method returns the instance of the class, allowing
for convenient method chaining.
Raises
------
TypeError
If the `source` is of an unsupported type.
StationError
If the ``.stn`` file is malformed or a valid header row
cannot be found.
Notes
-----
The file parser is designed to be flexible:
- It automatically detects whether the file is comma-delimited
or space-delimited.
- It intelligently searches for a header row by looking for
common keywords (e.g., 'station', 'easting'), rather than
assuming a fixed position.
- All column names are normalized to a canonical schema
(e.g., 'easting', 'northing', 'elevation') after reading.
Examples
--------
>>> from pycsamt.zonge.survey import Topography
>>> # Load topography from a .stn file
>>> topo = Topography().read('data/avg/K1.stn')
>>> print(topo.stations[:5])
[150. 200. 250. 300. 350.]
"""
if isinstance(source, (str, Path)):
df = read_stn(source)
elif isinstance(source, pd.DataFrame):
df = source.copy()
else:
raise TypeError("Source must be a file path or DataFrame.")
self._frame = self._normalize_stn_columns(df)
# initialize longitude and latitude
self._longitude = pd.Series(
np.zeros((self._frame.shape[0],), dtype=float)
)
self._latitude = pd.Series(
np.zeros((self._frame.shape[0],), dtype=float)
)
# reset the cache when new data is loaded
self._azimuths = None
return self
def _normalize_stn_columns(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Find and rename columns to a canonical schema.
"""
rename_map = {}
for col in df.columns:
col_lower = str(col).lower()
if "station" in col_lower or "dot" in col_lower:
rename_map[col] = "station"
elif "east" in col_lower or col_lower == "e":
rename_map[col] = "easting"
elif "north" in col_lower or col_lower == "n":
rename_map[col] = "northing"
elif "elev" in col_lower or col_lower == "h":
rename_map[col] = "elevation"
elif "head" in col_lower:
rename_map[col] = "heading"
elif "pitch" in col_lower:
rename_map[col] = "pitch"
elif "roll" in col_lower:
rename_map[col] = "roll"
df = df.rename(columns=rename_map)
# Convert all columns to numeric
for col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
required = ["station", "easting", "northing", "elevation"]
missing = [c for c in required if c not in df.columns]
if missing:
raise ProcessingError(
f"STN data is missing required columns: {missing}"
)
return df.dropna(subset=required)
[docs]
def convert_coords(
self,
to: Literal["utm", "ll", "auto"] = "auto",
*,
inplace: bool = True,
) -> pd.DataFrame | None:
r"""Convert between UTM and geographic (lat/lon) coordinates.
This method provides a high-level interface for coordinate
system conversion, leveraging the underlying utilities in the
:mod:`~pycsamt.gis.utils` module.
Parameters
----------
to : {'utm', 'll', 'auto'}, default 'auto'
The target coordinate system.
- 'utm': Convert to Universal Transverse Mercator.
- 'll': Convert to latitude/longitude decimal degrees.
- 'auto': Automatically detect the current system and
convert to the other.
update_inplace : bool, default True
If ``True``, the internal DataFrame of the `Topography`
object is updated with the new coordinate columns.
If ``False``, a new DataFrame containing both the original
and converted coordinates is returned.
Returns
-------
pandas.DataFrame or None
- If `update_inplace` is ``True``, returns ``None`` and
modifies the object's internal frame.
- If `update_inplace` is ``False``, returns a new DataFrame
with the added coordinate columns.
Notes
-----
The function first uses
:func:`~.gis.utils.assert_xy_coordinate_system` to determine
the current coordinate system of the data. It then calls either
:func:`~.gis.utils.to_utm` or :func:`~.gis.utils.to_ll` to
perform the conversion.
See Also
--------
pycsamt.gis.utils.to_utm : The underlying UTM conversion utility.
pycsamt.gis.utils.to_ll : The underlying lat/lon conversion utility.
"""
if self._frame.empty:
raise ProcessingError("Topography data has not been loaded.")
x_coords = self.easting
y_coords = self.northing
current_system = assert_xy_coordinate_system(x_coords, y_coords)
if to == "auto":
to = "ll" if current_system == "utm" else "utm"
if to == current_system:
if self.verbose:
self._logger.info(
f"Coordinates are already in '{to}' system. "
"No conversion performed."
)
return
if to == "ll":
if self.utm_zone is None and self.epsg is None:
self._logger.error(
"UTM zone or 'epsg' needs to be set for"
" converting easting/northing"
" to longitude/latitude"
)
new_df = pd.DataFrame(
{
"latitude": self._latitude,
"longitude": self._longitude,
},
index=self._frame.index,
)
else:
lat, lon = to_ll(
x_coords,
y_coords,
zone=self.utm_zone,
epsg=self.epsg,
as_frame=False,
)
new_df = pd.DataFrame(
{"latitude": lat, "longitude": lon},
index=self._frame.index,
)
self._longitude = new_df["longitude"]
self._latitude = new_df["latitude"]
elif to == "utm":
east, north, zone = to_utm(
x_coords,
y_coords,
epsg=self.epsg,
utm_zone=self.utm_zone,
as_frame=False,
)
new_df = pd.DataFrame(
{"easting": east, "northing": north, "utm_zone": zone},
index=self._frame.index,
)
else:
raise ValueError(
f"Invalid target system '{to}'. Must be "
"'utm', 'll', or 'auto'."
)
if inplace:
for col in new_df.columns:
self._frame[col] = new_df[col]
if self.verbose:
self._logger.info(
f"Coordinates converted to '{to}' and updated in place."
)
return None
return pd.concat([self._frame, new_df], axis=1)
[docs]
def to_grid(
self,
resolution: int = 100,
method: Literal["linear", "cubic", "nearest"] = "cubic",
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
r"""Interpolate scattered station data onto a regular 2D grid.
This method takes the scattered station locations (easting,
northing) and their corresponding elevations and
interpolates them onto a regular grid, which is essential
for creating contour maps or surface plots.
Parameters
----------
resolution : int, default 100
The number of points to create in each dimension (x and
y) of the output grid. A higher number results in a
finer grid.
method : {'linear', 'cubic', 'nearest'}, default 'cubic'
The interpolation method to be used by the underlying
:func:`scipy.interpolate.griddata` function.
- 'linear': Performs linear interpolation.
- 'cubic': Performs cubic interpolation for a smoother
surface.
- 'nearest': Uses the value of the nearest data point.
Returns
-------
grid_x : numpy.ndarray
A 2D array of the X (easting) coordinates of the grid.
grid_y : numpy.ndarray
A 2D array of the Y (northing) coordinates of the grid.
grid_z : numpy.ndarray
A 2D array of the interpolated Z (elevation) values on
the grid.
Raises
------
ProcessingError
If the topography data has not been loaded first.
See Also
--------
scipy.interpolate.griddata : The core interpolation
function used by this method.
pycsamt.zonge.plot.Plot.plot_location_map : A method for
visualizing the gridded data.
"""
if self._frame.empty:
raise ProcessingError("Topography data has not been loaded.")
points = self._frame[["easting", "northing"]].values
values = self.elevations
# Create the target grid
grid_x, grid_y = np.mgrid[
self.easting.min() : self.easting.max() : complex(resolution),
self.northing.min() : self.northing.max() : complex(resolution),
]
# Interpolate the data
grid_z = griddata(points, values, (grid_x, grid_y), method=method)
return grid_x, grid_y, grid_z
[docs]
def get_step(self) -> pd.Series:
r"""Calculate the distance between consecutive stations.
This method computes the Euclidean distance between each
station and the next one along the survey line, based on
their easting and northing coordinates.
Returns
-------
pandas.Series
A Series containing the calculated step distances in
meters. The first value is always 0. The length of the
Series matches the number of stations.
Notes
-----
The calculation assumes a Cartesian coordinate system (like
UTM) where the Pythagorean theorem can be applied to find
the distance between points. The result is useful for
assessing the regularity of station spacing and for
providing a default step size for other methods like
:meth:`correct_coords`.
Examples
--------
>>> from pycsamt.zonge.survey import Topography
>>> topo = Topography().read('data/avg/K1.stn')
>>> steps = topo.get_step()
>>> print(f"Average station spacing: {steps.mean():.2f} m")
"""
if self._frame.empty or len(self._frame) < 2:
return pd.Series(dtype=float)
dx = np.diff(self.easting)
dy = np.diff(self.northing)
steps = np.hypot(dx, dy)
return pd.Series(
np.concatenate(([0], steps)), index=self._frame.index
)
[docs]
def correct_coords(
self, step: float | None = None, *, inplace: bool = True
) -> pd.DataFrame | None:
r"""Regularize station coordinates to a best-fit straight line.
This processing tool corrects for minor deviations in survey
line geometry by projecting all station locations onto a
best-fit straight line and re-spacing them at a uniform
interval. This is a common step for preparing data for 2D
inversion or gridding.
Parameters
----------
step : float, optional
The desired uniform distance between stations along the
corrected line. If ``None``, the average step distance is
calculated automatically using the :meth:`get_step`
method.
update_inplace : bool, default True
If ``True``, the internal DataFrame of the `Topography`
object is updated in place with the new, corrected
coordinates. If ``False``, a new DataFrame with the
corrected coordinates is returned.
Returns
-------
pandas.DataFrame or None
- If `update_inplace` is ``True``, returns ``None``.
- If `update_inplace` is ``False``, returns a new
DataFrame with the corrected 'easting' and 'northing'
columns.
Notes
-----
The correction process involves two main steps:
1. A first-degree polynomial (a straight line) is fitted
to the original easting and northing coordinates using a
least-squares regression.
2. New station locations are generated along this ideal
line at a constant spacing defined by the `step`
parameter.
See Also
--------
get_step : The method used to automatically determine the
average station spacing.
"""
if self._frame.empty or len(self._frame) < 2:
warnings.warn(
"Not enough data to correct coordinates.", stacklevel=2
)
return
x = self.easting
y = self.northing
# 1. Determine the best-fit line
m, c = np.polyfit(x, y, 1) # y = mx + c
# 2. Calculate the uniform step distance
if step is None:
step = self.get_step().mean()
if self.verbose:
self._logger.info(
f"Using auto-detected average step of {step:.2f} m."
)
# 3. Project the first station onto the line
x0, y0 = x[0], y[0]
x_proj_start = (x0 + m * (y0 - c)) / (1 + m**2)
y_proj_start = m * x_proj_start + c
# 4. Generate new points along the line
line_direction = np.array([1, m]) / np.sqrt(1 + m**2)
distances = np.arange(len(x)) * step
new_eastings = x_proj_start + distances * line_direction[0]
new_northings = y_proj_start + distances * line_direction[1]
if inplace:
self._frame["easting"] = new_eastings
self._frame["northing"] = new_northings
if self.verbose:
self._logger.info(
"Coordinates have been corrected and updated in place."
)
return None
df_corrected = self._frame.copy()
df_corrected["easting"] = new_eastings
df_corrected["northing"] = new_northings
return df_corrected
[docs]
@staticmethod
def generate(
start_coord: tuple[float, float],
n_stations: int,
step: float,
azimuth: float,
*,
initial_station_name: float = 0.0,
initial_elevation: float = 0.0,
elevation_gradient: float = 0.0,
coord_type: Literal["utm", "ll"] = "utm",
) -> Topography:
r"""Generate a synthetic station topography dataset.
This static method acts as a factory for creating a new
:class:`Topography` object based on survey design
parameters. It is useful for creating test data, planning
surveys, or generating a regularized coordinate set for
modeling.
Parameters
----------
start_coord : tuple[float, float]
The starting coordinate of the survey line. The format
depends on `coord_type`:
- For 'utm': (easting, northing) in meters.
- For 'll': (latitude, longitude) in decimal degrees.
n_stations : int
The total number of stations to generate along the line.
step : float
The distance between consecutive stations, in meters.
azimuth : float
The azimuth of the survey line in degrees clockwise from
North (e.g., 0 for North, 90 for East).
initial_station_name : float, default 0.0
The name or number of the first station. Subsequent
station names are incremented by `step`.
initial_elevation : float, default 0.0
The elevation of the first station, in meters.
elevation_gradient : float, default 0.0
The change in elevation per meter along the survey line.
A positive value creates an upward slope.
coord_type : {'utm', 'll'}, default 'utm'
The coordinate system of the `start_coord`.
Returns
-------
Topography
A new, fully populated instance of the `Topography` class.
Notes
-----
When `coord_type` is 'll', the function uses geodetic
calculations to accurately generate new points along the
great-circle path and then converts the final lat/lon
coordinates to UTM for storage.
"""
if n_stations <= 0:
raise ValueError("Number of stations must be positive.")
station_names = np.arange(n_stations) * step + initial_station_name
distances = np.arange(n_stations) * step
elevations = initial_elevation + distances * elevation_gradient
start_coord = ensure_n_items(
items=start_coord,
name="(latitude, longitude)",
expect="numeric",
coerce=True,
n=2,
)
if coord_type == "ll":
import_optional_dependency(
"geopy",
extra=(
"'geopy' is required for geodetectic"
" position calculations"
),
)
# For lat/lon, we must calculate geodetic positions
from geopy.distance import geodesic
start_lat, start_lon = normalize_lat_lon(
*start_coord, assume="latlon"
)
# start_lat, start_lon = start_coord
points = [start_coord]
for _i in range(1, n_stations):
new_point = geodesic(meters=step).destination(
points[-1], bearing=azimuth
)
points.append((new_point.latitude, new_point.longitude))
latitudes, longitudes = zip(*points)
eastings, northings, _ = to_utm(latitudes, longitudes)
else: # UTM coordinates
azimuth_rad = np.deg2rad(90 - azimuth) # Convert from bearing
dx = distances * np.cos(azimuth_rad)
dy = distances * np.sin(azimuth_rad)
start_x, start_y = start_coord
eastings = start_x + dx
northings = start_y + dy
df = pd.DataFrame(
{
"station": station_names,
"easting": eastings,
"northing": northings,
"elevation": elevations,
}
)
return Topography(data=df)
[docs]
def write(self) -> Sequence[str]:
r"""Serialize the topography data to .stn format lines.
This method converts the internal DataFrame of topography
data into a list of strings that conform to the standard
Zonge ``.stn`` file format. This is useful for exporting
processed or generated topography data.
Returns
-------
list[str]
A list of strings, where the first string is the comma-
separated header and subsequent strings are the data
rows.
Notes
-----
The output is designed to be a "modern" ``.stn`` file, using
comma-separated values, which is compatible with the ASTATIC
program and other Zonge software.
Examples
--------
>>> from pycsamt.zonge.survey import Topography
>>> topo = Topography.generate(
... start_coord=(500000, 4000000), n_stations=3,
... step=100, azimuth=90
... )
>>> stn_lines = topo.write()
>>> for line in stn_lines:
... print(line)
station,easting,northing,elevation
0.0,500000.0,4000000.0,0.0
100.0,500100.0,4000000.0,0.0
200.0,500200.0,4000000.0,0.0
"""
if self._frame.empty:
return []
header = ",".join(self._frame.columns)
data_lines = (
self._frame.to_csv(index=False, header=False, lineterminator="\n")
.strip("\n")
.split("\n")
)
return [header] + data_lines
[docs]
def get_elevation_from(
self,
from_: Literal["utm", "api"] = "utm",
zone: str | None = None,
datum: str = "WGS84",
) -> np.ndarray:
r"""Fetches station elevations from an external API.
This method retrieves elevation data for each station in
the survey using an online service. It can operate using
either the existing UTM coordinates or geographic (lat/lon)
coordinates.
Parameters
----------
from_ : {'utm', 'api'}, default 'utm'
Specifies the coordinate system to use for the API
query.
- 'utm': Uses the ``easting`` and ``northing`` columns.
The ``zone`` parameter is required.
- 'api': Uses latitude and longitude. If these are not
present, they are automatically calculated from the
UTM coordinates.
zone : str, optional
The UTM zone designator (e.g., "11S", "32N"). This is
**required** when ``from_`` is 'utm'.
datum : str, default "WGS84"
The geodetic datum to assume for coordinate
conversions.
Returns
-------
numpy.ndarray
An array of elevation values in meters corresponding
to each station.
Raises
------
ProcessingError
If the topography data has not been loaded first.
ValueError
If ``from_`` is 'utm' but no ``zone`` is provided, or
if an invalid ``from_`` value is given.
See Also
--------
get_elevation_from_utm : Fetches elevation from UTM data.
get_elevation_from_api : Fetches elevation from lat/lon data.
convert_coords : Converts between coordinate systems.
"""
# Ensure the topography data frame has been loaded first
if self._frame.empty:
raise ProcessingError(
"Topography data has not been loaded. "
"Call the .read() method first."
)
elevations = np.array([], dtype=float)
zone = zone or self.utm_zone
if from_ == "utm":
# The UTM zone is required for this operation
if zone is None:
raise ValueError(
"A UTM 'zone' must be provided when from_='utm'."
)
# Fetch elevations using existing easting/northing
elevations = get_elevation_from_utm(
easting=self.easting,
northing=self.northing,
zone=zone,
datum=datum,
)
elif from_ == "api":
# Check if lat/lon data exists; if not, create it
if "latitude" not in self._frame.columns or (
"longitude" not in self._frame.columns
):
if self.verbose:
self._logger.info(
"Latitude/Longitude not found. Converting from UTM."
)
self.convert_coords(to="ll", inplace=True)
# Fetch elevations using latitude and longitude
elevations = get_elevation_from_api(
latitude=self.latitude,
longitude=self.longitude,
)
else:
raise ValueError(
f"Invalid 'from_' argument: '{from_}'. "
"Must be 'utm' or 'api'."
)
return elevations
[docs]
def get_azimuth(
self, *, mode: Literal["mean", "median"] | None = None
) -> float | np.ndarray:
r"""Returns the survey line azimuth(s).
This method can return the full array of segment azimuths
or a single aggregated value (mean or median) representing
the overall trend of the survey line.
Parameters
----------
mode : {'mean', 'median'}, optional
Determines the return format.
- ``None`` (default): Returns the full NumPy array of
azimuths for each station segment.
- 'mean': Returns the circular mean of all segment
azimuths, providing the average direction.
- 'median': Returns the median of all segment azimuths.
Returns
-------
float or numpy.ndarray
- If ``mode`` is ``None``, returns the full array of
azimuths.
- If ``mode`` is 'mean' or 'median', returns a single
float representing the aggregated value.
Notes
-----
The 'mean' is calculated using circular statistics to
correctly average angles (e.g., the mean of 350° and 10°
is 0°, not 180°). The 'median' is calculated linearly and
is a good approximation for relatively straight lines.
"""
# Retrieve the azimuths using the caching property
azimuths = self.azimuth
if mode is None:
return azimuths
# Filter out NaN values before aggregating
valid_azimuths = azimuths[~np.isnan(azimuths)]
if valid_azimuths.size == 0:
return np.nan if mode else np.array([])
if mode == "mean":
# Correctly average angles using circular statistics
rads = np.deg2rad(valid_azimuths)
sin_mean = np.mean(np.sin(rads))
cos_mean = np.mean(np.cos(rads))
mean_rad = np.arctan2(sin_mean, cos_mean)
mean_deg = np.rad2deg(mean_rad)
# Normalize to 0-360 range
return (mean_deg + 360) % 360
if mode == "median":
return np.median(valid_azimuths)
raise ValueError(
f"Invalid mode '{mode}'. Choose from 'mean', 'median', or None."
)
[docs]
@property
def azimuth(self) -> np.ndarray:
r"""Calculates and returns the forward azimuths.
This property computes the azimuth for each line segment
(from one station to the next) along the survey line. The
result is cached for efficiency.
Returns
-------
numpy.ndarray
An array of azimuths in degrees (0-360), where the
azimuth at index ``i`` corresponds to the direction
from station ``i`` to ``i+1``.
"""
# Check if the azimuths have already been calculated
if self._azimuths is None:
# Ensure there's enough data to calculate a direction
if self.easting.size < 2:
self._azimuths = np.array([], dtype=float)
else:
# If not cached, calculate and store the result
self._azimuths = calculate_azimuth(
self.easting, self.northing
)
return self._azimuths
[docs]
@property
def bearing(self) -> np.ndarray:
r"""Alias for the azimuth property.
In this context, bearing is treated as synonymous with
azimuth (0-360 degrees clockwise from North).
"""
return self.azimuth
[docs]
@property
def stations(self) -> np.ndarray:
# This will be deprecated and
return self._frame.get("station", pd.Series(dtype=float)).values
[docs]
@property
def easting(self) -> np.ndarray:
return self._frame.get("easting", pd.Series(dtype=float)).values
[docs]
@property
def northing(self) -> np.ndarray:
# north for single value of northing
return self._frame.get("northing", pd.Series(dtype=float)).values
[docs]
@property
def elevations(self) -> np.ndarray:
return self._frame.get("elevation", pd.Series(dtype=float)).values
[docs]
@property
def elevation(self):
# use elev for single station and elevation
# for pd.Series or array
# of multiple elevations
return self.elevations
[docs]
@property
def latitude(self) -> np.ndarray:
"""Exposes the latitude data as a NumPy array.
Returns
-------
numpy.ndarray
An array of station latitudes, or an empty array if
latitude data is not available in the frame.
"""
return self._frame.get("latitude", self._latitude).values
[docs]
@property
def longitude(self) -> np.ndarray:
"""Exposes the longitude data as a NumPy array.
Returns
-------
numpy.ndarray
An array of station longitudes, or an empty array if
longitude data is not available in the frame.
"""
return self._frame.get("longitude", self._longitude).values
def _to_float_series(s: pd.Series) -> pd.Series:
"""Coerce a Series to float, preserving NaN for non-numeric."""
return pd.to_numeric(s, errors="coerce").astype(float)
def _median_inc(vals: np.ndarray) -> float | None:
"""Median increment if grid-like, else None."""
if vals.size < 2:
return None
diffs = np.diff(vals)
inc = np.median(diffs)
if np.allclose(diffs, inc, rtol=0.0, atol=1e-6):
return float(inc)
return None
[docs]
@dc(slots=True)
class Station(AVGComponentBase):
"""
One-dimensional survey-line geometry container.
This component reads the *station* coordinate from a tidy AVG frame
(usually column ``'station'``), validates and stores unique positions,
and (optionally) normalizes the origin to zero. It also derives a
stable set of header keys ($Stn.*) for round-tripping modern headers.
Key features
-----------
• Accepts either a DataFrame (from AVG parsers) or raw arrays.
• Handles ragged frequency coverage (no equal-count requirement).
• Tracks units; internal helpers can work in metres when desired.
• Derives $Stn.Beg / $Stn.Inc / $Stn.Left / $Stn.Right if grid-like.
• Exposes dictionary mappings useful for downstream selection.
Notes
-----
We **do not** modify the caller's AVG frame; we keep our own view
in ``self._frame`` that at minimum contains a numeric ``'station'``
column and (if relevant) a ``'station_m'`` column when the input
length unit is feet ('ft') and conversion to metres is helpful.
"""
# configuration
unit: str = "m"
normalize: bool = False
allow_ragged: bool = True
# derived state
values: np.ndarray = field(default_factory=lambda: np.empty(0))
names: list[str] = field(default_factory=list)
index_by_value: dict[float, np.ndarray] = field(default_factory=dict)
index_by_name: dict[str, np.ndarray] = field(default_factory=dict)
[docs]
def read(
self,
source: pd.DataFrame | Sequence[float] | np.ndarray,
meta: Mapping[str, Any] | None = None,
*,
unit: str | None = None,
names: Sequence[str] | None = None,
normalize: bool | None = None,
allow_ragged: bool | None = None,
) -> None:
"""
Populate the component from *source*.
Parameters
----------
source
DataFrame with a ``'station'`` column **or** a 1-D array of
station values (possibly repeated across frequencies).
meta
Optional file metadata. If provided and ``unit`` is None, we
try ``meta['Unit.Length']`` for ('m'|'ft').
unit
Length unit of *source* values. Defaults to 'm' or to
``meta['Unit.Length']`` when available.
names
Optional labels for *unique* station positions. If omitted,
we auto-generate: 'S00', 'S01', ...
normalize
When True, shift the origin so the first station is 0.0.
allow_ragged
When False, all stations must have identical row counts
(one per frequency). When True (default), ragged coverage
is allowed.
"""
# resolve options
munit = (meta or {}).get("Unit.Length") if meta else None
unit = (unit or munit or self.unit or "m").lower()
normalize = self.normalize if normalize is None else normalize
allow_ragged = (
self.allow_ragged if allow_ragged is None else allow_ragged
)
# build a minimal frame with a numeric 'station' column
if isinstance(source, pd.DataFrame):
source = find_and_rename_column(source.copy(), "station")
if "station" not in source.columns:
raise StationError("column 'station' missing in frame")
frame = source[["station"]].copy()
frame["station"] = _to_float_series(frame["station"])
else:
arr = np.asarray(source, dtype=float).ravel()
if arr.size == 0:
raise StationError("empty station array")
frame = pd.DataFrame({"station": arr})
# drop rows where station cannot be parsed (all we can do)
frame = frame.loc[frame["station"].notna()].reset_index(drop=True)
if frame.empty:
raise StationError("no valid station values")
# normalize if requested
if normalize:
frame["station"] = frame["station"] - frame["station"].min()
# optionally provide a metres view if unit is feet
if unit == "ft":
frame["station_m"] = frame["station"] / 3.280839895
elif unit == "km":
frame["station_m"] = frame["station"] * 1e3
else:
# mirror for symmetry; callers can ignore it
frame["station_m"] = frame["station"]
# sort by station coord for stable introspection
frame.sort_values("station", kind="mergesort", inplace=True)
frame.reset_index(drop=True, inplace=True)
# unique positions and index maps
uniq = frame["station"].dropna().unique()
uniq.sort()
self.values = uniq.astype(float)
# build group index maps
groups = frame.groupby("station", sort=True, dropna=False).indices
idx_by_val: dict[float, np.ndarray] = {}
for k, v in groups.items():
try:
key = float(k)
except Exception:
# groupby won't give NaN key here (we dropped NaNs)
continue
idx_by_val[key] = np.asarray(sorted(v), dtype=int)
self.index_by_value = idx_by_val
# ragged check if requested
if not allow_ragged and self.values.size > 0:
counts = np.array([len(idx_by_val[val]) for val in self.values])
if not np.all(counts == counts[0]):
raise StationError("inconsistent rows per station")
# station names (one per unique position)
if names is not None:
if len(names) != self.values.size:
raise StationError(
"`names` length must match unique station count"
)
self.names = list(names)
else:
ids, _ = number_stations(self.values.size, 1, prefix="S")
self.names = ids
# name→index map (same row indices as value map)
self.index_by_name = {
nm: self.index_by_value[val]
for nm, val in zip(self.names, self.values, strict=True)
}
# stash the frame + a small meta view in the component payload
self._frame = frame
self._meta = dict(meta or {})
# remember configuration for write/pretty
self.unit = unit
self.normalize = normalize
self.allow_ragged = allow_ragged
[docs]
def write(self) -> Sequence[str]:
"""
Emit a small, human-friendly header preamble plus a CSV of the
station coordinate. This is mainly useful for diagnostics and
tests; full file writing should be orchestrated at a higher
level.
"""
if self._frame.empty:
return []
# Try to derive canonical $Stn.* keys for the header
keys = self.to_keywords()
lines: list[str] = []
lines.append(r"\ Station Geometry")
for k, v in keys.items():
lines.append(f"${k}={v}")
lines.append("") # spacer before table
# CSV of station and metres view (if present)
cols = ["station"] + (
["station_m"] if "station_m" in self._frame.columns else []
)
csv = self._frame.loc[:, cols].to_csv(
index=False, float_format="%.6g"
)
lines.extend(csv.splitlines())
return lines
[docs]
@property
def n_unique(self) -> int:
"""Number of unique station positions."""
return int(self.values.size)
[docs]
@property
def span(self) -> tuple[float, float] | None:
"""(min, max) of the station coordinate; None if empty."""
if self.values.size == 0:
return None
return (float(self.values.min()), float(self.values.max()))
[docs]
@property
def increment(self) -> float | None:
"""Grid increment if evenly spaced, else None."""
return _median_inc(self.values)
[docs]
def label_map(self) -> dict[float, str]:
"""Map station numeric value → generated label (e.g., 'S03')."""
return {
float(v): n for v, n in zip(self.values, self.names, strict=True)
}
[docs]
def to_keywords(self) -> dict[str, Any]:
"""
Derive a stable set of $Stn.* keys from current geometry:
- Stn.Beg : first station
- Stn.Inc : increment (when grid-like)
- Stn.Left : left edge (min)
- Stn.Right : right edge (max)
For convenience, we also mirror GDP-station versions when
increment is grid-like: Stn.GdpBeg / Stn.GdpInc.
"""
out: dict[str, Any] = {}
if self.values.size == 0:
return out
left, right = float(self.values.min()), float(self.values.max())
out["Stn.Left"] = left
out["Stn.Right"] = right
out["Stn.Beg"] = left
inc = self.increment
if inc is not None:
out["Stn.Inc"] = inc
out["Stn.GdpBeg"] = left
out["Stn.GdpInc"] = inc
return out
def __str__(self) -> str:
if self.values.size == 0:
return "Station(empty)"
span = self.span
inc = self.increment
span_txt = f"{span[0]:g}–{span[1]:g}" if span else "?"
inc_txt = f"{inc:g}" if inc is not None else "ragged"
return (
f"Station(n={self.n_unique}, span={span_txt} {self.unit}, "
f"inc={inc_txt})"
)
__repr__ = __str__