Source code for laytracer.api

r"""
High-level multi-ray tracing interface.

Provides :func:`trace_rays`, the main entry point for tracing all
source-receiver pairs through a 1-D layered velocity model, with
optional parallel execution using the ``loky`` backend.
"""

from __future__ import annotations

import math
import sys
import time
import warnings
from dataclasses import dataclass
from typing import Sequence

import numpy as np
import pandas as pd
import psutil
from joblib import Parallel, delayed, parallel_config

from .model import ModelArrays, build_layer_stack, normalize_phase, velocity_key
from .sensitivity import RaySensitivity, compute_ray_sensitivity
from .solver import SolveDiagnostics, solve, transmission_product


TRACE_OUTPUTS = frozenset({
    "travel_times",
    "rays",
    "ray_parameters",
    "tstar",
    "spreading",
    "trans_product",
    "complex_coefficient_product",
    "diagnostics",
    "sensitivities",
})
DEFAULT_REQUESTED = ("travel_times", "rays", "ray_parameters")

# Parallel task sizing (see the Notes of trace_rays). Tasks return compact
# columns that are copied into the output as soon as they finish, so these
# bound the transient memory and the scheduling granularity, not the output.
_TASKS_PER_WORKER = 16
_MAX_RAYS_PER_TASK = 65_536
_MAX_BYTES_PER_TASK = 16 * 2**20

# Per-ray result tuple layout shared by _trace_one and _trace_one_many.
_COLUMNS = (
    "tt", "rays", "p", "tstar", "spread", "trans", "complex", "diagnostics", "sensitivities",
)


[docs] @dataclass(frozen=True) class Interaction: """One ordered reflection or transmission in a prescribed itinerary. Parameters ---------- depth : float Model-interface depth (m, positive downward). kind : {"reflect", "transmit"} Whether propagation reverses or retains its vertical direction. outgoing_phase : {"P", "SV", "SH", "S"} Phase immediately after the interaction. ``"S"`` is normalized to ``"SV"``. """ depth: float kind: str outgoing_phase: str def __post_init__(self) -> None: depth = float(self.depth) kind = str(self.kind).lower() if not np.isfinite(depth): raise ValueError("Interaction depth must be finite.") if kind not in {"reflect", "transmit"}: raise ValueError("Interaction kind must be 'reflect' or 'transmit'.") object.__setattr__(self, "depth", depth) object.__setattr__(self, "kind", kind) object.__setattr__(self, "outgoing_phase", normalize_phase(self.outgoing_phase))
[docs] @dataclass(frozen=True) class RayItinerary: """An ordered, fixed-topology phase itinerary.""" source_phase: str interactions: Sequence[Interaction] def __post_init__(self) -> None: normalized: list[Interaction] = [] for interaction in self.interactions: if not isinstance(interaction, Interaction): raise TypeError("RayItinerary interactions must be Interaction objects.") normalized.append(interaction) object.__setattr__(self, "source_phase", normalize_phase(self.source_phase)) object.__setattr__(self, "interactions", tuple(normalized))
[docs] @dataclass class TraceResult: """Container for multi-ray tracing results. Attributes ---------- travel_times : numpy.ndarray Travel times (s), shape ``(n_rays,)``. rays : list of numpy.ndarray or None Ray paths; each element is shape ``(M_i, 3)`` in the original 3-D coordinate system. *None* if not requested. ray_parameters : numpy.ndarray or None Horizontal slowness *p* for each ray, shape ``(n_rays,)``. tstar : numpy.ndarray or None Attenuation operator :math:`t^*` for each ray, shape ``(n_rays,)``. spreading : numpy.ndarray or None Relative geometrical spreading factor for each ray, shape ``(n_rays,)``. trans_product : numpy.ndarray or None Real, nonnegative product of interface-coefficient magnitudes along each ray. source_phase : str or None Canonical source phase for this result. complex_coefficient_product : numpy.ndarray or None Signed/complex cumulative interface coefficient for each ray. diagnostics : list of SolveDiagnostics or None Per-ray numerical certificates, if requested. sensitivities : list of RaySensitivity or None Per-ray fixed-topology analytic derivatives, if requested. """ travel_times: np.ndarray rays: list[np.ndarray] | None = None ray_parameters: np.ndarray | None = None tstar: np.ndarray | None = None spreading: np.ndarray | None = None trans_product: np.ndarray | None = None source_phase: str | None = None complex_coefficient_product: np.ndarray | None = None diagnostics: list[SolveDiagnostics | None] | None = None sensitivities: list[RaySensitivity | None] | None = None
def _normalize_requested(requested: Sequence[str] | None) -> frozenset[str]: """Validate and normalize the requested output names.""" if requested is None: requested = DEFAULT_REQUESTED normalized = frozenset(str(name) for name in requested) invalid = normalized - TRACE_OUTPUTS if invalid: valid = ", ".join(sorted(TRACE_OUTPUTS)) invalid_str = ", ".join(sorted(invalid)) raise ValueError(f"Invalid requested outputs: {invalid_str}. Valid outputs: {valid}") if "travel_times" not in normalized: raise ValueError("requested must include 'travel_times'") return normalized def _normalize_source_phases(source_phase) -> tuple[tuple[str, ...], bool]: """Normalize source_phase while preserving list order and de-duplicating.""" if isinstance(source_phase, str): return (normalize_phase(source_phase),), False phases: list[str] = [] seen = set() for phase in source_phase: phase_norm = normalize_phase(phase) if phase_norm not in seen: phases.append(phase_norm) seen.add(phase_norm) if not phases: raise ValueError("source_phase must contain at least one phase.") return tuple(phases), True def _normalize_interaction_phases( arg: Sequence[tuple[float, str]] | None, ) -> list[tuple[float, str]]: """Normalize interaction phase labels.""" if arg is None: return [] return [(float(z), normalize_phase(ph)) for z, ph in arg] def _validate_sh_coupling(source_phase: str, interactions: list[tuple[float, str]]) -> None: """Reject SH/P-SV mode conversions in isotropic media.""" source_is_sh = source_phase == "SH" for _, phase in interactions: phase_is_sh = phase == "SH" if source_is_sh != phase_is_sh: raise ValueError("SH is decoupled from P-SV in isotropic 1-D media.") def _empty_columns(n: int, kw: dict) -> dict: """Allocate NaN- or None-filled result columns for ``n`` rays of one phase.""" def _floats(flag): return np.full(n, np.nan) if flag else None def _objects(flag): return [None] * n if flag else None return { "tt": np.full(n, np.nan), "rays": _objects(kw["need_rays"]), "p": _floats(kw["need_ray_parameters"]), "tstar": _floats(kw["need_tstar"]), "spread": _floats(kw["need_spreading"]), "trans": _floats(kw["need_trans_product"]), "complex": ( np.full(n, np.nan + 0.0j) if kw["need_complex_coefficient_product"] else None ), "diagnostics": _objects(kw["need_diagnostics"]), "sensitivities": _objects(kw["need_sensitivities"]), } def _store_ray(columns: dict, k: int, values: tuple) -> None: """Write one per-ray result tuple into position ``k`` of ``columns``.""" for key, value in zip(_COLUMNS, values): column = columns[key] if column is not None and value is not None: column[k] = value def _scatter_tile(out: dict, columns: dict, tile: tuple, n_rcv: int) -> None: """Copy one task's columns into the source-major output columns.""" s0, s1, r0, r1 = tile width = r1 - r0 for key, column in columns.items(): if column is None: continue if isinstance(column, np.ndarray): out[key].reshape(-1, n_rcv)[s0:s1, r0:r1] = column.reshape(s1 - s0, width) else: for row, isrc in enumerate(range(s0, s1)): start = isrc * n_rcv + r0 out[key][start:start + width] = column[row * width:(row + 1) * width] def _deep_sizeof(obj) -> int: """Approximate in-memory bytes of a result object and the objects it owns.""" size = sys.getsizeof(obj) if isinstance(obj, dict): return size + sum(_deep_sizeof(value) for value in obj.values()) if isinstance(obj, (list, tuple)): return size + sum(_deep_sizeof(value) for value in obj) if hasattr(obj, "__dict__"): return size + _deep_sizeof(vars(obj)) return size def _column_bytes(column) -> int: """In-memory bytes of one result column.""" if column is None: return 0 if isinstance(column, np.ndarray): return column.nbytes return 8 * len(column) + sum(_deep_sizeof(value) for value in column if value is not None) def _format_seconds(seconds: float) -> str: """Format a duration for progress messages.""" if seconds >= 3600: return f"{seconds / 3600:.1f}h" if seconds >= 60: return f"{seconds / 60:.1f}m" return f"{seconds:.1f}s" def _format_bytes(n_bytes: float) -> str: """Format a memory size for messages.""" if n_bytes >= 1e9: return f"{n_bytes / 1e9:.1f} GB" return f"{n_bytes / 1e6:.1f} MB" def _trace_batch(tile, src_tile, rcv_tile, kw): """Worker function: trace every source-receiver pair of one tile. Results are written straight into NumPy columns per phase, so a task returns (and pickles to) about 8 bytes per ray and requested float output, plus any requested ray paths, diagnostics or sensitivities, instead of one Python tuple per ray. Returns ``(tile, columns)``. """ kw = dict(kw) source_phases = kw.pop("source_phases") return_dict = kw.pop("return_dict") n = len(src_tile) * len(rcv_tile) columns = {phase: _empty_columns(n, kw) for phase in source_phases} k = 0 for src in src_tile: for rcv in rcv_tile: if return_dict: per_phase = _trace_one_many( src=src, rcv=rcv, source_phases=source_phases, **kw ) else: per_phase = { source_phases[0]: _trace_one( src=src, rcv=rcv, source_phase=source_phases[0], **kw ) } for phase, values in per_phase.items(): _store_ray(columns[phase], k, values) k += 1 return tile, columns def _project_ray_to_3d( ray2d: np.ndarray | None, sx: float, sy: float, dx: float, dy: float, epic: float, ) -> np.ndarray | None: """Project a 2-D ray in the epicentral plane back into 3-D.""" if ray2d is None: return None mpts = ray2d.shape[0] ray3d = np.empty((mpts, 3)) if epic > 1e-10: ux, uy = dx / epic, dy / epic else: ux, uy = 1.0, 0.0 ray3d[:, 0] = sx + ray2d[:, 0] * ux ray3d[:, 1] = sy + ray2d[:, 0] * uy ray3d[:, 2] = ray2d[:, 1] return ray3d def _direct_segments_for_phase( ma: ModelArrays, z_src: float, z_rcv: float, source_phase: str, ) -> list[dict] | None: """Build direct-path segments for coefficient reuse.""" stack = build_layer_stack(ma, z_src, z_rcv) vel = stack.v(velocity_key(source_phase)) valid = stack.h > 1e-9 if not np.any(valid): return None return [{ "h": stack.h[valid], "v": vel[valid], "vp": stack.vp[valid], "vs": stack.vs[valid], "rho": stack.rho[valid] if stack.rho is not None else None, "qp": stack.qp[valid] if stack.qp is not None else None, "qs": stack.qs[valid] if stack.qs is not None else None, "phase": source_phase, "start_z": z_src, "end_z": z_rcv, }] def _direct_trans_product( ma: ModelArrays, z_src: float, z_rcv: float, source_phase: str, ray_parameter: float | None, transcoef_method: str, ) -> float: """Compute direct-path transmission product for a solved ray.""" segments = _direct_segments_for_phase(ma, z_src, z_rcv, source_phase) if segments is None: return 1.0 p = 0.0 if ray_parameter is None or not np.isfinite(ray_parameter) else float(ray_parameter) return transmission_product(p, segments, [], transcoef_method) def _segment_between( ma: ModelArrays, start_z: float, end_z: float, phase: str, ) -> dict | None: """Build one monotonic solver segment.""" stack = build_layer_stack(ma, start_z, end_z) valid = stack.h > 1e-9 if not np.any(valid): return None velocity = stack.v(velocity_key(phase)) return { "h": stack.h[valid], "v": velocity[valid], "vp": stack.vp[valid], "vs": stack.vs[valid], "rho": stack.rho[valid] if stack.rho is not None else None, "qp": stack.qp[valid] if stack.qp is not None else None, "qs": stack.qs[valid] if stack.qs is not None else None, "phase": phase, "start_z": start_z, "end_z": end_z, } def _material_beyond_interaction( ma: ModelArrays, depth: float, going_down: bool, ) -> dict[str, float]: """Return material properties immediately beyond an interaction.""" delta = 1.0 if going_down: stack = build_layer_stack(ma, depth, depth + delta) else: stack = build_layer_stack(ma, depth - delta, depth) return { "vp": float(stack.vp[0]), "vs": float(stack.vs[0]), "rho": float(stack.rho[0]) if stack.rho is not None else 0.0, } def _compile_ordered_itinerary( ma: ModelArrays, source_depth: float, receiver_depth: float, source_phase: str, interactions: tuple[Interaction, ...], ) -> tuple[list[dict], list[dict]]: """Compile an explicit ordered itinerary into solver metadata.""" ray_segments: list[dict] = [] interaction_metadata: list[dict] = [] current_depth = source_depth current_phase = source_phase direction: float | None = None depth_tolerance = 1e-9 for interaction in interactions: delta = interaction.depth - current_depth if abs(delta) <= depth_tolerance: raise ValueError( f"Cannot {interaction.kind} at the starting depth {interaction.depth} immediately." ) incoming_direction = 1.0 if delta > 0.0 else -1.0 if direction is not None and incoming_direction != direction: raise ValueError( f"Interaction at depth {interaction.depth} is not reachable in the current " "vertical direction." ) direction = incoming_direction segment = _segment_between( ma, current_depth, interaction.depth, current_phase ) if segment is None: raise ValueError( f"Cannot {interaction.kind} at the starting depth {interaction.depth} immediately." ) ray_segments.append(segment) properties = _material_beyond_interaction( ma, interaction.depth, going_down=direction > 0.0 ) interaction_metadata.append( { "type": "reflection" if interaction.kind == "reflect" else "refraction", "depth": interaction.depth, "in_phase": current_phase, "out_phase": interaction.outgoing_phase, "seg_idx": len(ray_segments) - 1, "vp_beyond": properties["vp"], "vs_beyond": properties["vs"], "rho_beyond": properties["rho"], } ) current_depth = interaction.depth current_phase = interaction.outgoing_phase if interaction.kind == "reflect": direction *= -1.0 receiver_delta = receiver_depth - current_depth if abs(receiver_delta) > depth_tolerance: receiver_direction = 1.0 if receiver_delta > 0.0 else -1.0 if direction is not None and receiver_direction != direction: raise ValueError( "Receiver depth is not reachable after the final itinerary interaction." ) segment = _segment_between(ma, current_depth, receiver_depth, current_phase) if segment is not None: ray_segments.append(segment) return ray_segments, interaction_metadata def _trace_one_many( ma: ModelArrays, src: np.ndarray, rcv: np.ndarray, source_phases: tuple[str, ...], refl_list: list[tuple[float, str]], refr_list: list[tuple[float, str]], ordered_interactions: tuple[Interaction, ...] | None, need_rays: bool, need_ray_parameters: bool, need_tstar: bool, need_spreading: bool, need_trans_product: bool, need_complex_coefficient_product: bool, need_diagnostics: bool, need_sensitivities: bool, transcoef_method: str, tol: float, max_iter: int, ) -> dict[str, tuple]: """Trace one source-receiver pair for multiple source phases.""" sz = float(src[2]) rz = float(rcv[2]) can_share_direct = ( not refl_list and not refr_list and not ordered_interactions and not need_complex_coefficient_product ) cache: dict[str, tuple] = {} results = {} for source_phase in source_phases: _validate_sh_coupling(source_phase, refl_list + refr_list) family = velocity_key(source_phase) if can_share_direct and family in cache: tt, ray3d, p_val, tstar, spreading, _, complex_product, diagnostics, sensitivity = cache[family] trans = ( _direct_trans_product(ma, sz, rz, source_phase, p_val, transcoef_method) if need_trans_product else None ) results[source_phase] = ( tt, ray3d, p_val if need_ray_parameters else None, tstar, spreading, trans, complex_product, diagnostics, sensitivity, ) continue base = _trace_one( ma=ma, src=src, rcv=rcv, source_phase=source_phase, refl_list=refl_list, refr_list=refr_list, ordered_interactions=ordered_interactions, need_rays=need_rays, need_ray_parameters=( need_ray_parameters or need_trans_product or need_sensitivities ), need_tstar=need_tstar, need_spreading=need_spreading, need_trans_product=False if can_share_direct else need_trans_product, need_complex_coefficient_product=need_complex_coefficient_product, need_diagnostics=need_diagnostics, need_sensitivities=need_sensitivities, transcoef_method=transcoef_method, tol=tol, max_iter=max_iter, ) if can_share_direct: cache[family] = base tt, ray3d, p_val, tstar, spreading, _, complex_product, diagnostics, sensitivity = base trans = ( _direct_trans_product(ma, sz, rz, source_phase, p_val, transcoef_method) if need_trans_product else None ) results[source_phase] = ( tt, ray3d, p_val if need_ray_parameters else None, tstar, spreading, trans, complex_product, diagnostics, sensitivity, ) else: results[source_phase] = base return results def _trace_one( ma: ModelArrays, src: np.ndarray, rcv: np.ndarray, source_phase: str, refl_list: list[tuple[float, str]], refr_list: list[tuple[float, str]], ordered_interactions: tuple[Interaction, ...] | None, need_rays: bool, need_ray_parameters: bool, need_tstar: bool, need_spreading: bool, need_trans_product: bool, need_complex_coefficient_product: bool, need_diagnostics: bool, need_sensitivities: bool, transcoef_method: str, tol: float, max_iter: int, ) -> tuple: """Trace a single source->receiver ray.""" source_phase = normalize_phase(source_phase) _validate_sh_coupling(source_phase, refl_list + refr_list) sx, sy, sz = float(src[0]), float(src[1]), float(src[2]) rx, ry, rz = float(rcv[0]), float(rcv[1]), float(rcv[2]) dx, dy = rx - sx, ry - sy epic = np.sqrt(dx * dx + dy * dy) if not refl_list and not refr_list and not ordered_interactions: stack = build_layer_stack(ma, sz, rz) vel = stack.v(velocity_key(source_phase)) valid = stack.h > 1e-9 if not np.any(valid): ray3d = np.array([[sx, sy, sz], [rx, ry, rz]]) if need_rays else None if epic < 1e-10: diagnostics = SolveDiagnostics( converged=True, method="q_newton", iterations=0, initial_q=0.0, final_q=0.0, initial_ray_parameter=0.0, ray_parameter=0.0, signed_offset_residual=0.0, absolute_offset_residual=0.0, conditioning=1.0, criticality_margin=1.0, ) if need_diagnostics else None sensitivity = ( compute_ray_sensitivity( ma, src, rcv, source_phase, [], 0.0 ) if need_sensitivities else None ) return ( 0.0, ray3d, 0.0 if need_ray_parameters else None, 0.0 if need_tstar else None, 0.0 if need_spreading else None, 1.0 if need_trans_product else None, 1.0 + 0.0j if need_complex_coefficient_product else None, diagnostics, sensitivity, ) v_hz = float(vel[0]) tt_hz = epic / v_hz p_hz = 1.0 / v_hz q_arr = stack.qp if source_phase == "P" else stack.qs if need_tstar: q_name = "Qp" if source_phase == "P" else "Qs" if ( q_arr is None or not np.isfinite(q_arr[0]) or q_arr[0] <= 0.0 ): raise ValueError( f"{q_name} is required and must be positive when tstar " "is requested." ) tstar_hz = float(epic / (v_hz * q_arr[0])) else: tstar_hz = None spreading_hz = epic * v_hz if need_spreading else None trans_hz = 1.0 if need_trans_product else None complex_hz = 1.0 + 0.0j if need_complex_coefficient_product else None diagnostics_hz = SolveDiagnostics( converged=True, method="q_newton", iterations=0, initial_q=np.inf, final_q=np.inf, initial_ray_parameter=p_hz, ray_parameter=p_hz, signed_offset_residual=0.0, absolute_offset_residual=0.0, conditioning=0.0, criticality_margin=0.0, ) if need_diagnostics else None sensitivity_hz = ( compute_ray_sensitivity( ma, src, rcv, source_phase, [], p_hz ) if need_sensitivities else None ) return ( tt_hz, ray3d, p_hz if need_ray_parameters else None, tstar_hz, spreading_hz, trans_hz, complex_hz, diagnostics_hz, sensitivity_hz, ) h_f = stack.h[valid] v_f = vel[valid] seg = { "h": h_f, "v": v_f, "vp": stack.vp[valid], "vs": stack.vs[valid], "rho": stack.rho[valid] if stack.rho is not None else None, "qp": stack.qp[valid] if stack.qp is not None else None, "qs": stack.qs[valid] if stack.qs is not None else None, "phase": source_phase, "start_z": sz, "end_z": rz, } res = solve( h=h_f, v=v_f, segments=[seg], interactions=[], epicentral_dist=epic, z_src=sz, z_rcv=rz, return_ray_path=need_rays, need_ray_parameter=need_ray_parameters or need_sensitivities, need_tstar=need_tstar, need_spreading=need_spreading, need_trans_product=need_trans_product, need_complex_coefficient_product=need_complex_coefficient_product, need_diagnostics=need_diagnostics, transcoef_method=transcoef_method, tol=tol, max_iter=max_iter, ) ray3d = _project_ray_to_3d(res.ray_path, sx, sy, dx, dy, epic) sensitivity = ( compute_ray_sensitivity( ma, src, rcv, source_phase, [seg], res.ray_parameter ) if need_sensitivities else None ) return ( res.travel_time, ray3d, res.ray_parameter, res.tstar, res.spreading, res.trans_product, res.complex_coefficient_product, res.diagnostics, sensitivity, ) if ordered_interactions: ray_segments, inter_meta = _compile_ordered_itinerary( ma, sz, rz, source_phase, ordered_interactions ) if not ray_segments: raise ValueError("The itinerary does not contain a finite-length path.") all_h = np.concatenate([segment["h"] for segment in ray_segments]) all_v = np.concatenate([segment["v"] for segment in ray_segments]) res = solve( h=all_h, v=all_v, segments=ray_segments, interactions=inter_meta, epicentral_dist=epic, z_src=sz, z_rcv=rz, return_ray_path=need_rays, need_ray_parameter=need_ray_parameters or need_sensitivities, need_tstar=need_tstar, need_spreading=need_spreading, need_trans_product=need_trans_product, need_complex_coefficient_product=need_complex_coefficient_product, need_diagnostics=need_diagnostics, transcoef_method=transcoef_method, tol=tol, max_iter=max_iter, ) ray3d = _project_ray_to_3d(res.ray_path, sx, sy, dx, dy, epic) sensitivity = ( compute_ray_sensitivity( ma, src, rcv, source_phase, ray_segments, res.ray_parameter ) if need_sensitivities else None ) return ( res.travel_time, ray3d, res.ray_parameter, res.tstar, res.spreading, res.trans_product, res.complex_coefficient_product, res.diagnostics, sensitivity, ) ray_segments = [] curr_z = sz curr_ph = source_phase directional_targets = [(z, ph) for z, ph in refl_list] itinerary_points = directional_targets + [(rz, None)] inter_meta = [] for target_z, target_ph_after_turn in itinerary_points: going_down = target_z >= curr_z relevant_refr = [] for r_z, r_ph in refr_list: if going_down: if curr_z < r_z < target_z: relevant_refr.append((r_z, r_ph)) else: if target_z < r_z < curr_z: relevant_refr.append((r_z, r_ph)) if going_down: relevant_refr.sort(key=lambda x: x[0]) else: relevant_refr.sort(key=lambda x: x[0], reverse=True) sub_targets = relevant_refr + [(target_z, target_ph_after_turn)] for sub_z, sub_out_phase in sub_targets: stack = build_layer_stack(ma, curr_z, sub_z) vel = stack.v(velocity_key(curr_ph)) valid_mask = stack.h > 1e-9 if np.any(valid_mask): ray_segments.append({ "h": stack.h[valid_mask], "v": vel[valid_mask], "vp": stack.vp[valid_mask], "vs": stack.vs[valid_mask], "rho": stack.rho[valid_mask] if stack.rho is not None else None, "qp": stack.qp[valid_mask] if stack.qp is not None else None, "qs": stack.qs[valid_mask] if stack.qs is not None else None, "phase": curr_ph, "start_z": curr_z, "end_z": sub_z, }) is_major_turn = (sub_z == target_z) and (target_ph_after_turn is not None or target_z == rz) def _get_material_props(z_int: float, is_down_interaction: bool) -> dict[str, float]: delta = 1.0 if is_down_interaction: p_stack = build_layer_stack(ma, z_int, z_int + delta) else: p_stack = build_layer_stack(ma, z_int - delta, z_int) return { "vp": float(p_stack.vp[0]), "vs": float(p_stack.vs[0]), "rho": float(p_stack.rho[0]) if p_stack.rho is not None else 0.0, } if is_major_turn: if target_ph_after_turn is not None: props_beyond = _get_material_props(sub_z, going_down) seg_idx = len(ray_segments) - 1 if seg_idx < 0: raise ValueError(f"Cannot reflect at the starting depth {sub_z} immediately.") inter_meta.append({ "type": "reflection", "depth": sub_z, "in_phase": curr_ph, "out_phase": target_ph_after_turn, "seg_idx": seg_idx, "vp_beyond": props_beyond["vp"], "vs_beyond": props_beyond["vs"], "rho_beyond": props_beyond["rho"], }) curr_ph = target_ph_after_turn else: props_beyond = _get_material_props(sub_z, going_down) seg_idx = len(ray_segments) - 1 if seg_idx < 0: raise ValueError(f"Cannot refract at the starting depth {sub_z} immediately.") inter_meta.append({ "type": "refraction", "depth": sub_z, "in_phase": curr_ph, "out_phase": sub_out_phase, "seg_idx": seg_idx, "vp_beyond": props_beyond["vp"], "vs_beyond": props_beyond["vs"], "rho_beyond": props_beyond["rho"], }) curr_ph = sub_out_phase curr_z = sub_z if len(ray_segments) == 0: ray3d = np.array([[sx, sy, sz], [rx, ry, rz]]) if need_rays else None is_same_point = epic < 1e-10 and abs(sz - rz) < 1e-10 return ( 0.0 if is_same_point else np.nan, ray3d, np.nan if need_ray_parameters else None, np.nan if need_tstar else None, np.nan if need_spreading else None, np.nan if need_trans_product else None, np.nan + 0.0j if need_complex_coefficient_product else None, None, compute_ray_sensitivity(ma, src, rcv, source_phase, [], np.nan) if need_sensitivities else None, ) all_h = np.concatenate([s["h"] for s in ray_segments]) all_v = np.concatenate([s["v"] for s in ray_segments]) res = solve( h=all_h, v=all_v, segments=ray_segments, interactions=inter_meta, epicentral_dist=epic, z_src=sz, z_rcv=rz, return_ray_path=need_rays, need_ray_parameter=need_ray_parameters or need_sensitivities, need_tstar=need_tstar, need_spreading=need_spreading, need_trans_product=need_trans_product, need_complex_coefficient_product=need_complex_coefficient_product, need_diagnostics=need_diagnostics, transcoef_method=transcoef_method, tol=tol, max_iter=max_iter, ) ray3d = _project_ray_to_3d(res.ray_path, sx, sy, dx, dy, epic) sensitivity = ( compute_ray_sensitivity( ma, src, rcv, source_phase, ray_segments, res.ray_parameter ) if need_sensitivities else None ) return ( res.travel_time, ray3d, res.ray_parameter, res.tstar, res.spreading, res.trans_product, res.complex_coefficient_product, res.diagnostics, sensitivity, ) def _unpack_results( results: list, requested: frozenset[str], source_phase: str | None = None, ) -> TraceResult: """Unpack a flat list of per-ray result tuples into a TraceResult.""" def _maybe_array(values): if all(v is None for v in values): return None return np.array([np.nan if v is None else v for v in values], dtype=float) def _value(result, index): return result[index] if len(result) > index else None tt = np.array([r[0] for r in results], dtype=float) rays = [r[1] for r in results] if "rays" in requested else None p_arr = _maybe_array([r[2] for r in results]) if "ray_parameters" in requested else None tstar = _maybe_array([r[3] for r in results]) if "tstar" in requested else None spreading = _maybe_array([r[4] for r in results]) if "spreading" in requested else None trans_product = _maybe_array([r[5] for r in results]) if "trans_product" in requested else None complex_product = ( np.array( [np.nan + 0.0j if _value(r, 6) is None else _value(r, 6) for r in results], dtype=np.complex128, ) if "complex_coefficient_product" in requested else None ) diagnostics = ( [_value(r, 7) for r in results] if "diagnostics" in requested else None ) sensitivities = ( [_value(r, 8) for r in results] if "sensitivities" in requested else None ) return TraceResult( travel_times=tt, rays=rays, ray_parameters=p_arr, tstar=tstar, spreading=spreading, trans_product=trans_product, source_phase=source_phase, complex_coefficient_product=complex_product, diagnostics=diagnostics, sensitivities=sensitivities, ) def _unpack_multi_results( results: list[dict[str, tuple]], requested: frozenset[str], source_phases: tuple[str, ...], ) -> dict[str, TraceResult]: """Unpack per-ray multi-phase dictionaries into per-phase TraceResult objects.""" return { phase: _unpack_results([ray_result[phase] for ray_result in results], requested, phase) for phase in source_phases }
[docs] def trace_rays( sources: np.ndarray, receivers: np.ndarray, velocity_df: pd.DataFrame, source_phase: str | Sequence[str] = "P", reflection: Sequence[tuple[float, str]] | None = None, refraction: Sequence[tuple[float, str]] | None = None, requested: Sequence[str] | None = DEFAULT_REQUESTED, transcoef_method: str = "standard", n_jobs: int = -1, backend: str = "loky", sequential_limit: int = 10_000, rays_per_chunk: int | None = None, tol: float = 1e-4, max_iter: int = 10, verbose: bool = True, itinerary: RayItinerary | None = None, ) -> TraceResult | dict[str, TraceResult]: r"""Trace rays for all source-receiver pairs. Every source is paired with every receiver, producing ``n_src x n_rcv`` rays (each source traced to all receivers). Parameters ---------- sources : numpy.ndarray Source coordinates, shape ``(n_src, 3)`` or ``(3,)``. receivers : numpy.ndarray Receiver coordinates, shape ``(n_rcv, 3)`` or ``(3,)``. velocity_df : pandas.DataFrame Velocity model with columns ``Depth``, ``Vp``, ``Vs`` and optionally ``Rho``, ``Qp``, ``Qs``. source_phase : str or sequence of str Initial wave phase(s) at source: ``'P'``, ``'SV'``, ``'SH'``, or legacy ``'S'`` (alias for ``'SV'``). If a sequence is provided, a dictionary of ``TraceResult`` objects keyed by canonical phase is returned. reflection : list of (depth, phase), optional Reflection points as ``(depth, out_phase)`` tuples. refraction : list of (depth, phase), optional Refraction / mode-conversion points as ``(depth, out_phase)`` tuples. requested : sequence of str, optional Explicit set of requested outputs. Valid names are ``travel_times``, ``rays``, ``ray_parameters``, ``tstar``, ``spreading``, ``trans_product``, ``complex_coefficient_product``, ``diagnostics``, and ``sensitivities``. The set must include ``travel_times``. Diagnostic and sensitivity records are allocated only when explicitly requested. transcoef_method : str ``'standard'`` (Zoeppritz) or ``'normalized'``. The legacy spelling ``'angle'`` is an alias for ``'standard'``. n_jobs : int Number of parallel jobs (``-1`` = all physical cores). backend : str Joblib parallel backend (default ``'loky'``). sequential_limit : int If the total number of rays is below this threshold, run sequentially to avoid parallel overhead. rays_per_chunk : int or None Maximum number of rays per task above ``sequential_limit``. It bounds the memory a task holds while it runs, not the size of the output. If *None*, it is chosen from the number of rays and workers and from the measured output size per ray (see Notes). tol : float Newton convergence tolerance (m). max_iter : int Maximum Newton iterations. verbose : bool If *True*, print the task plan, the estimated output size and the progress of tracing above ``sequential_limit``. itinerary : RayItinerary, optional Explicit ordered interaction sequence. It cannot be combined with legacy ``reflection`` or ``refraction`` arguments. The itinerary's source phase is authoritative; a non-default explicit ``source_phase`` must agree with it. Returns ------- TraceResult or dict of TraceResult Notes ----- Above ``sequential_limit`` the source-receiver matrix is split into tiles of at most ``rays_per_chunk`` rays. Each task writes its rays into NumPy columns, and the columns are copied into the preallocated output as soon as the task finishes. Memory use is therefore the output itself plus a few tiles per worker, independent of the number of rays. With the ``loky`` backend each worker gets one BLAS/OpenMP thread, because tracing makes no BLAS calls and OpenBLAS on Windows commits a buffer for every thread when NumPy is imported. The output size per ray is measured on a probe of up to 64 rays: exactly for array outputs and approximately for ray paths, diagnostics and sensitivities. With ``rays_per_chunk=None`` a tile holds at most 65,536 rays and 16 MiB of results, and each worker gets at least 16 tasks. A ``RuntimeWarning`` is issued if the estimated output exceeds the available memory. """ ordered_interactions: tuple[Interaction, ...] | None = None if itinerary is not None: if not isinstance(itinerary, RayItinerary): raise TypeError("itinerary must be a RayItinerary object.") if reflection is not None or refraction is not None: raise ValueError( "itinerary cannot be combined with reflection or refraction." ) if not isinstance(source_phase, str): raise ValueError("itinerary cannot be combined with multiple source phases.") supplied_phase = normalize_phase(source_phase) if supplied_phase != "P" and supplied_phase != itinerary.source_phase: raise ValueError("source_phase must agree with itinerary.source_phase.") source_phase = itinerary.source_phase ordered_interactions = tuple(itinerary.interactions) source_phases, return_dict = _normalize_source_phases(source_phase) requested_set = _normalize_requested(requested) need_rays = "rays" in requested_set need_ray_parameters = "ray_parameters" in requested_set need_tstar = "tstar" in requested_set need_spreading = "spreading" in requested_set need_trans_product = "trans_product" in requested_set need_complex_coefficient_product = "complex_coefficient_product" in requested_set need_diagnostics = "diagnostics" in requested_set need_sensitivities = "sensitivities" in requested_set sources = np.atleast_2d(sources) receivers = np.atleast_2d(receivers) n_src = sources.shape[0] n_rcv = receivers.shape[0] n_rays = n_src * n_rcv refl_list = _normalize_interaction_phases(reflection) refr_list = _normalize_interaction_phases(refraction) model_depths = velocity_df["Depth"].values tol_depth = 1e-6 def _validate_depths(interactions, name): for z, ph in interactions: if not np.any(np.abs(model_depths - z) < tol_depth): raise ValueError( f"Invalid {name} depth {z}. Must match a model interface: {model_depths}" ) if name == "reflection" and z < tol_depth: raise ValueError( "Reflection at the surface (z=0.0) is not currently supported " "for physical amplitude calculations. Please use a shallow " "internal interface instead." ) normalize_phase(ph) _validate_depths(refl_list, "reflection") _validate_depths(refr_list, "refraction") if ordered_interactions is not None: explicit_reflections = [ (interaction.depth, interaction.outgoing_phase) for interaction in ordered_interactions if interaction.kind == "reflect" ] explicit_transmissions = [ (interaction.depth, interaction.outgoing_phase) for interaction in ordered_interactions if interaction.kind == "transmit" ] _validate_depths(explicit_reflections, "reflection") _validate_depths(explicit_transmissions, "transmission") for phase in source_phases: explicit_phases = ( [(item.depth, item.outgoing_phase) for item in ordered_interactions] if ordered_interactions is not None else [] ) _validate_sh_coupling(phase, refl_list + refr_list + explicit_phases) refl_z = {z for z, _ in refl_list} refr_z = {z for z, _ in refr_list} common = refl_z.intersection(refr_z) if common: raise ValueError(f"Cannot strictly reflect and refract at the same depth(s): {common}") ma = ModelArrays.from_dataframe(velocity_df) common_kw = dict( ma=ma, source_phases=source_phases, return_dict=return_dict, refl_list=refl_list, refr_list=refr_list, ordered_interactions=ordered_interactions, need_rays=need_rays, need_ray_parameters=need_ray_parameters, need_tstar=need_tstar, need_spreading=need_spreading, need_trans_product=need_trans_product, need_complex_coefficient_product=need_complex_coefficient_product, need_diagnostics=need_diagnostics, need_sensitivities=need_sensitivities, transcoef_method=transcoef_method, tol=tol, max_iter=max_iter, ) if n_rays <= sequential_limit: if return_dict: results = [ _trace_one_many( src=sources[i], rcv=receivers[j], ma=ma, source_phases=source_phases, refl_list=refl_list, refr_list=refr_list, ordered_interactions=ordered_interactions, need_rays=need_rays, need_ray_parameters=need_ray_parameters, need_tstar=need_tstar, need_spreading=need_spreading, need_trans_product=need_trans_product, need_complex_coefficient_product=need_complex_coefficient_product, need_diagnostics=need_diagnostics, need_sensitivities=need_sensitivities, transcoef_method=transcoef_method, tol=tol, max_iter=max_iter, ) for i in range(n_src) for j in range(n_rcv) ] return _unpack_multi_results(results, requested_set, source_phases) results = [ _trace_one( src=sources[i], rcv=receivers[j], ma=ma, source_phase=source_phases[0], refl_list=refl_list, refr_list=refr_list, ordered_interactions=ordered_interactions, need_rays=need_rays, need_ray_parameters=need_ray_parameters, need_tstar=need_tstar, need_spreading=need_spreading, need_trans_product=need_trans_product, need_complex_coefficient_product=need_complex_coefficient_product, need_diagnostics=need_diagnostics, need_sensitivities=need_sensitivities, transcoef_method=transcoef_method, tol=tol, max_iter=max_iter, ) for i in range(n_src) for j in range(n_rcv) ] return _unpack_results(results, requested_set, source_phases[0]) if n_jobs == -1: n_workers = min(psutil.cpu_count(logical=False) or 4, n_rays) elif n_jobs < 0: n_workers = max(1, (psutil.cpu_count(logical=False) or 4) + n_jobs + 1) else: n_workers = n_jobs # Measure the output size per ray on a small probe: exact for arrays, # approximate for ray paths, diagnostics and sensitivities. probe_src = sources[np.unique(np.linspace(0, n_src - 1, min(n_src, 8)).astype(int))] probe_rcv = receivers[np.unique(np.linspace(0, n_rcv - 1, min(n_rcv, 8)).astype(int))] _, probe = _trace_batch(None, probe_src, probe_rcv, common_kw) bytes_per_ray = sum( _column_bytes(column) for columns in probe.values() for column in columns.values() ) / (len(probe_src) * len(probe_rcv)) if rays_per_chunk is None: rays_per_task = min( _MAX_RAYS_PER_TASK, -(-n_rays // (_TASKS_PER_WORKER * n_workers)), max(1, int(_MAX_BYTES_PER_TASK // bytes_per_ray)), ) else: rays_per_task = max(1, int(rays_per_chunk)) # Near-square tiles keep the source and receiver slices sent to workers small. src_per_tile = min(n_src, max(1, math.isqrt(rays_per_task))) rcv_per_tile = min(n_rcv, max(1, rays_per_task // src_per_tile)) src_per_tile = min(n_src, max(1, rays_per_task // rcv_per_tile)) tiles = [ (s0, min(s0 + src_per_tile, n_src), r0, min(r0 + rcv_per_tile, n_rcv)) for s0 in range(0, n_src, src_per_tile) for r0 in range(0, n_rcv, rcv_per_tile) ] output_bytes = n_rays * bytes_per_ray transient_bytes = (2 * n_workers + 1) * rays_per_task * bytes_per_ray available_mem = psutil.virtual_memory().available if output_bytes + transient_bytes > available_mem: warnings.warn( f"trace_rays: the requested outputs for {n_rays:,} rays need about " f"{_format_bytes(output_bytes)}, but only {_format_bytes(available_mem)} " "of memory is available; expect heavy swapping or a MemoryError. " "Request fewer outputs or trace the receivers in parts.", RuntimeWarning, stacklevel=2, ) if verbose: print( f"Tracing {n_rays:,} rays in {len(tiles):,} tasks of up to " f"{rays_per_task:,} rays (n_jobs={n_workers}; output " f"{_format_bytes(output_bytes)}, {_format_bytes(available_mem)} available)..." ) out = {phase: _empty_columns(n_rays, common_kw) for phase in source_phases} # Tracing makes no BLAS calls, so loky workers get one BLAS/OpenMP thread: # OpenBLAS on Windows commits a buffer per thread when NumPy is imported. worker_threads = {"inner_max_num_threads": 1} if backend == "loky" else {} total_start = time.time() n_done = 0 reported = 0 with parallel_config(backend=backend, **worker_threads): tile_results = Parallel( n_jobs=n_workers, return_as="generator_unordered", max_nbytes=None, )( delayed(_trace_batch)( tile, sources[tile[0]:tile[1]], receivers[tile[2]:tile[3]], common_kw ) for tile in tiles ) for tile, columns in tile_results: for phase, phase_columns in columns.items(): _scatter_tile(out[phase], phase_columns, tile, n_rcv) n_done += (tile[1] - tile[0]) * (tile[3] - tile[2]) if verbose and 10 * n_done // n_rays > reported: reported = 10 * n_done // n_rays elapsed = time.time() - total_start print( f" {10 * reported}% done ({_format_seconds(elapsed)}) - " f"ETA: {_format_seconds(elapsed * (n_rays - n_done) / n_done)}" ) if verbose: print(f"All tasks complete. Total time: {_format_seconds(time.time() - total_start)}") results = { phase: TraceResult( travel_times=arr["tt"], rays=arr["rays"], ray_parameters=arr["p"], tstar=arr["tstar"], spreading=arr["spread"], trans_product=arr["trans"], source_phase=phase, complex_coefficient_product=arr["complex"], diagnostics=arr["diagnostics"], sensitivities=arr["sensitivities"], ) for phase, arr in out.items() } return results if return_dict else results[source_phases[0]]