gragra 0.2.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. gragra/__init__.py +46 -0
  2. gragra/_analytic_evaluation.py +413 -0
  3. gragra/_arrays.py +194 -0
  4. gragra/_continuous_contract.py +427 -0
  5. gragra/_continuous_fourier.py +890 -0
  6. gragra/_continuous_rigid_motion.py +275 -0
  7. gragra/_evaluation_setup.py +680 -0
  8. gragra/_finite_window_fourier.py +627 -0
  9. gragra/_interactions.py +26 -0
  10. gragra/_periodic_fourier.py +522 -0
  11. gragra/_phasor_matrix.py +74 -0
  12. gragra/_plotting/__init__.py +1 -0
  13. gragra/_plotting/_appearance.py +38 -0
  14. gragra/_plotting/_contribution.py +363 -0
  15. gragra/_plotting/_diagnostics.py +662 -0
  16. gragra/_plotting/_dynamic_contribution.py +1281 -0
  17. gragra/_plotting/_field.py +704 -0
  18. gragra/_plotting/_formula_latex.py +59 -0
  19. gragra/_plotting/_hist.py +775 -0
  20. gragra/_plotting/_map.py +316 -0
  21. gragra/_plotting/_radial_histogram.py +1029 -0
  22. gragra/_plotting/_rayleigh_setup.py +567 -0
  23. gragra/_plotting/_result_field.py +670 -0
  24. gragra/_plotting/_setup.py +447 -0
  25. gragra/_plotting/_setup_shapes.py +308 -0
  26. gragra/_plotting/_source_contracts.py +287 -0
  27. gragra/_plotting/_source_data.py +478 -0
  28. gragra/_plotting/_source_field.py +1243 -0
  29. gragra/_plotting/_source_quantities.py +541 -0
  30. gragra/_rayleigh_contract.py +859 -0
  31. gragra/_rayleigh_evaluation_setup.py +214 -0
  32. gragra/_rayleigh_response_setup.py +151 -0
  33. gragra/_spectral_from_timeseries.py +136 -0
  34. gragra/_time_matrix.py +146 -0
  35. gragra/adapters/__init__.py +136 -0
  36. gragra/adapters/_gwexpy_series.py +210 -0
  37. gragra/adapters/field_series_hdf5.py +906 -0
  38. gragra/adapters/finite_body_mesh.py +153 -0
  39. gragra/adapters/gwexpy_adapter.py +243 -0
  40. gragra/adapters/gwexpy_density_series.py +69 -0
  41. gragra/adapters/gwexpy_displacement_series.py +35 -0
  42. gragra/adapters/gwexpy_observable.py +545 -0
  43. gragra/adapters/gwexpy_source.py +336 -0
  44. gragra/adapters/gwexpy_surface_density_series.py +42 -0
  45. gragra/adapters/hdf5_field.py +814 -0
  46. gragra/adapters/hex8_series.py +174 -0
  47. gragra/adapters/lebedev.py +59 -0
  48. gragra/adapters/mesh_quality.py +252 -0
  49. gragra/adapters/meshio_adapter.py +658 -0
  50. gragra/adapters/openfoam_density.py +531 -0
  51. gragra/adapters/specfem/__init__.py +55 -0
  52. gragra/adapters/specfem/__main__.py +8 -0
  53. gragra/adapters/specfem/_validation.py +501 -0
  54. gragra/adapters/specfem/cli.py +137 -0
  55. gragra/adapters/specfem/export.py +844 -0
  56. gragra/adapters/specfem/gll.py +569 -0
  57. gragra/adapters/specfem/provenance.py +939 -0
  58. gragra/adapters/specfem/reader.py +152 -0
  59. gragra/adapters/specfem/schema.py +1248 -0
  60. gragra/adapters/specfem/specfem_bin.py +970 -0
  61. gragra/adapters/specfem/streaming.py +412 -0
  62. gragra/adapters/specfem/writer.py +332 -0
  63. gragra/analytic/__init__.py +130 -0
  64. gragra/analytic/_bessel.py +376 -0
  65. gragra/analytic/_fullspace_bodywave.py +126 -0
  66. gragra/analytic/_u10_shell_series.py +240 -0
  67. gragra/analytic/_u9_spatial_derivatives.py +295 -0
  68. gragra/analytic/_validation.py +140 -0
  69. gragra/analytic/atmospheric/__init__.py +59 -0
  70. gragra/analytic/atmospheric/_acoustic.py +372 -0
  71. gragra/analytic/atmospheric/_cavity/__init__.py +1 -0
  72. gragra/analytic/atmospheric/_cavity/_cuboid.py +1674 -0
  73. gragra/analytic/atmospheric/_cavity/_cylinder.py +3236 -0
  74. gragra/analytic/atmospheric/_cavity/_model.py +760 -0
  75. gragra/analytic/atmospheric/_cavity/_numerics.py +1168 -0
  76. gragra/analytic/atmospheric/_cavity/_sphere_harmonics.py +177 -0
  77. gragra/analytic/atmospheric/_temperature.py +591 -0
  78. gragra/analytic/bodywave_literature.py +562 -0
  79. gragra/analytic/cavity_coupling.py +193 -0
  80. gragra/analytic/cavity_cuboid.py +488 -0
  81. gragra/analytic/cavity_cylinder.py +1345 -0
  82. gragra/analytic/cavity_sphere.py +848 -0
  83. gragra/analytic/cylinder_potential.py +215 -0
  84. gragra/analytic/cylinder_sca.py +47 -0
  85. gragra/analytic/halfspace.py +155 -0
  86. gragra/analytic/halfspace_psv.py +202 -0
  87. gragra/analytic/objects/__init__.py +44 -0
  88. gragra/analytic/objects/_cuboid_model.py +370 -0
  89. gragra/analytic/objects/_cylinder_model.py +287 -0
  90. gragra/analytic/objects/_gen1_motion.py +789 -0
  91. gragra/analytic/objects/_uniform_sphere.py +59 -0
  92. gragra/analytic/oriented.py +104 -0
  93. gragra/analytic/rayleigh_literature.py +367 -0
  94. gragra/analytic/sca_tensor.py +308 -0
  95. gragra/analytic/seismic/__init__.py +36 -0
  96. gragra/analytic/seismic/_gen1_point_force.py +422 -0
  97. gragra/analytic/seismic/_gen1_sca_wall.py +404 -0
  98. gragra/analytic/seismic/_halfspace_psv_model.py +629 -0
  99. gragra/analytic/seismic/_phase_speed.py +139 -0
  100. gragra/analytic/seismic/_rayleigh.py +73 -0
  101. gragra/analytic/seismic/_rayleigh_response.py +104 -0
  102. gragra/analytic/seismic/_spherical_cavity_model.py +572 -0
  103. gragra/analytic/seismic/_spherical_shell_model.py +563 -0
  104. gragra/analytic/spherical_cavity_series.py +343 -0
  105. gragra/analytic/stochastic_acoustic.py +375 -0
  106. gragra/analytic/stochastic_bodywave.py +201 -0
  107. gragra/analytic/stochastic_rayleigh.py +170 -0
  108. gragra/analytic/stochastic_temperature.py +221 -0
  109. gragra/analytic/u10_spherical_shell_general.py +308 -0
  110. gragra/analytic/u11_rayleigh_small_cavity.py +246 -0
  111. gragra/analytic/u9_sca_wall_observables.py +213 -0
  112. gragra/analytic/uniform_sphere.py +245 -0
  113. gragra/analytic/values.py +279 -0
  114. gragra/analytic/water.py +893 -0
  115. gragra/analytic/wave456_reference.py +1192 -0
  116. gragra/array_types.py +35 -0
  117. gragra/coherent/__init__.py +20 -0
  118. gragra/coherent/average.py +165 -0
  119. gragra/coherent/bulk_weights.py +156 -0
  120. gragra/coherent/cross.py +103 -0
  121. gragra/coherent/ensemble.py +110 -0
  122. gragra/coherent/weights.py +177 -0
  123. gragra/constants.py +5 -0
  124. gragra/evaluation.py +611 -0
  125. gragra/exceptions.py +184 -0
  126. gragra/fields/__init__.py +135 -0
  127. gragra/fields/_series.py +157 -0
  128. gragra/fields/_temperature.py +313 -0
  129. gragra/fields/background.py +52 -0
  130. gragra/fields/batch.py +285 -0
  131. gragra/fields/density_series.py +100 -0
  132. gragra/fields/dipole_diagnostics.py +288 -0
  133. gragra/fields/displacement_series.py +69 -0
  134. gragra/fields/evaluation.py +300 -0
  135. gragra/fields/finite_body.py +441 -0
  136. gragra/fields/finite_body_p1.py +595 -0
  137. gragra/fields/generic_integration.py +597 -0
  138. gragra/fields/hex8_geometry.py +198 -0
  139. gragra/fields/material_hex8.py +249 -0
  140. gragra/fields/regular_grid.py +364 -0
  141. gragra/fields/surface.py +247 -0
  142. gragra/fields/surface_density_series.py +83 -0
  143. gragra/fields/surface_orientation.py +354 -0
  144. gragra/fields/thermodynamic.py +115 -0
  145. gragra/fields/unstructured.py +396 -0
  146. gragra/geometry/__init__.py +62 -0
  147. gragra/geometry/_appearance.py +34 -0
  148. gragra/geometry/_cuboid.py +58 -0
  149. gragra/geometry/_cylinder.py +64 -0
  150. gragra/geometry/_frame.py +242 -0
  151. gragra/geometry/_grid.py +202 -0
  152. gragra/geometry/_halfspace.py +20 -0
  153. gragra/geometry/_sphere.py +59 -0
  154. gragra/geometry/_spherical_shell.py +64 -0
  155. gragra/geometry/primitives.py +981 -0
  156. gragra/geometry/surface_mesh.py +467 -0
  157. gragra/geometry/tetra_quadrature.py +155 -0
  158. gragra/gravity_sources/__init__.py +74 -0
  159. gragra/gravity_sources/_discretize.py +77 -0
  160. gragra/gravity_sources/_force_history.py +411 -0
  161. gragra/gravity_sources/_models.py +165 -0
  162. gragra/gravity_sources/_rigid_motion_model.py +505 -0
  163. gragra/gravity_sources/surface.py +158 -0
  164. gragra/gravity_sources/trajectory.py +313 -0
  165. gragra/gravity_sources/volume.py +107 -0
  166. gragra/gravity_sources/weighted_points.py +127 -0
  167. gragra/integrators/__init__.py +22 -0
  168. gragra/integrators/generic.py +1343 -0
  169. gragra/integrators/point_direct.py +393 -0
  170. gragra/integrators/rigid_motion.py +981 -0
  171. gragra/integrators/streaming.py +684 -0
  172. gragra/integrators/trajectory_direct.py +191 -0
  173. gragra/interactions/__init__.py +5 -0
  174. gragra/kernels/__init__.py +23 -0
  175. gragra/kernels/_collision.py +20 -0
  176. gragra/kernels/_cpp_inverse_square.py +184 -0
  177. gragra/kernels/_cpp_parametric.py +140 -0
  178. gragra/kernels/_cpp_planewave.py +55 -0
  179. gragra/kernels/_cpp_required.py +38 -0
  180. gragra/kernels/_cupy_inverse_square.py +263 -0
  181. gragra/kernels/_cupy_parametric.py +101 -0
  182. gragra/kernels/_cupy_planewave.py +127 -0
  183. gragra/kernels/_numba_inverse_square.py +796 -0
  184. gragra/kernels/_numba_parametric.py +366 -0
  185. gragra/kernels/_numba_planewave.py +57 -0
  186. gragra/kernels/_source_terms.py +137 -0
  187. gragra/kernels/generic_backend.py +1021 -0
  188. gragra/kernels/inverse_square.py +679 -0
  189. gragra/kernels/parametric.py +358 -0
  190. gragra/observables/__init__.py +171 -0
  191. gragra/observables/_source_terms.py +64 -0
  192. gragra/observables/acceleration.py +131 -0
  193. gragra/observables/analytic.py +818 -0
  194. gragra/observables/bodywave_literature.py +97 -0
  195. gragra/observables/coherent.py +274 -0
  196. gragra/observables/dipole.py +145 -0
  197. gragra/observables/gradient.py +60 -0
  198. gragra/observables/gravitoelastic.py +196 -0
  199. gragra/observables/halfspace_psv.py +39 -0
  200. gragra/observables/potential.py +57 -0
  201. gragra/observables/projection.py +102 -0
  202. gragra/observables/rayleigh_literature.py +50 -0
  203. gragra/observables/rigid_motion.py +253 -0
  204. gragra/observables/series.py +195 -0
  205. gragra/observables/stochastic_acoustic.py +91 -0
  206. gragra/observables/stochastic_bodywave.py +71 -0
  207. gragra/observables/stochastic_rayleigh.py +52 -0
  208. gragra/observables/stochastic_temperature.py +50 -0
  209. gragra/observables/streaming.py +115 -0
  210. gragra/observables/trajectory.py +190 -0
  211. gragra/observables/u10_spherical_shell_general.py +28 -0
  212. gragra/observables/u11_rayleigh_small_cavity.py +21 -0
  213. gragra/observables/u9_sca_wall_observables.py +29 -0
  214. gragra/observables/water.py +458 -0
  215. gragra/observables/wave456_reference.py +86 -0
  216. gragra/plotting/__init__.py +119 -0
  217. gragra/py.typed +1 -0
  218. gragra/reference/__init__.py +23 -0
  219. gragra/reference/_cavern.py +130 -0
  220. gragra/reference/_context.py +69 -0
  221. gragra/reference/_types.py +49 -0
  222. gragra/research/__init__.py +1 -0
  223. gragra/research/rayleigh_timefit.py +55 -0
  224. gragra/results/__init__.py +150 -0
  225. gragra/results/_binding.py +70 -0
  226. gragra/results/_comparison.py +655 -0
  227. gragra/results/_context.py +301 -0
  228. gragra/results/_coords.py +92 -0
  229. gragra/results/_evaluation.py +574 -0
  230. gragra/results/_field_definition.py +213 -0
  231. gragra/results/_gen1_definitions.py +142 -0
  232. gragra/results/_protocols.py +45 -0
  233. gragra/results/_record.py +65 -0
  234. gragra/results/_representation.py +18 -0
  235. gragra/results/_response.py +589 -0
  236. gragra/results/_spectral.py +163 -0
  237. gragra/results/_time.py +64 -0
  238. gragra/results/_time_grid.py +149 -0
  239. gragra/targets/__init__.py +19 -0
  240. gragra/targets/_high_points.py +71 -0
  241. gragra/targets/points.py +45 -0
  242. gragra/waves/__init__.py +61 -0
  243. gragra/waves/directions.py +115 -0
  244. gragra/waves/displacement.py +112 -0
  245. gragra/waves/halfspace.py +53 -0
  246. gragra/waves/phase.py +154 -0
  247. gragra/waves/plane.py +331 -0
  248. gragra/waves/polarization.py +198 -0
  249. gragra/waves/rayleigh.py +352 -0
  250. gragra/waves/stochastic.py +345 -0
  251. gragra-0.2.0rc1.dist-info/METADATA +98 -0
  252. gragra-0.2.0rc1.dist-info/RECORD +254 -0
  253. gragra-0.2.0rc1.dist-info/WHEEL +5 -0
  254. gragra-0.2.0rc1.dist-info/licenses/LICENSE +21 -0
gragra/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """gragra: A Python package for gravitational perturbation calculation."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from gragra.constants import G_SI
6
+ from gragra.gravity_sources import PointMass, PointMassCloud, WeightedPointSource
7
+ from gragra.observables import (
8
+ coherent_acceleration_csd,
9
+ coherent_gravitoelastic_tensor,
10
+ coherent_point_acceleration,
11
+ coherent_point_gravity_gradient,
12
+ displacement_field_acceleration,
13
+ displacement_transfer_acceleration,
14
+ point_acceleration,
15
+ point_gravity_gradient,
16
+ point_potential,
17
+ )
18
+
19
+ __all__ = [
20
+ "G_SI",
21
+ "WeightedPointSource",
22
+ "PointMass",
23
+ "PointMassCloud",
24
+ "TargetPoints",
25
+ "point_potential",
26
+ "point_acceleration",
27
+ "point_gravity_gradient",
28
+ "coherent_point_acceleration",
29
+ "coherent_point_gravity_gradient",
30
+ "coherent_gravitoelastic_tensor",
31
+ "coherent_acceleration_csd",
32
+ "displacement_transfer_acceleration",
33
+ "displacement_field_acceleration",
34
+ ]
35
+
36
+ if TYPE_CHECKING:
37
+ from gragra.targets._high_points import TargetPoints
38
+
39
+
40
+ def __getattr__(name: str):
41
+ """Load high-level target types only when callers request them."""
42
+ if name == "TargetPoints":
43
+ from gragra.targets._high_points import TargetPoints
44
+
45
+ return TargetPoints
46
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,413 @@
1
+ """Shared evaluation bridge for analytic model families."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from importlib.metadata import version
8
+ from platform import python_version
9
+ from types import MappingProxyType
10
+ from typing import TypeAlias
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ from astropy import units as u
15
+ from astropy.table import QTable
16
+ from gwexpy.frequencyseries import FrequencySeriesMatrix
17
+
18
+ from gragra._interactions import Newtonian
19
+ from gragra._time_matrix import make_time_matrix
20
+ from gragra.constants import G_SI
21
+ from gragra.exceptions import ConvergenceError, NumericalDiagnostic
22
+ from gragra.geometry._frame import Frame
23
+ from gragra.results import GravityResult
24
+ from gragra.results._context import EvaluationContext
25
+ from gragra.results._evaluation import EvaluationResult
26
+ from gragra.results._record import OBSERVABLES, EvaluationRecord
27
+ from gragra.results._representation import (
28
+ StaticRepresentation,
29
+ TimeSeriesRepresentation,
30
+ )
31
+ from gragra.results._spectral import PhasorRepresentation
32
+
33
+ NativeValues: TypeAlias = Mapping[str, object]
34
+
35
+ _UNITS = {
36
+ "potential": u.m**2 / u.s**2,
37
+ "acceleration": u.m / u.s**2,
38
+ "gradient": u.s**-2,
39
+ }
40
+ _COMPONENTS = {
41
+ "potential": ("potential",),
42
+ "acceleration": ("x", "y", "z"),
43
+ "gradient": ("xx", "xy", "xz", "yx", "yy", "yz", "zx", "zy", "zz"),
44
+ }
45
+
46
+
47
+ @dataclass(frozen=True, slots=True, init=False)
48
+ class NativeEvaluation:
49
+ """G-free family output before common frame transforms and SI packaging."""
50
+
51
+ values: NativeValues
52
+ diagnostics: tuple[NumericalDiagnostic, ...]
53
+
54
+ def __init__(
55
+ self,
56
+ values: Mapping[str, object],
57
+ diagnostics: tuple[NumericalDiagnostic, ...] = (),
58
+ ) -> None:
59
+ if not isinstance(values, Mapping):
60
+ raise TypeError("native values must be an observable mapping")
61
+ snapshot = dict(values)
62
+ if any(
63
+ not isinstance(name, str) or name not in OBSERVABLES for name in snapshot
64
+ ):
65
+ raise ValueError("native values contain an unknown observable")
66
+ if isinstance(diagnostics, (str, bytes)):
67
+ raise TypeError("diagnostics must be an iterable of NumericalDiagnostic")
68
+ try:
69
+ diagnostic_snapshot = tuple(diagnostics)
70
+ except TypeError as exc:
71
+ raise TypeError(
72
+ "diagnostics must be an iterable of NumericalDiagnostic"
73
+ ) from exc
74
+ if any(
75
+ not isinstance(item, NumericalDiagnostic) for item in diagnostic_snapshot
76
+ ):
77
+ raise TypeError("diagnostics must contain NumericalDiagnostic values")
78
+ object.__setattr__(self, "values", MappingProxyType(snapshot))
79
+ object.__setattr__(self, "diagnostics", diagnostic_snapshot)
80
+
81
+
82
+ NativeCallback: TypeAlias = Callable[[np.ndarray, tuple[str, ...]], NativeEvaluation]
83
+
84
+
85
+ @dataclass(frozen=True, eq=False, slots=True, init=False)
86
+ class AnalyticEvaluation:
87
+ """Shared static, phasor, and time-series evaluation adapter.
88
+
89
+ Family callbacks return G-free arrays. This adapter owns validation of the
90
+ shared context, component transforms, one application of ``G_SI``, and
91
+ native result construction. Family-specific physics and target rejection
92
+ stay in the model callback and optional validator.
93
+ """
94
+
95
+ context: EvaluationContext
96
+ computation_frame: Frame
97
+ _target_positions_bytes: bytes
98
+ _target_count: int
99
+ evaluate_native: NativeCallback
100
+ validate_targets: Callable[[np.ndarray], object] | None
101
+
102
+ def __init__(
103
+ self,
104
+ *,
105
+ context: EvaluationContext,
106
+ computation_frame: Frame,
107
+ target_positions_m: object,
108
+ evaluate_native: NativeCallback,
109
+ validate_targets: Callable[[np.ndarray], object] | None = None,
110
+ ) -> None:
111
+ if not isinstance(context, EvaluationContext):
112
+ raise TypeError("context must be an EvaluationContext")
113
+ if not isinstance(getattr(context.model_spec, "interaction", None), Newtonian):
114
+ raise TypeError("analytic evaluation requires a Newtonian interaction")
115
+ if not isinstance(computation_frame, Frame):
116
+ raise TypeError("computation_frame must be a Frame")
117
+ if not callable(evaluate_native):
118
+ raise TypeError("evaluate_native must be callable")
119
+ if validate_targets is not None and not callable(validate_targets):
120
+ raise TypeError("validate_targets must be callable or None")
121
+ context.targets.frame._path_to(computation_frame)
122
+ computation_frame._path_to(context.frame)
123
+
124
+ raw = np.asarray(target_positions_m)
125
+ if np.iscomplexobj(raw):
126
+ raise ValueError("target_positions_m must contain only real coordinates")
127
+ if np.issubdtype(raw.dtype, np.bool_):
128
+ raise TypeError("target_positions_m must not contain bool values")
129
+ try:
130
+ positions = np.asarray(raw, dtype=np.float64)
131
+ except (TypeError, ValueError, OverflowError) as exc:
132
+ raise TypeError("target_positions_m must contain real coordinates") from exc
133
+ expected_shape = (len(context.targets), 3)
134
+ if positions.shape != expected_shape:
135
+ raise ValueError(
136
+ "target_positions_m must have shape "
137
+ f"{expected_shape}, got {positions.shape}"
138
+ )
139
+ if not np.isfinite(positions).all():
140
+ raise ValueError("target_positions_m must contain only finite coordinates")
141
+
142
+ object.__setattr__(self, "context", context)
143
+ object.__setattr__(self, "computation_frame", computation_frame)
144
+ object.__setattr__(self, "_target_positions_bytes", positions.copy().tobytes())
145
+ object.__setattr__(self, "_target_count", len(positions))
146
+ object.__setattr__(self, "evaluate_native", evaluate_native)
147
+ object.__setattr__(self, "validate_targets", validate_targets)
148
+
149
+ @property
150
+ def target_positions_m(self) -> np.ndarray:
151
+ """Read-only positions in the numerical kernel's computation frame."""
152
+ return np.frombuffer(self._target_positions_bytes, dtype=np.float64).reshape(
153
+ self._target_count, 3
154
+ )
155
+
156
+ @property
157
+ def model_spec(self):
158
+ return self.context.model_spec
159
+
160
+ @property
161
+ def parameters(self) -> QTable:
162
+ value = getattr(self.model_spec, "parameters", None)
163
+ return QTable() if value is None else value
164
+
165
+ @property
166
+ def formulas(self) -> pd.DataFrame:
167
+ value = getattr(self.model_spec, "formulas", None)
168
+ return pd.DataFrame() if value is None else value
169
+
170
+ @property
171
+ def references(self) -> pd.DataFrame:
172
+ value = getattr(self.model_spec, "references", None)
173
+ return pd.DataFrame() if value is None else value
174
+
175
+ def validate(self) -> None:
176
+ """Recheck shared interpretation and then run family target checks."""
177
+ self.context.validate()
178
+ if not isinstance(
179
+ getattr(self.context.model_spec, "interaction", None), Newtonian
180
+ ):
181
+ raise TypeError("analytic evaluation requires a Newtonian interaction")
182
+ positions = self.target_positions_m
183
+ if (
184
+ positions.shape != (len(self.context.targets), 3)
185
+ or not np.isfinite(positions).all()
186
+ ):
187
+ raise ValueError("analytic evaluation target coordinates are invalid")
188
+ self.context.targets.frame._path_to(self.computation_frame)
189
+ self.computation_frame._path_to(self.context.frame)
190
+ if self.validate_targets is not None:
191
+ self.validate_targets(positions)
192
+
193
+ def run(self) -> EvaluationResult:
194
+ """Evaluate once and return all requested observables or raise."""
195
+ self.validate()
196
+ try:
197
+ native = self.evaluate_native(
198
+ self.target_positions_m, self.context.observables
199
+ )
200
+ except Exception as cause:
201
+ raise self._evaluation_error(cause) from cause
202
+ if not isinstance(native, NativeEvaluation):
203
+ raise TypeError("evaluate_native must return NativeEvaluation")
204
+ self._validate_diagnostics(native.diagnostics)
205
+ if any(
206
+ diagnostic.termination in {"resource_limit", "uncertainty_unresolved"}
207
+ for diagnostic in native.diagnostics
208
+ ):
209
+ raise ValueError(
210
+ "successful evaluations cannot contain failed numerical diagnostics"
211
+ )
212
+ arrays = self._validate_native_values(native.values)
213
+ arrays = self._rotate_and_apply_g(arrays)
214
+ values = self._package_values(arrays)
215
+ return EvaluationResult(
216
+ values=GravityResult(**values),
217
+ evaluation=self,
218
+ record=self._record(native.diagnostics),
219
+ )
220
+
221
+ def _evaluation_error(self, cause: Exception):
222
+ from gragra.evaluation import EvaluationError
223
+
224
+ target_index = 0 if self._target_count else None
225
+ observable = self.context.observables[0]
226
+ if isinstance(cause, ConvergenceError):
227
+ try:
228
+ self._validate_diagnostics(cause.diagnostics)
229
+ except (TypeError, ValueError):
230
+ pass
231
+ else:
232
+ failure = next(
233
+ diagnostic
234
+ for diagnostic in cause.diagnostics
235
+ if diagnostic.termination
236
+ in {"resource_limit", "uncertainty_unresolved"}
237
+ )
238
+ target_index = failure.target_index
239
+ observable = failure.observable
240
+ target_id = (
241
+ None if target_index is None else self.context.targets.ids[target_index]
242
+ )
243
+ return EvaluationError(
244
+ target_id=target_id,
245
+ source_index=None,
246
+ observables=self.context.observables,
247
+ observable=observable,
248
+ cause=cause,
249
+ )
250
+
251
+ def _validate_diagnostics(
252
+ self, diagnostics: tuple[NumericalDiagnostic, ...]
253
+ ) -> None:
254
+ for diagnostic in diagnostics:
255
+ index = diagnostic.target_index
256
+ if index >= len(self.context.targets):
257
+ raise ValueError("diagnostic target_index is outside context targets")
258
+ expected_id = self.context.targets.ids[index]
259
+ if diagnostic.target_id != expected_id:
260
+ raise ValueError(
261
+ "diagnostic target_id must match context target ID at target_index"
262
+ )
263
+
264
+ def _validate_native_values(
265
+ self, values: Mapping[str, object]
266
+ ) -> dict[str, np.ndarray]:
267
+ names = self.context.observables
268
+ if set(values) != set(names):
269
+ raise ValueError("native values must match requested observables exactly")
270
+ representation = self.context.representation
271
+ target_count = len(self.context.targets)
272
+ if isinstance(representation, StaticRepresentation):
273
+ axis_count = None
274
+ elif isinstance(representation, PhasorRepresentation):
275
+ axis_count = len(self.context.frequencies_hz or ())
276
+ elif isinstance(representation, TimeSeriesRepresentation):
277
+ sampling = self.context.time_sampling
278
+ if sampling is None:
279
+ raise ValueError("time_series context requires TimeSampling")
280
+ axis_count = sampling.count
281
+ else:
282
+ raise TypeError("analytic representation is unsupported")
283
+
284
+ arrays: dict[str, np.ndarray] = {}
285
+ for name in names:
286
+ raw = np.asarray(values[name])
287
+ if not np.issubdtype(raw.dtype, np.number):
288
+ raise TypeError(f"native {name} values must be numeric")
289
+ if isinstance(representation, StaticRepresentation):
290
+ expected_shape = {
291
+ "potential": (target_count,),
292
+ "acceleration": (target_count, 3),
293
+ "gradient": (target_count, 3, 3),
294
+ }[name]
295
+ complex_required = False
296
+ else:
297
+ expected_shape = (
298
+ target_count,
299
+ len(_COMPONENTS[name]),
300
+ axis_count,
301
+ )
302
+ complex_required = isinstance(representation, PhasorRepresentation)
303
+ if raw.shape != expected_shape:
304
+ raise ValueError(
305
+ f"native {name} values must have shape {expected_shape}, "
306
+ f"got {raw.shape}"
307
+ )
308
+ if complex_required and not np.iscomplexobj(raw):
309
+ raise ValueError(f"native phasor {name} values must be complex")
310
+ if not complex_required and np.iscomplexobj(raw):
311
+ raise ValueError(f"native {name} values must be real")
312
+ dtype = np.complex128 if complex_required else np.float64
313
+ try:
314
+ array = np.asarray(raw, dtype=dtype)
315
+ except (TypeError, ValueError, OverflowError) as exc:
316
+ raise ValueError(f"native {name} values cannot be represented") from exc
317
+ if not np.isfinite(array).all():
318
+ raise ValueError(f"native {name} values must be finite")
319
+ arrays[name] = array
320
+ return arrays
321
+
322
+ def _rotate_and_apply_g(
323
+ self, arrays: Mapping[str, np.ndarray]
324
+ ) -> dict[str, np.ndarray]:
325
+ rotation = self.computation_frame.rotation_to(self.context.frame)
326
+ representation = self.context.representation
327
+ result: dict[str, np.ndarray] = {}
328
+ for name, values in arrays.items():
329
+ with np.errstate(over="ignore", invalid="ignore"):
330
+ if name == "potential":
331
+ output = G_SI * values
332
+ elif isinstance(representation, StaticRepresentation):
333
+ if name == "acceleration":
334
+ output = G_SI * np.einsum("ij,nj->ni", rotation, values)
335
+ else:
336
+ output = G_SI * np.einsum(
337
+ "ia,nab,jb->nij", rotation, values, rotation
338
+ )
339
+ elif name == "acceleration":
340
+ output = G_SI * np.einsum("ij,njt->nit", rotation, values)
341
+ else:
342
+ count, _, axis_count = values.shape
343
+ tensors = values.reshape(count, 3, 3, axis_count)
344
+ output = G_SI * np.einsum(
345
+ "ia,nabt,jb->nijt", rotation, tensors, rotation
346
+ ).reshape(count, 9, axis_count)
347
+ if not np.isfinite(output).all():
348
+ raise ValueError(f"SI {name} values became non-finite")
349
+ result[name] = output
350
+ return result
351
+
352
+ def _package_values(self, arrays: Mapping[str, np.ndarray]) -> dict[str, object]:
353
+ representation = self.context.representation
354
+ if isinstance(representation, StaticRepresentation):
355
+ return {
356
+ name: arrays[name] * _UNITS[name] for name in self.context.observables
357
+ }
358
+ if isinstance(representation, PhasorRepresentation):
359
+ matrices = {}
360
+ frequencies = (
361
+ np.asarray(self.context.frequencies_hz, dtype=np.float64) * u.Hz
362
+ )
363
+ target_ids = self.context.targets.ids
364
+ for name in self.context.observables:
365
+ raw = arrays[name]
366
+ components = _COMPONENTS[name]
367
+ matrices[name] = FrequencySeriesMatrix(
368
+ raw.copy(),
369
+ frequencies=frequencies,
370
+ units=np.full(
371
+ (len(target_ids), len(components)), _UNITS[name], dtype=object
372
+ ),
373
+ rows=list(target_ids),
374
+ cols=list(components),
375
+ name=f"Gen1 {name}",
376
+ )
377
+ return matrices
378
+ if isinstance(representation, TimeSeriesRepresentation):
379
+ sampling = self.context.time_sampling
380
+ if sampling is None:
381
+ raise ValueError("time_series context requires TimeSampling")
382
+ return {
383
+ name: make_time_matrix(
384
+ arrays[name],
385
+ sampling=sampling,
386
+ unit=_UNITS[name],
387
+ rows=self.context.targets.ids,
388
+ cols=_COMPONENTS[name],
389
+ name=f"Gen1 {name}",
390
+ )
391
+ for name in self.context.observables
392
+ }
393
+ raise TypeError("analytic representation is unsupported")
394
+
395
+ @staticmethod
396
+ def _record(diagnostics: tuple[NumericalDiagnostic, ...]) -> EvaluationRecord:
397
+ return EvaluationRecord(
398
+ backend="gen1_analytic_native",
399
+ versions={
400
+ "gragra": version("gragra"),
401
+ "python": python_version(),
402
+ "numpy": np.__version__,
403
+ "scipy": version("scipy"),
404
+ "astropy": version("astropy"),
405
+ "gwexpy": version("gwexpy"),
406
+ },
407
+ gravitational_constant_si=G_SI,
408
+ g_applications=1,
409
+ numerical_diagnostics=diagnostics,
410
+ )
411
+
412
+
413
+ __all__ = ["AnalyticEvaluation", "NativeEvaluation"]
gragra/_arrays.py ADDED
@@ -0,0 +1,194 @@
1
+ """Internal array normalization and validation helpers."""
2
+
3
+ import numpy as np
4
+
5
+ from gragra.array_types import ComplexArray, FloatArray
6
+
7
+
8
+ def as_xyz_vector(name: str, value: list | tuple | np.ndarray) -> FloatArray:
9
+ """Normalize and validate a single 3D vector.
10
+
11
+ Expects a value convertable to a float64 array of shape (3,).
12
+ Sets the writeable flag of the returned array to False (read-only).
13
+
14
+ Parameters
15
+ ----------
16
+ name : str
17
+ Parameter name for error messages.
18
+ value : array-like
19
+ The input coordinates, e.g., [x, y, z].
20
+
21
+ Returns
22
+ -------
23
+ FloatArray
24
+ A read-only float64 array of shape (3,).
25
+ """
26
+ try:
27
+ arr = np.asarray(value, dtype=np.float64)
28
+ except (ValueError, TypeError) as e:
29
+ raise TypeError(f"{name} must be numeric convertible to float64: {e}") from e
30
+
31
+ if arr.shape != (3,):
32
+ raise ValueError(f"{name} must have shape (3,), got {arr.shape}")
33
+
34
+ if not np.isfinite(arr).all():
35
+ raise ValueError(f"{name} must contain only finite numbers")
36
+
37
+ # Defensive copy and set to read-only
38
+ arr = arr.copy()
39
+ arr.setflags(write=False)
40
+ return arr
41
+
42
+
43
+ def as_xyz_table(name: str, value: list | tuple | np.ndarray) -> FloatArray:
44
+ """Normalize and validate a table of 3D vectors.
45
+
46
+ Expects a value convertable to a float64 array of shape (N, 3).
47
+ Strictly rejects shape (3,).
48
+ Sets the writeable flag of the returned array to False (read-only).
49
+
50
+ Parameters
51
+ ----------
52
+ name : str
53
+ Parameter name for error messages.
54
+ value : array-like
55
+ The input coordinates table, e.g., [[x1, y1, z1], [x2, y2, z2], ...].
56
+
57
+ Returns
58
+ -------
59
+ FloatArray
60
+ A read-only float64 array of shape (N, 3).
61
+ """
62
+ is_1d = False
63
+ try:
64
+ val_arr = np.asarray(value)
65
+ if val_arr.ndim == 1:
66
+ is_1d = True
67
+ except (ValueError, TypeError):
68
+ pass
69
+
70
+ if is_1d:
71
+ raise ValueError(f"{name} must be a 2D table of shape (N, 3), not a 1D vector")
72
+
73
+ try:
74
+ arr = np.asarray(value, dtype=np.float64)
75
+ except (ValueError, TypeError) as e:
76
+ raise TypeError(f"{name} must be numeric convertible to float64: {e}") from e
77
+
78
+ if arr.ndim != 2 or arr.shape[1] != 3:
79
+ raise ValueError(f"{name} must have shape (N, 3), got {arr.shape}")
80
+
81
+ if not np.isfinite(arr).all():
82
+ raise ValueError(f"{name} must contain only finite numbers")
83
+
84
+ arr = arr.copy()
85
+ arr.setflags(write=False)
86
+ return arr
87
+
88
+
89
+ def as_weights(
90
+ name: str, value: list | tuple | np.ndarray
91
+ ) -> FloatArray | ComplexArray:
92
+ """Normalize and validate weight vectors (real or complex).
93
+
94
+ Expects a value convertable to float64 or complex128 array of shape (N,).
95
+ Keeps real inputs as float64 and complex inputs as complex128.
96
+ Sets the writeable flag of the returned array to False (read-only).
97
+
98
+ Parameters
99
+ ----------
100
+ name : str
101
+ Parameter name for error messages.
102
+ value : array-like
103
+ The weights array, e.g. [w1, w2, ...].
104
+
105
+ Returns
106
+ -------
107
+ FloatArray or ComplexArray
108
+ A read-only array of shape (N,).
109
+ """
110
+ raw_arr = np.asarray(value)
111
+
112
+ # Determine appropriate dtype (preserve complex if input is complex)
113
+ dtype = np.complex128 if np.iscomplexobj(raw_arr) else np.float64
114
+
115
+ try:
116
+ arr = np.asarray(value, dtype=dtype)
117
+ except (ValueError, TypeError) as e:
118
+ raise TypeError(
119
+ f"{name} must be convertible to float64 or complex128: {e}"
120
+ ) from e
121
+
122
+ if arr.ndim != 1:
123
+ raise ValueError(
124
+ f"{name} must be a 1D array of shape (N,), got shape {arr.shape}"
125
+ )
126
+
127
+ if not np.isfinite(arr).all():
128
+ raise ValueError(f"{name} must contain only finite numbers")
129
+
130
+ arr = arr.copy()
131
+ arr.setflags(write=False)
132
+ return arr
133
+
134
+
135
+ def as_index_table(
136
+ name: str, value: list | tuple | np.ndarray, n_max: int
137
+ ) -> np.ndarray:
138
+ """Normalize and validate an integer connectivity table of shape (Nt, 3).
139
+
140
+ Expects integer vertex indices in the half-open range ``[0, n_max)``.
141
+ Returns a read-only ``np.intp`` array of shape (Nt, 3), independent of the
142
+ input integer dtype, so callers obtain a canonical connectivity table.
143
+
144
+ This consolidates the integer-connectivity validation that is otherwise
145
+ duplicated across the field/adapter helpers; it rejects bool, non-integer
146
+ dtype, wrong shape, and both lower (``< 0``) and upper (``>= n_max``)
147
+ out-of-range indices.
148
+
149
+ Parameters
150
+ ----------
151
+ name : str
152
+ Parameter name for error messages.
153
+ value : array-like
154
+ The connectivity table, e.g. [[i0, i1, i2], ...].
155
+ n_max : int
156
+ Exclusive upper bound for indices (typically the number of vertices).
157
+
158
+ Returns
159
+ -------
160
+ np.ndarray
161
+ A read-only ``np.intp`` array of shape (Nt, 3).
162
+ """
163
+ _check_no_bool(value, name)
164
+
165
+ raw = np.asarray(value)
166
+ if raw.dtype.kind not in "iu":
167
+ raise TypeError(f"{name} must be of integer type, got {raw.dtype}")
168
+
169
+ if raw.ndim != 2 or raw.shape[1] != 3:
170
+ raise ValueError(f"{name} must have shape (Nt, 3), got {raw.shape}")
171
+
172
+ arr = raw.astype(np.intp, copy=True)
173
+ if arr.size > 0 and ((arr < 0).any() or (arr >= n_max).any()):
174
+ raise ValueError(f"{name} indices must be within [0, {n_max})")
175
+
176
+ arr.setflags(write=False)
177
+ return arr
178
+
179
+
180
+ def _check_no_bool(val, name):
181
+ """Helper to raise TypeError if any boolean value is found in inputs."""
182
+ if isinstance(val, (bool, np.bool_)):
183
+ raise TypeError(f"{name} must not contain bool")
184
+ if hasattr(val, "dtype"):
185
+ if val.dtype.kind == "b":
186
+ raise TypeError(f"{name} must not contain bool")
187
+ if val.dtype.kind in "fiuc":
188
+ return
189
+ if isinstance(val, np.ndarray):
190
+ for x in val.flat:
191
+ _check_no_bool(x, name)
192
+ elif isinstance(val, (list, tuple)):
193
+ for x in val:
194
+ _check_no_bool(x, name)