r"""Topology-safe local approximation of dense traveltime tables.
This module turns analytic fixed-topology endpoint derivatives into reusable
traveltime predictors. It does not estimate approximation error or retrace
unsupported targets automatically.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import pandas as pd
from .api import RayItinerary, TraceResult, trace_rays
from .model import ModelArrays, normalize_phase
from .sensitivity import RaySensitivity, compile_path_legs
_REASON_OK = "ok"
_REASON_UNREACHABLE = "unreachable_topology"
_REASON_NO_MATCH = "no_topology_match"
_REASON_OUTSIDE = "outside_anchor_distance"
_REASON_INVALID_SENSITIVITY = "invalid_anchor_sensitivity"
[docs]
@dataclass(frozen=True)
class LinearizedRayChange:
"""First-order changes predicted from one ray sensitivity record.
Attributes
----------
delta_travel_time : float
Predicted traveltime change (s).
delta_ray_parameter : float
Predicted physical horizontal-slowness change (s/m).
"""
delta_travel_time: float
delta_ray_parameter: float
[docs]
@dataclass(frozen=True)
class AnchorSelection:
"""Deterministic point-cloud anchor selection and assignments.
Attributes
----------
indices : numpy.ndarray
Indices of selected anchors in the input point array.
points : numpy.ndarray
Selected anchor coordinates, shape ``(n_anchors, 3)``.
assignments : numpy.ndarray
Anchor-position assigned to each input point.
distances : numpy.ndarray
Euclidean distance from each point to its assigned anchor (m).
"""
indices: np.ndarray
points: np.ndarray
assignments: np.ndarray
distances: np.ndarray
[docs]
@dataclass(frozen=True)
class TravelTimePrediction:
"""Predicted traveltimes and anchor-assignment diagnostics.
All one-dimensional arrays use source-major ordering: every receiver for
source zero, followed by every receiver for source one, and so on.
Attributes
----------
travel_times : numpy.ndarray
Predicted traveltimes (s); invalid entries are ``NaN``.
valid_mask : numpy.ndarray
Whether each prediction is valid.
reasons : numpy.ndarray
Reason code for each prediction.
anchor_ray_indices : numpy.ndarray
Flat index of the selected exact anchor ray, or ``-1`` if invalid.
source_anchor_distances, receiver_anchor_distances : numpy.ndarray
Endpoint distances to the selected anchor ray (m), or ``NaN`` if
invalid.
n_sources, n_receivers : int
Dimensions used by the matrix-view properties.
"""
travel_times: np.ndarray
valid_mask: np.ndarray
reasons: np.ndarray
anchor_ray_indices: np.ndarray
source_anchor_distances: np.ndarray
receiver_anchor_distances: np.ndarray
n_sources: int
n_receivers: int
@property
def travel_time_matrix(self) -> np.ndarray:
"""Return traveltimes with shape ``(n_sources, n_receivers)``."""
return self.travel_times.reshape(self.n_sources, self.n_receivers)
@property
def valid_matrix(self) -> np.ndarray:
"""Return the validity mask with shape ``(n_sources, n_receivers)``."""
return self.valid_mask.reshape(self.n_sources, self.n_receivers)
@property
def reason_matrix(self) -> np.ndarray:
"""Return reason codes with shape ``(n_sources, n_receivers)``."""
return self.reasons.reshape(self.n_sources, self.n_receivers)
@property
def anchor_ray_index_matrix(self) -> np.ndarray:
"""Return selected anchor-ray indices in source-major matrix form."""
return self.anchor_ray_indices.reshape(self.n_sources, self.n_receivers)
@property
def source_anchor_distance_matrix(self) -> np.ndarray:
"""Return source-to-anchor distances in source-major matrix form."""
return self.source_anchor_distances.reshape(self.n_sources, self.n_receivers)
@property
def receiver_anchor_distance_matrix(self) -> np.ndarray:
"""Return receiver-to-anchor distances in source-major matrix form."""
return self.receiver_anchor_distances.reshape(self.n_sources, self.n_receivers)
def _normalize_points(points, name: str) -> np.ndarray:
"""Return a finite ``(N, 3)`` coordinate array."""
array = np.asarray(points, dtype=np.float64)
if array.ndim == 1:
array = np.atleast_2d(array)
if array.ndim != 2 or array.shape[1] != 3 or array.shape[0] == 0:
raise ValueError(f"{name} must have shape (N, 3) or (3,).")
if not np.all(np.isfinite(array)):
raise ValueError(f"{name} must contain only finite coordinates.")
return array
def _delta_for_indices(values, indices: np.ndarray, name: str) -> np.ndarray:
"""Extract a sparse derivative-compatible perturbation array."""
if values is None:
return np.zeros(indices.size, dtype=np.float64)
array = np.asarray(values, dtype=np.float64)
if array.ndim != 1:
raise ValueError(f"{name} must be a one-dimensional full-model array.")
if not np.all(np.isfinite(array)):
raise ValueError(f"{name} must contain only finite perturbations.")
if indices.size and array.size <= int(np.max(indices)):
raise ValueError(f"{name} does not contain every referenced model index.")
return array[indices]
def _endpoint_delta(values, name: str) -> np.ndarray:
"""Return one three-component endpoint perturbation."""
if values is None:
return np.zeros(3, dtype=np.float64)
array = np.asarray(values, dtype=np.float64)
if array.shape != (3,) or not np.all(np.isfinite(array)):
raise ValueError(f"{name} must be a finite three-component vector.")
return array
[docs]
def linearized_ray_change(
sensitivity: RaySensitivity,
*,
delta_vp=None,
delta_vs=None,
delta_interface_depths=None,
delta_source=None,
delta_receiver=None,
) -> LinearizedRayChange:
r"""Predict first-order output changes for one fixed-topology ray.
Parameters
----------
sensitivity : RaySensitivity
Valid sparse sensitivity record returned by :func:`trace_rays`.
delta_vp, delta_vs : array-like, optional
Full model-row-indexed velocity perturbations (m/s).
delta_interface_depths : array-like, optional
Full model-row-indexed interface-depth perturbations (m).
delta_source, delta_receiver : array-like, optional
Three-component endpoint-coordinate perturbations (m).
Returns
-------
LinearizedRayChange
First-order traveltime and physical ray-parameter changes.
Notes
-----
Omitted perturbations are treated as zero. The calculation is valid only
while the ray topology represented by ``sensitivity`` remains unchanged.
"""
if not isinstance(sensitivity, RaySensitivity):
raise TypeError("sensitivity must be a RaySensitivity object.")
if not sensitivity.valid:
reason = sensitivity.reason or "unspecified reason"
raise ValueError(f"Cannot use an invalid sensitivity: {reason}")
vp = _delta_for_indices(delta_vp, sensitivity.vp_layer_indices, "delta_vp")
vs = _delta_for_indices(delta_vs, sensitivity.vs_layer_indices, "delta_vs")
interfaces = _delta_for_indices(
delta_interface_depths,
sensitivity.interface_indices,
"delta_interface_depths",
)
source = _endpoint_delta(delta_source, "delta_source")
receiver = _endpoint_delta(delta_receiver, "delta_receiver")
delta_time = (
np.dot(sensitivity.dtravel_time_dvp, vp)
+ np.dot(sensitivity.dtravel_time_dvs, vs)
+ np.dot(sensitivity.dtravel_time_dinterface_depths, interfaces)
+ np.dot(sensitivity.dtravel_time_dsource, source)
+ np.dot(sensitivity.dtravel_time_dreceiver, receiver)
)
delta_parameter = (
np.dot(sensitivity.dray_parameter_dvp, vp)
+ np.dot(sensitivity.dray_parameter_dvs, vs)
+ np.dot(sensitivity.dray_parameter_dinterface_depths, interfaces)
+ np.dot(sensitivity.dray_parameter_dsource, source)
+ np.dot(sensitivity.dray_parameter_dreceiver, receiver)
)
return LinearizedRayChange(float(delta_time), float(delta_parameter))
def _stable_groups(groups, n_points: int) -> list[np.ndarray]:
"""Return group indices in first-occurrence order."""
if groups is None:
return [np.arange(n_points, dtype=np.int64)]
labels = np.asarray(groups)
if labels.ndim != 1 or labels.size != n_points:
raise ValueError("groups must be one-dimensional with one label per point.")
grouped: dict[object, list[int]] = {}
for index, label in enumerate(labels.tolist()):
try:
grouped.setdefault(label, []).append(index)
except TypeError as exc:
raise ValueError("groups must contain hashable labels.") from exc
return [np.asarray(indices, dtype=np.int64) for indices in grouped.values()]
[docs]
def select_anchors(points, max_distance: float, groups=None) -> AnchorSelection:
"""Select deterministic farthest-point anchors covering a point cloud.
Parameters
----------
points : array-like
Finite point coordinates, shape ``(n_points, 3)``.
max_distance : float
Maximum Euclidean point-to-anchor distance (m).
groups : array-like, optional
One group label per point. Coverage is constructed independently in
each group, which can prevent anchors from crossing model layers.
Returns
-------
AnchorSelection
Selected indices and coordinates plus nearest-anchor assignments.
Notes
-----
Selection begins at the lexicographically smallest point in each group,
then repeatedly adds the farthest uncovered point. Ties are resolved by
original point index, making the result deterministic.
"""
coordinates = _normalize_points(points, "points")
max_distance = float(max_distance)
if not np.isfinite(max_distance) or max_distance <= 0.0:
raise ValueError("max_distance must be positive and finite.")
selected: list[int] = []
tolerance = 1e-12 * max(1.0, max_distance)
for group_indices in _stable_groups(groups, len(coordinates)):
group_points = coordinates[group_indices]
order = np.lexsort((group_indices, group_points[:, 2], group_points[:, 1], group_points[:, 0]))
first = int(group_indices[order[0]])
group_selected = [first]
nearest = np.linalg.norm(group_points - coordinates[first], axis=1)
while float(np.max(nearest)) > max_distance + tolerance:
farthest_distance = float(np.max(nearest))
candidates = np.flatnonzero(np.isclose(nearest, farthest_distance, rtol=0.0, atol=tolerance))
next_index = int(group_indices[candidates[np.argmin(group_indices[candidates])]])
group_selected.append(next_index)
distance = np.linalg.norm(group_points - coordinates[next_index], axis=1)
nearest = np.minimum(nearest, distance)
selected.extend(group_selected)
indices = np.asarray(selected, dtype=np.int64)
anchor_points = coordinates[indices]
assignments = np.empty(len(coordinates), dtype=np.int64)
distances = np.empty(len(coordinates), dtype=np.float64)
group_arrays = _stable_groups(groups, len(coordinates))
for group_indices in group_arrays:
if groups is None:
group_anchor_positions = np.arange(len(indices), dtype=np.int64)
else:
group_labels = np.asarray(groups)
label = group_labels[group_indices[0]]
group_anchor_positions = np.flatnonzero(group_labels[indices] == label)
pair_distances = np.linalg.norm(
coordinates[group_indices, None, :] - anchor_points[group_anchor_positions][None, :, :],
axis=2,
)
nearest_positions = np.argmin(pair_distances, axis=1)
assignments[group_indices] = group_anchor_positions[nearest_positions]
distances[group_indices] = pair_distances[np.arange(len(group_indices)), nearest_positions]
return AnchorSelection(indices, anchor_points.copy(), assignments, distances)
def _layer_indices(depths: np.ndarray, model_depths: np.ndarray) -> np.ndarray:
"""Return physical layer indices for endpoint depths."""
return np.maximum(np.searchsorted(model_depths, depths, side="right") - 1, 0)
def _topology_key(
model: ModelArrays,
source: np.ndarray,
receiver: np.ndarray,
source_phase: str,
itinerary: RayItinerary | None,
):
"""Return an ordered path-leg key, or ``None`` if unreachable."""
source_depth = float(source[2])
receiver_depth = float(receiver[2])
current_depth = source_depth
current_phase = source_phase
direction = None
segments: list[dict] = []
tolerance = 1e-9
interactions = itinerary.interactions if itinerary is not None else ()
for interaction in interactions:
delta = interaction.depth - current_depth
if abs(delta) <= tolerance:
return None
incoming_direction = 1 if delta > 0.0 else -1
if direction is not None and incoming_direction != direction:
return None
direction = incoming_direction
segments.append({
"start_z": current_depth,
"end_z": interaction.depth,
"phase": current_phase,
})
current_depth = interaction.depth
current_phase = interaction.outgoing_phase
if interaction.kind == "reflect":
direction *= -1
receiver_delta = receiver_depth - current_depth
if abs(receiver_delta) > tolerance:
receiver_direction = 1 if receiver_delta > 0.0 else -1
if direction is not None and receiver_direction != direction:
return None
segments.append({
"start_z": current_depth,
"end_z": receiver_depth,
"phase": current_phase,
})
legs = compile_path_legs(model, segments)
if legs:
return tuple((leg.layer_index, leg.phase, int(leg.direction)) for leg in legs)
horizontal_distance = float(np.linalg.norm(receiver[:2] - source[:2]))
layer = int(_layer_indices(np.array([source_depth]), model.depths)[0])
if horizontal_distance > tolerance:
return ((layer, current_phase, 0),)
return ((layer, current_phase, "coincident"),)
def _distance_allowed(distance: np.ndarray, limit: float | None) -> np.ndarray:
"""Return whether endpoint distances satisfy one anchor limit."""
if limit is None:
return distance <= 1e-9
return distance <= limit + 1e-12 * max(1.0, limit)
def _normalized_distance(distance: np.ndarray, limit: float | None) -> np.ndarray:
"""Normalize endpoint distances for joint anchor ranking."""
if limit is None:
return np.zeros_like(distance)
return distance / limit
def _extend_anchor_cover(
points: np.ndarray,
target_indices: np.ndarray,
selected: set[int],
max_distance: float,
) -> None:
"""Extend an anchor set to cover one topology-compatible point group."""
target_indices = np.asarray(target_indices, dtype=np.int64)
group_points = points[target_indices]
group_selected = np.asarray(
sorted(selected.intersection(target_indices.tolist())), dtype=np.int64
)
if group_selected.size == 0:
order = np.lexsort(
(
target_indices,
group_points[:, 2],
group_points[:, 1],
group_points[:, 0],
)
)
first = int(target_indices[order[0]])
selected.add(first)
group_selected = np.array([first], dtype=np.int64)
nearest = np.min(
np.linalg.norm(
group_points[:, None, :] - points[group_selected][None, :, :], axis=2
),
axis=1,
)
tolerance = 1e-12 * max(1.0, max_distance)
while float(np.max(nearest)) > max_distance + tolerance:
farthest_distance = float(np.max(nearest))
candidates = np.flatnonzero(
np.isclose(nearest, farthest_distance, rtol=0.0, atol=tolerance)
)
next_index = int(
target_indices[candidates[np.argmin(target_indices[candidates])]]
)
selected.add(next_index)
distance = np.linalg.norm(group_points - points[next_index], axis=1)
nearest = np.minimum(nearest, distance)
def _quadratic_endpoint_time_correction(
sensitivity: RaySensitivity,
anchor_source: np.ndarray,
anchor_receiver: np.ndarray,
delta_source: np.ndarray,
delta_receiver: np.ndarray,
) -> float:
r"""Return the fixed-topology second-order endpoint correction.
For horizontal anchor offset :math:`R`, ray parameter :math:`p`, and
:math:`X_p=\partial X/\partial p`, the directional curvature is
.. math::
T'' = X_p (p')^2 + p\|\delta r_\perp\|^2/R.
The zero-offset limit replaces the second term by
:math:`\|\delta r\|^2/X_p`.
"""
x_p = float(sensitivity.doffset_dray_parameter)
p = float(sensitivity.ray_parameter)
if not np.isfinite(x_p) or x_p <= 0.0 or not np.isfinite(p):
return 0.0
delta_parameter = float(
np.dot(sensitivity.dray_parameter_dsource, delta_source)
+ np.dot(sensitivity.dray_parameter_dreceiver, delta_receiver)
)
relative_change = delta_receiver[:2] - delta_source[:2]
horizontal = anchor_receiver[:2] - anchor_source[:2]
offset = float(np.linalg.norm(horizontal))
curvature = x_p * delta_parameter**2
if offset > 1e-12:
direction = horizontal / offset
transverse = relative_change - direction * np.dot(direction, relative_change)
curvature += p * float(np.dot(transverse, transverse)) / offset
else:
curvature += float(np.dot(relative_change, relative_change)) / x_p
return 0.5 * curvature
[docs]
@dataclass
class TravelTimeApproximator:
"""Fitted topology-safe endpoint-Taylor traveltime approximator.
Use :meth:`fit` to construct instances. The fitted velocity model and
itinerary are fixed; model changes require refitting.
Attributes
----------
source_anchor_indices, receiver_anchor_indices : numpy.ndarray
Original endpoint indices retained as anchors.
anchor_sources, anchor_receivers : numpy.ndarray
Anchor coordinates.
anchor_trace : TraceResult
Exact Cartesian-product ray trace at the anchors.
source_max_distance, receiver_max_distance : float or None
Endpoint coverage limits used during fitting.
"""
velocity_model: pd.DataFrame
source_phase: str
itinerary: RayItinerary | None
fit_sources: np.ndarray
fit_receivers: np.ndarray
source_max_distance: float | None
receiver_max_distance: float | None
source_anchor_indices: np.ndarray
receiver_anchor_indices: np.ndarray
anchor_sources: np.ndarray
anchor_receivers: np.ndarray
anchor_trace: TraceResult
anchor_topologies: np.ndarray
@property
def exact_ray_count(self) -> int:
"""Number of exact source-anchor to receiver-anchor rays."""
return len(self.anchor_sources) * len(self.anchor_receivers)
[docs]
@classmethod
def fit(
cls,
sources,
receivers,
velocity_model: pd.DataFrame,
*,
source_max_distance: float | None,
receiver_max_distance: float | None,
source_phase: str = "P",
itinerary: RayItinerary | None = None,
n_jobs: int = -1,
backend: str = "loky",
sequential_limit: int = 5000,
rays_per_chunk: int | None = None,
tol: float = 1e-6,
max_iter: int = 20,
verbose: bool = False,
) -> "TravelTimeApproximator":
"""Fit exact anchors to a source-receiver target domain.
Parameters
----------
sources, receivers : array-like
Endpoint coordinates with shape ``(N, 3)`` or ``(3,)``.
velocity_model : pandas.DataFrame
Layer model accepted by :func:`trace_rays`.
source_max_distance, receiver_max_distance : float or None
Maximum endpoint-to-anchor distances (m). ``None`` retains every
supplied endpoint of that type as an exact anchor.
source_phase : {"P", "SV", "SH", "S"}
One direct-ray source phase. Ignored when ``itinerary`` is given.
itinerary : RayItinerary, optional
Explicit ordered fixed-topology path.
n_jobs, backend, sequential_limit, rays_per_chunk, tol, max_iter, verbose
Controls forwarded to the exact anchor call to :func:`trace_rays`.
Returns
-------
TravelTimeApproximator
Fitted anchor set and exact anchor rays.
"""
source_points = _normalize_points(sources, "sources")
receiver_points = _normalize_points(receivers, "receivers")
if not isinstance(velocity_model, pd.DataFrame):
raise TypeError("velocity_model must be a pandas DataFrame.")
model_frame = velocity_model.copy(deep=True)
model = ModelArrays.from_dataframe(model_frame)
if itinerary is not None:
if not isinstance(itinerary, RayItinerary):
raise TypeError("itinerary must be a RayItinerary object.")
phase = itinerary.source_phase
else:
if not isinstance(source_phase, str):
raise ValueError("TravelTimeApproximator supports one source phase.")
phase = normalize_phase(source_phase)
for value, name in (
(source_max_distance, "source_max_distance"),
(receiver_max_distance, "receiver_max_distance"),
):
if value is not None and (not np.isfinite(value) or float(value) <= 0.0):
raise ValueError(f"{name} must be positive and finite, or None.")
source_limit = None if source_max_distance is None else float(source_max_distance)
receiver_limit = None if receiver_max_distance is None else float(receiver_max_distance)
source_layers = _layer_indices(source_points[:, 2], model.depths)
receiver_layers = _layer_indices(receiver_points[:, 2], model.depths)
if source_limit is None:
source_indices = np.arange(len(source_points), dtype=np.int64)
else:
source_indices = select_anchors(source_points, source_limit, source_layers).indices
if receiver_limit is None:
receiver_indices = np.arange(len(receiver_points), dtype=np.int64)
else:
receiver_indices = select_anchors(receiver_points, receiver_limit, receiver_layers).indices
topology_cache: dict[tuple[float, float, bool], object] = {}
def topology(source_point, receiver_point):
horizontal = float(np.linalg.norm(receiver_point[:2] - source_point[:2]))
cache_key = (
float(source_point[2]),
float(receiver_point[2]),
horizontal <= 1e-9,
)
if cache_key not in topology_cache:
topology_cache[cache_key] = _topology_key(
model, source_point, receiver_point, phase, itinerary
)
return topology_cache[cache_key]
target_topologies = np.empty(len(source_points) * len(receiver_points), dtype=object)
for source_index, source_point in enumerate(source_points):
for receiver_index, receiver_point in enumerate(receiver_points):
flat_index = source_index * len(receiver_points) + receiver_index
target_topologies[flat_index] = topology(source_point, receiver_point)
source_set = set(source_indices.tolist())
receiver_set = set(receiver_indices.tolist())
if receiver_limit is None and source_limit is not None:
for receiver_index in range(len(receiver_points)):
grouped_sources: dict[object, list[int]] = {}
for source_index in range(len(source_points)):
key = target_topologies[
source_index * len(receiver_points) + receiver_index
]
if key is not None:
grouped_sources.setdefault(key, []).append(source_index)
for group_indices in grouped_sources.values():
_extend_anchor_cover(
source_points,
np.asarray(group_indices, dtype=np.int64),
source_set,
source_limit,
)
elif source_limit is None and receiver_limit is not None:
for source_index in range(len(source_points)):
grouped_receivers: dict[object, list[int]] = {}
start = source_index * len(receiver_points)
for receiver_index in range(len(receiver_points)):
key = target_topologies[start + receiver_index]
if key is not None:
grouped_receivers.setdefault(key, []).append(receiver_index)
for group_indices in grouped_receivers.values():
_extend_anchor_cover(
receiver_points,
np.asarray(group_indices, dtype=np.int64),
receiver_set,
receiver_limit,
)
else:
while True:
sorted_sources = np.asarray(sorted(source_set), dtype=np.int64)
sorted_receivers = np.asarray(sorted(receiver_set), dtype=np.int64)
pair_source_indices = np.repeat(sorted_sources, len(sorted_receivers))
pair_receiver_indices = np.tile(sorted_receivers, len(sorted_sources))
anchor_groups: dict[object, list[int]] = {}
for pair_index, (source_index, receiver_index) in enumerate(zip(
pair_source_indices, pair_receiver_indices
)):
key = topology(source_points[source_index], receiver_points[receiver_index])
if key is not None:
anchor_groups.setdefault(key, []).append(pair_index)
uncovered_groups: dict[object, list[tuple[int, int]]] = {}
for flat_index, target_key in enumerate(target_topologies):
if target_key is None:
continue
source_index = flat_index // len(receiver_points)
receiver_index = flat_index % len(receiver_points)
candidate_positions = anchor_groups.get(target_key, ())
if candidate_positions:
candidate_positions = np.asarray(candidate_positions, dtype=np.int64)
source_distances = np.linalg.norm(
source_points[pair_source_indices[candidate_positions]]
- source_points[source_index],
axis=1,
)
receiver_distances = np.linalg.norm(
receiver_points[pair_receiver_indices[candidate_positions]]
- receiver_points[receiver_index],
axis=1,
)
covered = np.any(
_distance_allowed(source_distances, source_limit)
& _distance_allowed(receiver_distances, receiver_limit)
)
else:
covered = False
if not covered:
uncovered_groups.setdefault(target_key, []).append(
(source_index, receiver_index)
)
if not uncovered_groups:
break
previous_sizes = (len(source_set), len(receiver_set))
for uncovered in uncovered_groups.values():
uncovered_array = np.asarray(uncovered, dtype=np.int64)
candidate_sources = np.unique(uncovered_array[:, 0])
candidate_receivers = np.unique(uncovered_array[:, 1])
if source_limit is None:
source_set.update(candidate_sources.tolist())
else:
selected = select_anchors(
source_points[candidate_sources], source_limit
).indices
source_set.update(candidate_sources[selected].tolist())
if receiver_limit is None:
receiver_set.update(candidate_receivers.tolist())
else:
selected = select_anchors(
receiver_points[candidate_receivers], receiver_limit
).indices
receiver_set.update(candidate_receivers[selected].tolist())
if previous_sizes == (len(source_set), len(receiver_set)):
first_uncovered = next(iter(uncovered_groups.values()))[0]
source_set.add(first_uncovered[0])
receiver_set.add(first_uncovered[1])
source_indices = np.asarray(sorted(source_set), dtype=np.int64)
receiver_indices = np.asarray(sorted(receiver_set), dtype=np.int64)
anchor_sources = source_points[source_indices]
anchor_receivers = receiver_points[receiver_indices]
anchor_trace = trace_rays(
anchor_sources,
anchor_receivers,
model_frame,
source_phase=phase,
itinerary=itinerary,
requested={"travel_times", "sensitivities", "diagnostics"},
n_jobs=n_jobs,
backend=backend,
sequential_limit=sequential_limit,
rays_per_chunk=rays_per_chunk,
tol=tol,
max_iter=max_iter,
verbose=verbose,
)
anchor_topologies = np.empty(len(anchor_sources) * len(anchor_receivers), dtype=object)
for source_index, source_point in enumerate(anchor_sources):
for receiver_index, receiver_point in enumerate(anchor_receivers):
flat_index = source_index * len(anchor_receivers) + receiver_index
anchor_topologies[flat_index] = topology(source_point, receiver_point)
return cls(
velocity_model=model_frame,
source_phase=phase,
itinerary=itinerary,
fit_sources=source_points.copy(),
fit_receivers=receiver_points.copy(),
source_max_distance=source_limit,
receiver_max_distance=receiver_limit,
source_anchor_indices=source_indices,
receiver_anchor_indices=receiver_indices,
anchor_sources=anchor_sources.copy(),
anchor_receivers=anchor_receivers.copy(),
anchor_trace=anchor_trace,
anchor_topologies=anchor_topologies,
)
[docs]
def predict(self, sources=None, receivers=None, *, order: int = 1) -> TravelTimePrediction:
"""Predict traveltimes for targets inside the fitted anchor domain.
Parameters
----------
sources, receivers : array-like, optional
Query endpoints. Omitted arrays default to the corresponding
points supplied to :meth:`fit`.
order : {1, 2}
Endpoint Taylor order. Second order adds fixed-topology curvature
and evaluates direct same-layer paths exactly. The default retains
the original first-order predictor.
Returns
-------
TravelTimePrediction
Source-major predictions, masks, reason codes, and anchor mapping.
Notes
-----
Unsupported targets are returned as ``NaN`` and are never retraced.
Possible reason codes are ``"unreachable_topology"``,
``"no_topology_match"``, ``"outside_anchor_distance"``, and
``"invalid_anchor_sensitivity"``.
"""
if order not in (1, 2):
raise ValueError("order must be 1 or 2.")
source_points = self.fit_sources if sources is None else _normalize_points(sources, "sources")
receiver_points = self.fit_receivers if receivers is None else _normalize_points(receivers, "receivers")
model = ModelArrays.from_dataframe(self.velocity_model)
n_rays = len(source_points) * len(receiver_points)
travel_times = np.full(n_rays, np.nan, dtype=np.float64)
valid = np.zeros(n_rays, dtype=bool)
reasons = np.full(n_rays, _REASON_NO_MATCH, dtype="<U32")
anchor_indices = np.full(n_rays, -1, dtype=np.int64)
source_used_distances = np.full(n_rays, np.nan, dtype=np.float64)
receiver_used_distances = np.full(n_rays, np.nan, dtype=np.float64)
anchor_groups: dict[object, np.ndarray] = {}
grouped_positions: dict[object, list[int]] = {}
for index, key in enumerate(self.anchor_topologies):
if key is not None:
grouped_positions.setdefault(key, []).append(index)
for key, positions in grouped_positions.items():
anchor_groups[key] = np.asarray(positions, dtype=np.int64)
sensitivity_valid = np.asarray(
[
sensitivity is not None and sensitivity.valid
for sensitivity in self.anchor_trace.sensitivities
],
dtype=bool,
)
receiver_distance_matrix = np.linalg.norm(
receiver_points[:, None, :] - self.anchor_receivers[None, :, :], axis=2
)
source_layers = _layer_indices(source_points[:, 2], model.depths)
receiver_layers = _layer_indices(receiver_points[:, 2], model.depths)
topology_cache: dict[tuple[float, float, bool], object] = {}
for source_index, source_point in enumerate(source_points):
source_distances = np.linalg.norm(self.anchor_sources - source_point, axis=1)
for receiver_index, receiver_point in enumerate(receiver_points):
flat_index = source_index * len(receiver_points) + receiver_index
horizontal = float(np.linalg.norm(receiver_point[:2] - source_point[:2]))
cache_key = (
float(source_point[2]),
float(receiver_point[2]),
horizontal <= 1e-9,
)
if cache_key not in topology_cache:
topology_cache[cache_key] = _topology_key(
model,
source_point,
receiver_point,
self.source_phase,
self.itinerary,
)
target_key = topology_cache[cache_key]
if target_key is None:
reasons[flat_index] = _REASON_UNREACHABLE
continue
topology_candidates = anchor_groups.get(target_key, np.empty(0, dtype=np.int64))
if topology_candidates.size == 0:
reasons[flat_index] = _REASON_NO_MATCH
continue
receiver_distances = receiver_distance_matrix[receiver_index]
candidate_source_indices = topology_candidates // len(self.anchor_receivers)
candidate_receiver_indices = topology_candidates % len(self.anchor_receivers)
candidate_source_distances = source_distances[candidate_source_indices]
candidate_receiver_distances = receiver_distances[candidate_receiver_indices]
within_distance = (
_distance_allowed(candidate_source_distances, self.source_max_distance)
& _distance_allowed(candidate_receiver_distances, self.receiver_max_distance)
)
if not np.any(within_distance):
reasons[flat_index] = _REASON_OUTSIDE
continue
candidates = topology_candidates[within_distance]
candidate_source_distances = candidate_source_distances[within_distance]
candidate_receiver_distances = candidate_receiver_distances[within_distance]
ranking = np.hypot(
_normalized_distance(candidate_source_distances, self.source_max_distance),
_normalized_distance(candidate_receiver_distances, self.receiver_max_distance),
)
ranking[~sensitivity_valid[candidates]] = np.inf
position = int(np.argmin(ranking))
if not np.isfinite(ranking[position]):
reasons[flat_index] = _REASON_INVALID_SENSITIVITY
continue
candidate = int(candidates[position])
sensitivity = self.anchor_trace.sensitivities[candidate]
anchor_source_index = candidate // len(self.anchor_receivers)
anchor_receiver_index = candidate % len(self.anchor_receivers)
anchor_source = self.anchor_sources[anchor_source_index]
anchor_receiver = self.anchor_receivers[anchor_receiver_index]
delta_source = source_point - anchor_source
delta_receiver = receiver_point - anchor_receiver
delta_time = float(
np.dot(sensitivity.dtravel_time_dsource, delta_source)
+ np.dot(sensitivity.dtravel_time_dreceiver, delta_receiver)
)
predicted_time = self.anchor_trace.travel_times[candidate] + delta_time
if order == 2:
if (
self.itinerary is None
and source_layers[source_index] == receiver_layers[receiver_index]
):
layer_index = source_layers[source_index]
velocity = (
model.vp[layer_index]
if self.source_phase == "P"
else model.vs[layer_index]
)
predicted_time = float(
np.linalg.norm(receiver_point - source_point) / velocity
)
else:
predicted_time += _quadratic_endpoint_time_correction(
sensitivity,
anchor_source,
anchor_receiver,
delta_source,
delta_receiver,
)
travel_times[flat_index] = predicted_time
valid[flat_index] = True
reasons[flat_index] = _REASON_OK
anchor_indices[flat_index] = candidate
source_used_distances[flat_index] = candidate_source_distances[position]
receiver_used_distances[flat_index] = candidate_receiver_distances[position]
return TravelTimePrediction(
travel_times=travel_times,
valid_mask=valid,
reasons=reasons,
anchor_ray_indices=anchor_indices,
source_anchor_distances=source_used_distances,
receiver_anchor_distances=receiver_used_distances,
n_sources=len(source_points),
n_receivers=len(receiver_points),
)