diff --git a/PIQQA.py b/PIQQA.py
index cafbb9b..4a60760 100755
--- a/PIQQA.py
+++ b/PIQQA.py
@@ -24,7 +24,7 @@
"""
-version = "v1.0.2"
+version = "v1.1.0"
import reportUtils
import pandas as pd
@@ -42,18 +42,20 @@
def checkAvailability(
- thisNetwork, thisStation, thisLocation, thisChannel, thisStart, thisEnd
+ thisNetwork, thisStation, thisLocation, thisChannel, thisStart, thisEnd, tsMetricsExist
):
- thisAvDF = reportUtils.addMetricToDF(
- "ts_percent_availability_total",
- pd.DataFrame(),
- thisNetwork,
- thisStation,
- thisLocation,
- thisChannel,
- thisStart,
- thisEnd,
- )
+ thisAvDF = pd.DataFrame()
+ if tsMetricsExist:
+ thisAvDF = reportUtils.addMetricToDF(
+ "ts_percent_availability_total",
+ pd.DataFrame(),
+ thisNetwork,
+ thisStation,
+ thisLocation,
+ thisChannel,
+ thisStart,
+ thisEnd,
+ )
if thisAvDF.empty:
# need to round out the start and end times for non-ts_percent_availability_total, in case it's very short
@@ -103,7 +105,7 @@ def checkAvailability(
except:
thisPctAvail = 0
print(
- f"**** WARNING: No Percent Availability found for {thisNetwork.thisStation.thisLocation.thisChannel} - are services down? ****"
+ f"**** WARNING: No Percent Availability found for {thisNetwork}.{thisStation}.{thisLocation}.{thisChannel} - are services down? ****"
)
return thisPctAvail
@@ -125,7 +127,14 @@ def doAvailability(
nBottom,
tolerance,
imageDir,
+ tsMetricsExist,
):
+ # Test whether the availability service(s) relevant to this network are up (fdsnws-availability
+ # and ph5ws-availability can go down independently of each other); if not, fall back to
+ # MUSTANG percent_availability extents (no gap-level detail) rather than skipping the plot
+ availability_exists_by_service = reportUtils.checkAvailabilityServicesExist(
+ network, stations, locations, channels, startDate, endDate
+ )
print("INFO: Producing availability plot")
avFilesDictionary = {} # collects plot filenames
@@ -152,7 +161,7 @@ def doAvailability(
actualChannels = sorted(list(set(allMetadataDF["channel"])))
except:
quit(
- "\nQUITTING: No information found for network and times, is something wrong? Is there data at IRIS for this network? Does this network have channels with the Z component?"
+ "\nQUITTING: No information found for network and times, is something wrong? Is there data at EarthScope for this network? Does this network have channels with the Z component?"
)
## Loop over the channel groups
@@ -162,30 +171,32 @@ def doAvailability(
tmpMetadataDF = allMetadataDF[allMetadataDF["channel"] == channelGroup]
## Get percent availability numbers for all targets for this channelGroup so that you can narrow it down to the top/bottom available
- # First try to use ts_percent_availability_total
+ # First try to use ts_percent_availability_total, unless we already know ts_ metrics are down
pctAvDF = pd.DataFrame()
- print(
- f" INFO: Retrieving percent availability information for {channelGroup}"
- )
- try:
- pctAvDF = reportUtils.addMetricToDF(
- "ts_percent_availability_total",
- pctAvDF,
- network,
- stations,
- locations,
- channelGroup,
- startDate,
- endDate,
+ if tsMetricsExist:
+ print(
+ f" INFO: Retrieving percent availability information for {channelGroup}"
)
- except:
- print(f"ERROR: Trouble accessing ts_percent_availability_total")
+ try:
+ pctAvDF = reportUtils.addMetricToDF(
+ "ts_percent_availability_total",
+ pctAvDF,
+ network,
+ stations,
+ locations,
+ channelGroup,
+ startDate,
+ endDate,
+ )
+ except:
+ print(f"ERROR: Trouble accessing ts_percent_availability_total")
- if not pctAvDF.empty:
- pctAvDF.rename(
- columns={"ts_percent_availability_total": "availability"}, inplace=True
- )
+ if not pctAvDF.empty:
+ pctAvDF.rename(
+ columns={"ts_percent_availability_total": "availability"},
+ inplace=True,
+ )
# If not all targets have ts_percent_availability_total, then try getting it from percent_availability
@@ -259,12 +270,35 @@ def doAvailability(
) # Only used to get the number of stations, the list isn't actually used otherwise
nsta = len(stationList)
+ # Determine which availability service(s) this channel group's targets actually need
+ # (fdsnws and/or ph5ws), and only draw the extents/gaps plot if all of them are up.
+ relevantServices = {
+ reportUtils.serviceForQuality(s.split(".")[-1]) for s in pctAvDF["snclq"]
+ }
+ channelGroupAvailabilityExists = all(
+ availability_exists_by_service.get(svc, False) for svc in relevantServices
+ )
+
if nsta > nBoxPlotSta:
splitPlots = 1
topStations = pctAvDF.iloc[:nTop, :]
bottomStations = pctAvDF.iloc[-nBottom:, :]
+ # Station selection only needs MUSTANG percent-availability data, so this stays
+ # populated (for PDFs/Spectrograms downstream) even if the availability service is down.
+ try:
+ topStationList = topStations["station"].tolist()
+ except:
+ topStationList = tmpMetadataDF["station"].tolist()[:nTop]
+
+ topStationsDict[channelGroup] = topStationList
+
+ if not channelGroupAvailabilityExists:
+ # Can't draw the extents/gaps plot without the availability service - skip the plot,
+ # but topStationsDict above still lets PDFs/Spectrograms pick stations for this channel group.
+ continue
+
height = max(min(0.3 * nsta, 0.3 * nBoxPlotSta), 2)
width = 15
f, (ax1, ax2) = plt.subplots(2, 1, sharex=True, figsize=(width, height))
@@ -284,18 +318,11 @@ def doAvailability(
if service not in services_used:
services_used.append(service)
- try:
- topStationList = topStations["station"].tolist()
- except:
- topStationList = tmpMetadataDF["station"].tolist()[:nTop]
-
topLabels = [
f"{a} ({b:.3f}%)"
for a, b in zip(topStations["station"], topStations["availability"])
]
- topStationsDict[channelGroup] = topStationList
-
datalines = []
metadatalines = []
gaplines = []
@@ -405,6 +432,7 @@ def doAvailability(
bottomStationsAvDF, services = reportUtils.getAvailability(
bottomStations["snclq"], startDate, endDate, tolerance, ""
)
+
for service in services:
if service not in services_used:
services_used.append(service)
@@ -422,7 +450,6 @@ def doAvailability(
stn = 0
for station in bottomStationList:
-
# data extents and gaps extents
if (
not bottomStations[
@@ -516,7 +543,7 @@ def doAvailability(
ax2.add_collection(GapLines)
ax2.autoscale()
ax2.invert_yaxis()
- ax2.set_yticks(np.arange(nTop))
+ ax2.set_yticks(np.arange(nBottom))
ax2.set_yticklabels(bottomLabels)
ax2.set_xlim(
[mpl.dates.datestr2num(startDate), mpl.dates.datestr2num(endDate)]
@@ -553,6 +580,13 @@ def doAvailability(
## Then repeat for the case where they aren't broken up by top/bottom
else:
+ allStationList = pctAvDF["station"].tolist()
+ topStationsDict[channelGroup] = allStationList
+
+ if not channelGroupAvailabilityExists:
+ # Can't draw the extents/gaps plot without the availability service - skip the plot,
+ # but topStationsDict above still lets PDFs/Spectrograms pick stations for this channel group.
+ continue
height = max(min(0.3 * nsta, 0.3 * nBoxPlotSta), 2)
width = 15
@@ -565,12 +599,10 @@ def doAvailability(
if service not in services_used:
services_used.append(service)
- allStationList = pctAvDF["station"].tolist()
allLabels = [
f"{a} ({b:.3f}%)"
for a, b in zip(pctAvDF["station"], pctAvDF["availability"])
]
- topStationsDict[channelGroup] = allStationList
datalines = []
metadatalines = []
@@ -715,6 +747,7 @@ def doAvailability(
avFilesDictionary,
services_used,
availabilityType,
+ availability_exists_by_service,
)
@@ -736,10 +769,17 @@ def doBoxPlots(
nBottom,
includeOutliers,
imageDir,
+ tsMetricsExist,
):
# Retrieve metrics from mustang service
print("INFO: Generating Boxplots")
print(" INFO: Retrieving metrics from the MUSTANG webservices...")
+ if not tsMetricsExist:
+ if "ts_num_gaps_total" in metricList:
+ metricList.remove("ts_num_gaps_total")
+ if "ts_num_gaps" in metricList:
+ metricList.remove("ts_num_gaps")
+ metricList.append("num_gaps")
metricsWithPlots = list()
metricDF = pd.DataFrame()
@@ -820,7 +860,7 @@ def doBoxPlots(
if tmpDF.empty:
tmpDF = thisDF.copy()
else:
- tmpDF = tmpDF.append(thisDF, ignore_index=True)
+ tmpDF = pd.concat([tmpDF, thisDF], ignore_index=True)
columnsToKeep = [
"snclq",
@@ -931,7 +971,7 @@ def doBoxPlots(
print(f" INFO: Generating Boxplots for {channelGroup}")
tmpDF = scaledDF[scaledDF["target"].str.endswith(channelGroup)]
- grouped = tmpDF.groupby(["station"])
+ grouped = tmpDF.groupby("station")
filenames = list()
for metric in metricList:
@@ -1140,6 +1180,8 @@ def doPDFs(
network,
stations,
locations,
+ availabilityExistsByService,
+ tsMetricsExist,
):
pdfDictionary = {}
@@ -1157,7 +1199,7 @@ def doPDFs(
# Use the dataframe with the SCALED sample_rms to determine the top/bottom station/target
tmpDF = scaledDF[scaledDF["target"].str.endswith(channelGroup)]
- grouped = tmpDF.groupby(["snclq"])
+ grouped = tmpDF.groupby("snclq")
try:
df2 = pd.DataFrame(
@@ -1194,28 +1236,33 @@ def doPDFs(
thisChannel = lowestTarget.split(".")[3]
# Check the availablity service to get the start and end to the data
- thisStationAvDF, service = reportUtils.getAvailability(
- [lowestTarget], startDate, endDate, tolerance, ""
- )
- thisAvail = thisStationAvDF[
- (thisStationAvDF["station"] == thisStation)
- & (thisStationAvDF["network"] == thisNetwork)
- & (thisStationAvDF["location"] == thisLocation)
- & (thisStationAvDF["channel"] == thisChannel)
- ]
-
- thisStart = thisAvail["earliest"].dt.strftime("%Y-%m-%dT%H:%M:%S").min()
- thisEnd = thisAvail["latest"].dt.strftime("%Y-%m-%dT%H:%M:%S").max()
+ thisStart, thisEnd = reportUtils.getDataStartEnd(
+ lowestTarget,
+ thisNetwork,
+ thisStation,
+ thisLocation,
+ thisChannel,
+ startDate,
+ endDate,
+ tolerance,
+ availabilityExistsByService,
+ )
+ foundit = False
print(
f" INFO: Checking percent availability for {thisNetwork}.{thisStation}.{thisLocation}.{thisChannel} to see if there is as least 75% availability"
)
# get the ts_percent_availatility_total (or percent_availability if ph5) for the time between the data start and end.
thisPctAvail = checkAvailability(
- thisNetwork, thisStation, thisLocation, thisChannel, thisStart, thisEnd
+ thisNetwork,
+ thisStation,
+ thisLocation,
+ thisChannel,
+ thisStart,
+ thisEnd,
+ tsMetricsExist,
)
- foundit = False
if thisPctAvail > 75:
# Then we can use it and break the cycle.
foundit = True
@@ -1268,28 +1315,33 @@ def doPDFs(
thisChannel = largestTarget.split(".")[3]
# Get start and end to the data for this target
- thisStationAvDF, service = reportUtils.getAvailability(
- [largestTarget], startDate, endDate, tolerance, ""
- )
- thisAvail = thisStationAvDF[
- (thisStationAvDF["station"] == thisStation)
- & (thisStationAvDF["network"] == thisNetwork)
- & (thisStationAvDF["location"] == thisLocation)
- & (thisStationAvDF["channel"] == thisChannel)
- ]
-
- thisStart = thisAvail["earliest"].dt.strftime("%Y-%m-%dT%H:%M:%S").min()
- thisEnd = thisAvail["latest"].dt.strftime("%Y-%m-%dT%H:%M:%S").max()
+ thisStart, thisEnd = reportUtils.getDataStartEnd(
+ largestTarget,
+ thisNetwork,
+ thisStation,
+ thisLocation,
+ thisChannel,
+ startDate,
+ endDate,
+ tolerance,
+ availabilityExistsByService,
+ )
+ foundit = False
print(
f" INFO: Checking percent availability for {thisNetwork}.{thisStation}.{thisLocation}.{thisChannel} to see if there is as least 75% availability"
)
# get the percent availability between the start and end times of the data
thisPctAvail = checkAvailability(
- thisNetwork, thisStation, thisLocation, thisChannel, thisStart, thisEnd
+ thisNetwork,
+ thisStation,
+ thisLocation,
+ thisChannel,
+ thisStart,
+ thisEnd,
+ tsMetricsExist,
)
- foundit = False
if thisPctAvail > 75:
# Needs to have >75% and not be the same target that we've already selected for lowest RMS
# Then we can use it and break the cycle. Otherwise break with foundit still = False so it will use the original choice
@@ -1378,6 +1430,8 @@ def doSpectrograms(
powerRanges,
spectColorPalette,
imageDir,
+ availabilityExistsByService,
+ tsMetricsExist,
):
spectDictionary = {} # used to track the plots to be used in the final report
@@ -1406,7 +1460,7 @@ def doSpectrograms(
]
# Select the lowest and greatest rms from those stations
- grouped = tmpDF.groupby(["snclq"])
+ grouped = tmpDF.groupby("snclq")
try:
df2 = pd.DataFrame(
@@ -1444,28 +1498,33 @@ def doSpectrograms(
thisChannel = lowestTarget.split(".")[3]
# Get the start and end to the data for this target
- thisStationAvDF, service = reportUtils.getAvailability(
- [lowestTarget], startDate, endDate, tolerance, ""
- )
- thisAvail = thisStationAvDF[
- (thisStationAvDF["station"] == thisStation)
- & (thisStationAvDF["network"] == thisNetwork)
- & (thisStationAvDF["location"] == thisLocation)
- & (thisStationAvDF["channel"] == thisChannel)
- ]
-
- thisStart = thisAvail["earliest"].dt.strftime("%Y-%m-%dT%H:%M:%S").min()
- thisEnd = thisAvail["latest"].dt.strftime("%Y-%m-%dT%H:%M:%S").max()
+ thisStart, thisEnd = reportUtils.getDataStartEnd(
+ lowestTarget,
+ thisNetwork,
+ thisStation,
+ thisLocation,
+ thisChannel,
+ startDate,
+ endDate,
+ tolerance,
+ availabilityExistsByService,
+ )
+ foundit = False
print(
f" INFO: Checking percent availability for {thisNetwork}.{thisStation}.{thisLocation}.{thisChannel} to see if there is as least 75% availability"
)
# Get the percent availability between the start and end for this target
thisPctAvail = checkAvailability(
- thisNetwork, thisStation, thisLocation, thisChannel, thisStart, thisEnd
+ thisNetwork,
+ thisStation,
+ thisLocation,
+ thisChannel,
+ thisStart,
+ thisEnd,
+ tsMetricsExist,
)
- foundit = False
if thisPctAvail > 75:
# Then we can use it and break the cycle.
foundit = True
@@ -1503,7 +1562,7 @@ def doSpectrograms(
spectDictionary[f"{channelGroup}_smallest"] = sorted(spectFiles)
# 2. largest corrected sample_rms station
- print(" INFO: Searching for lowest corrected sample_rms station")
+ print(" INFO: Searching for highest corrected sample_rms station")
for ii in reversed(range(len(meds.index))):
largestTarget = meds.index[ii]
largestNSL = largestTarget[:-3]
@@ -1517,28 +1576,33 @@ def doSpectrograms(
thisChannel = largestTarget.split(".")[3]
# Get the start/end times of the data for this target
- thisStationAvDF, service = reportUtils.getAvailability(
- [largestTarget], startDate, endDate, tolerance, ""
- )
- thisAvail = thisStationAvDF[
- (thisStationAvDF["station"] == thisStation)
- & (thisStationAvDF["network"] == thisNetwork)
- & (thisStationAvDF["location"] == thisLocation)
- & (thisStationAvDF["channel"] == thisChannel)
- ]
-
- thisStart = thisAvail["earliest"].dt.strftime("%Y-%m-%dT%H:%M:%S").min()
- thisEnd = thisAvail["latest"].dt.strftime("%Y-%m-%dT%H:%M:%S").max()
+ thisStart, thisEnd = reportUtils.getDataStartEnd(
+ largestTarget,
+ thisNetwork,
+ thisStation,
+ thisLocation,
+ thisChannel,
+ startDate,
+ endDate,
+ tolerance,
+ availabilityExistsByService,
+ )
+ foundit = False
print(
f" INFO: Checking percent availability for {thisNetwork}.{thisStation}.{thisLocation}.{thisChannel} to see if there is as least 75% availability"
)
# Get the percent availability between the start and end times for this target
thisPctAvail = checkAvailability(
- thisNetwork, thisStation, thisLocation, thisChannel, thisStart, thisEnd
+ thisNetwork,
+ thisStation,
+ thisLocation,
+ thisChannel,
+ thisStart,
+ thisEnd,
+ tsMetricsExist,
)
- foundit = False
if thisPctAvail > 75:
# Needs to have >75% and not be the same target that we've already selected for lowest RMS
# Then we can use it and break the cycle. Otherwise break with foundit still = False so it will use the original choice
@@ -1728,7 +1792,7 @@ def doReport(
f"This report is intended as a quick, broad overview of the quality of the data archived for the network "
f"and time period specified above. IRIS' goal in generating these reports is to give PIs for temporary "
f"experiments better insight into the quality of their data archived at the DMC, as well as to demonstrate "
- f'the utility provided by the IRIS DMC quality assurance system MUSTANG '
+ f'the utility provided by the IRIS DMC quality assurance system MUSTANG '
f"and the many metrics and products it generates. "
f"For PIs, we hope that these reports will "
f"inform the range of data availability, continuity, and noise levels existing across the network and potentially "
@@ -1749,11 +1813,18 @@ def doReport(
)
###### AVAILABILITY ######
- for service in services:
- stationServiceLink = f'{service}-station service,'
- availabilityServiceLink = f'{service}-availability service,'
- stationServiceLink = f"{stationServiceLink[:-1]}"
- availabilityServiceLink = f"{availabilityServiceLink[:-1]}"
+ # for service in services:
+ # stationServiceLink = f'{service}-station service,'
+ # availabilityServiceLink = f'{service}-availability service,'
+ # stationServiceLink = f"{stationServiceLink[:-1]}"
+ if services:
+ availabilityServiceLink = " and ".join(
+ f'{service}-availability service'
+ for service in services
+ )
+ else:
+ availabilityServiceLink = f'mustang service'
+ stationServiceLink = f'fdsnws-station service'
availabilityIntroText = (
f"The plot(s) below gives an overview of the available data for the requested timespan. "
@@ -1768,7 +1839,7 @@ def doReport(
f"are from the IRIS DMC {availabilityServiceLink}. "
f"The gap tolerance used for this report is {tolerance} seconds.
"
f"In addition, each channel lists the percent of data available, using the "
- f'{availabilityType} metric'
) # the punctuation at the end of this sentence depends on the metric
@@ -1800,39 +1871,55 @@ def doReport(
f"{availabilityIntroText}
Displaying all stations in the network.
"
)
- try:
- availabilityOutroText = "To view the availability numbers used to create the availability plot(s), see:"
+ if not services:
+ availabilityIntroText = (
+ f"{availabilityIntroText}
"
+ f"Note: the availability service was unavailable when this report was generated, "
+ f"so the data-extent and gap plot(s) below could not be produced. Station selection above was "
+ f"still based on percent-availability metrics from MUSTANG.
"
+ )
- for service in services:
- serviceLink = (
- f"http://service.iris.edu/{service}/availability/1/query?format=text&"
- f'net={network}&sta={stations}&loc={locations}&cha={",".join(chans)}&'
- f"starttime={startDate}&endtime={endDate}&orderby=nslc_time_quality_samplerate&"
- f"mergegaps={tolerance}&includerestricted=true&nodata=404"
- )
+ if services:
+ try:
+ availabilityOutroText = "To view the availability numbers used to create the availability plot(s), see:"
- availabilityOutroText = f'
{availabilityOutroText}
{serviceLink}'
+ for service in services:
+ serviceLink = (
+ f"http://service.earthscope.org/{service}/availability/1/query?format=text&"
+ f'net={network}&sta={stations}&loc={locations}&cha={",".join(chans)}&'
+ f"starttime={startDate}&endtime={endDate}&orderby=nslc_time_quality_samplerate&"
+ f"mergegaps={tolerance}&includerestricted=true&nodata=404"
+ )
- availabilityOutroText = f"{availabilityOutroText}
To view the channel metadata time extents used, see: "
+ availabilityOutroText = f'
{availabilityOutroText}
{serviceLink}'
- for service in services:
- serviceLink = (
- f"http://service.iris.edu/{service}/station/1/query?"
- f'net={network}&sta={stations}&loc={locations}&cha={",".join(chans)}&'
- f"starttime={startDate}&endtime={endDate}&level=channel&"
- f"format=text&includecomments=true&nodata=404"
- )
+ availabilityOutroText = f"{availabilityOutroText}
To view the channel metadata time extents used, see: "
- availabilityOutroText = f'{availabilityOutroText}
{serviceLink}'
+ for service in services:
+ serviceLink = (
+ f"http://service.earthscope.org/{service}/station/1/query?"
+ f'net={network}&sta={stations}&loc={locations}&cha={",".join(chans)}&'
+ f"starttime={startDate}&endtime={endDate}&level=channel&"
+ f"format=text&includecomments=true&nodata=404"
+ )
- except:
- availabilityOutroText = "No availability was found for the selected channels."
+ availabilityOutroText = f'{availabilityOutroText}
{serviceLink}'
+
+ except:
+ availabilityOutroText = (
+ "No availability was found for the selected channels."
+ )
+ else:
+ availabilityOutroText = (
+ "The availability service was unavailable when this report was generated, "
+ "so there are no availability/metadata query links to display for this section."
+ )
###### BOXPLOTS ######
defaultMetricText = "The metrics presented here are"
for metric in metricList:
defaultMetricText = (
- f'{defaultMetricText} {metric},'
)
if metric == metricList[-2]:
@@ -1925,15 +2012,15 @@ def doReport(
for metric in metricsWithPlots:
if metric == "scale_corrected_sample_rms":
metric = "sample_rms"
- boxPlotIntroText3 = f'{boxPlotIntroText3}
This section contains Probability Density Function (PDF) plots calculated from Power Spectral Densities (PSD). The first two plots display PDFs " - f'for the stations with the highest and lowest median daily-RMS variance (sample_rms, scaled by metadata sensitivity), ' - f'with an additional criteria that omits station-channels with less than 75% data availability (sample_rms, scaled by metadata sensitivity), ' + f'with an additional criteria that omits station-channels with less than 75% data availability ({availabilityType} metric, ' f"calculated between the first and last timestamp of the data for that channel), unless none of the stations have at least 75% availability. " f"These plots are intended to illustrate the range of characteristic noise levels across the experiment, avoiding stations with significant " @@ -1941,7 +2028,7 @@ def doReport( f"errors. The third PDF is a composite plot of all stations for each channel set, giving an overview of the most common noise levels for the " f"experiment as a whole.
" f"Detailed information about these PDF plots and how MUSTANG generates them " - f'can be found by visiting the ' + f'can be found by visiting the ' f"noise-pdf web service.
" f"Please note that you can also click the PDF Browser links below to access the MUSTANG PDF Browser, which allows you to view PDF plots " f"for all stations in the network on a total, annual, monthly, daily, or custom time period basis. Within the Browser, you can " @@ -1952,7 +2039,7 @@ def doReport( spectrgramPlotIntoText = ( f"The daily-PDF-mode spectrogram plots in this section illustrate the power spectra values across time for two stations with high data " f"availability and continuity. For the purpose of showing potentially different station behavior, they are secondarily selected for " - f'differing median daily-RMS variance (sample_rms ' + f'differing median daily-RMS variance (sample_rms ' f" metric, scaled by metadata sensitivity). For each channel group, the station selection steps are: " f"
Detailed information about these spectrogram plots and how MUSTANG " - f'generates them can be found by visiting the noise-spectrogram web service.
' f"Please note that you can also click the Spectrogram Browser links below to access the MUSTANG spectrogram " f"browser for each channel group below, which allows you to view spectrogram plots from all the stations in your network.
" @@ -2274,7 +2361,7 @@ def doReport( "MUSTANG is the Quality Assurance system at the IRIS DMC. It contains around 45 metrics related to the quality of data in the archives there.\n\n" ) f.write( - 'The majority of metrics are available via the measurements web service.\n\n' + 'The majority of metrics are available via the measurements web service.\n\n' ) f.write( 'To learn more about the metrics, navigate to the measurements service Service Interface page and hit the red "Current List of all metrics" for a brief description and links to more detailed documentation.\n\n' @@ -2293,7 +2380,7 @@ def doReport( if metric == "scale_corrected_sample_rms": metric = "sample_rms" metricsLinks = ( - f"http://service.iris.edu/mustang/measurements/1/query?metric={metric}" + f"http://service.earthscope.org/mustang/measurements/1/query?metric={metric}" f"&network={network}&station={stations}&location={locations}&channel={channels}" f"&start={startDate}&end={endDate}&format=text" ) @@ -2331,7 +2418,7 @@ def doReport( nStations = 1 pdfLink = ( - f"http://service.iris.edu/mustang/noise-pdf-browser/1/gallery?" + f"http://service.earthscope.org/mustang/noise-pdf-browser/1/gallery?" f"network={network}&channel={channel[0:2]}?&interval=all&" f"starttime={startDate}&endtime={endDate}" ) @@ -2418,7 +2505,7 @@ def doReport( spectPowerRange = ",".join([str(int) for int in userDefinedPowerRange]) spectLink = ( - f"http://service.iris.edu/mustang/noise-pdf-browser/1/spectrogram?" + f"http://service.earthscope.org/mustang/noise-pdf-browser/1/spectrogram?" f"network={network}&channel={channel[0:2]}?&" f"starttime={startDate}&endtime={endDate}&color.palette={spectColorPalette}&powerrange={spectPowerRange}" ) @@ -2594,7 +2681,7 @@ def main(): --metric[s]=: comma-separated list of metrics to run for the boxplots; defaults: {','.join(metricList)} --maxplot=: number of stations to include in the boxplots; defaults to {nBoxPlotSta} --colorpalette=: color palette for spectrograms; defaults to '{spectColorPalette}' - options available at http://service.iris.edu/mustang/noise-spectrogram/1/ + options available at http://service.earthscope.org/mustang/noise-spectrogram/1/ --includeoutliers=: whether to include outliers in the boxplots, True/False; defaults to {includeOutliers} --spectralrange=: power range to use in the PDFs and spectrograms, comma separated values: min,max; defaults depend on channel type --basemap=: the name of the basemap to be used for the map; defaults to '{basemap}' @@ -2753,22 +2840,32 @@ def main(): nTop = int(nBoxPlotSta / 2) nBottom = int(nBoxPlotSta - nTop) + # Check independently whether the ts_ (time-series) MUSTANG metrics exist right now - + # this can go down (or come back) on its own schedule, separate from the fdsnws-availability service + tsMetricsExist = reportUtils.checkTsMetricsExist(startDate, endDate) + # Create Availability Plots - [topStationsDict, splitPlots, avFilesDictionary, services, availabilityType] = ( - doAvailability( - splitPlots, - startDate, - endDate, - network, - stations, - locations, - channels, - nBoxPlotSta, - nTop, - nBottom, - tolerance, - imageDir, - ) + [ + topStationsDict, + splitPlots, + avFilesDictionary, + services, + availabilityType, + availabilityExistsByService, + ] = doAvailability( + splitPlots, + startDate, + endDate, + network, + stations, + locations, + channels, + nBoxPlotSta, + nTop, + nBottom, + tolerance, + imageDir, + tsMetricsExist, ) # Create BoxPlots @@ -2788,6 +2885,7 @@ def main(): nBottom, includeOutliers, imageDir, + tsMetricsExist, ) ) @@ -2806,6 +2904,8 @@ def main(): network, stations, locations, + availabilityExistsByService, + tsMetricsExist, ) # Create Spectrogram Plots @@ -2821,6 +2921,8 @@ def main(): powerRanges, spectColorPalette, imageDir, + availabilityExistsByService, + tsMetricsExist, ) # Generate Station Map diff --git a/README.md b/README.md index 9f91b28..32745c7 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ perform installs, updates, or run PIQQA. ``` cd piqqa conda update conda -conda create --name piqqa python=3.9 -c conda-forge --file piqqa-conda-install.txt +conda create --name piqqa python=3.12 -c conda-forge --file piqqa-conda-install.txt conda activate piqqa ``` @@ -122,7 +122,7 @@ Running PIQQA.py without any arguments, or with the -h/--h flags, will produce s --metrics=: comma-separated list of metrics to run for the boxplots; defaults: ts_channel_continuity,sample_rms,ts_num_gaps --maxplot=: number of stations to include in the boxplots; defaults to 30 --colorpalette=: color palette for spectrograms; defaults to 'RdYlBu' - options available at http://service.iris.edu/mustang/noise-spectrogram/1/ + options available at http://service.earthscope.org/mustang/noise-spectrogram/1/ --includeoutliers=: whether to include outliers in the boxplots, True/False; defaults to False --spectralrange=: power range to use in the PDFs and spectrograms, comma separated values: min, max; defaults depend on channel type --basemap=: the name of the basemap to be used for the map; defaults to 'stamen-terrain' @@ -149,7 +149,7 @@ In addition to those fields, there are a number of optional fields. `maxplot` is how you can limit the number of stations displayed in the boxplots. -`colorpalette` determines the color palette used in the PDFs and spectrograms, and a list of all options can be found in the service interface page for the [noise-spectrgram](http://service.iris.edu/mustang/noise-spectrogram/1/) service +`colorpalette` determines the color palette used in the PDFs and spectrograms, and a list of all options can be found in the service interface page for the [noise-spectrgram](http://service.earthscope.org/mustang/noise-spectrogram/1/) service `includeoutliers` toggles the outliers on/off in the boxplots. Generally, it is easier to view the boxplots with the outliers turned off, but there may be cases where they should be displayed. diff --git a/piqqa-conda-install.txt b/piqqa-conda-install.txt index d7ec76f..29e7fa9 100644 --- a/piqqa-conda-install.txt +++ b/piqqa-conda-install.txt @@ -1,5 +1,5 @@ -pandas=1.3.0 -numpy=1.21.0 -requests= 2.25.1 -matplotlib= 3.4.2 -plotly +pandas=2.1.4 +numpy=1.26.3 +requests=2.31.0 +matplotlib=3.8.2 +plotly=6.0.1 diff --git a/reportUtils.py b/reportUtils.py index 9406d3a..5b5ef3b 100644 --- a/reportUtils.py +++ b/reportUtils.py @@ -1,8 +1,8 @@ -''' +""" :copyright: Copyright (C) 2021 Laura Keyson, IRIS Data Management Center - -:license: + +:license: Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information @@ -18,464 +18,822 @@ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations -under the License. - -''' +under the License. +""" import requests from io import StringIO import pandas as pd import numpy as np import time -import urllib +import urllib.request + +USER_AGENT = "earthscope-piqqa" + +# pandas' read_csv falls back to urllib for plain http(s) URLs (no fsspec installed), +# so installing a default opener here also sets the User-Agent for every pd.read_csv(url) +# call and any bare urllib.request.urlopen() call in this process, not just SESSION.get(). +_opener = urllib.request.build_opener() +_opener.addheaders = [("User-Agent", USER_AGENT)] +urllib.request.install_opener(_opener) + +SESSION = requests.Session() +SESSION.headers.update({"User-Agent": USER_AGENT}) + + +def get_extents_from_mustang(snclqs, startDate, endDate): + availabilityDF = pd.DataFrame() + for snclq in snclqs: + staloc = ( + snclq.split(".")[1] + + "." + + (snclq.split(".")[2] if snclq.split(".")[2] != "" else "--") + ) + URL = ( + f"https://service.earthscope.org/mustang/measurements/1/" + f"query?metric=percent_availability&target={snclq}&format=text&timewindow={startDate},{endDate}&nodata=404" + ) + + try: + tempDF = pd.read_csv(URL, header=1, parse_dates=["start", "end"]) + + # find the first and last non-0 availability values and use those as the start and end times + nonZeroDF = tempDF[tempDF["value"] > 0] + if not nonZeroDF.empty: + startTime = nonZeroDF["start"].min() + endTime = nonZeroDF["end"].max() + tempDF = pd.DataFrame( + { + "network": [snclq.split(".")[0]], + "station": [snclq.split(".")[1]], + "location": [snclq.split(".")[2]], + "channel": [snclq.split(".")[3]], + "quality": [snclq.split(".")[4]], + "staloc": [staloc], + "earliest": [startTime], + "latest": [endTime], + } + ) + else: + tempDF = pd.DataFrame() + + availabilityDF = pd.concat([availabilityDF, tempDF], ignore_index=True) + except Exception as e: + print( + f" INFO: Unable to retrieve availability for {snclq} from Mustang service" + ) + print(f" {URL}") + pass + + return availabilityDF + + +def mergeGapsWithinTolerance(availabilityDF, tolerance): + """Merge adjacent time spans (per network/station/location/channel/quality) that are + separated by a gap no larger than `tolerance` seconds. The availability service used + to do this server-side via the mergegaps parameter; the migrated service no longer + honors it, so this reproduces the same gap-tolerance behavior client-side instead.""" + if availabilityDF.empty: + return availabilityDF + + tolerance = float(tolerance) + groupCols = [ + c + for c in ["network", "station", "location", "channel", "quality"] + if c in availabilityDF.columns + ] + if not groupCols: + return availabilityDF + + mergedRows = [] + for _, group in availabilityDF.groupby(groupCols, dropna=False): + group = group.sort_values("earliest") + current = None + for _, row in group.iterrows(): + if current is None: + current = row.copy() + continue + gapSeconds = (row["earliest"] - current["latest"]).total_seconds() + if gapSeconds <= tolerance: + if row["latest"] > current["latest"]: + current["latest"] = row["latest"] + else: + mergedRows.append(current) + current = row.copy() + if current is not None: + mergedRows.append(current) + + return pd.DataFrame(mergedRows).reset_index(drop=True) def getAvailability(snclqs, startDate, endDate, tolerance, avtype): availabilityDF = pd.DataFrame() services = [] - - - if avtype == '': + + if avtype == "": for snclq in snclqs: - snclqList = snclq.split('.') - n =snclqList[0] + snclqList = snclq.split(".") + n = snclqList[0] s = snclqList[1] l = snclqList[2] - if l == '': - luse = '--' + if l == "": + luse = "--" else: luse = l c = snclqList[3] q = snclqList[4] - + if q == "M": service = "fdsnws" elif q == "D": service = "ph5ws" - + if service not in services: services.append(service) - URL = f'http://service.iris.edu/{service}/availability/1/query?format=text&' \ - f'net={n}&sta={s}&loc={luse}&cha={c}&quality={q}&' \ - f'starttime={startDate}&endtime={endDate}&orderby=nslc_time_quality_samplerate&' \ - f'mergegaps={tolerance}&includerestricted=true&nodata=404' - try: - tmpDF = pd.read_csv(URL, sep=' ', dtype={'Location': str, 'Station': str}, parse_dates=['Earliest','Latest']) - tmpDF['staloc'] = f'{s}.{luse}' - availabilityDF = availabilityDF.append(tmpDF, ignore_index=True) + URL = ( + f"http://service.earthscope.org/{service}/availability/1/query?format=text&" + f"net={n}&sta={s}&loc={luse}&cha={c}&quality={q}&" + f"starttime={startDate}&endtime={endDate}&includerestricted=true&nodata=404" + ) + try: + tmpDF = pd.read_csv( + URL, + sep=" ", + dtype={"Location": str, "Station": str}, + parse_dates=["Earliest", "Latest"], + ) + tmpDF["staloc"] = f"{s}.{luse}" + availabilityDF = pd.concat([availabilityDF, tmpDF], ignore_index=True) except Exception as e: pass - - - elif avtype == 'extents': + + elif avtype == "extents": # Don't loop over the stations unless necessary - nets = ','.join(list(set([n.split('.')[0] for n in snclqs]))) - stas = ','.join(list(set([n.split('.')[1] for n in snclqs]))) + nets = ",".join(list(set([n.split(".")[0] for n in snclqs]))) + stas = ",".join(list(set([n.split(".")[1] for n in snclqs]))) stas = "*" - locs = ','.join(list(set([n.split('.')[2] for n in snclqs]))) + locs = ",".join(list(set([n.split(".")[2] for n in snclqs]))) if locs == "": - locs = '*' - chans = ','.join(list(set([n.split('.')[3] for n in snclqs]))) - qs = ','.join(list(set([n.split('.')[4] for n in snclqs]))) - - if qs == 'D': - service = 'ph5ws' - elif qs == 'M': - service = 'fdsnws' - + locs = "*" + chans = ",".join(list(set([n.split(".")[3] for n in snclqs]))) + qs = ",".join(list(set([n.split(".")[4] for n in snclqs]))) + + if qs == "D": + service = "ph5ws" + elif qs == "M": + service = "fdsnws" + if service not in services: services.append(service) - - URL = f'http://service.iris.edu/{service}/availability/1/extent?format=text&' \ - f'net={nets}&sta={stas}&loc={locs}&cha={chans}&quality={qs}&' \ - f'starttime={startDate}&endtime={endDate}&orderby=nslc_time_quality_samplerate&' \ - f'includerestricted=true&nodata=404' + + URL = ( + f"http://service.earthscope.org/{service}/availability/1/extent?format=text&" + f"net={nets}&sta={stas}&loc={locs}&cha={chans}&quality={qs}&" + f"starttime={startDate}&endtime={endDate}&includerestricted=true&nodata=404" + ) try: - availabilityDF = pd.read_csv(URL, sep=' ', dtype={'Location': str, 'Station': str}, parse_dates=['Earliest','Latest']) + availabilityDF = pd.read_csv( + URL, + sep=" ", + dtype={"Location": str, "Station": str}, + parse_dates=["Earliest", "Latest"], + ) except: - print(" INFO: Unable to retrieve availability in one go, trying to loop over stations instead") - + print( + " INFO: Unable to retrieve availability in one go, trying to loop over stations instead" + ) + for snclq in snclqs: - snclqList = snclq.split('.') - n =snclqList[0] + snclqList = snclq.split(".") + n = snclqList[0] s = snclqList[1] l = snclqList[2] - if l == '': - luse = '--' + if l == "": + luse = "--" else: luse = l c = snclqList[3] q = snclqList[4] - + if q == "M": service = "fdsnws" elif q == "D": service = "ph5ws" - + if service not in services: services.append(service) - - URL = f'http://service.iris.edu/{service}/availability/1/extent?format=text&' \ - f'net={n}&sta={s}&loc={luse}&cha={c}&quality={q}&' \ - f'starttime={startDate}&endtime={endDate}&orderby=nslc_time_quality_samplerate&' \ - f'&includerestricted=true&nodata=404' - - try: - tmpDF = pd.read_csv(URL, sep=' ', dtype={'Location': str, 'Station': str}, parse_dates=['Earliest','Latest']) - tmpDF['staloc'] = f'{s}.{luse}' - availabilityDF = availabilityDF.append(tmpDF, ignore_index=True) + + URL = ( + f"http://service.earthscope.org/{service}/availability/1/extent?format=text&" + f"net={n}&sta={s}&loc={luse}&cha={c}&quality={q}&" + f"starttime={startDate}&endtime={endDate}&includerestricted=true&nodata=404" + ) + + try: + tmpDF = pd.read_csv( + URL, + sep=" ", + dtype={"Location": str, "Station": str}, + parse_dates=["Earliest", "Latest"], + ) + tmpDF["staloc"] = f"{s}.{luse}" + availabilityDF = pd.concat( + [availabilityDF, tmpDF], ignore_index=True + ) except: pass - - - - - - -# availabilityDF = availabilityDF.apply(lambda x: x.str.strip() if x.dtype == "object" else x) + + # availabilityDF = availabilityDF.apply(lambda x: x.str.strip() if x.dtype == "object" else x) availabilityDF.rename(columns=lambda x: x.strip().lower(), inplace=True) - availabilityDF.rename(columns = {'#network': 'network'}, inplace=True) - - return availabilityDF, services - + availabilityDF.rename(columns={"#network": "network"}, inplace=True) + + if avtype == "": + # The availability service no longer merges gaps server-side (the mergegaps + # parameter is a no-op on the migrated service), so do it here instead. + availabilityDF = mergeGapsWithinTolerance(availabilityDF, tolerance) + + return availabilityDF, services + + +def getDataStartEnd( + target, + thisNetwork, + thisStation, + thisLocation, + thisChannel, + startDate, + endDate, + tolerance, + availabilityExistsByService, +): + """Get the start/end times of the data for a target, via the availability + service if it's up, otherwise falling back to MUSTANG extents. Falls back + to the report's own startDate/endDate if no start/end can be determined + either way (e.g. percent_availability is also down or has no data). + availabilityExistsByService is a dict like {"fdsnws": True, "ph5ws": False} - + the target's own quality code (its last dot-component) picks which entry applies, + since fdsnws-availability and ph5ws-availability can go down independently.""" + reportStart = f"{startDate}T00:00:00" + reportEnd = f"{endDate}T00:00:00" + + serviceKey = serviceForQuality(target.split(".")[-1]) + availabilityExists = availabilityExistsByService.get(serviceKey, False) + + if availabilityExists: + thisStationAvDF, _ = getAvailability( + [target], startDate, endDate, tolerance, "" + ) + if thisStationAvDF.empty: + return reportStart, reportEnd + + thisAvail = thisStationAvDF[ + (thisStationAvDF["station"] == thisStation) + & (thisStationAvDF["network"] == thisNetwork) + & (thisStationAvDF["location"] == thisLocation) + & (thisStationAvDF["channel"] == thisChannel) + ] + if thisAvail.empty: + return reportStart, reportEnd + + thisStart = thisAvail["earliest"].dt.strftime("%Y-%m-%dT%H:%M:%S").min() + thisEnd = thisAvail["latest"].dt.strftime("%Y-%m-%dT%H:%M:%S").max() + else: + thisStationAvDF = get_extents_from_mustang([target], startDate, endDate) + if thisStationAvDF.empty: + print( + f" WARNING: Unable to determine data start/end for {target} " + f"(percent_availability returned no data from Mustang), using report timespan instead" + ) + return reportStart, reportEnd + + thisStart = thisStationAvDF["earliest"].dt.strftime("%Y-%m-%dT%H:%M:%S").min() + thisEnd = thisStationAvDF["latest"].dt.strftime("%Y-%m-%dT%H:%M:%S").max() + + return thisStart, thisEnd + def retrieveMetrics(URL, metric): - response = requests.get(URL) + response = SESSION.get(URL) tempDF = pd.read_csv(StringIO(response.text), header=1) - tempDF.rename(columns={'target':'snclq'}, inplace=True) - tempDF['target'] = tempDF['snclq'].apply(lambda x: '.'.join(x.split('.')[0:4])) - tempDF['station'] = tempDF['snclq'].apply(lambda x: '.'.join(x.split('.')[1:3])) ## Because "station" is really "station.location" - tempDF['station'] = [x + '--' if x.endswith('.') else x for x in tempDF['station'] ] - - if (not metric == 'transfer_function') and (not metric == 'orientation_check'): - tempDF.rename(columns = {'value': metric}, inplace=True) - tempDF[metric] = tempDF[metric].map(float) - tempDF.drop('lddate', axis=1, inplace=True) - - tempDF['start'] = pd.to_datetime(tempDF['start']) - tempDF['end'] = pd.to_datetime(tempDF['end']) - + tempDF.rename(columns={"target": "snclq"}, inplace=True) + tempDF["target"] = tempDF["snclq"].apply(lambda x: ".".join(x.split(".")[0:4])) + tempDF["station"] = tempDF["snclq"].apply( + lambda x: ".".join(x.split(".")[1:3]) + ) ## Because "station" is really "station.location" + tempDF["station"] = [x + "--" if x.endswith(".") else x for x in tempDF["station"]] + + if (not metric == "transfer_function") and (not metric == "orientation_check"): + tempDF.rename(columns={"value": metric}, inplace=True) + tempDF[metric] = tempDF[metric].map(float) + tempDF.drop("lddate", axis=1, inplace=True) + + tempDF["start"] = pd.to_datetime(tempDF["start"]) + tempDF["end"] = pd.to_datetime(tempDF["end"]) + if metric == "sample_rms" and tempDF.empty: print(f" --> Unable to get measurements for {metric}") print(f" {URL}") - + return tempDF -def addMetricToDF(metric, DF, network, stations, locations, channels, startDate, endDate): - if not (metric== 'ts_percent_availability_total' or metric == 'percent_availability'): + +def checkTsMetricsExist(startDate, endDate, timeout=15): + # Probe against a long-running reference station (IU.ANMO) rather than the + # network being reported on, so the result reflects whether ts_ metrics exist + # at all rather than whether this particular network has data for them. + testURL = ( + f"https://service.earthscope.org/mustang/measurements/1/query?" + f"metric=ts_percent_availability_total&net=IU&sta=ANMO&loc=00&chan=BHZ&" + f"format=text&timewindow={startDate},{endDate}&nodata=404" + ) + try: + response = SESSION.get(testURL, timeout=timeout) + if "Could not find metric" in response.text: + return False + return True + except Exception: + return False + + +def addMetricToDF( + metric, DF, network, stations, locations, channels, startDate, endDate +): + if not ( + metric == "ts_percent_availability_total" or metric == "percent_availability" + ): print(f" Retrieving {metric}") chanList = list() - for chan in channels.split(','): - if (len(chan) == 3) and (chan[2] in ['*', '?', '.', '_']): - chan = f'{chan[0:2]}Z' + for chan in channels.split(","): + if (len(chan) == 3) and (chan[2] in ["*", "?", ".", "_"]): + chan = f"{chan[0:2]}Z" elif len(chan) == 2: chan = f"{chan}Z" elif chan == "*": chan = "??Z" chanList.append(chan) - URL = f"http://service.iris.edu/mustang/measurements/1/query?metric={metric}&net={network}&" \ - f"sta={stations}&loc={locations}&chan={','.join(chanList)}" \ - f'&format=text&timewindow={startDate},{endDate}&nodata=404' - - + URL = ( + f"http://service.earthscope.org/mustang/measurements/1/query?metric={metric}&net={network}&" + f"sta={stations}&loc={locations}&chan={','.join(chanList)}" + f"&format=text&timewindow={startDate},{endDate}&nodata=404" + ) + try: tempDF = retrieveMetrics(URL, metric) except Exception as e: - if not metric== 'ts_percent_availability_total': - print(f" --> Unable to get measurements for {metric}, waiting 5 seconds and trying again") + if not metric == "ts_percent_availability_total": + print( + f" --> Unable to get measurements for {metric}, waiting 5 seconds and trying again" + ) print(f" {URL}") -# print(f" {e}") + # print(f" {e}") time.sleep(5) try: tempDF = retrieveMetrics(URL, metric) except: - if not metric== 'ts_percent_availability_total': - print(f" --> Still unable to get measurements for {metric}, bypassing" ) + if not metric == "ts_percent_availability_total": + print( + f" --> Still unable to get measurements for {metric}, bypassing" + ) tempDF = pd.DataFrame() - - + if DF.empty: DF = tempDF.copy() else: try: - DF = pd.merge(DF, tempDF, how='outer', left_on=['target','snclq', 'station', 'start', 'end'], right_on=['target','snclq','station', 'start', 'end']) + DF = pd.merge( + DF, + tempDF, + how="outer", + left_on=["target", "snclq", "station", "start", "end"], + right_on=["target", "snclq", "station", "start", "end"], + ) except: print(f" ERROR: Something went wrong with the {metric}") - + return DF - + + +def serviceForQuality(quality): + # PH5/PASSCAL data (quality "D") is served by ph5ws (service.iris.edu), while + # regular FDSN data (quality "M") is served by fdsnws (service.earthscope.org) - + # these can go down independently of each other. + return "ph5ws" if quality == "D" else "fdsnws" + + +def getAvailabilityServiceURLs( + network, stations, locations, channels, startDate, endDate +): + """Determine which availability service(s) - fdsnws and/or ph5ws - actually apply + to this network via FedCatalog, so callers can check/use the correct one(s) instead + of assuming fdsnws. Returns a dict like {"fdsnws": "https://.../availability/1/"}. + """ + chanList = list() + for chan in channels.split(","): + if (len(chan) == 3) and (chan[2] in ["*", "?", ".", "_"]): + chan = f"{chan[0:2]}Z" + elif len(chan) == 2: + chan = f"{chan}Z" + elif chan == "*": + chan = "??Z" + chanList.append(chan) + + fedURL = ( + f"http://service.earthscope.org/irisws/fedcatalog/1/query?" + f"net={network}&sta={stations}&loc={locations}&cha={','.join(chanList)}&" + f"starttime={startDate}&endtime={endDate}" + f"&format=request&includeoverlaps=false" + ) + + serviceURLs = {} + try: + with urllib.request.urlopen(fedURL) as response: + html_content = response.read().decode("utf-8") + for ln in html_content.split("\n"): + if ln.startswith("AVAILABILITYSERVICE="): + url = ln.split("=")[1] + if "ph5ws" in url: + serviceURLs["ph5ws"] = url + elif "fdsnws" in url: + serviceURLs["fdsnws"] = url + except Exception as e: + print( + f" ERROR: unable to retrieve fed catalog information about where the data lives - {fedURL}\n{e}" + ) + # Fall back to checking both - each is probed independently, so this is conservative, not incorrect. + serviceURLs = { + "fdsnws": "https://service.earthscope.org/fdsnws/availability/1/", + "ph5ws": "http://service.iris.edu/ph5ws/availability/1/", + } + + return serviceURLs + + +def checkAvailabilityServicesExist( + network, stations, locations, channels, startDate, endDate +): + """Probe each availability service relevant to this network independently, since + fdsnws-availability and ph5ws-availability can go down independently of each other. + Returns a dict like {"fdsnws": True, "ph5ws": False} covering only the service(s) + actually relevant to this network.""" + serviceURLs = getAvailabilityServiceURLs( + network, stations, locations, channels, startDate, endDate + ) + availabilityExistsByService = {} + for serviceKey, url in serviceURLs.items(): + try: + urllib.request.urlopen(url) + availabilityExistsByService[serviceKey] = True + except urllib.error.HTTPError as e: + if e.code == 410: + print( + f"WARNING: {serviceKey}-availability service is down, falling back to MUSTANG percent_availability for data extents" + ) + else: + print( + f"WARNING: {serviceKey}-availability service returned HTTP {e.code}, falling back to MUSTANG percent_availability for data extents" + ) + availabilityExistsByService[serviceKey] = False + except Exception as e: + print( + f"WARNING: {serviceKey}-availability service is down ({e}), falling back to MUSTANG percent_availability for data extents" + ) + availabilityExistsByService[serviceKey] = False + + return availabilityExistsByService + + def getMetadata(network, stations, locations, channels, startDate, endDate, level): # This one and getStations are almost redundant, except that they return at different # levels. Merge into one function that also takes level as an input? - - if level == 'channel': + + if level == "channel": stationDF = pd.DataFrame() - if level == 'station': - stationDF = pd.DataFrame(columns=['#Network', 'Station', 'Latitude' , 'Longitude' , 'Elevation' , 'SiteName' , 'StartTime' , 'EndTime']) - if level == 'network': - stationDF = pd.DataFrame(columns=['#Network',]) - + if level == "station": + stationDF = pd.DataFrame( + columns=[ + "#Network", + "Station", + "Latitude", + "Longitude", + "Elevation", + "SiteName", + "StartTime", + "EndTime", + ] + ) + if level == "network": + stationDF = pd.DataFrame( + columns=[ + "#Network", + ] + ) + chanList = list() - for chan in channels.split(','): - if (len(chan) == 3) and (chan[2] in ['*', '?', '.', '_']): - chan = f'{chan[0:2]}Z' + for chan in channels.split(","): + if (len(chan) == 3) and (chan[2] in ["*", "?", ".", "_"]): + chan = f"{chan[0:2]}Z" elif len(chan) == 2: -# chan = f"{chan}Z,{chan}1" + # chan = f"{chan}Z,{chan}1" chan = f"{chan}Z" elif chan == "*": -# chan = "??Z,??1" + # chan = "??Z,??1" chan = "??Z" chanList.append(chan) - try: - # Call Fed Catalog to know what service the network can be retrieved using. + # Call Fed Catalog to know what service the network can be retrieved using. print(" Calling on Fed Catalog") - fedURL = f"http://service.iris.edu/irisws/fedcatalog/1/query?" \ - f"net={network}&sta={stations}&loc={locations}&cha={','.join(chanList)}&" \ - f"starttime={startDate}&endtime={endDate}" \ - f"&format=request&includeoverlaps=false" - + fedURL = ( + f"http://service.earthscope.org/irisws/fedcatalog/1/query?" + f"net={network}&sta={stations}&loc={locations}&cha={','.join(chanList)}&" + f"starttime={startDate}&endtime={endDate}" + f"&format=request&includeoverlaps=false" + ) try: with urllib.request.urlopen(fedURL) as response: - html_content = response.read().decode('utf-8') - + html_content = response.read().decode("utf-8") + services = [] - for ln in html_content.split('\n'): + for ln in html_content.split("\n"): if ln.startswith("STATIONSERVICE="): - serviceURL = ln.split('=')[1] - if 'iris' in serviceURL: + serviceURL = ln.split("=")[1] + if "earthscope" in serviceURL or "iris" in serviceURL: services.append(serviceURL) - + except Exception as e: - print(" ERROR: unable to retrieve fed catalog information about where the data lives - %s\n%s " % (fedURL, e)) - services = ['http://service.iris.edu/fdsnws/station/1/', 'http://service.iris.edu/ph5ws/station/1/'] - + print( + " ERROR: unable to retrieve fed catalog information about where the data lives - %s\n%s " + % (fedURL, e) + ) + services = [ + "http://service.earthscope.org/fdsnws/station/1/", + "http://service.earthscope.org/ph5ws/station/1/", + ] + for service in services: # To prevent needing to know a priori where it's from, try both and only add if attempt is successful # Most experiments are one-archive only, but some have been split in the past + tmpDF = pd.DataFrame() try: print(" Calling on Station Service") - stationURL = f"{service}query?" \ - f"net={network}&sta={stations}&loc={locations}&cha={','.join(chanList)}&" \ - f"starttime={startDate}&endtime={endDate}&level={level}" \ - f"&format=text&includecomments=true&nodata=404" + stationURL = ( + f"{service}query?" + f"net={network}&sta={stations}&loc={locations}&cha={','.join(chanList)}&" + f"starttime={startDate}&endtime={endDate}&level={level}" + f"&format=text&includecomments=true&nodata=404" + ) - if level == 'channel': + if level == "channel": try: - tmpDF = pd.read_csv(stationURL, sep='|', dtype={' Location ': str, ' Station ': str}) + tmpDF = pd.read_csv( + stationURL, + sep="|", + dtype={" Location ": str, " Station ": str}, + ) tmpDF.rename(columns=lambda x: x.strip(), inplace=True) - tmpDF.rename(columns = {'#Network': 'Network'}, inplace=True) - tmpDF['Location'] = tmpDF.Location.replace(np.nan, '', regex=True) - tmpDF['Target'] = tmpDF[['Network', 'Station', 'Location','Channel']].apply(lambda x: '.'.join(x.map(str)), axis=1) + tmpDF.rename(columns={"#Network": "Network"}, inplace=True) + tmpDF["Location"] = tmpDF.Location.replace( + np.nan, "", regex=True + ) + tmpDF["Target"] = tmpDF[ + ["Network", "Station", "Location", "Channel"] + ].apply(lambda x: ".".join(x.map(str)), axis=1) tmpDF.columns = tmpDF.columns.str.lower() - - tmpDF['starttime'] = pd.to_datetime(tmpDF['starttime']) - tmpDF['endtime'] = pd.to_datetime(tmpDF['endtime']) - + + tmpDF["starttime"] = pd.to_datetime(tmpDF["starttime"]) + tmpDF["endtime"] = pd.to_datetime(tmpDF["endtime"]) + except Exception as e: - print(f" ERROR: Unable to retrieve channel information from {stationURL}") - - elif level == 'station' or level == 'network': + print( + f" ERROR: Unable to retrieve channel information from {stationURL}" + ) + tmpDF = pd.DataFrame() + + elif level == "station" or level == "network": try: - tmpDF = pd.read_csv(stationURL, sep='|') + tmpDF = pd.read_csv(stationURL, sep="|") tmpDF.rename(columns=lambda x: x.strip(), inplace=True) except: - print(f" ERROR: Unable to retrieve metadata information from {stationURL}") + print( + f" ERROR: Unable to retrieve metadata information from {stationURL}" + ) + tmpDF = pd.DataFrame() except: tmpDF = pd.DataFrame() - - stationDF = pd.concat([stationDF, tmpDF], ignore_index=True) + + if tmpDF.empty: + continue + if stationDF.empty: + stationDF = tmpDF.copy() + else: + stationDF = pd.concat([stationDF, tmpDF], ignore_index=True) except: - print(" ERROR: Unable to retrieve metadata") + print(" ERROR: Unable to retrieve metadata") return stationDF - + return stationDF + def retrieveExpectedPDFs(NSLC, startDate, endDate): - net = NSLC.split('.')[0] - sta = NSLC.split('.')[1] - loc = NSLC.split('.')[2] + net = NSLC.split(".")[0] + sta = NSLC.split(".")[1] + loc = NSLC.split(".")[2] cha = f"{NSLC.split('.')[3]}?" - URL = f'http://service.iris.edu/mustang/noise-pdf-browser/1/availability?network={net}&station={sta}&location={loc}&channel={cha}&starttime={startDate}&endtime={endDate}&interval=all' + URL = f"http://service.earthscope.org/mustang/noise-pdf-browser/1/availability?network={net}&station={sta}&location={loc}&channel={cha}&starttime={startDate}&endtime={endDate}&interval=all" - response = requests.get(URL) + response = SESSION.get(URL) if response.text.startswith("Error"): # Wait 5 seconds and try again - print(f" --> Error retrieving list of expected PDFs for {NSLC}, waiting 5 seconds and trying again") + print( + f" --> Error retrieving list of expected PDFs for {NSLC}, waiting 5 seconds and trying again" + ) time.sleep(5) - response = requests.get(URL) + response = SESSION.get(URL) if response.text.startswith("Error"): print(f" --> Unable to retrieve PDF list for {NSLC}") -# print(response.text) + # print(response.text) expectedTargets = list() - - # doing it this way so that this section will run if either the first or second attempt was successful - if not response.text.startswith("Error"): - expectedTargets = [x.split(',')[0] for x in response.text.split('\n') if not x == ''] - + + # doing it this way so that this section will run if either the first or second attempt was successful + if not response.text.startswith("Error"): + expectedTargets = [ + x.split(",")[0] for x in response.text.split("\n") if not x == "" + ] + return expectedTargets + def getPDF(target, startDate, endDate, spectPowerRange, imageDir): - net = target.split('.')[0] - sta = target.split('.')[1] - loc = target.split('.')[2] - if loc == '': - loc = '--' - cha = target.split('.')[3] - - - plot_titlefont=20 - plot_subtitlefont=18 - plot_axisfont=16 - plot_labelfont=18 - plotArguments = f"plot.titlefont.size={plot_titlefont}&plot.subtitlefont.size={plot_subtitlefont}" \ - f"&plot.axisfont.size={plot_axisfont}&plot.labelfont.size={plot_labelfont}" - - URL = f"http://service.iris.edu/mustang/noise-pdf/1/query?&" \ - f"network={net}&station={sta}&location={loc}&channel={cha}&quality=?&" \ - f"starttime={startDate}&endtime={endDate}&format=plot&plot.interpolation=bicubic&nodata=404&" \ - f"plot.power.min={spectPowerRange[0]}&plot.power.max={spectPowerRange[1]}&{plotArguments}" - - response = requests.get(URL) - filename = (f"{imageDir}/{target}_PDF.png").replace('*','').replace('?','') - + net = target.split(".")[0] + sta = target.split(".")[1] + loc = target.split(".")[2] + if loc == "": + loc = "--" + cha = target.split(".")[3] + + plot_titlefont = 20 + plot_subtitlefont = 18 + plot_axisfont = 16 + plot_labelfont = 18 + plotArguments = ( + f"plot.titlefont.size={plot_titlefont}&plot.subtitlefont.size={plot_subtitlefont}" + f"&plot.axisfont.size={plot_axisfont}&plot.labelfont.size={plot_labelfont}" + ) + + URL = ( + f"http://service.earthscope.org/mustang/noise-pdf/1/query?&" + f"network={net}&station={sta}&location={loc}&channel={cha}&quality=?&" + f"starttime={startDate}&endtime={endDate}&format=plot&plot.interpolation=bicubic&nodata=404&" + f"plot.power.min={spectPowerRange[0]}&plot.power.max={spectPowerRange[1]}&{plotArguments}" + ) + + response = SESSION.get(URL) + filename = (f"{imageDir}/{target}_PDF.png").replace("*", "").replace("?", "") + file = open(filename, "wb") file.write(response.content) file.close() - + return filename - -def getSpectrogram(target, startDate, endDate, spectPowerRange, spectColorPalette, imageDir): - powerRange = ','.join([str(x) for x in spectPowerRange]) - plot_titlefont=20 - plot_subtitlefont=18 - plot_axisfont=16 - plot_labelfont=18 - plotArguments = f"plot.titlefont.size={plot_titlefont}&plot.subtitlefont.size={plot_subtitlefont}" \ - f"&plot.axisfont.size={plot_axisfont}&plot.labelfont.size={plot_labelfont}" - - URL = f"http://service.iris.edu/mustang/noise-spectrogram/1/query?target={target}&" \ - f"starttime={startDate}&endtime={endDate}&output=power&format=plot&plot.color.palette={spectColorPalette}&" \ - f"plot.powerscale.range={powerRange}&plot.horzaxis=time&plot.time.matchrequest=true&{plotArguments}&" \ - f"plot.time.tickunit=auto&plot.time.invert=false&plot.powerscale.show=true&plot.powerscale.orientation=horz&nodata=404" - - - response = requests.get(URL) + + +def getSpectrogram( + target, startDate, endDate, spectPowerRange, spectColorPalette, imageDir +): + powerRange = ",".join([str(x) for x in spectPowerRange]) + plot_titlefont = 20 + plot_subtitlefont = 18 + plot_axisfont = 16 + plot_labelfont = 18 + plotArguments = ( + f"plot.titlefont.size={plot_titlefont}&plot.subtitlefont.size={plot_subtitlefont}" + f"&plot.axisfont.size={plot_axisfont}&plot.labelfont.size={plot_labelfont}" + ) + + URL = ( + f"http://service.earthscope.org/mustang/noise-spectrogram/1/query?target={target}&" + f"starttime={startDate}&endtime={endDate}&output=power&format=plot&plot.color.palette={spectColorPalette}&" + f"plot.powerscale.range={powerRange}&plot.horzaxis=time&plot.time.matchrequest=true&{plotArguments}&" + f"plot.time.tickunit=auto&plot.time.invert=false&plot.powerscale.show=true&plot.powerscale.orientation=horz&nodata=404" + ) + + response = SESSION.get(URL) filename = f"{imageDir}/{target}_spectrogram.png" file = open(filename, "wb") file.write(response.content) file.close() - + return filename def getBoundsZoomLevel(bounds, mapDim): + """ + source: https://stackoverflow.com/questions/6048975/google-maps-v3-how-to-calculate-the-zoom-level-for-a-given-bounds + :param bounds: list of ne and sw lat/lon + :param mapDim: dictionary with image size in pixels + :return: zoom level to fit bounds in the visible area + """ + n_lat = bounds[0] + w_long = bounds[1] + s_lat = bounds[2] + e_long = bounds[3] + + scale = ( + 2 # adjustment to reflect MapBox base tiles are 512x512 vs. Google's 256x256 + ) + WORLD_DIM = {"height": 256 * scale, "width": 256 * scale} + ZOOM_MAX = 16 + ZOOM_MIN = 0.5 + + def latRad(lat): + sin = np.sin(lat * np.pi / 180) + radX2 = np.log((1 + sin) / (1 - sin)) / 2 + return max(min(radX2, np.pi), -np.pi) / 2 + + def zoom(mapPx, worldPx, fraction): + return np.floor(np.log(mapPx / worldPx / fraction) / np.log(2)) + + latFraction = (latRad(n_lat) - latRad(s_lat)) / np.pi + + lngDiff = e_long - w_long + lngFraction = ((lngDiff + 360) if lngDiff < 0 else lngDiff) / 360 + lat_lng_fraction = max(latFraction, lngFraction) / min(latFraction, lngFraction) + + latZoom = zoom(mapDim["height"], WORLD_DIM["height"], latFraction) + lngZoom = zoom(mapDim["width"], WORLD_DIM["width"], lngFraction) + zoomDiff = max(latZoom, lngZoom) + + if lat_lng_fraction > 10: + zoomDiff = max(latZoom, lngZoom) - min(latZoom, lngZoom) + + return min(max(zoomDiff, ZOOM_MIN), ZOOM_MAX) + - """ - source: https://stackoverflow.com/questions/6048975/google-maps-v3-how-to-calculate-the-zoom-level-for-a-given-bounds - :param bounds: list of ne and sw lat/lon - :param mapDim: dictionary with image size in pixels - :return: zoom level to fit bounds in the visible area - """ - n_lat = bounds[0] - w_long = bounds[1] - s_lat = bounds[2] - e_long = bounds[3] - - scale = 2 # adjustment to reflect MapBox base tiles are 512x512 vs. Google's 256x256 - WORLD_DIM = {'height': 256 * scale, 'width': 256 * scale} - ZOOM_MAX = 16 - ZOOM_MIN = 0.5 - - def latRad(lat): - sin = np.sin(lat * np.pi / 180) - radX2 = np.log((1 + sin) / (1 - sin)) / 2 - return max(min(radX2, np.pi), -np.pi) / 2 - - def zoom(mapPx, worldPx, fraction): - return np.floor(np.log(mapPx / worldPx / fraction) / np.log(2)) - - latFraction = (latRad(n_lat) - latRad(s_lat)) / np.pi - - lngDiff = e_long - w_long - lngFraction = ((lngDiff + 360) if lngDiff < 0 else lngDiff) / 360 - lat_lng_fraction = max(latFraction, lngFraction) / min(latFraction, lngFraction) - - latZoom = zoom(mapDim['height'], WORLD_DIM['height'], latFraction) - lngZoom = zoom(mapDim['width'], WORLD_DIM['width'], lngFraction) - zoomDiff = max(latZoom, lngZoom) - - if lat_lng_fraction > 10: - zoomDiff = max(latZoom, lngZoom) - min(latZoom, lngZoom) - - return min(max(zoomDiff, ZOOM_MIN), ZOOM_MAX) - - def getMetricLabel(metric): - metricLabels = {'amplifier_saturation':'daily flag count \n(number of occurrences)', - 'calibration_signal':'daily flag count \n(number of occurrences)', - 'clock_locked':'daily flag count \n(number of occurrences)', - 'cross_talk':'correlation coefficient \n', # no units - 'data_latency':'latency (seconds) \n', - 'dc_offset':'daily indicator of likelihood of \nDC offset shift', # no units - 'dead_channel_gsn':'indicator \n', - 'dead_channel_lin':'standard deviation of residuals (dB) \n', - 'digital_filter_charging':'daily flag count \n(number of occurrences)', - 'digitizer_clipping':'daily flag count \n(number of occurrences)', - 'event_begin':'daily flag count \n(number of occurrences)', - 'event_end':'daily flag count \n(number of occurrences)', - 'event_in_progress':'daily flag count \n(number of occurrences)', - 'feed_latency':'latency (seconds) \n', - 'gap_list':'daily gap length \n(seconds)', - 'glitches':'daily flag count \n(number of occurrences)', - 'max_gap':'daily maximum gap length \n(seconds)', - 'max_overlap':'daily overlap length \n(seconds)', - 'max_range':'daily maximum amplitude range, \nwindowed (counts)', - 'max_stalta':'daily \nshort-term average / long-term \naverage', # no units - 'missing_padded_data':'daily flag count \n(number of occurrences)', - 'num_gaps':'daily gap count \n(number of occurrences)', - 'num_overlaps':'daily overlap count \n(number of occurrences)', - 'num_spikes':'daily outlier count \n(number of occurrences)', - 'pct_above_nhnm':'daily PDF matrix above \nNew High Noise Model (%)', - 'pct_below_nlnm':'daily PDF matrix below \nNew Low Noise Model (%)', - 'percent_availability':'daily availability (%) \n', - 'polarity_check':'maximum cross-correlation \nfunction', # no units - 'pressure_effects':'daily zero-lag \ncross-correlation function', # no units - 'sample_max':'daily maximum amplitude \n(counts)', - 'sample_mean':'daily mean amplitude \n(counts)', - 'sample_median':'daily median amplitude \n(counts)', - 'sample_min':'daily minimum amplitude \n(counts)', - 'sample_rate_channel':'daily indicator \n', - 'sample_rate_resp':'daily indicator \n', - 'sample_rms':'daily root-mean-square variance (counts) \n', - 'scale_corrected_sample_rms':'daily root-mean-squared variance,\nscaled by sensitivity', - 'sample_snr':'signal-to-noise ratio \n', # no units - 'sample_unique':'daily unique sample values \n(number of occurrences)', - 'spikes':'daily flag count \n(number of occurrences)', - 'suspect_time_tag':'daily flag count \n(number of occurrences)', - 'telemetry_sync_error':'daily flag count \n(number of occurrences)', - 'timing_correction':'daily flag count \n(number of occurrences)', - 'timing_quality':'daily average timing quality (%) \n', - 'total_latency':'latency (seconds) \n', - 'ts_num_gaps':'daily gap count \n(number of occurrences)', - 'ts_num_gaps_total':'gap count \n(number of occurrences)', - 'ts_max_gap':'daily maximum gap length \n(seconds)', - 'ts_max_gap_total':'maximum gap length \n(seconds)', - 'ts_gap_length':'daily total gap length \n(seconds)', - 'ts_gap_length_total':'total gap length (seconds) \n', - 'ts_percent_availability':'daily availability (%) \n', - 'ts_percent_availability_total':'availability (%) \n', - 'ts_channel_up_time':'daily trace segment length \n(seconds)', - 'ts_channel_continuity':'trace segment length (seconds) \n', - 'gain_ratio':'data/metadata gain ratio', # no units - 'phase_diff':'data-metadata phase difference \n(degrees)', - 'ms_coherence':'coherence function \n', # no units - } - + metricLabels = { + "amplifier_saturation": "daily flag count \n(number of occurrences)", + "calibration_signal": "daily flag count \n(number of occurrences)", + "clock_locked": "daily flag count \n(number of occurrences)", + "cross_talk": "correlation coefficient \n", # no units + "data_latency": "latency (seconds) \n", + "dc_offset": "daily indicator of likelihood of \nDC offset shift", # no units + "dead_channel_gsn": "indicator \n", + "dead_channel_lin": "standard deviation of residuals (dB) \n", + "digital_filter_charging": "daily flag count \n(number of occurrences)", + "digitizer_clipping": "daily flag count \n(number of occurrences)", + "event_begin": "daily flag count \n(number of occurrences)", + "event_end": "daily flag count \n(number of occurrences)", + "event_in_progress": "daily flag count \n(number of occurrences)", + "feed_latency": "latency (seconds) \n", + "gap_list": "daily gap length \n(seconds)", + "glitches": "daily flag count \n(number of occurrences)", + "max_gap": "daily maximum gap length \n(seconds)", + "max_overlap": "daily overlap length \n(seconds)", + "max_range": "daily maximum amplitude range, \nwindowed (counts)", + "max_stalta": "daily \nshort-term average / long-term \naverage", # no units + "missing_padded_data": "daily flag count \n(number of occurrences)", + "num_gaps": "daily gap count \n(number of occurrences)", + "num_overlaps": "daily overlap count \n(number of occurrences)", + "num_spikes": "daily outlier count \n(number of occurrences)", + "pct_above_nhnm": "daily PDF matrix above \nNew High Noise Model (%)", + "pct_below_nlnm": "daily PDF matrix below \nNew Low Noise Model (%)", + "percent_availability": "daily availability (%) \n", + "polarity_check": "maximum cross-correlation \nfunction", # no units + "pressure_effects": "daily zero-lag \ncross-correlation function", # no units + "sample_max": "daily maximum amplitude \n(counts)", + "sample_mean": "daily mean amplitude \n(counts)", + "sample_median": "daily median amplitude \n(counts)", + "sample_min": "daily minimum amplitude \n(counts)", + "sample_rate_channel": "daily indicator \n", + "sample_rate_resp": "daily indicator \n", + "sample_rms": "daily root-mean-square variance (counts) \n", + "scale_corrected_sample_rms": "daily root-mean-squared variance,\nscaled by sensitivity", + "sample_snr": "signal-to-noise ratio \n", # no units + "sample_unique": "daily unique sample values \n(number of occurrences)", + "spikes": "daily flag count \n(number of occurrences)", + "suspect_time_tag": "daily flag count \n(number of occurrences)", + "telemetry_sync_error": "daily flag count \n(number of occurrences)", + "timing_correction": "daily flag count \n(number of occurrences)", + "timing_quality": "daily average timing quality (%) \n", + "total_latency": "latency (seconds) \n", + "ts_num_gaps": "daily gap count \n(number of occurrences)", + "ts_num_gaps_total": "gap count \n(number of occurrences)", + "ts_max_gap": "daily maximum gap length \n(seconds)", + "ts_max_gap_total": "maximum gap length \n(seconds)", + "ts_gap_length": "daily total gap length \n(seconds)", + "ts_gap_length_total": "total gap length (seconds) \n", + "ts_percent_availability": "daily availability (%) \n", + "ts_percent_availability_total": "availability (%) \n", + "ts_channel_up_time": "daily trace segment length \n(seconds)", + "ts_channel_continuity": "trace segment length (seconds) \n", + "gain_ratio": "data/metadata gain ratio", # no units + "phase_diff": "data-metadata phase difference \n(degrees)", + "ms_coherence": "coherence function \n", # no units + } + labelText = metricLabels[metric] return labelText