seismic-response 0.1.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.
- seismic_response/__init__.py +121 -0
- seismic_response/__main__.py +8 -0
- seismic_response/cli.py +543 -0
- seismic_response/errors.py +33 -0
- seismic_response/evaluate.py +1094 -0
- seismic_response/gui/__init__.py +1 -0
- seismic_response/gui/__main__.py +23 -0
- seismic_response/gui/server.py +194 -0
- seismic_response/gui/static/app.js +1165 -0
- seismic_response/gui/static/favicon.svg +5 -0
- seismic_response/gui/static/icon.svg +5 -0
- seismic_response/gui/static/index.html +253 -0
- seismic_response/gui/static/style.css +242 -0
- seismic_response/model.py +258 -0
- seismic_response/output.py +120 -0
- seismic_response/py.typed +0 -0
- seismic_response/resp.py +421 -0
- seismic_response/sacpz.py +250 -0
- seismic_response/selection.py +170 -0
- seismic_response/stationxml.py +329 -0
- seismic_response/timeutil.py +61 -0
- seismic_response/units.py +110 -0
- seismic_response/web_api.py +247 -0
- seismic_response-0.1.0.dist-info/METADATA +226 -0
- seismic_response-0.1.0.dist-info/RECORD +28 -0
- seismic_response-0.1.0.dist-info/WHEEL +4 -0
- seismic_response-0.1.0.dist-info/entry_points.txt +3 -0
- seismic_response-0.1.0.dist-info/licenses/LICENSE +165 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Evaluate seismological instrument responses.
|
|
2
|
+
|
|
3
|
+
Read a StationXML, SEED RESP or SAC poles-and-zeros response, pick a channel and time, and compute the
|
|
4
|
+
complex frequency response with results that match the C ``evalresp`` program::
|
|
5
|
+
|
|
6
|
+
import seismic_response as sr
|
|
7
|
+
|
|
8
|
+
channels = sr.read_response("RESP.IU.ANMO.00.BHZ")
|
|
9
|
+
chan = sr.select_channel(channels, "FDSN:IU_ANMO_00_B_H_Z", time=datetime(2000, 1, 1, tzinfo=timezone.utc))
|
|
10
|
+
freqs = sr.generate_frequencies(0.001, 50, 200)
|
|
11
|
+
result = chan.evaluate(freqs, unit="vel")
|
|
12
|
+
print(result.amplitudes[0], result.phases(unwrap=True)[0])
|
|
13
|
+
|
|
14
|
+
The package is pure Python (no dependencies) so it also runs under Pyodide.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import List, Union
|
|
21
|
+
|
|
22
|
+
from .errors import (
|
|
23
|
+
ChannelNotFoundError,
|
|
24
|
+
ResponseParseError,
|
|
25
|
+
ResponseValueError,
|
|
26
|
+
SeismicResponseError,
|
|
27
|
+
UnsupportedFeatureError,
|
|
28
|
+
)
|
|
29
|
+
from .evaluate import (
|
|
30
|
+
ResponseInfo,
|
|
31
|
+
ResponseResult,
|
|
32
|
+
StageInfo,
|
|
33
|
+
evaluate,
|
|
34
|
+
generate_frequencies,
|
|
35
|
+
phase_degrees,
|
|
36
|
+
)
|
|
37
|
+
from .model import (
|
|
38
|
+
FIR,
|
|
39
|
+
Channel,
|
|
40
|
+
Decimation,
|
|
41
|
+
Gain,
|
|
42
|
+
Generic,
|
|
43
|
+
IIRCoefficients,
|
|
44
|
+
PolesZeros,
|
|
45
|
+
Polynomial,
|
|
46
|
+
ResponseList,
|
|
47
|
+
Stage,
|
|
48
|
+
nslc_to_source_id,
|
|
49
|
+
source_id_to_nslc,
|
|
50
|
+
)
|
|
51
|
+
from .resp import looks_like_resp, parse_resp
|
|
52
|
+
from .sacpz import looks_like_sacpz, parse_sacpz
|
|
53
|
+
from .selection import select_channel, select_channels
|
|
54
|
+
from .stationxml import looks_like_stationxml, parse_stationxml
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"__version__",
|
|
58
|
+
"read_response",
|
|
59
|
+
"parse_response",
|
|
60
|
+
"evaluate",
|
|
61
|
+
"generate_frequencies",
|
|
62
|
+
"select_channel",
|
|
63
|
+
"select_channels",
|
|
64
|
+
"phase_degrees",
|
|
65
|
+
"parse_resp",
|
|
66
|
+
"parse_stationxml",
|
|
67
|
+
"parse_sacpz",
|
|
68
|
+
"looks_like_sacpz",
|
|
69
|
+
"looks_like_resp",
|
|
70
|
+
"looks_like_stationxml",
|
|
71
|
+
"Channel",
|
|
72
|
+
"Stage",
|
|
73
|
+
"PolesZeros",
|
|
74
|
+
"FIR",
|
|
75
|
+
"IIRCoefficients",
|
|
76
|
+
"ResponseList",
|
|
77
|
+
"Polynomial",
|
|
78
|
+
"Generic",
|
|
79
|
+
"Decimation",
|
|
80
|
+
"Gain",
|
|
81
|
+
"ResponseResult",
|
|
82
|
+
"ResponseInfo",
|
|
83
|
+
"StageInfo",
|
|
84
|
+
"nslc_to_source_id",
|
|
85
|
+
"source_id_to_nslc",
|
|
86
|
+
"SeismicResponseError",
|
|
87
|
+
"ResponseParseError",
|
|
88
|
+
"ResponseValueError",
|
|
89
|
+
"ChannelNotFoundError",
|
|
90
|
+
"UnsupportedFeatureError",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_response(content: Union[str, bytes], origin: str = "", fmt: str = "auto") -> List[Channel]:
|
|
95
|
+
"""Parse StationXML, SAC poles-and-zeros or RESP content.
|
|
96
|
+
|
|
97
|
+
``fmt`` is ``"auto"`` (detect), ``"stationxml"``, ``"sacpz"`` or ``"resp"``. Like
|
|
98
|
+
evalresp, anything that is neither StationXML nor SAC PZ is treated as RESP.
|
|
99
|
+
"""
|
|
100
|
+
fmt = fmt.lower()
|
|
101
|
+
if fmt == "auto":
|
|
102
|
+
if looks_like_stationxml(content):
|
|
103
|
+
fmt = "stationxml"
|
|
104
|
+
elif looks_like_sacpz(content):
|
|
105
|
+
fmt = "sacpz"
|
|
106
|
+
else:
|
|
107
|
+
fmt = "resp"
|
|
108
|
+
if fmt in ("stationxml", "xml", "sxml"):
|
|
109
|
+
return parse_stationxml(content, origin)
|
|
110
|
+
if fmt in ("sacpz", "pz", "sac"):
|
|
111
|
+
return parse_sacpz(content, origin)
|
|
112
|
+
if fmt == "resp":
|
|
113
|
+
text = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content
|
|
114
|
+
return parse_resp(text, origin)
|
|
115
|
+
raise ValueError(f"Unknown response format {fmt!r}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_response(path: Union[str, Path], fmt: str = "auto") -> List[Channel]:
|
|
119
|
+
"""Read all channel epochs from a StationXML, SAC PZ or RESP file."""
|
|
120
|
+
p = Path(path)
|
|
121
|
+
return parse_response(p.read_bytes(), origin=p.name, fmt=fmt)
|
seismic_response/cli.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""Command line interfaces.
|
|
2
|
+
|
|
3
|
+
``pyevalresp`` is a drop-in for the C ``evalresp`` command line::
|
|
4
|
+
|
|
5
|
+
pyevalresp STALST CHALST YYYY DAY MINFREQ MAXFREQ NFREQ [options]
|
|
6
|
+
|
|
7
|
+
``seismic-response`` is the umbrella command with sub-commands::
|
|
8
|
+
|
|
9
|
+
seismic-response evalresp ... # same as pyevalresp
|
|
10
|
+
seismic-response info FILE # list channels and stages in a response file
|
|
11
|
+
seismic-response gui # open the interactive plotting GUI
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import glob
|
|
18
|
+
import itertools
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import time as _time
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from typing import List, Optional, Sequence, Tuple
|
|
25
|
+
|
|
26
|
+
from . import __version__, parse_response
|
|
27
|
+
from .errors import ResponseParseError, ResponseValueError, SeismicResponseError, UnsupportedFeatureError
|
|
28
|
+
from .evaluate import evaluate, generate_frequencies
|
|
29
|
+
from .model import Channel
|
|
30
|
+
from .output import describe, render
|
|
31
|
+
from .selection import _select, split_patterns
|
|
32
|
+
|
|
33
|
+
# Exit codes follow evalresp's error enumeration
|
|
34
|
+
EXIT_OK = 0
|
|
35
|
+
EXIT_IO = 2
|
|
36
|
+
EXIT_INPUT = 3
|
|
37
|
+
EXIT_PARSE = 5
|
|
38
|
+
EXIT_VALIDATION = 7
|
|
39
|
+
|
|
40
|
+
USAGE = """
|
|
41
|
+
EVALRESP (seismic-response {version})
|
|
42
|
+
|
|
43
|
+
USAGE: {prog} STALST CHALST YYYY DAY MINFREQ MAXFREQ NFREQ [options]
|
|
44
|
+
|
|
45
|
+
OPTIONS:
|
|
46
|
+
|
|
47
|
+
-f file (directory-name|filename)
|
|
48
|
+
-u units ('def'|'dis'|'vel'|'acc'; default 'def', evalresp: 'vel')
|
|
49
|
+
-t time-of-day (HH:MM:SS)
|
|
50
|
+
-s type-of-spacing (log|lin)
|
|
51
|
+
-n netid ('II'|'IU'|'G'|'*'...)
|
|
52
|
+
-l locid ('01'|'AA,AB,AC'|'A?'|'*'...)
|
|
53
|
+
-r resp_type ('ap'=amp/pha | 'cs'=complex spectra |
|
|
54
|
+
'fap'=freq/amp/pha)
|
|
55
|
+
-stage start [stop] (start and stop are integer stage numbers)
|
|
56
|
+
-stdio (take input from stdin, output to stdout)
|
|
57
|
+
-use-estimated-delay (use estimated delay instead of correction applied
|
|
58
|
+
in computation of ASYM FIR response)
|
|
59
|
+
-il (interpolate List blockette output)
|
|
60
|
+
-ii (same as -il)
|
|
61
|
+
-unwrap (unwrap phase if the output is AP)
|
|
62
|
+
-ts (use total sensitivity from stage 0 instead of
|
|
63
|
+
computed)
|
|
64
|
+
-b62_x value (sample value/volts where we compute response for
|
|
65
|
+
B62)
|
|
66
|
+
-v (verbose; list parameters on stderr)
|
|
67
|
+
-x (expect FDSN StationXML format, default autodetect)
|
|
68
|
+
|
|
69
|
+
NOTES:
|
|
70
|
+
|
|
71
|
+
(1) If the 'file' argument is a directory, that directory will be
|
|
72
|
+
searched for files of the form RESP.NETID.STA.LOC.CHA
|
|
73
|
+
(2) If the 'file' argument is a file, the format of the file is
|
|
74
|
+
autodetected to be StationXML, SAC poles-and-zeros or RESP,
|
|
75
|
+
defaulting to RESP.
|
|
76
|
+
The expected format can be forced to StationXML using '-x'.
|
|
77
|
+
(3) If the 'file' argument is missing, the current directory
|
|
78
|
+
will be searched for files of the form RESP.NETID.STA.LOC.CHA.
|
|
79
|
+
(4) The directory indicated by the environment variable SEEDRESP
|
|
80
|
+
will also be searched for the requested files (if it is defined).
|
|
81
|
+
(5) The NETID indicates a one or two letter network code. If no network
|
|
82
|
+
code exists in the SEED volume, a value of '', '??', or '*' will
|
|
83
|
+
match the 'no network' case.
|
|
84
|
+
(6) The '-stage' option allows the user to specify the range of
|
|
85
|
+
stages that will be included in the calculation. If only one
|
|
86
|
+
stage number is given, a response will only be calculated for
|
|
87
|
+
that stage. If both a start and stop stage number are given,
|
|
88
|
+
any stage between (and including) the start and stop stages
|
|
89
|
+
will be included in the calculation.
|
|
90
|
+
(7) -b62_x defines a value in counts or volts where response is
|
|
91
|
+
computed. This flag only is applied to responses with B62.
|
|
92
|
+
|
|
93
|
+
EXAMPLES:
|
|
94
|
+
|
|
95
|
+
{prog} AAK,ARU,TLY VHZ 1992 21 0.001 10 100 -f /EVRESP/NEW/rdseed.out
|
|
96
|
+
{prog} KONO BHN,BHE 1992 1 0.001 10 100 -f /EVRESP/NEW -t 12:31:04 -v
|
|
97
|
+
{prog} FRB BHE,BHZ 1994 31 0.001 10 100 -f resp.all_stations -n '*' -v
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _InputError(Exception):
|
|
102
|
+
"""Bad command line input."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _EvalrespFormatter(logging.Formatter):
|
|
106
|
+
"""Log lines like evalresp's: ``Thu Nov 5 15:22:38 2026 [INFO] message``."""
|
|
107
|
+
|
|
108
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
109
|
+
# "%e" (space-padded day) is not supported by strftime on Windows, so pad by hand
|
|
110
|
+
t = _time.localtime(record.created)
|
|
111
|
+
stamp = _time.strftime("%a %b ", t) + f"{t.tm_mday:2d}" + _time.strftime(" %H:%M:%S %Y", t)
|
|
112
|
+
level = {"WARNING": "WARN", "CRITICAL": "ERROR"}.get(record.levelname, record.levelname)
|
|
113
|
+
return f"{stamp} [{level}] {record.getMessage()}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _setup_logging(verbose: int) -> None:
|
|
117
|
+
log = logging.getLogger("seismic_response")
|
|
118
|
+
for h in list(log.handlers):
|
|
119
|
+
log.removeHandler(h)
|
|
120
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
121
|
+
handler.setFormatter(_EvalrespFormatter())
|
|
122
|
+
log.addHandler(handler)
|
|
123
|
+
log.setLevel(logging.INFO if verbose else logging.WARNING)
|
|
124
|
+
log.propagate = False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------ argument parsing
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_real(s: str) -> bool:
|
|
131
|
+
try:
|
|
132
|
+
float(s)
|
|
133
|
+
except ValueError:
|
|
134
|
+
return False
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_int(s: str) -> bool:
|
|
139
|
+
try:
|
|
140
|
+
int(s)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return False
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Args:
|
|
147
|
+
def __init__(self) -> None:
|
|
148
|
+
self.stations = ""
|
|
149
|
+
self.channels = ""
|
|
150
|
+
self.year = 0
|
|
151
|
+
self.jday = 0
|
|
152
|
+
self.minfreq = 1.0
|
|
153
|
+
self.maxfreq: Optional[float] = None
|
|
154
|
+
self.nfreq = 1
|
|
155
|
+
self.file: Optional[str] = None
|
|
156
|
+
self.unit = "def"
|
|
157
|
+
self.time = (0, 0, 0.0)
|
|
158
|
+
self.spacing = "log"
|
|
159
|
+
self.network: Optional[str] = None
|
|
160
|
+
self.location: Optional[str] = None
|
|
161
|
+
self.format: Optional[str] = None
|
|
162
|
+
self.start_stage: Optional[int] = None
|
|
163
|
+
self.stop_stage: Optional[int] = None
|
|
164
|
+
self.stdio = False
|
|
165
|
+
self.estimated_delay = False
|
|
166
|
+
self.interpolate = False
|
|
167
|
+
self.unwrap = False
|
|
168
|
+
self.total_sensitivity = False
|
|
169
|
+
self.b62_x = 0.0
|
|
170
|
+
self.verbose = 0
|
|
171
|
+
self.xml = False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_VALUE_OPTIONS = {
|
|
175
|
+
"-f": "file", "--file": "file",
|
|
176
|
+
"-u": "unit", "--units": "unit",
|
|
177
|
+
"-t": "time", "--time": "time",
|
|
178
|
+
"-s": "spacing", "--spacing": "spacing",
|
|
179
|
+
"-n": "network", "--network": "network",
|
|
180
|
+
"-l": "location", "--location": "location",
|
|
181
|
+
"-r": "format", "--response": "format",
|
|
182
|
+
"-b62_x": "b62_x", "--b62_x": "b62_x",
|
|
183
|
+
}
|
|
184
|
+
_FLAG_OPTIONS = {
|
|
185
|
+
"-stdio": "stdio", "--stdio": "stdio",
|
|
186
|
+
"-use-estimated-delay": "estimated_delay", "--use-estimated-delay": "estimated_delay",
|
|
187
|
+
"-use-delay": "estimated_delay", "--use-delay": "estimated_delay",
|
|
188
|
+
"-il": "interpolate", "--il": "interpolate",
|
|
189
|
+
"-ii": "interpolate", "--ii": "interpolate",
|
|
190
|
+
"-unwrap": "unwrap", "--unwrap": "unwrap",
|
|
191
|
+
"-ts": "total_sensitivity", "--ts": "total_sensitivity",
|
|
192
|
+
"-x": "xml", "--xml": "xml",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_time_of_day(text: str) -> Tuple[int, int, float]:
|
|
197
|
+
"""``hh[:mm[:ss[.sss]]]`` as accepted by evalresp."""
|
|
198
|
+
parts = text.split(":")
|
|
199
|
+
try:
|
|
200
|
+
hour = int(parts[0])
|
|
201
|
+
minute = int(parts[1]) if len(parts) > 1 else 0
|
|
202
|
+
second = float(parts[2]) if len(parts) > 2 else 0.0
|
|
203
|
+
if len(parts) > 3:
|
|
204
|
+
raise ValueError
|
|
205
|
+
except ValueError:
|
|
206
|
+
raise _InputError(f"Cannot parse '{text}' as a time") from None
|
|
207
|
+
return hour, minute, second
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def parse_evalresp_args(argv: Sequence[str]) -> _Args:
|
|
211
|
+
"""Parse an evalresp style command line (``argv`` without the program name)."""
|
|
212
|
+
a = _Args()
|
|
213
|
+
tokens = list(argv)
|
|
214
|
+
|
|
215
|
+
# Leading positionals run until the first option (negative numbers are not options)
|
|
216
|
+
n_pos = 0
|
|
217
|
+
while n_pos < len(tokens) and (not tokens[n_pos].startswith("-") or _is_real(tokens[n_pos])):
|
|
218
|
+
n_pos += 1
|
|
219
|
+
positionals, rest = tokens[:n_pos], tokens[n_pos:]
|
|
220
|
+
|
|
221
|
+
if len(positionals) < 4:
|
|
222
|
+
raise _InputError(
|
|
223
|
+
f"Not all of the required inputs are present ({4 - len(positionals)} missing)"
|
|
224
|
+
)
|
|
225
|
+
a.stations, a.channels = positionals[0], positionals[1]
|
|
226
|
+
try:
|
|
227
|
+
a.year = int(positionals[2])
|
|
228
|
+
a.jday = int(positionals[3])
|
|
229
|
+
a.minfreq = float(positionals[4]) if len(positionals) > 4 else 1.0
|
|
230
|
+
a.maxfreq = float(positionals[5]) if len(positionals) > 5 else a.minfreq
|
|
231
|
+
a.nfreq = int(positionals[6]) if len(positionals) > 6 else 1
|
|
232
|
+
except ValueError as exc:
|
|
233
|
+
raise _InputError(f"Cannot parse numeric argument: {exc}") from None
|
|
234
|
+
for extra in positionals[7:]:
|
|
235
|
+
logging.getLogger("seismic_response").warning("Unrecognized parameter: %s", extra)
|
|
236
|
+
|
|
237
|
+
i = 0
|
|
238
|
+
while i < len(rest):
|
|
239
|
+
tok = rest[i]
|
|
240
|
+
i += 1
|
|
241
|
+
if tok in ("-v", "--verbose"):
|
|
242
|
+
a.verbose += 1
|
|
243
|
+
elif tok in ("-vv",):
|
|
244
|
+
a.verbose += 2
|
|
245
|
+
elif tok in ("-stage", "--stage"):
|
|
246
|
+
if i >= len(rest) or not _is_int(rest[i]):
|
|
247
|
+
raise _InputError("Missing argument for option 'stage'")
|
|
248
|
+
a.start_stage = int(rest[i])
|
|
249
|
+
i += 1
|
|
250
|
+
if i < len(rest) and _is_int(rest[i]):
|
|
251
|
+
a.stop_stage = int(rest[i])
|
|
252
|
+
i += 1
|
|
253
|
+
elif tok in _FLAG_OPTIONS:
|
|
254
|
+
setattr(a, _FLAG_OPTIONS[tok], True)
|
|
255
|
+
elif tok in _VALUE_OPTIONS:
|
|
256
|
+
if i >= len(rest):
|
|
257
|
+
raise _InputError(f"Missing argument for option '{tok.lstrip('-')}'")
|
|
258
|
+
name, value = _VALUE_OPTIONS[tok], rest[i]
|
|
259
|
+
i += 1
|
|
260
|
+
if name == "time":
|
|
261
|
+
a.time = _parse_time_of_day(value)
|
|
262
|
+
elif name == "b62_x":
|
|
263
|
+
try:
|
|
264
|
+
a.b62_x = float(value)
|
|
265
|
+
except ValueError:
|
|
266
|
+
raise _InputError(f"Cannot parse block 62 x value '{value}'") from None
|
|
267
|
+
elif name == "unit":
|
|
268
|
+
if value.lower() not in ("def", "dis", "vel", "acc"):
|
|
269
|
+
raise _InputError(f"Could not match '{value}' for output unit")
|
|
270
|
+
a.unit = value.lower()
|
|
271
|
+
elif name == "spacing":
|
|
272
|
+
if value.lower() not in ("log", "lin"):
|
|
273
|
+
raise _InputError(f"Could not match '{value}' for frequency spacing")
|
|
274
|
+
a.spacing = value.lower()
|
|
275
|
+
elif name == "format":
|
|
276
|
+
if value.lower() not in ("ap", "fap", "cs"):
|
|
277
|
+
raise _InputError(f"Could not match '{value}' for file format")
|
|
278
|
+
a.format = value.lower()
|
|
279
|
+
else:
|
|
280
|
+
setattr(a, name, value)
|
|
281
|
+
else:
|
|
282
|
+
logging.getLogger("seismic_response").warning("Unrecognized option: %s", tok)
|
|
283
|
+
return a
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ------------------------------------------------------------------ file handling
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _read_channels(data: bytes, origin: str, force_xml: bool) -> List[Channel]:
|
|
290
|
+
return parse_response(data, origin, fmt="stationxml" if force_xml else "auto")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _find_files(file_arg: Optional[str], sncls) -> List[str]:
|
|
294
|
+
"""Locate input files like evalresp: a file, a directory of RESP.* files, or the cwd."""
|
|
295
|
+
if file_arg:
|
|
296
|
+
if os.path.isdir(file_arg):
|
|
297
|
+
dirs = [file_arg]
|
|
298
|
+
else:
|
|
299
|
+
return [file_arg]
|
|
300
|
+
else:
|
|
301
|
+
dirs = ["."]
|
|
302
|
+
seedresp = os.environ.get("SEEDRESP")
|
|
303
|
+
if seedresp:
|
|
304
|
+
dirs.append(seedresp)
|
|
305
|
+
|
|
306
|
+
found: List[str] = []
|
|
307
|
+
for net, sta, loc, cha in sncls:
|
|
308
|
+
loc_wild = loc in ("*", "??")
|
|
309
|
+
for d in dirs:
|
|
310
|
+
names = sorted(glob.glob(os.path.join(d, f"RESP.{net}.{sta}.{'*' if loc_wild else loc}.{cha}")))
|
|
311
|
+
if not names and loc_wild:
|
|
312
|
+
names = sorted(glob.glob(os.path.join(d, f"RESP.{net}.{sta}.{cha}")))
|
|
313
|
+
if not names:
|
|
314
|
+
logging.getLogger("seismic_response").warning(
|
|
315
|
+
"no files match '%s'", os.path.join(d, f"RESP.{net}.{sta}.{loc}.{cha}")
|
|
316
|
+
)
|
|
317
|
+
for n in names:
|
|
318
|
+
if n not in found:
|
|
319
|
+
found.append(n)
|
|
320
|
+
return found
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _build_sncls(a: _Args):
|
|
324
|
+
stations = split_patterns(a.stations)
|
|
325
|
+
channels = split_patterns(a.channels)
|
|
326
|
+
if a.location is None:
|
|
327
|
+
locations = ["*"]
|
|
328
|
+
else:
|
|
329
|
+
locations = a.location.split(",") if a.location != "" else [""]
|
|
330
|
+
net = a.network if a.network is not None else "*"
|
|
331
|
+
return [(net, sta, loc, cha) for sta, loc, cha in itertools.product(stations, locations, channels)]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ------------------------------------------------------------------ main entry points
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def evalresp_main(argv: Optional[Sequence[str]] = None) -> int:
|
|
338
|
+
"""Entry point for ``pyevalresp`` (evalresp compatible command line)."""
|
|
339
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
340
|
+
prog = os.path.basename(sys.argv[0]) if sys.argv and sys.argv[0] else "pyevalresp"
|
|
341
|
+
if prog in ("__main__.py", "-c", "") or prog.endswith(".py"):
|
|
342
|
+
prog = "pyevalresp"
|
|
343
|
+
_setup_logging(0)
|
|
344
|
+
log = logging.getLogger("seismic_response")
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
a = parse_evalresp_args(argv)
|
|
348
|
+
except _InputError as exc:
|
|
349
|
+
log.error("%s", exc)
|
|
350
|
+
print(USAGE.format(version=__version__, prog=prog), file=sys.stderr)
|
|
351
|
+
return EXIT_INPUT
|
|
352
|
+
|
|
353
|
+
_setup_logging(a.verbose)
|
|
354
|
+
if a.verbose:
|
|
355
|
+
log.info("<< EVALRESP RESPONSE OUTPUT (seismic-response %s) >>", __version__)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
return _run_evalresp(a)
|
|
359
|
+
except _InputError as exc:
|
|
360
|
+
log.error("%s", exc)
|
|
361
|
+
return EXIT_INPUT
|
|
362
|
+
except UnsupportedFeatureError as exc:
|
|
363
|
+
log.error("%s", exc)
|
|
364
|
+
return EXIT_INPUT
|
|
365
|
+
except ResponseParseError as exc:
|
|
366
|
+
log.error("%s", exc)
|
|
367
|
+
return EXIT_PARSE
|
|
368
|
+
except ResponseValueError as exc:
|
|
369
|
+
log.error("%s", exc)
|
|
370
|
+
return EXIT_VALIDATION
|
|
371
|
+
except OSError as exc:
|
|
372
|
+
log.error("%s", exc)
|
|
373
|
+
return EXIT_IO
|
|
374
|
+
except (SeismicResponseError, ValueError) as exc:
|
|
375
|
+
log.error("%s", exc)
|
|
376
|
+
return EXIT_INPUT
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _exit_code(exc: Exception) -> int:
|
|
380
|
+
if isinstance(exc, UnsupportedFeatureError):
|
|
381
|
+
return EXIT_INPUT
|
|
382
|
+
if isinstance(exc, ResponseParseError):
|
|
383
|
+
return EXIT_PARSE
|
|
384
|
+
if isinstance(exc, ResponseValueError):
|
|
385
|
+
return EXIT_VALIDATION
|
|
386
|
+
return EXIT_INPUT
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _run_evalresp(a: _Args) -> int:
|
|
390
|
+
log = logging.getLogger("seismic_response")
|
|
391
|
+
hour, minute, second = a.time
|
|
392
|
+
try:
|
|
393
|
+
when = datetime(a.year, 1, 1, tzinfo=timezone.utc) + timedelta(
|
|
394
|
+
days=a.jday - 1, hours=hour, minutes=minute, seconds=second
|
|
395
|
+
)
|
|
396
|
+
except (ValueError, OverflowError):
|
|
397
|
+
raise _InputError(f"Invalid year/day: {a.year} {a.jday}") from None
|
|
398
|
+
|
|
399
|
+
freqs = generate_frequencies(a.minfreq, a.maxfreq, a.nfreq, linear=a.spacing == "lin")
|
|
400
|
+
sncls = _build_sncls(a)
|
|
401
|
+
fmt = a.format or ("fap" if a.stdio else "ap")
|
|
402
|
+
|
|
403
|
+
sources: List[Tuple[str, bytes]] = []
|
|
404
|
+
if a.stdio:
|
|
405
|
+
if a.file:
|
|
406
|
+
log.warning("Using stdio so ignoring file '%s'", a.file)
|
|
407
|
+
sources.append(("stdin", sys.stdin.buffer.read()))
|
|
408
|
+
else:
|
|
409
|
+
for name in _find_files(a.file, sncls):
|
|
410
|
+
try:
|
|
411
|
+
with open(name, "rb") as fp:
|
|
412
|
+
sources.append((name, fp.read()))
|
|
413
|
+
except OSError:
|
|
414
|
+
log.error("Cannot open %s", name)
|
|
415
|
+
return EXIT_IO
|
|
416
|
+
|
|
417
|
+
stages = None
|
|
418
|
+
if a.start_stage is not None:
|
|
419
|
+
# evalresp: a lone start stage selects just that stage
|
|
420
|
+
stages = (a.start_stage, a.stop_stage if a.stop_stage else a.start_stage)
|
|
421
|
+
|
|
422
|
+
results = []
|
|
423
|
+
matched = False
|
|
424
|
+
status = EXIT_OK
|
|
425
|
+
for name, data in sources:
|
|
426
|
+
try:
|
|
427
|
+
channels = _read_channels(data, os.path.basename(name), a.xml)
|
|
428
|
+
except SeismicResponseError as exc:
|
|
429
|
+
log.error("%s", exc)
|
|
430
|
+
status = status or _exit_code(exc)
|
|
431
|
+
continue
|
|
432
|
+
for chan in _select(channels, sncls, None, when):
|
|
433
|
+
matched = True
|
|
434
|
+
try:
|
|
435
|
+
res = evaluate(
|
|
436
|
+
chan,
|
|
437
|
+
freqs,
|
|
438
|
+
unit=a.unit,
|
|
439
|
+
stages=stages,
|
|
440
|
+
use_total_sensitivity=a.total_sensitivity,
|
|
441
|
+
use_estimated_delay=a.estimated_delay,
|
|
442
|
+
b62_x=a.b62_x,
|
|
443
|
+
interpolate_list=a.interpolate,
|
|
444
|
+
)
|
|
445
|
+
except SeismicResponseError as exc: # report and carry on with the next channel
|
|
446
|
+
log.error("%s", exc)
|
|
447
|
+
status = status or _exit_code(exc)
|
|
448
|
+
continue
|
|
449
|
+
results.append((name, res))
|
|
450
|
+
|
|
451
|
+
if not matched:
|
|
452
|
+
log.warning("No channels matched the requested stations, channels and time")
|
|
453
|
+
|
|
454
|
+
for name, res in results:
|
|
455
|
+
if a.verbose:
|
|
456
|
+
for line in describe(res, source=name if not a.stdio else ""):
|
|
457
|
+
log.info("%s", line)
|
|
458
|
+
|
|
459
|
+
# Traditionally FAP is always unwrapped; AP only on request
|
|
460
|
+
for _, res in results:
|
|
461
|
+
for filename, text in render(res, fmt, unwrap=a.unwrap, stdio=a.stdio):
|
|
462
|
+
if a.stdio:
|
|
463
|
+
sep = " " + "-" * 50 + "\n"
|
|
464
|
+
sys.stdout.write(f"{sep} {filename}\n{sep}{text}{sep}")
|
|
465
|
+
else:
|
|
466
|
+
with open(filename, "w", encoding="utf-8", newline="\n") as fp:
|
|
467
|
+
fp.write(text)
|
|
468
|
+
return status
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ------------------------------------------------------------------ umbrella command
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _cmd_info(args: argparse.Namespace) -> int:
|
|
475
|
+
from . import read_response
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
channels = read_response(args.file)
|
|
479
|
+
except (OSError, SeismicResponseError) as exc:
|
|
480
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
481
|
+
return EXIT_IO if isinstance(exc, OSError) else EXIT_PARSE
|
|
482
|
+
|
|
483
|
+
for c in channels:
|
|
484
|
+
print(c)
|
|
485
|
+
if args.stages:
|
|
486
|
+
for st in c.stages:
|
|
487
|
+
label = "total sensitivity" if st.sequence == 0 else f"stage {st.sequence}"
|
|
488
|
+
extra = ""
|
|
489
|
+
if st.gain is not None:
|
|
490
|
+
extra = f", gain {st.gain.value:.6g} @ {st.gain.frequency:g} Hz"
|
|
491
|
+
units = f" [{st.input_units} -> {st.output_units}]" if st.input_units else ""
|
|
492
|
+
print(f" {label}: {st.filter_kind}{extra}{units}")
|
|
493
|
+
return EXIT_OK
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _cmd_gui(args: argparse.Namespace) -> int:
|
|
497
|
+
from .gui.server import serve
|
|
498
|
+
|
|
499
|
+
return serve(host=args.host, port=args.port, open_browser=not args.no_browser, native=args.app, files=args.files)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
503
|
+
"""Entry point for ``seismic-response``."""
|
|
504
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
505
|
+
|
|
506
|
+
# `seismic-response evalresp ...` passes everything through untouched so that
|
|
507
|
+
# evalresp's single-dash options are not interpreted by argparse.
|
|
508
|
+
if argv and argv[0] == "evalresp":
|
|
509
|
+
return evalresp_main(argv[1:])
|
|
510
|
+
|
|
511
|
+
parser = argparse.ArgumentParser(
|
|
512
|
+
prog="seismic-response",
|
|
513
|
+
description="Evaluate seismological instrument responses (StationXML, SEED RESP).",
|
|
514
|
+
epilog="Use 'seismic-response evalresp -h'-style arguments or the 'pyevalresp' command "
|
|
515
|
+
"for the evalresp compatible interface.",
|
|
516
|
+
)
|
|
517
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
518
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
519
|
+
|
|
520
|
+
sub.add_parser("evalresp", help="evalresp compatible command line (see pyevalresp)")
|
|
521
|
+
|
|
522
|
+
p_info = sub.add_parser("info", help="list the channels (and stages) in a response file")
|
|
523
|
+
p_info.add_argument("file")
|
|
524
|
+
p_info.add_argument("-s", "--stages", action="store_true", help="also list each stage")
|
|
525
|
+
p_info.set_defaults(func=_cmd_info)
|
|
526
|
+
|
|
527
|
+
p_gui = sub.add_parser("gui", help="launch the interactive response plotting GUI")
|
|
528
|
+
p_gui.add_argument("files", nargs="*", help="response files to load at startup")
|
|
529
|
+
p_gui.add_argument("--host", default="127.0.0.1")
|
|
530
|
+
p_gui.add_argument("--port", type=int, default=0, help="port to serve on (default: any free port)")
|
|
531
|
+
p_gui.add_argument("--no-browser", action="store_true", help="do not open a browser window")
|
|
532
|
+
p_gui.add_argument("--app", action="store_true", help="use a native desktop window (requires pywebview)")
|
|
533
|
+
p_gui.set_defaults(func=_cmd_gui)
|
|
534
|
+
|
|
535
|
+
args = parser.parse_args(argv)
|
|
536
|
+
if not getattr(args, "func", None):
|
|
537
|
+
parser.print_help()
|
|
538
|
+
return EXIT_OK
|
|
539
|
+
return args.func(args)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
if __name__ == "__main__": # pragma: no cover
|
|
543
|
+
sys.exit(main())
|