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.
@@ -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)
@@ -0,0 +1,8 @@
1
+ """``python -m seismic_response`` runs the ``seismic-response`` command."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -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())