specmod 0.2.0__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 (62) hide show
  1. specmod/__init__.py +17 -0
  2. specmod/_vendor/__init__.py +21 -0
  3. specmod/_vendor/qiinv.py +243 -0
  4. specmod/acquire.py +358 -0
  5. specmod/api.py +480 -0
  6. specmod/cli.py +139 -0
  7. specmod/config/__init__.py +44 -0
  8. specmod/config/layers.py +168 -0
  9. specmod/config/provenance.py +77 -0
  10. specmod/config/sections.py +385 -0
  11. specmod/config/serialize.py +58 -0
  12. specmod/core/__init__.py +41 -0
  13. specmod/core/bandwidth.py +187 -0
  14. specmod/core/collection.py +549 -0
  15. specmod/core/noise.py +478 -0
  16. specmod/core/scalogram.py +234 -0
  17. specmod/core/spectrum.py +326 -0
  18. specmod/core/units.py +116 -0
  19. specmod/datasets.py +316 -0
  20. specmod/distance.py +190 -0
  21. specmod/exceptions.py +58 -0
  22. specmod/fitting/__init__.py +58 -0
  23. specmod/fitting/base.py +50 -0
  24. specmod/fitting/event.py +284 -0
  25. specmod/fitting/guess.py +170 -0
  26. specmod/fitting/spectrum.py +330 -0
  27. specmod/io.py +241 -0
  28. specmod/magnitude.py +312 -0
  29. specmod/picks/__init__.py +182 -0
  30. specmod/picks/base.py +250 -0
  31. specmod/picks/delimited.py +224 -0
  32. specmod/picks/events.py +157 -0
  33. specmod/picks/resolution.py +149 -0
  34. specmod/picks/snuffler.py +92 -0
  35. specmod/pipeline.py +280 -0
  36. specmod/plotting.py +203 -0
  37. specmod/preprocess.py +554 -0
  38. specmod/smoothing/__init__.py +50 -0
  39. specmod/smoothing/base.py +56 -0
  40. specmod/smoothing/konno_ohmachi.py +83 -0
  41. specmod/smoothing/log_bins.py +171 -0
  42. specmod/sources/__init__.py +65 -0
  43. specmod/sources/attenuation.py +110 -0
  44. specmod/sources/composite.py +135 -0
  45. specmod/sources/motion.py +40 -0
  46. specmod/sources/source.py +147 -0
  47. specmod/spreading.py +209 -0
  48. specmod/staged.py +523 -0
  49. specmod/tables.py +110 -0
  50. specmod/transforms/__init__.py +50 -0
  51. specmod/transforms/base.py +242 -0
  52. specmod/transforms/cwt.py +219 -0
  53. specmod/transforms/fft.py +157 -0
  54. specmod/transforms/multitaper.py +357 -0
  55. specmod/transforms/prieto.py +272 -0
  56. specmod/transforms/quadratic.py +221 -0
  57. specmod/utils.py +305 -0
  58. specmod-0.2.0.dist-info/METADATA +294 -0
  59. specmod-0.2.0.dist-info/RECORD +62 -0
  60. specmod-0.2.0.dist-info/WHEEL +4 -0
  61. specmod-0.2.0.dist-info/entry_points.txt +2 -0
  62. specmod-0.2.0.dist-info/licenses/LICENSE +21 -0
specmod/staged.py ADDED
@@ -0,0 +1,523 @@
1
+ """The two-stage event fit, and the channel selection that feeds it.
2
+
3
+ Fitting a source model to one spectrum is not a unique inversion. The source
4
+ corner and the path attenuation trade off against each other on the falling
5
+ limb, and two minimisers can reach the same reduced chi-squared at corner
6
+ frequencies differing by tens of percent — a factor of several in stress drop,
7
+ which scales as ``fc**3``. On the 28 PNR windows, Powell and ``leastsq`` land
8
+ at 21.26 Hz and 14.75 Hz on one station at redchi 0.0259 against 0.0254.
9
+
10
+ That is not resolvable from one spectrum by any minimiser, and the published
11
+ workflow does not try. ``f_c`` belongs to the **source**: every station sees
12
+ the same rupture, so there is one value of it for the event. ``t*`` belongs to
13
+ the **path**, and every station has a different one. So a station whose ``t*``
14
+ came out too high returns a corner that is too high, and the next station's
15
+ error does not point the same way. Averaging over the ensemble is not
16
+ cosmetic smoothing — it uses the fact that the quantity being averaged is
17
+ common to all of them while the contaminating one is not.
18
+
19
+ The two stages
20
+ --------------
21
+ 1. ``Omega``, ``f_c`` and ``t*`` free at every station independently. The
22
+ output is not the answer; it is N noisy estimates of one number plus N
23
+ estimates of N different numbers.
24
+ 2. The event ``f_c`` — a weighted mean over the stations that survived
25
+ selection — is held fixed, and every station refits ``Omega`` and ``t*``
26
+ against a corner it can no longer trade against.
27
+
28
+ Measured on the same 28 windows, with the same 28 channels contributing to
29
+ both: the two minimisers differ by a factor 1.44 in ``f_c`` at the worst
30
+ station and by **0.4%** on the event value. After stage two they agree to
31
+ 0.23% on ``t*`` and 1.7e-3 log10 units on ``Omega``.
32
+
33
+ "With the same channels contributing" is load-bearing, and is the trap in this
34
+ module. See :attr:`ChannelSelection.require_pass` — comparing two minimisers
35
+ under the default selection compares two different ensembles, and gives 125%
36
+ rather than 0.4%.
37
+
38
+ Be clear about what that last number is not. Fixing ``f_c`` removes the
39
+ parameter the minimisers were disagreeing about, so of course they then agree.
40
+ What it shows is that the residual two-parameter problem is well conditioned:
41
+ once the corner is pinned, ``Omega`` and ``t*`` are determined by the spectrum
42
+ rather than negotiable. The judgement is concentrated into one number for the
43
+ whole event, and that number came from the ensemble.
44
+
45
+ Choosing which channels contribute
46
+ ----------------------------------
47
+ Selection is the part that cannot be automated away, because it is where
48
+ quality control enters. A station with a bad instrument response, a clipped
49
+ record or a pick on the wrong phase produces a corner frequency that is
50
+ confidently wrong, and averaging it in moves the event value for every other
51
+ station.
52
+
53
+ So the ensemble is chosen by :class:`ChannelSelection`, which reads
54
+ ``[fitting]`` and can be overridden per call. The order is fixed and each step
55
+ is recorded with a reason, so ``StagedFit.excluded`` says why any given
56
+ channel is not contributing:
57
+
58
+ 1. anything not matching ``include`` (when ``include`` is non-empty),
59
+ 2. anything matching ``exclude``,
60
+ 3. anything whose stage-1 fit failed its bounds, when ``require_pass``,
61
+ 4. anything left with no fit at all.
62
+
63
+ Patterns are shell globs, and they match at whichever level you write them.
64
+ A trace id is ``NET.STA.LOC.CHA``, and a pattern is tried against the whole
65
+ id, against ``NET.STA``, and against each component on its own — so all of
66
+ these do what they look like they do::
67
+
68
+ "AQ07" every channel of that station
69
+ "UR" every station of that network
70
+ "UR.AQ07" that station, spelled unambiguously
71
+ "HHE" every east component, at every station
72
+ "HH?" every high-gain broadband channel
73
+ "UR.AQ07.00.HHE" exactly that channel
74
+ "LV.L00[123]..HH?" a glob over the full id
75
+
76
+ Writing the station code alone is the common case after quality control —
77
+ a clipped record or a bad response is a property of the instrument, not of one
78
+ component — and needing ``"UR.AQ07.*"`` for it is the kind of detail that gets
79
+ mistyped as ``"AQ07"`` and silently matches nothing. Station and channel codes
80
+ do not collide in practice; where a pattern could match at two levels it
81
+ matches, and :attr:`StagedFit.excluded` records which level it hit.
82
+ """
83
+
84
+ from __future__ import annotations
85
+
86
+ import contextlib
87
+ import fnmatch
88
+ import io
89
+ from dataclasses import dataclass, field
90
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
91
+
92
+ import numpy as np
93
+
94
+ from .config import load_config
95
+ from .distance import DistanceMeasure, resolve_distance_measure
96
+ from .fitting import FitSpectra
97
+
98
+ if TYPE_CHECKING: # pragma: no cover
99
+ from collections.abc import Iterable, Mapping, Sequence
100
+
101
+ from numpy.typing import NDArray
102
+
103
+ __all__ = [
104
+ "WEIGHT_MODELS",
105
+ "ChannelSelection",
106
+ "StagedFit",
107
+ "WeightModel",
108
+ "fit_event",
109
+ "get_weight_model",
110
+ ]
111
+
112
+
113
+ @runtime_checkable
114
+ class WeightModel(Protocol):
115
+ """How much each station's stage-1 estimate counts toward the event value.
116
+
117
+ Given the stage-1 table and the spectra it was fitted to, return one weight
118
+ per row. Weights need not sum to anything; they are normalised on use.
119
+ """
120
+
121
+ name: str
122
+
123
+ def weights(
124
+ self, table: Any, spectra: Any, ids: Sequence[str]
125
+ ) -> NDArray[np.float64]: ... # pragma: no cover
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class InverseDistance:
130
+ """Weight by ``1 / distance``, the published choice.
131
+
132
+ The nearer station has less path between the source and the sensor, so
133
+ less of its high-frequency falloff can be attenuation and its corner is the
134
+ better constrained of the two. That is the argument for it; it is a
135
+ modelling choice rather than a derivation, which is why this is a registry
136
+ and not a hardcoded expression.
137
+
138
+ **Which distance is itself a choice**, and at short range not a small one:
139
+ see :mod:`specmod.distance`. ``measure=None`` takes the project-wide
140
+ setting, so a study that has decided on epicentral does not have to say so
141
+ again here.
142
+ """
143
+
144
+ #: ``None`` means "whatever the configuration says". Distance is needed by
145
+ #: geometric spreading as well as by weighting, so the choice belongs in
146
+ #: one place rather than being restated per consumer.
147
+ measure: str | DistanceMeasure | None = None
148
+ name: str = "inverse_distance"
149
+
150
+ def weights(
151
+ self, table: Any, spectra: Any, ids: Sequence[str]
152
+ ) -> NDArray[np.float64]:
153
+ distances = resolve_distance_measure(self.measure).distances(spectra, ids)
154
+ weights: NDArray[np.float64] = 1.0 / distances
155
+ return weights
156
+
157
+
158
+ @dataclass(frozen=True, slots=True)
159
+ class Uniform:
160
+ """Every contributing station counts the same.
161
+
162
+ The honest default when the geometry is unknown, and the right one when
163
+ the stations are at comparable distances — where inverse-distance weighting
164
+ adds variance without adding information.
165
+ """
166
+
167
+ name: str = "uniform"
168
+
169
+ def weights(
170
+ self, table: Any, spectra: Any, ids: Sequence[str]
171
+ ) -> NDArray[np.float64]:
172
+ return np.ones(len(ids), dtype=np.float64)
173
+
174
+
175
+ @dataclass(frozen=True, slots=True)
176
+ class InverseVariance:
177
+ """Weight by ``1 / stderr**2`` on the aggregated parameter.
178
+
179
+ Statistically the right answer when the uncertainties are real, and
180
+ unavailable under the shipped minimiser: Powell estimates no covariance
181
+ matrix, so lmfit reports ``stderr`` as ``None`` for every parameter. This
182
+ raises rather than falling back, because silently becoming uniform is the
183
+ kind of substitution that ends up in a paper.
184
+ """
185
+
186
+ name: str = "inverse_variance"
187
+
188
+ def weights(
189
+ self, table: Any, spectra: Any, ids: Sequence[str]
190
+ ) -> NDArray[np.float64]:
191
+ indexed = table.set_index("id")
192
+ column = f"{_aggregated_parameter()}-stderr"
193
+ if column not in indexed:
194
+ raise ValueError(f"the fit table has no {column!r} column")
195
+ errors = indexed.loc[list(ids), column].to_numpy(dtype=np.float64)
196
+ if not np.isfinite(errors).all():
197
+ raise ValueError(
198
+ "inverse-variance weighting needs an uncertainty on every "
199
+ "contributing station, and some are missing. The configured "
200
+ "minimiser is likely 'powell', which estimates no covariance "
201
+ "matrix — use 'leastsq', or weight another way."
202
+ )
203
+ weights: NDArray[np.float64] = 1.0 / errors**2
204
+ return weights
205
+
206
+
207
+ #: Registered weightings, resolved by name from ``[fitting] event_weighting``.
208
+ WEIGHT_MODELS: dict[str, Any] = {
209
+ # Follows the configured distance measure, so a project-wide choice is
210
+ # honoured in one place. The shipped default.
211
+ "inverse_distance": InverseDistance,
212
+ # And explicit spellings, for a study that wants to say which it used
213
+ # regardless of what the rest of the configuration says.
214
+ "inverse_hypocentral_distance": lambda: InverseDistance(measure="rhyp"),
215
+ "inverse_epicentral_distance": lambda: InverseDistance(measure="repi"),
216
+ "uniform": Uniform,
217
+ "inverse_variance": InverseVariance,
218
+ }
219
+
220
+
221
+ def get_weight_model(name: str) -> WeightModel:
222
+ """Resolve a registered weighting by name, with its defaults."""
223
+ try:
224
+ factory = WEIGHT_MODELS[name]
225
+ except KeyError:
226
+ raise ValueError(
227
+ f"Unknown weighting {name!r}. Available: {sorted(WEIGHT_MODELS)}."
228
+ ) from None
229
+ model: WeightModel = factory()
230
+ return model
231
+
232
+
233
+ def _aggregated_parameter() -> str:
234
+ return str(load_config().config.fitting.event_parameter)
235
+
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class ChannelSelection:
239
+ """Which channels contribute to the event value.
240
+
241
+ Defaults come from ``[fitting]``; pass one of these to override per call.
242
+ The point of it being a value rather than four arguments is that a
243
+ selection can be written down, compared and stored — a run that dropped
244
+ three stations should be able to say so afterwards.
245
+ """
246
+
247
+ include: tuple[str, ...] = ()
248
+ exclude: tuple[str, ...] = ()
249
+ #: Drop a station whose stage-1 fit ended with a parameter against a bound.
250
+ #:
251
+ #: **This interacts with the minimiser, and not symmetrically.**
252
+ #: ``pass_fitting`` asks whether ``value +/- stderr`` reaches a bound.
253
+ #: Powell — the shipped default — estimates no covariance matrix, so
254
+ #: ``stderr`` is ``None``, the spread is zero, and the test degenerates to
255
+ #: "is the value exactly on the bound", which essentially never fires. On
256
+ #: these 28 windows it drops **0** stations under Powell and **6** under
257
+ #: ``leastsq``.
258
+ #:
259
+ #: So changing the minimiser changes *which stations vote*, not just how
260
+ #: each one is fitted. Comparing two minimisers under the default
261
+ #: selection therefore compares two different ensembles: 12.081 Hz from 28
262
+ #: channels against 5.376 Hz from 22, a 125% difference that says almost
263
+ #: nothing about the minimisers. Holding the ensemble fixed with
264
+ #: ``require_pass=False`` gives 12.081 against 12.029 — 0.4%, which is the
265
+ #: honest comparison and the number quoted in the module docstring.
266
+ #:
267
+ #: Left ``True`` because the flag is doing the right thing when it fires:
268
+ #: a pinned parameter reports its bound rather than a measurement, and
269
+ #: averaging that in averages in a constant. But a study comparing
270
+ #: minimisers, or reporting a corner frequency alongside one fitted
271
+ #: another way, has to set this ``False`` or account for it.
272
+ require_pass: bool = True
273
+
274
+ @classmethod
275
+ def from_config(cls) -> ChannelSelection:
276
+ fitting = load_config().config.fitting
277
+ return cls(
278
+ include=tuple(fitting.include),
279
+ exclude=tuple(fitting.exclude),
280
+ require_pass=bool(fitting.require_pass),
281
+ )
282
+
283
+ def choose(self, fit: FitSpectra) -> tuple[list[str], dict[str, str]]:
284
+ """Split ``fit``'s stations into contributors and reasoned exclusions."""
285
+ contributing: list[str] = []
286
+ excluded: dict[str, str] = {}
287
+
288
+ table = fit.table.set_index("id") if len(fit.table) else None
289
+ # `ids()` on a SpectrumSet, plain iteration on anything else — the same
290
+ # duck-typed contract the fitter itself accepts.
291
+ spectra = fit.spectra
292
+ ids = spectra.ids() if hasattr(spectra, "ids") else list(spectra)
293
+ for id in ids:
294
+ if id not in fit.models:
295
+ excluded[id] = "no fit: the station did not pass the band gate"
296
+ continue
297
+ if self.include and _match(id, self.include) is None:
298
+ excluded[id] = f"not matched by include={list(self.include)}"
299
+ continue
300
+ hit = _match(id, self.exclude)
301
+ if hit is not None:
302
+ level, pattern = hit
303
+ excluded[id] = f"matched exclude={pattern!r} at {level}"
304
+ continue
305
+ if table is None or id not in table.index:
306
+ excluded[id] = "no row in the stage-1 table: the fit did not run"
307
+ continue
308
+ if self.require_pass and not bool(table.loc[id, "pass_fitting"]):
309
+ excluded[id] = (
310
+ "stage-1 fit failed: a parameter is pinned against a bound, "
311
+ "so the value reported is the bound rather than a measurement"
312
+ )
313
+ continue
314
+ contributing.append(id)
315
+
316
+ return contributing, excluded
317
+
318
+
319
+ #: The parts of ``NET.STA.LOC.CHA`` a pattern may be written against, in the
320
+ #: order a match is reported. Full id first so the most specific spelling wins
321
+ #: the explanation.
322
+ def _levels(id: str) -> list[tuple[str, str]]:
323
+ parts = id.split(".")
324
+ if len(parts) != 4:
325
+ # Not a SEED id — match it whole and say nothing more about it.
326
+ return [("id", id)]
327
+ net, sta, loc, cha = parts
328
+ return [
329
+ ("id", id),
330
+ ("station", f"{net}.{sta}"),
331
+ ("network", net),
332
+ ("station", sta),
333
+ ("location", loc),
334
+ ("channel", cha),
335
+ ]
336
+
337
+
338
+ def _match(id: str, patterns: Iterable[str]) -> tuple[str, str] | None:
339
+ """The (level, pattern) a channel matched at, or ``None``.
340
+
341
+ Returned rather than a bool so an exclusion can say *why*: "matched
342
+ exclude='AQ07' at station" is actionable where "excluded" is not.
343
+ """
344
+ for pattern in patterns:
345
+ for level, value in _levels(id):
346
+ if fnmatch.fnmatch(value, pattern):
347
+ return level, pattern
348
+ return None
349
+
350
+
351
+ @dataclass(frozen=True, slots=True)
352
+ class StagedFit:
353
+ """The result of :func:`fit_event`: both stages and how they were joined."""
354
+
355
+ #: Every station fitted independently. Kept because the spread across it is
356
+ #: the evidence for how well constrained the event value is, and discarding
357
+ #: it would leave only a number with no error on it.
358
+ stage1: FitSpectra
359
+ #: Every station refitted with the event parameter held fixed. ``None`` when
360
+ #: no station survived selection, in which case there is nothing to fix it
361
+ #: to and the honest result is stage one alone.
362
+ stage2: FitSpectra | None
363
+ #: The aggregated value and what produced it.
364
+ parameter: str
365
+ value: float
366
+ weighting: str
367
+ contributing: tuple[str, ...]
368
+ excluded: Mapping[str, str] = field(default_factory=dict)
369
+
370
+ @property
371
+ def table(self) -> Any:
372
+ """The stage-2 table where there is one, else stage one's."""
373
+ return self.stage1.table if self.stage2 is None else self.stage2.table
374
+
375
+ def spread(self) -> dict[str, float]:
376
+ """How much the contributing stations disagreed, before aggregation.
377
+
378
+ The number worth reporting beside the event value. A 2% spread and a
379
+ 60% spread give the same weighted mean and mean very different things.
380
+ """
381
+ if not self.contributing:
382
+ return {}
383
+ values = (
384
+ self.stage1.table.set_index("id")
385
+ .loc[list(self.contributing), self.parameter]
386
+ .to_numpy(dtype=np.float64)
387
+ )
388
+ return {
389
+ "n": float(values.size),
390
+ "min": float(values.min()),
391
+ "max": float(values.max()),
392
+ "median": float(np.median(values)),
393
+ "weighted_mean": self.value,
394
+ "relative_spread": float((values.max() - values.min()) / self.value)
395
+ if self.value
396
+ else float("nan"),
397
+ }
398
+
399
+ def describe(self) -> str:
400
+ """One paragraph a caller can print or paste into a log."""
401
+ lines = [
402
+ f"{self.parameter} = {self.value:.4g} "
403
+ f"from {len(self.contributing)} channels, weighted by "
404
+ f"{self.weighting}",
405
+ ]
406
+ spread = self.spread()
407
+ if spread:
408
+ lines.append(
409
+ f" stage-1 range {spread['min']:.4g} to {spread['max']:.4g} "
410
+ f"({100 * spread['relative_spread']:.0f}% of the event value)"
411
+ )
412
+ if self.excluded:
413
+ lines.append(f" {len(self.excluded)} excluded:")
414
+ lines += [f" {id}: {why}" for id, why in sorted(self.excluded.items())]
415
+ return "\n".join(lines)
416
+
417
+
418
+ def fit_event(
419
+ spectra: Any,
420
+ *,
421
+ model: Any = None,
422
+ guess: Mapping[str, Mapping[str, float]] | None = None,
423
+ fit_bins: bool | None = None,
424
+ parameter: str | None = None,
425
+ weighting: str | WeightModel | None = None,
426
+ selection: ChannelSelection | None = None,
427
+ quiet: bool = True,
428
+ **fit_kwargs: Any,
429
+ ) -> StagedFit:
430
+ """Fit an event in two stages, with the ensemble deciding the source term.
431
+
432
+ Every argument has a configured default, so ``fit_event(spectra)`` is the
433
+ published workflow and the rest is there for when the default is wrong.
434
+
435
+ Parameters
436
+ ----------
437
+ spectra
438
+ A :class:`~specmod.core.SpectrumSet`, or anything mapping trace ids to
439
+ paired spectra.
440
+ model, guess, fit_bins
441
+ Passed to :class:`~specmod.fitting.FitSpectra` for both stages, so the
442
+ two are fitting the same thing.
443
+ parameter
444
+ Which parameter the ensemble determines and stage two holds fixed.
445
+ ``[fitting] event_parameter``, default ``"fc"`` — the corner frequency
446
+ is the term that belongs to the source. ``"ts"`` is the meaningful
447
+ alternative, for a study with an independent handle on ``Q``.
448
+ weighting
449
+ A registered name or a :class:`WeightModel`. ``[fitting]
450
+ event_weighting``, default inverse hypocentral distance.
451
+ selection
452
+ Which channels contribute. Defaults to :meth:`ChannelSelection.from_config`.
453
+ quiet
454
+ Suppress the fitter's per-station chatter, which is two full passes of
455
+ it here. The failures are on :attr:`StagedFit.excluded` either way.
456
+ **fit_kwargs
457
+ Passed to :meth:`~specmod.fitting.FitSpectra.fit_spectra` for both
458
+ stages — ``method=`` most usefully.
459
+
460
+ Notes
461
+ -----
462
+ Stage two is skipped, with ``stage2=None``, when selection leaves nothing.
463
+ Fixing the parameter to a mean of no stations would be inventing the very
464
+ number the second stage exists to constrain, and a caller who asked for two
465
+ stages and got one should be able to see that rather than read a value.
466
+ """
467
+ fitting = load_config().config.fitting
468
+ parameter = parameter or str(fitting.event_parameter)
469
+ if selection is None:
470
+ selection = ChannelSelection.from_config()
471
+ if weighting is None:
472
+ weighting = str(fitting.event_weighting)
473
+ model_ = get_weight_model(weighting) if isinstance(weighting, str) else weighting
474
+
475
+ def run(constant: tuple[str, float] | None = None) -> FitSpectra:
476
+ with contextlib.redirect_stdout(io.StringIO() if quiet else None):
477
+ fit = FitSpectra(spectra, model=model, guess=guess, fit_bins=fit_bins)
478
+ if constant is not None:
479
+ fit.set_const(*constant)
480
+ fit.fit_spectra(**fit_kwargs)
481
+ return fit
482
+
483
+ stage1 = run()
484
+ contributing, excluded = selection.choose(stage1)
485
+
486
+ if not contributing:
487
+ return StagedFit(
488
+ stage1=stage1,
489
+ stage2=None,
490
+ parameter=parameter,
491
+ value=float("nan"),
492
+ weighting=getattr(model_, "name", str(weighting)),
493
+ contributing=(),
494
+ excluded=excluded,
495
+ )
496
+
497
+ table = stage1.table.set_index("id")
498
+ values = table.loc[contributing, parameter].to_numpy(dtype=np.float64)
499
+ weights = np.asarray(
500
+ model_.weights(stage1.table, spectra, contributing), dtype=np.float64
501
+ )
502
+ if weights.shape != values.shape:
503
+ raise ValueError(
504
+ f"{getattr(model_, 'name', weighting)} returned {weights.size} "
505
+ f"weights for {values.size} channels"
506
+ )
507
+ total = float(weights.sum())
508
+ if total <= 0:
509
+ raise ValueError(
510
+ f"{getattr(model_, 'name', weighting)} gave every contributing "
511
+ "channel zero weight, so there is no event value to compute"
512
+ )
513
+ value = float((values * weights).sum() / total)
514
+
515
+ return StagedFit(
516
+ stage1=stage1,
517
+ stage2=run(constant=(parameter, value)),
518
+ parameter=parameter,
519
+ value=value,
520
+ weighting=getattr(model_, "name", str(weighting)),
521
+ contributing=tuple(contributing),
522
+ excluded=excluded,
523
+ )
specmod/tables.py ADDED
@@ -0,0 +1,110 @@
1
+ """Fit results as a table, on disk.
2
+
3
+ The other half of ``REFACTOR_PLAN`` §4.6. Spectra are arrays asked about one
4
+ event at a time and live in HDF5 (:mod:`specmod.io`); fit results are a
5
+ columnar scan over every event ever fitted — *"give me f_c and Omega for all
6
+ 635 events and regress them"* — and live here. The published Magna run produced
7
+ **11,226 rows** of exactly that, and a multi-event catalogue is larger.
8
+
9
+ **Parquet as the primary, CSV as an export.** This is not fashion. CSV loses
10
+ dtypes, so a column of floats comes back as floats only if pandas guesses
11
+ right and as strings if one cell says ``None``; it round-trips every float
12
+ through decimal text; and it has to be read in full to read any of it. Parquet
13
+ is typed, compressed, and queryable with DuckDB or polars *without loading the
14
+ file*. CSV stays because journal supplements want it, and because a human with
15
+ a text editor is a legitimate reader.
16
+
17
+ The format follows from the suffix, so the choice is visible at the call site
18
+ rather than buried in a keyword.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ import pandas as pd
27
+
28
+ if TYPE_CHECKING: # pragma: no cover
29
+ from collections.abc import Mapping
30
+
31
+ __all__ = ["read_table", "write_table"]
32
+
33
+ #: Suffix to format. ``.parquet`` is the default because it is what the
34
+ #: analysis actually wants; the others exist because other tools do.
35
+ _FORMATS = {".parquet": "parquet", ".pq": "parquet", ".csv": "csv"}
36
+
37
+
38
+ def _resolve(path: str | Path) -> tuple[Path, str]:
39
+ path = Path(path)
40
+ try:
41
+ return path, _FORMATS[path.suffix.lower()]
42
+ except KeyError:
43
+ raise ValueError(
44
+ f"cannot tell what format {path.name!r} should be from its suffix. "
45
+ f"Use one of {', '.join(sorted(_FORMATS))}."
46
+ ) from None
47
+
48
+
49
+ def _require_pyarrow() -> None:
50
+ try:
51
+ import pyarrow # noqa: F401, PLC0415
52
+ except ImportError as exc: # pragma: no cover - depends on the environment
53
+ raise ImportError(
54
+ "writing Parquet needs pyarrow. Install it with "
55
+ "`pip install specmod[io]`, or use a .csv path."
56
+ ) from exc
57
+
58
+
59
+ def write_table(
60
+ path: str | Path,
61
+ table: pd.DataFrame,
62
+ *,
63
+ meta: Mapping[str, Any] | None = None,
64
+ ) -> Path:
65
+ """Write a fit table, choosing the format from ``path``'s suffix.
66
+
67
+ ``meta`` is stored in the file's own metadata for Parquet — where it
68
+ survives as key-value pairs a reader can get at without parsing the data —
69
+ and dropped for CSV, which has nowhere to put it. That asymmetry is stated
70
+ rather than hidden: a CSV export is lossy about provenance, and pretending
71
+ otherwise is how a run stops being reproducible.
72
+
73
+ The parent directory is created if absent.
74
+ """
75
+ path, format = _resolve(path)
76
+ parent = path.parent
77
+ if parent != Path():
78
+ parent.mkdir(parents=True, exist_ok=True)
79
+
80
+ if format == "csv":
81
+ table.to_csv(path, index=False)
82
+ return path
83
+
84
+ _require_pyarrow()
85
+ import pyarrow as pa # noqa: PLC0415
86
+ import pyarrow.parquet as pq # noqa: PLC0415
87
+
88
+ arrow = pa.Table.from_pandas(table, preserve_index=False)
89
+ if meta:
90
+ # Parquet key-value metadata is bytes-to-bytes, and the existing schema
91
+ # metadata carries pandas' own type information — dropping it would
92
+ # lose the dtypes this format exists to preserve.
93
+ existing = arrow.schema.metadata or {}
94
+ arrow = arrow.replace_schema_metadata(
95
+ {
96
+ **existing,
97
+ **{str(k).encode(): str(v).encode() for k, v in meta.items()},
98
+ }
99
+ )
100
+ pq.write_table(arrow, path, compression="zstd")
101
+ return path
102
+
103
+
104
+ def read_table(path: str | Path) -> pd.DataFrame:
105
+ """Read a fit table back, choosing the format from ``path``'s suffix."""
106
+ path, format = _resolve(path)
107
+ if format == "csv":
108
+ return pd.read_csv(path)
109
+ _require_pyarrow()
110
+ return pd.read_parquet(path)
@@ -0,0 +1,50 @@
1
+ """Pluggable time-to-frequency estimators.
2
+
3
+ All backends satisfy the contract in :mod:`specmod.transforms.base`, so one
4
+ test suite pins every one of them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .base import SpectralEstimator, TaperCorrection
10
+ from .cwt import CWTEstimator
11
+ from .fft import FFTEstimator, WelchEstimator
12
+ from .multitaper import MultitaperEstimator
13
+ from .prieto import PrietoMultitaperEstimator
14
+ from .quadratic import QuadraticMultitaperEstimator
15
+
16
+ #: Name -> estimator, for resolving `TransformConfig.estimator`.
17
+ ESTIMATORS: dict[str, type[SpectralEstimator]] = {
18
+ "fft": FFTEstimator,
19
+ "welch": WelchEstimator,
20
+ "multitaper": MultitaperEstimator,
21
+ "prieto": PrietoMultitaperEstimator,
22
+ "cwt": CWTEstimator,
23
+ "quadratic": QuadraticMultitaperEstimator,
24
+ }
25
+
26
+
27
+ def get_estimator(name: str, **kwargs: object) -> SpectralEstimator:
28
+ """Construct an estimator by name."""
29
+ try:
30
+ cls = ESTIMATORS[name]
31
+ except KeyError:
32
+ raise ValueError(
33
+ f"Unknown estimator {name!r}. Available: {sorted(ESTIMATORS)}."
34
+ ) from None
35
+ estimator: SpectralEstimator = cls(**kwargs)
36
+ return estimator
37
+
38
+
39
+ __all__ = [
40
+ "ESTIMATORS",
41
+ "CWTEstimator",
42
+ "FFTEstimator",
43
+ "MultitaperEstimator",
44
+ "PrietoMultitaperEstimator",
45
+ "QuadraticMultitaperEstimator",
46
+ "SpectralEstimator",
47
+ "TaperCorrection",
48
+ "WelchEstimator",
49
+ "get_estimator",
50
+ ]