diff --git a/.gitignore b/.gitignore index 0f95b42..e2d826e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ # Directory that gets created when installing /cube_conversion.egg-info/ + +# IRIS StationXML validator which may be downloaded +/stationxml-validator-*.jar diff --git a/README.md b/README.md index d64eff8..4b5cad6 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,25 @@ cube_conversion =============== -This command-line tool converts [DiGOS](https://digos.eu/) DATA-CUBE3 -files into miniSEED files of a desired length of time with specified metadata. -Output miniSEED files have units of Pa, unless the user selects to export the files in -a form suitable for submission to EarthScope (formerly IRIS). The tool -can differentiate between channels for 3 channel DATA-CUBE3 files and -optionally extract coordinates from the digitizer's GPS. The code only looks for -files from digitizers defined in the `digitizer_sensor_pairs.json` file. Therefore, -this file must be updated if pairings change or new pairings are added. The user -can specify a custom "breakout box factor" for setups that modify the signal -voltage via a voltage divider. This tool is currently only set up for conversion -of infrasound data, but future updates will accommodate seismic as well. +These command-line tools convert [DiGOS](https://digos.eu/) DATA-CUBE³ files into +miniSEED files of a desired length of time with specified metadata, and produce +validated StationXML files containing deployment, sensor, and digitizer information +including sensor responses adjusted for individual sensor sensitivities. Output miniSEED +files have units of Pa, unless the user selects to export the files in a form suitable +for submission to EarthScope (formerly IRIS) — namely, integer counts. The miniSEED +conversion tool can differentiate between channels for 3 channel DATA-CUBE³ files and +optionally extract coordinates from the digitizer's GPS. The code only looks for files +from digitizers defined in the `digitizer_sensor_pairs.json` file. Therefore, this file +must be updated if pairings change or new pairings are added. The user can specify a +custom "breakout box factor" for setups that modify the signal voltage via a voltage +divider. This tool is currently only set up for conversion of infrasound data, but +future updates could accommodate seismic as well. Installation ------------ It's recommended that you run this script within a new or pre-existing -[conda](https://docs.conda.io/projects/conda/en/latest/index.html) environment. +[conda](https://docs.conda.io/projects/conda/en/stable/) environment. (If you choose the latter option, ensure that your environment contains all of the packages listed in the [Dependencies](#dependencies) section.) @@ -72,45 +74,58 @@ Supplemental files * `digitizer_offsets.json` — Digitizer offsets in V (We have found that each digitizer has a slight voltage offset from zero) -* `sensor_sensitivities.json` — Infrasound sensor sensitivities in V/Pa +* `sensor_sensitivities.json` — Infrasound sensor model, sensitivity in V/Pa, + and frequency of sensitivity measurement in Hz + +For `cube_stationxml`, you may optionally use a local copy of the Nominal Response +Library (NRL). It can be obtained by following the instructions +[here](https://ds.iris.edu/ds/nrl/). Be sure to unzip the downloaded file to create an +`NRL/` directory. **Note:** Obviously, since this is offline, you may need to update +your local copy if a key response change is made, or if a new sensor is added! Hence, +we recommend only using this local copy option if you're working offline. Usage ----- -To print the script's help menu, execute the following terminal commands: +To run either command-line tool first activate your conda environment, e.g., ``` conda activate uafinfra # Or your pre-existing env +``` + +## `cube_convert` + +To print the conversion script's help menu, run: +``` cube_convert --help # Or: python /path/to/cube_conversion/cube_conversion/cube_convert.py --help ``` The help menu is shown below. ``` -usage: cube_convert [-h] [-v] [--grab-gps] - [--bob-factor BREAKOUT_BOX_FACTOR] [--earthscope] +usage: cube_convert [-h] [-v] [--grab-gps] [--bob-factor BREAKOUT_BOX_FACTOR] + [--earthscope] input_dir [input_dir ...] output_dir network station {01,02,03,04,AUTO} {AUTO,BDF,HDF,CDF} -Convert DATA-CUBE files to miniSEED files while trimming, adding metadata, and -renaming. Optionally extract coordinates from digitizer GPS. +Convert DATA-CUBE files to miniSEED files while trimming, adding metadata, and renaming. +Optionally extract coordinates from digitizer GPS. positional arguments: - input_dir one or more directories containing raw DATA-CUBE files - (all files must originate from a single digitizer) - [wildcards (*) supported] + input_dir one or more directories containing raw DATA-CUBE files (all files + must originate from a single digitizer) [wildcards (*) supported] output_dir directory for output miniSEED and GPS-related files network desired SEED network code (2 characters, A-Z) station desired SEED station code (3-5 characters, A-Z & 0-9) - {01,02,03,04,AUTO} desired SEED location code (if AUTO, choose - automatically for 3 channel DATA-CUBE files) - {AUTO,BDF,HDF,CDF} desired SEED channel code (if AUTO, determine - automatically using SEED convention [preferred]) + {01,02,03,04,AUTO} desired SEED location code (if AUTO, choose automatically for 3 + channel DATA-CUBE files) + {AUTO,BDF,HDF,CDF} desired SEED channel code (if AUTO, determine automatically using + SEED convention [preferred]) options: -h, --help show this help message and exit -v, --verbose enable verbosity for GIPPtools commands --grab-gps additionally extract coordinates from digitizer GPS --bob-factor BREAKOUT_BOX_FACTOR - factor by which to divide sensitivity values (for - custom breakout boxes [4.5 for UAF DATA-CUBEs]) + factor by which to divide sensitivity values (for custom breakout + boxes [4.5 for UAF DATA-CUBEs]) --earthscope format miniSEED files for EarthScope (formerly IRIS) data upload ``` For example, the command @@ -122,6 +137,41 @@ means "convert all files in the subdirectories of `~/data/raw/` and place in **01**, and an automatically determined channel code, dividing the sensitivity by 4.5 and extracting coordinates from the digitizer's GPS." +## `cube_stationxml` + +To print the StationXML generation script's help menu, run: +``` +cube_stationxml --help # Or: python /path/to/cube_conversion/cube_conversion/cube_stationxml.py --help +``` +The help menu is shown below. +``` +usage: cube_stationxml [-h] [--nrl-path NRL_PATH] [--validate] + input_dir station_mapping [station_mapping ...] output_filename + +Generate StationXML files from DATA-CUBE³ miniSEED files and metadata. + +positional arguments: + input_dir directory containing miniSEED files and coordinate files produced + by cube_convert + station_mapping one or more mappings of the form + STATION_CODE:CUBE_NAME:SENSOR_SERIAL, for example UAF1:AVJ:903V2 + output_filename filename for the output StationXML file (full path) + +options: + -h, --help show this help message and exit + --nrl-path NRL_PATH path to local copy of the NRL (Nominal Response Library) + directory, if not provided makes web services call + --validate run the IRIS StationXML validator on the output file +``` +For example, the command +``` +cube_stationxml ~/data/miniseed/ UAF1:B52:903V2 ~/data/station.xml --validate +``` +means "process all miniSEED files in `~/data/miniseed/` assuming that station **UAF1** +consisted of DATA-CUBE³ **B52** connected to sensor serial number **903V2**, and output +a StationXML file to `~/data/station.xml`, validating the output file with the IRIS +StationXML validator." + A note on SEED band codes ------------------------- @@ -147,7 +197,7 @@ letter of the channel code; e.g., the "B" in "BDF". | R | Extremely long-period | ≥ 0.0001 to < 0.001 | | Note that the band code depends on both the sample rate of the digitizer and -the corner period of the sensor. In `cube_convert.py` we allow for "B", "H", or +the corner period of the sensor. In `cube_convert` we allow for "B", "H", or "C", which covers a range of sample rates from 10 to 1000 Hz, all for corner periods of 10 s or greater. While this covers most infrasound sensors, please confirm that your digitizer sample rate and sensor corner period fit into the diff --git a/cube_conversion/cube_convert.py b/cube_conversion/cube_convert.py index dab04c5..1ee159a 100755 --- a/cube_conversion/cube_convert.py +++ b/cube_conversion/cube_convert.py @@ -161,7 +161,7 @@ def main(): else: sensor = digitizer_sensor_pairs[digitizer] try: - sensitivity = sensitivities[sensor] + sensitivity = sensitivities[sensor]['sensitivity'] except KeyError: warnings.warn('No matching sensitivities. Using default of ' f'{DEFAULT_SENSITIVITY} V/Pa.') diff --git a/cube_conversion/cube_stationxml.py b/cube_conversion/cube_stationxml.py new file mode 100755 index 0000000..c2e3bde --- /dev/null +++ b/cube_conversion/cube_stationxml.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python + +""" +Make StationXML files from scratch, given an existing directory of miniSEED files and +metadata generated by cube_convert. Adopted from the ObsPy tutorial here: + https://docs.obspy.org/tutorial/code_snippets/stationxml_file_from_scratch.html + +The code assumes that your sensors are all Chaparral Physics products, that your +digitizers are all DiGOS DATA-CUBE³s, and — IMPORTANTLY — that you only have one sensor +connected to each digitizer (i.e., one channel per station). +""" + +import argparse +import json +import os +import subprocess +import warnings +import xml.etree.ElementTree as ET +from pathlib import Path +from urllib.request import urlretrieve + +import requests +from obspy import Stream, read, read_inventory +from obspy.clients.nrl import NRL +from obspy.core.inventory import ( + Channel, + Equipment, + Inventory, + Network, + PolesZerosResponseStage, + Site, + Station, +) + +# -------------------------------------------------------------------------------------- +# Advanced configuration options +# -------------------------------------------------------------------------------------- +GAIN = 1 # DiGOS DATA-CUBE³ amplifier gain (this should usually be 1!) +BOB_FACTOR = 10 # Breakout box factor for DATA-CUBE³s (this should usually be 10!) +# -------------------------------------------------------------------------------------- + +# These names must match EXACTLY what is in the NRL (v2). See here: +# https://ds.iris.edu/ds/nrl/ +_SENSOR_MANUFACTURER = 'Chaparral' +_DATALOGGER_MANUFACTURER = 'DiGOSOmnirecs' +_DATALOGGER_MODEL = 'DataCube' + +# Base url for all NRL web service queries +_BASE_URL = 'https://service.iris.edu/irisws/nrl/1/' + +# Ignore warning about Pa units +warnings.filterwarnings( + 'ignore', + category=UserWarning, + message="ObsPy can not map unit 'PA' to displacement, velocity, or acceleration", +) + + +# Define callable main function to work with [project.scripts] +def main(): + + # Set up command-line interface + parser = argparse.ArgumentParser( + description='Generate StationXML files from DATA-CUBE³ miniSEED files and metadata.', + allow_abbrev=False, + ) + parser.add_argument( + 'input_dir', + help='directory containing miniSEED files and coordinate files produced by cube_convert', + ) + parser.add_argument( + 'station_mapping', + nargs='+', + help='one or more mappings of the form STATION_CODE:CUBE_NAME:SENSOR_SERIAL, for example UAF1:AVJ:903V2', + ) + parser.add_argument( + 'output_filename', help='filename for the output StationXML file (full path)' + ) + parser.add_argument( + '--nrl-path', + default=None, + help='path to local copy of the NRL (Nominal Response Library) directory, if not provided makes web services call', + ) + parser.add_argument( + '--validate', + action='store_true', + help='run the IRIS StationXML validator on the output file', + ) + input_args = parser.parse_args() + + # Check if input directory is valid + input_dir = Path(input_args.input_dir) + if not input_dir.is_dir(): + raise NotADirectoryError(f'Input directory \'{input_dir}\' doesn\'t ' 'exist.') + + # Parse mappings into dictionary with keys being the station codes (TODO: validate!) + station_mappings = {} + for mapping in input_args.station_mapping: + station_code, cube_name, sensor_serial = mapping.split(':') + station_mappings[station_code] = { + 'cube_name': cube_name, + 'sensor_serial': sensor_serial, + } + + # Check if NRL path is a directory if provided + nrl_path = input_args.nrl_path + if nrl_path is not None: + nrl_path = Path(input_args.nrl_path).expanduser().absolute() + if not nrl_path.is_dir(): + raise NotADirectoryError( + f'NRL path `{nrl_path}{os.sep}` is not a directory!' + ) + print(f'Using local copy of NRL at `{nrl_path}{os.sep}`') + + # Find root directory for cube_conversion repo + root_dir = Path(__file__).parents[1] + + # Load sensor information including sensitivities, model numbers, etc. + with open(root_dir / 'sensor_sensitivities.json') as f: + sensor_info = json.load(f) + + # Read in all data into Stream object for processing + st = Stream() + for mseed_file in sorted(input_dir.glob('??.*.*.???.????.???.??')): + st += read(mseed_file) + + # Process SEED network code + network_codes = set(tr.stats.network for tr in st) + assert len(network_codes) == 1, 'Multiple network codes found in data!' + network_code = network_codes.pop() + + # Process SEED station codes + station_codes = sorted(set(tr.stats.station for tr in st)) + + # Check SEED location codes, as these can indicate if multiple sensors are connected + # to the same digitizer (not supported by this script) + location_codes = sorted(set(tr.stats.location for tr in st)) + assert len(location_codes) == 1, 'Multiple location codes found in data!' + location_code = location_codes.pop() + + # Create Inventory with all required components + inv = Inventory() + net = Network(code=network_code) + for station in station_codes: + + # Get info about this station from mappings and sensor info + serial_number = station_mappings[station]['sensor_serial'] + cube_name = station_mappings[station]['cube_name'] + sensor_model = sensor_info[serial_number]['model'] + print('\n------------------------------') + print(f'{network_code}.{station}') + print('------------------------------') + print(f'Cube name: {cube_name}') + print(f'Sensor serial number: {serial_number}') + print(f'Sensor model: {sensor_model}') + + # Get station start and end times, SEED channel code, sample rate... + st_station = st.select(station=station) + station_starttime = min(tr.stats.starttime for tr in st_station) + station_endtime = max(tr.stats.endtime for tr in st_station) + channel_codes = sorted(set(tr.stats.channel for tr in st_station)) + assert ( + len(channel_codes) == 1 + ), f'Multiple channel codes found in for station {station}!' + channel_code = channel_codes.pop() + sample_rates = sorted(set(tr.stats.sampling_rate for tr in st_station)) + assert len(sample_rates) == 1, 'Multiple sample rates found in data!' + sample_rate = sample_rates.pop() + # Read in coordinates from JSON file + coord_json_file = sorted( + input_dir.glob( + f'{network_code}.{station}.{location_code}.{channel_code}.json' + ) + ) + if len(coord_json_file) == 0: + raise FileNotFoundError( + f'No coordinate JSON file found for station {station}!' + ) + elif len(coord_json_file) > 1: + raise ValueError # Almost impossible, but just in case + else: + coord_json_file = coord_json_file[0] + with open(coord_json_file) as f: + latitude, longitude, elevation = json.load(f) + # Make Station object + sta = Station( + code=station, + latitude=latitude, + longitude=longitude, + elevation=elevation, + site=Site(name=station), # Bare minumum... could make this more detailed + start_date=station_starttime, + end_date=station_endtime, + ) + # Define sensor Equipment object + sensor = Equipment( + type='Infrasound sensor', + description=f'{_SENSOR_MANUFACTURER} {sensor_model}', # MDA shows this! + manufacturer=_SENSOR_MANUFACTURER, + model=sensor_model, + serial_number=serial_number, + ) + # Define digitizer Equipment object + data_logger = Equipment( + type='Digitizer', + manufacturer=_DATALOGGER_MANUFACTURER, + model=_DATALOGGER_MODEL, + serial_number=cube_name, + ) + # Make Channel object + cha = Channel( + code=channel_code, + location_code=location_code, + latitude=latitude, # Could be different if multi-channel setup! + longitude=longitude, # Could be different if multi-channel setup! + elevation=elevation, + depth=0, # Required, always 0? + sample_rate=sample_rate, + start_date=station_starttime, # Could be different if multi-channel setup! + end_date=station_endtime, # Could be different if multi-channel setup! + sensor=sensor, + data_logger=data_logger, + ) + # Access the NRL to get response information. If the user provided a local path + # to the NRL, use that; otherwise, use the NRL web service (requires network + # connection). + if nrl_path is not None: + nrl = NRL(str(nrl_path)) + lp_corner = list(nrl.sensors[_SENSOR_MANUFACTURER][sensor_model]) + assert len(lp_corner) == 1, 'Multiple low-pass corner options found!' + lp_corner = lp_corner[0] + hf_corner = list(nrl.sensors[_SENSOR_MANUFACTURER][sensor_model][lp_corner]) + assert len(hf_corner) == 1, 'Multiple high-pass corner options found!' + hf_corner = hf_corner[0] + sensor_keys = [_SENSOR_MANUFACTURER, sensor_model, lp_corner, hf_corner] + datalogger_keys = [ + _DATALOGGER_MANUFACTURER, + _DATALOGGER_MODEL, + f'{GAIN:g}', + f'{sample_rate:g} Hz', + ] + # The contents of the NRL can be explored interactively in a Python prompt, see + # API documentation of NRL submodule: + # http://docs.obspy.org/packages/obspy.clients.nrl.html + # Here we assume that the end point of data logger and sensor are already known. + + # Get the nominal response for this combination of sensor and digitizer + try: + response = nrl.get_response( + sensor_keys=sensor_keys, datalogger_keys=datalogger_keys + ) + except KeyError as e: + msg = ( + f'Could not find response in NRL for sensor keys {sensor_keys} and ' + f'datalogger keys {datalogger_keys}.' + f'\n\n{nrl.sensors[sensor_keys[0]]}' + ) + raise Exception(msg) from e + else: # Use NRL web service + # (1) Locate the `instconfig` for the Chaparral Physics sensor + params_catalog = dict( + element='sensor', + manufacturer=_SENSOR_MANUFACTURER, + model=sensor_model, + format='xml', + level='configuration', + ) + response = requests.get(url=_BASE_URL + 'catalog', params=params_catalog) + root = ET.fromstring(response.text) + sensor_instconfigs = [element.text for element in root.iter('instconfig')] + assert len(sensor_instconfigs) == 1, 'Multiple instrument configs found!' + sensor_instconfig = sensor_instconfigs[0] + # (2) Define the `instconfig` for the DiGOS DATA-CUBE³ digitizer (easy!) + digitizer_instconfig = f'datalogger_{_DATALOGGER_MANUFACTURER}_{_DATALOGGER_MODEL}_PG{GAIN:g}_FR{sample_rate:g}' + # (3) Combine the two `instconfig` responses into a single StationXML response + params_combine = dict( + instconfig=':'.join([sensor_instconfig, digitizer_instconfig]), + format='stationxml', + ) + request = requests.Request( + 'GET', url=_BASE_URL + 'combine', params=params_combine + ) + stationxml_url = request.prepare().url + # (4) Read the combined StationXML response into ObsPy + response = read_inventory(stationxml_url)[0][0][0].response + # KEY: Add a response stage which applies the breakout box factor, after first + # stage (i.e., right after Pa --> V) — this should be a PZ stage, see: + # https://docs.fdsn.org/projects/stationxml/en/latest/reference.html#response-stage + bob_stage_sequence_number = 2 + bob_stage = PolesZerosResponseStage( + description='DiGOS breakout box voltage step-down', + stage_sequence_number=bob_stage_sequence_number, + stage_gain=1 / BOB_FACTOR, + stage_gain_frequency=1, # [Hz] Anywhere where the response is flat? + input_units='V', + output_units='V', + pz_transfer_function_type='LAPLACE (RADIANS/SECOND)', + normalization_frequency=0, + normalization_factor=1, + poles=[], + zeros=[], + ) + response.response_stages.insert(bob_stage_sequence_number - 1, bob_stage) + for response_stage in response.response_stages[bob_stage_sequence_number:]: + response_stage.stage_sequence_number += 1 # Increment following stage #s + # KEY: Update with our specific measured sensor sensitivity + nominal_sensitivity = response.response_stages[0].stage_gain + measured_sensitivity = sensor_info[serial_number]['sensitivity'] + nominal_frequency = response.response_stages[0].stage_gain_frequency + measured_frequency = sensor_info[serial_number]['frequency'] + response.response_stages[0].stage_gain = measured_sensitivity + response.response_stages[0].stage_gain_frequency = measured_frequency + print('Updated sensor sensitivity:') + print(f'\t{nominal_sensitivity} --> {measured_sensitivity} V/Pa') + print(f'\t{nominal_frequency:.2f} --> {measured_frequency:.2f} Hz') + # KEY: Recalculate overall sensitivity (at the calibration frequency) + response.recalculate_overall_sensitivity(frequency=measured_frequency) + + cha.response = response + sta.channels.append(cha) + net.stations.append(sta) + + # Set network start and end dates from station info + net.start_date = min([sta.start_date for sta in net]) + net.end_date = max([sta.end_date for sta in net]) + + inv.networks.append(net) + + # Write to StationXML file + output_filename = ( + Path(input_args.output_filename.rstrip('.xml') + '.xml').expanduser().absolute() + ) + inv.write(output_filename, format='stationxml', validate=True) + print(f'\nWrote StationXML file to `{output_filename}`\n') + + # Download and run the StationXML validator, if user wants to + if input_args.validate: + # Functions for colored printing... helpful for highlighting errors in validator + # output! + # fmt: off + def print_red(string): + print('\u001b[31m' + string + '\u001b[0m') + def print_yellow(string): + print('\u001b[33m' + string + '\u001b[0m') + # fmt: on + # JAR file will downloaded here if it doesn't already exist + jar_file_path = root_dir / 'stationxml-validator-1.7.5.jar' # Selects version! + if jar_file_path.is_file(): + print(f'Found `{jar_file_path.name}` — running command:') + else: + print(f'Downloading `{jar_file_path.name}`...') + url_base = 'https://github.com/iris-edu/stationxml-validator/releases/' + urlretrieve( + url_base + f'download/{jar_file_path.stem}/{jar_file_path.name}', + jar_file_path, + ) + print('...done. Running command:') + args = ['java', '-jar', jar_file_path, output_filename] + print('```') + print(' '.join([str(arg) for arg in args])) + print('```') + print('------------------------------') + print('Output from validator') + print('------------------------------') + process = subprocess.run(args, capture_output=True, text=True) + if process.stderr: # Something went wrong! + print_red(process.stderr.strip()) + elif process.stdout: # Normal operation + for line in process.stdout.strip().split('\n'): + if ',Error,' in line: + print_red(line) + elif ',Warning,' in line: + print_yellow(line) + else: + print(line) + else: # This will never happen? + raise OSError + + +# Run the main function if this is called as a script +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml index fa190c0..5ab6ca4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,3 +8,4 @@ build-backend = "setuptools.build_meta" [project.scripts] cube_convert = "cube_conversion.cube_convert:main" +cube_stationxml = "cube_conversion.cube_stationxml:main" diff --git a/sensor_sensitivities.json b/sensor_sensitivities.json index d29aff7..a7ba515 100644 --- a/sensor_sensitivities.json +++ b/sensor_sensitivities.json @@ -1,18 +1,82 @@ { -"SN46" : 0.00902, -"SN47" : 0.00898, -"SN48" : 0.00909, -"SN49" : 0.00904, -"SN50" : 0.00905, -"SN51" : 0.00902, -"SN73" : 0.00898, -"SN74" : 0.00903, -"SN80" : 0.00905, -"SN81" : 0.00898, -"SN82" : 0.00901, -"SN83" : 0.00902, -"SN84" : 0.00902, -"SN85" : 0.00902, -"SN86" : 0.00898, -"SN87" : 0.00903 + "SN46": { + "model": "?", + "sensitivity": 0.00902, + "frequency": -9999 + }, + "SN47": { + "model": "?", + "sensitivity": 0.00898, + "frequency": -9999 + }, + "SN48": { + "model": "60-UHP", + "sensitivity": 0.00909, + "frequency": -9999 + }, + "SN49": { + "model": "60-UHP", + "sensitivity": 0.00904, + "frequency": -9999 + }, + "SN50": { + "model": "60-UHP", + "sensitivity": 0.00905, + "frequency": -9999 + }, + "SN51": { + "model": "60-UHP", + "sensitivity": 0.00902, + "frequency": -9999 + }, + "SN73": { + "model": "?", + "sensitivity": 0.00898, + "frequency": -9999 + }, + "SN74": { + "model": "60-UHP", + "sensitivity": 0.00903, + "frequency": -9999 + }, + "SN80": { + "model": "?", + "sensitivity": 0.00905, + "frequency": -9999 + }, + "SN81": { + "model": "?", + "sensitivity": 0.00898, + "frequency": -9999 + }, + "SN82": { + "model": "?", + "sensitivity": 0.00901, + "frequency": -9999 + }, + "SN83": { + "model": "?", + "sensitivity": 0.00902, + "frequency": -9999 + }, + "SN84": { + "model": "?", + "sensitivity": 0.00902, + "frequency": -9999 + }, + "SN85": { + "model": "?", + "sensitivity": 0.00902, + "frequency": -9999 + }, + "SN86": { + "model": "?", + "sensitivity": 0.00898, + "frequency": -9999 + }, + "SN87": { + "model": "?", + "sensitivity": 0.00903, + "frequency": -9999 + } }