diff --git a/bin/Hypo71PC b/bin/Hypo71PC index 597f281..810fda4 100755 Binary files a/bin/Hypo71PC and b/bin/Hypo71PC differ diff --git a/bin/dlsv2inv b/bin/dlsv2inv index c87d522..54c7f7c 100755 Binary files a/bin/dlsv2inv and b/bin/dlsv2inv differ diff --git a/bin/ew2sc b/bin/ew2sc index f020fe4..7c6e17d 100755 Binary files a/bin/ew2sc and b/bin/ew2sc differ diff --git a/bin/fdsnws b/bin/fdsnws index ba6fe8b..fe98504 100755 --- a/bin/fdsnws +++ b/bin/fdsnws @@ -40,7 +40,7 @@ import seiscomp.logging import seiscomp.client import seiscomp.system -from seiscomp.math import KM_OF_DEGREE +from seiscomp.math import WGS84_KM_OF_DEGREE from seiscomp.fdsnws.utils import isRestricted, u_str, b_str from seiscomp.fdsnws.dataselect import ( @@ -72,6 +72,14 @@ from seiscomp.fdsnws.http import ( ) from seiscomp.fdsnws.log import Log +try: + from seiscomp.fdsnws.jwt import JWT + + _jwtSupported = True + +except ImportError: + _jwtSupported = False + def logSC3(entry): try: @@ -411,6 +419,14 @@ class FDSNWS(seiscomp.client.Application): self._access = None self._checker = None + self._jwtEnabled = False + self._jwtIssuers = ["https://geofon.gfz.de/eas2", "https://login.earthscope.org/"] + self._jwtAudience = ["eas", "fdsn"] + self._jwtAlgorithms = ["RS256"] + self._jwtUpdateMin = 300 + self._jwtUpdateMax = 86400 + self._jwt = None + self._requestLog = None self.__reloadRequested = False self.__timeInventoryLoaded = None @@ -745,6 +761,42 @@ class FDSNWS(seiscomp.client.Application): except Exception: pass + # enable JWT extension? + try: + self._jwtEnabled = self.configGetBool("jwt.enable") + except Exception: + pass + + # JWT issuers + try: + self._jwtIssuers = self.configGetStrings("jwt.issuers") + except Exception: + pass + + # JWT audience + try: + self._jwtAudience = self.configGetStrings("jwt.audience") + except Exception: + pass + + # JWT algorithms + try: + self._jwtAlgorithms = self.configGetStrings("jwt.algorithms") + except Exception: + pass + + # JWT minimum update period + try: + self._jwtUpdateMin = self.configGetStrings("jwt.updateMinSeconds") + except Exception: + pass + + # JWT maximum update period + try: + self._jwtUpdateMax = self.configGetStrings("jwt.updateMaxSeconds") + except Exception: + pass + # If the database connection is passed via command line or # configuration file then messaging is disabled. Messaging is only used # to get the configured database connection URI. @@ -878,7 +930,7 @@ Execute on command line with debug output if self._invCoordinatePrecision is not None: invCoordinatePrecisionStr = ( f"{self._invCoordinatePrecision} decimal places (≅" - f"{int(KM_OF_DEGREE * 1000 / 10**self._invCoordinatePrecision)}m)" + f"{int(WGS84_KM_OF_DEGREE * 1000 / 10**self._invCoordinatePrecision)}m)" ) else: invCoordinatePrecisionStr = "unlimited" @@ -926,6 +978,13 @@ configuration read: auth enabled : {self._authEnabled} gnupgHome : {self._authGnupgHome} + JWT + enabled : {self._jwtEnabled} + issuers : {self._jwtIssuers} + audience : {self._jwtAudience} + algorithms : {self._jwtAlgorithms} + updateMinSeconds : {self._jwtUpdateMin} + updateMaxSeconds : {self._jwtUpdateMax} requestLog : {self._requestLogFile}""" ) @@ -937,6 +996,17 @@ configuration read: seiscomp.logging.error("all services disabled through configuration") return None + if self._jwtEnabled: + if not _jwtSupported: + seiscomp.logging.error( + "JWT is not supported due to missing dependencies" + ) + return None + + self._jwt = JWT( + self._jwtIssuers, self._jwtAudience, self._jwtAlgorithms, self._jwtUpdateMin, self._jwtUpdateMax + ) + # access logger if requested if self._accessLogFile: self._accessLog = Log(self._accessLogFile) @@ -1019,9 +1089,16 @@ configuration read: dataselect.putChild(b"1", dataselect1) # query - dataselect1.putChild( - b"query", FDSNDataSelect(dataSelectInv, self._recordBulkSize) - ) + if self._jwtEnabled: + authSession = self._jwt.getAuthSessionWrapper( + FDSNDataSelect, dataSelectInv, self._recordBulkSize, self._access + ) + dataselect1.putChild(b"query", authSession) + + else: + dataselect1.putChild( + b"query", FDSNDataSelect(dataSelectInv, self._recordBulkSize) + ) # queryauth if self._authEnabled: @@ -1050,7 +1127,8 @@ configuration read: dataselect1.putChild(b"builder", fileRes) if self._authEnabled: - from seiscomp.fdsnws.http import AuthResource + from seiscomp.fdsnws.authresource import AuthResource + dataselect1.putChild( b"auth", AuthResource(DataSelectVersion, self._authGnupgHome, self._userdb), @@ -1180,7 +1258,13 @@ configuration read: availability.putChild(b"1", availability1) # query - availability1.putChild(b"query", FDSNAvailabilityQuery()) + if self._jwtEnabled: + authSession = self._jwt.getAuthSessionWrapper( + FDSNAvailabilityQuery, self._access + ) + availability1.putChild(b"query", authSession) + else: + availability1.putChild(b"query", FDSNAvailabilityQuery()) # queryauth if self._authEnabled: @@ -1192,7 +1276,13 @@ configuration read: availability1.putChild(b"queryauth", authSession) # extent - availability1.putChild(b"extent", FDSNAvailabilityExtent()) + if self._jwtEnabled: + authSession = self._jwt.getAuthSessionWrapper( + FDSNAvailabilityExtent, self._access + ) + availability1.putChild(b"extent", authSession) + else: + availability1.putChild(b"extent", FDSNAvailabilityExtent()) # extentauth if self._authEnabled: diff --git a/bin/fdsnxml2inv b/bin/fdsnxml2inv index fcdcf86..5c68f2b 100755 Binary files a/bin/fdsnxml2inv and b/bin/fdsnxml2inv differ diff --git a/bin/invextr b/bin/invextr index 79143b4..5766752 100755 Binary files a/bin/invextr and b/bin/invextr differ diff --git a/bin/load_timetable b/bin/load_timetable index 197b09a..8df258d 100755 Binary files a/bin/load_timetable and b/bin/load_timetable differ diff --git a/bin/msrtsimul b/bin/msrtsimul index 10f9a87..b489a3e 100755 --- a/bin/msrtsimul +++ b/bin/msrtsimul @@ -107,19 +107,19 @@ def rt_simul(f, speed=1.0, jump=0.0, delaydict=None): # ------------------------------------------------------------------------------ def usage(): print( - """Usage: - msrtsimul [options] file + f"""Usage: + {os.path.basename(__file__)} [options] file miniSEED real-time playback and simulation -msrtsimul reads sorted (and possibly multiplexed) miniSEED files and writes -individual records in pseudo-real-time. This is useful e.g. for testing and +{os.path.basename(__file__)} reads sorted (and possibly multiplexed) miniSEED files and +writes individual records in pseudo-real-time. This is useful e.g. for testing and simulating data acquisition. Output is $SEISCOMP_ROOT/var/run/seedlink/mseedfifo unless --seedlink or -c is used. Verbosity: - -h, --help Display this help message - -v, --verbose Verbose mode + -h, --help Display this help message. + -v, --verbose Verbose mode. Playback: -j, --jump Minutes to skip (float). @@ -131,14 +131,15 @@ Playback: -m --mode Choose between 'realtime' and 'historic'. -s, --speed Speed factor (float). --test Test mode. - -u, --unlimited Allow miniSEED records which are not 512 bytes + -u, --unlimited Allow miniSEED records which are not 512 bytes. By default + seedlink supports 512 bytes only. Examples: Play back miniSEED waveforms in real time with verbose output - msrtsimul -v data.mseed + {os.path.basename(__file__)} -v data.mseed Play back miniSEED waveforms in real time skipping the first 1.5 minutes - msrtsimul -j 1.5 data.mseed + {os.path.basename(__file__)} -j 1.5 data.mseed """ ) @@ -170,7 +171,7 @@ def main(): "help", "mode=", "seedlink=", - "unlimited" + "unlimited", ], ) except GetoptError: @@ -278,7 +279,7 @@ Check if SeedLink is running and configured for real-time playback. time_diff = None print( - f"Starting msrtsimul at {datetime.datetime.utcnow()}", + f"Starting msrtsimul at {datetime.datetime.now(datetime.UTC)}", file=sys.stderr, ) for rec in inp: @@ -292,7 +293,7 @@ starting on {str(rec.begin_time)}: length != 512 Bytes.", if time_diff is None: ms = 1000000.0 * (rec.nsamp / rec.fsamp) time_diff = ( - datetime.datetime.utcnow() + datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - rec.begin_time - datetime.timedelta(microseconds=ms) ) diff --git a/bin/optodas_inventory b/bin/optodas_inventory index c4e7d86..eb104d2 100755 --- a/bin/optodas_inventory +++ b/bin/optodas_inventory @@ -84,7 +84,7 @@ def main(): resp = seiscomp.datamodel.ResponsePAZ_Create() resp.setType("A") - resp.setGain(args.gain * header["sensitivities"][0]["factor"] / header["dataScale"]) + resp.setGain(args.gain / header["dataScale"]) resp.setGainFrequency(0) resp.setNormalizationFactor(1) resp.setNormalizationFrequency(0) @@ -93,9 +93,9 @@ def main(): inv.add(resp) sensor = seiscomp.datamodel.Sensor_Create() - sensor.setName(header["instrument"]) - sensor.setDescription(header["instrument"]) - sensor.setUnit(header["sensitivities"][0]["unit"]) + sensor.setName(header["experiment"]) + sensor.setDescription(header["measurement"]) + sensor.setUnit(header["unit"]) sensor.setResponse(resp.publicID()) inv.add(sensor) @@ -131,8 +131,8 @@ def main(): cha = seiscomp.datamodel.Stream_Create() cha.setCode(args.channel) cha.setStart(net.start()) - cha.setGain(args.gain * header["sensitivities"][0]["factor"] / header["dataScale"]) - cha.setGainUnit(header["sensitivities"][0]["unit"]) + cha.setGain(args.gain / header["dataScale"]) + cha.setGainUnit(header["unit"]) cha.setGainFrequency(0) cha.setSensor(sensor.publicID()) cha.setDatalogger(datalogger.publicID()) diff --git a/bin/ql2sc b/bin/ql2sc index bca2949..6e37355 100755 Binary files a/bin/ql2sc and b/bin/ql2sc differ diff --git a/bin/run_with_lock b/bin/run_with_lock index 0b73944..02e92ab 100755 Binary files a/bin/run_with_lock and b/bin/run_with_lock differ diff --git a/bin/sc2pa b/bin/sc2pa index 26b0e86..9dc4c14 100755 --- a/bin/sc2pa +++ b/bin/sc2pa @@ -13,11 +13,14 @@ # https://www.gnu.org/licenses/agpl-3.0.html. # ############################################################################ -import time import sys import os -import time -import seiscomp.core, seiscomp.client, seiscomp.datamodel, seiscomp.logging + +import seiscomp.core +import seiscomp.client +import seiscomp.datamodel +import seiscomp.logging + from seiscomp.scbulletin import Bulletin, stationCount @@ -40,8 +43,8 @@ class ProcAlert(seiscomp.client.Application): self.minPickCount = 25 self.procAlertScript = "" - - ep = seiscomp.datamodel.EventParameters() + self.bulletin = None + self.cache = None def createCommandLineDescription(self): try: @@ -61,7 +64,8 @@ class ProcAlert(seiscomp.client.Application): self.commandline().addStringOption( "Publishing", "procalert-script", - "Specify the script to publish an event. The ProcAlert file and the event id are passed as parameter $1 and $2", + "Specify the script to publish an event. The ProcAlert file and the " + "event id are passed as parameter $1 and $2", ) self.commandline().addOption( "Publishing", "test", "Test mode, no messages are sent" @@ -174,13 +178,14 @@ class ProcAlert(seiscomp.client.Application): return False def send_procalert(self, txt, evid): - if self.procAlertScript: - tmp = f"/tmp/yyy{evid.replace('/', '_').replace(':', '-')}" - f = file(tmp, "w") - f.write(f"{txt}") - f.close() + if not self.procAlertScript: + return - os.system(self.procAlertScript + " " + tmp + " " + evid) + file = f"/tmp/yyy{evid.replace('/', '_').replace(':', '-')}" + with open(file, "w", encoding="utf8") as f: + print(txt, file=f) + + os.system(self.procAlertScript + " " + file + " " + evid) def coordinates(self, org): return org.latitude().value(), org.longitude().value(), org.depth().value() @@ -194,7 +199,7 @@ class ProcAlert(seiscomp.client.Application): seiscomp.logging.error("suspicious region/depth - ignored") publish = False - if stationCount(org) < self.minPickCount: + if stationCount(org, 0.5) < self.minPickCount: seiscomp.logging.error("too few picks - ignored") publish = False diff --git a/bin/scalert b/bin/scalert index c960e45..a249767 100755 --- a/bin/scalert +++ b/bin/scalert @@ -39,6 +39,8 @@ class ObjectAlert(seiscomp.client.Application): self.addMessagingSubscription("EVENT") self.addMessagingSubscription("LOCATION") self.addMessagingSubscription("MAGNITUDE") + self.addMessagingSubscription("AMPLITUDE") + self.addMessagingSubscription("PICK") self.setAutoApplyNotifierEnabled(True) self.setInterpretNotifierEnabled(True) @@ -76,50 +78,57 @@ class ObjectAlert(seiscomp.client.Application): self.commandline().addOption( "Generic", "first-new", - "calls an event a new event when it is seen the first time", + "Calls an event a new event when it is seen the first time.", ) self.commandline().addGroup("Alert") self.commandline().addStringOption( - "Alert", "amp-type", "amplitude type to listen to", self._ampType + "Alert", + "amp-type", + "Amplitude type to listen to.", + self._ampType, ) self.commandline().addStringOption( "Alert", "pick-script", - "script to be called when a pick arrived, network-, station code pick " - "publicID are passed as parameters $1, $2, $3 and $4", + "Script to be called when a pick arrived, network-, station code pick " + "publicID are passed as parameters $1, $2, $3 and $4.", ) self.commandline().addStringOption( "Alert", "amp-script", - "script to be called when a station amplitude arrived, network-, station " - "code, amplitude and amplitude publicID are passed as parameters $1, $2, $3 and $4", + "Script to be called when a station amplitude arrived, network-, station " + "code, amplitude and amplitude publicID are passed as parameters $1, $2, " + "$3 and $4.", ) self.commandline().addStringOption( "Alert", "alert-script", - "script to be called when a preliminary origin arrived, latitude and " - "longitude are passed as parameters $1 and $2", + "Script to be called when a preliminary origin arrived, latitude and " + "longitude are passed as parameters $1 and $2.", ) self.commandline().addStringOption( "Alert", "event-script", - "script to be called when an event has been declared; the message string, a " - "flag (1=new event, 0=update event), the EventID, the arrival count and the " - "magnitude (optional when set) are passed as parameter $1, $2, $3, $4 and $5", + "Script to be called when an event has been declared; the message string, " + "a flag (1=new event, 0=update event), the EventID, the arrival count and " + "the magnitude (optional when set) are passed as parameter $1, $2, $3, $4 " + "and $5.", ) self.commandline().addGroup("Cities") self.commandline().addStringOption( "Cities", "max-dist", - "maximum distance for using the distance from a city to the earthquake", + "Maximum distance for using the distance from a city to the earthquake.", + str(self._citiesMaxDist), ) self.commandline().addStringOption( "Cities", "min-population", - "minimum population for a city to become a point of interest", + "Minimum population for a city to become a point of interest.", + str(self._citiesMinPopulation), ) self.commandline().addGroup("Debug") - self.commandline().addStringOption("Debug", "eventid,E", "specify Event ID") + self.commandline().addStringOption("Debug", "eventid,E", "Specify event ID.") return True def init(self): @@ -174,7 +183,7 @@ class ObjectAlert(seiscomp.client.Application): phaseStreams = self.configGetStrings("constraints.phaseStreams") for item in phaseStreams: rule = item.strip() - # rule is NET.STA.LOC.CHA and the special charactes ? * | ( ) are allowed + # allowned: NET.STA.LOC.CHA and the special charactes ? * | ( ) if not re.fullmatch(r"[A-Z|a-z|0-9|\?|\*|\||\(|\)|\.]+", rule): seiscomp.logging.error( f"Wrong stream ID format in `constraints.phaseStreams`: {item}" @@ -559,7 +568,8 @@ class ObjectAlert(seiscomp.client.Application): break if not matched: seiscomp.logging.debug( - f" + stream ID {waveformID} does not match constraints.phaseStreams rules" + f" + stream ID {waveformID} does not match " + "constraints.phaseStreams rules" ) return @@ -568,7 +578,8 @@ class ObjectAlert(seiscomp.client.Application): self.notifyPick(obj) else: seiscomp.logging.debug( - f" + phase hint {phaseHint} does not match '{self._phaseHints}'" + f" + phase hint {phaseHint} does not match " + f"'{self._phaseHints}'" ) else: seiscomp.logging.debug( @@ -739,6 +750,11 @@ class ObjectAlert(seiscomp.client.Application): seiscomp.logging.debug(f"desc: {dsc}") + try: + evType = seiscomp.datamodel.EEventTypeNames.name(evt.type()) + except Exception: + evType = "earthquake" + dep = org.depth().value() now = seiscomp.core.Time.GMT() otm = org.time().value() @@ -756,14 +772,10 @@ class ObjectAlert(seiscomp.client.Application): dt = f"{int(dt)} seconds ago" if preliminary: - message = f"earthquake, XXL, preliminary, {dt}, {dsc}" + message = f"{evType}, XXL, preliminary, {dt}, {dsc}" else: - message = "earthquake, %s, %s, %s, depth %d kilometers" % ( - dt, - dsc, - mag, - int(dep + 0.5), - ) + message = f"{evType}, {dt}, {dsc}, {mag}, depth {int(dep + 0.5)} kilometers" + seiscomp.logging.info(message) if not self._eventScript: diff --git a/bin/scamp b/bin/scamp index 32fb4d2..53a3591 100755 Binary files a/bin/scamp and b/bin/scamp differ diff --git a/bin/scanloc b/bin/scanloc index 4fbdeca..d95e71d 100755 Binary files a/bin/scanloc and b/bin/scanloc differ diff --git a/bin/scardac b/bin/scardac index 3423f36..ae29005 100755 Binary files a/bin/scardac and b/bin/scardac differ diff --git a/bin/scart b/bin/scart index cfacf82..2e06eba 100755 --- a/bin/scart +++ b/bin/scart @@ -300,10 +300,15 @@ class StreamIterator: self.file = workdir + file # print "Starting at file %s" % self.file - self.record, self.index = ar.findIndex(begin, end, self.file) - if self.record: - self.current = self.record.startTime() - self.currentEnd = self.record.endTime() + while begin < end: + self.record, self.index = ar.findIndex(begin, end, self.file) + if self.record: + self.current = self.record.startTime() + self.currentEnd = self.record.endTime() + break + begin = self.archive.stepTime(begin) + workdir, file = ar.location(begin, net, sta, loc, cha) + self.file = workdir + file def __next__(self): while True: @@ -458,7 +463,8 @@ class RecordRenamer: def printRules(self): for r in self.renameRules: print( - f"Renaming {(r.pattern.pattern if r.pattern is not None else '*.*.*.*')} " + "Renaming " + f"{(r.pattern.pattern if r.pattern is not None else '*.*.*.*')} " f"to {r.newNet}.{r.newSta}.{r.newLoc}.{r.newCha}", file=sys.stderr, ) @@ -805,10 +811,9 @@ Usage: {os.path.basename(__file__)} -d [options] [archive] {os.path.basename(__file__)} --check [options] [archive] -Import miniSEED waveforms or dump records from an SDS structure, sort them, -modify the time and replay them. Also check files and archives. -For Import and Dump mode the data streams can be selected in three ways -using the combinations of options: -n -c -t or --nslc -t or --list +Import or export miniSEED waveforms into/from an SDS structure. Also check files and +archives. Data streams can be selected in three ways using the combinations of options: +-n -c -t or --nslc -t or --list. Verbosity: -h, --help Display this help message. @@ -843,7 +848,7 @@ Processing: 2007-03-28 15:48;2007-03-28 16:18;GE.LAST.*.* 2007-03-28 15:48;2007-03-28 16:18;GE.PMBI..BH? -m, --modify Dump mode: Modify the record time for real time playback - when dumping. + when dumping. Implicitly sets the speed parameter to 1. -n arg Import, dump mode: Data stream selection as a comma separated list "stream1,stream2,streamX" where each stream can be NET or NET.STA or NET.STA.LOC or NET.STA.LOC.CHA. If CHA is omitted, @@ -858,16 +863,18 @@ Processing: A rule is "[match-stream:]rename-stream" and match-stream is optional. match-stream and rename-stream are in the "NET.STA.LOC.CHA" format. match-stream supports special - charactes "?" "*" "|" "(" ")". rename-stream supports the + characters "?" "*" "|" "(" ")". rename-stream supports the special character "-" that can be used in place of NET, STA, LOC, CHA codes with the meaning of not renaming those. "-" can also be used as the last character in CHA code. Multiple rules can be provided as a comma separated list or by providing multiple --rename options. -s, --sort Dump mode: Sort records. - --speed arg Dump mode: Specify the speed to dump the records. A value - of 0 means no delay. Otherwise speed is a multiplier of - the real time difference between the records. + --speed arg Dump mode: Specify the speed to dump the records as a + multiplier of the real time difference between the records. + A value > 1 will speed up the playback while a value > 0 + and < 1 will slow the playback down. This option implies + sorting of the records. -t, --time-window t1~t2 Import, dump mode: UTC time window filter to be applied to the data streams. Format: "StartTime~EndTime". Example: @@ -886,7 +893,7 @@ Output: --print-streams. --with-filecheck Import mode: Check all accessed files after import. Unsorted or unreadable files are reported to stderr. Checks are only - complete for files containing exactly one stream. More + complete for files containing exactly one stream. More complete checks are made with scmssort. --with-filename Import mode: Print all accessed files to sterr after import. @@ -901,11 +908,16 @@ Import miniSEED data into a SDS archive, check all modified files for errors {os.path.basename(__file__)} -I file.mseed --with-filecheck $SEISCOMP_ROOT/var/lib/archive Import miniSEED data from FDSNWS into a SDS archive for specific time range and streams - {os.path.basename(__file__)} -I fdsnws://geofon.gfz-potsdam.de \ + {os.path.basename(__file__)} -I fdsnws://geofon.gfz.de \ -t 2022-03-28T15:48~2022-03-28T16:18 --nslc list.file $SEISCOMP_ROOT/var/lib/archive Check an archive for files with out-of-order records {os.path.basename(__file__)} --check /archive + +Play back miniSEED data from archive at normal speed as in real time and pipe \ +them into another application, here scrttv + + {os.path.basename(__file__)} -dmv -t 2026-05-01~2026-05-02 /archive | scrttv -I - --offline --no-inventory """ @@ -964,7 +976,7 @@ def main(): # default = stdin recordURL = "file://-" - speed = 0 + speed = None stdout = False outputFile = None ignoreRecords = False @@ -1038,7 +1050,23 @@ def main(): else: usage(exitcode=1) - if not dump and not checkSDS and not importMode: + if dump: + if modifyTime and speed is None: + speed = 1 + sort = True + elif speed is not None: + if speed <= 0: + print("'--speed' must be greater than 0", file=sys.stderr) + return -1 + + sort = True + if modifyTime and speed != 1: + print( + "Modify time requested with '--speed' value other than 1. Gaps " + "or overlaps will be created.", + file=sys.stderr, + ) + elif not checkSDS and not importMode: importMode = True if files: @@ -1116,18 +1144,21 @@ def main(): print(f"Stream file: '{nslcFile}'", file=sys.stderr) if dump: - if not sort and not modifyTime: - print("Mode: DUMP", file=sys.stderr) - elif sort and not modifyTime: - print("Mode: DUMP & SORT", file=sys.stderr) - elif not sort and modifyTime: - print("Mode: DUMP & MODIFY_TIME", file=sys.stderr) - elif sort and modifyTime: - print("Mode: DUMP & SORT & MODIFY_TIME", file=sys.stderr) + flags = [] + if speed: + flags.append(f"speed={speed}") + if sort: + flags.append("sort") + if modifyTime: + flags.append("modify time") + flagStr = "" + if flags: + flagStr = f" ({', '.join(flags)})" + print(f"Mode: DUMP{flagStr}", file=sys.stderr) print(f"Archive: {archiveDirectory}", file=sys.stderr) if checkSDS: - print("Mode: Check", file=sys.stderr) + print("Mode: CHECK", file=sys.stderr) if importMode: print("Mode: IMPORT", file=sys.stderr) @@ -1157,7 +1188,7 @@ def main(): else: out = sys.stdout.buffer - # list file witht times takes priority over nslc list + # list file with times takes priority over nslc list if listFile: nslcFile = None @@ -1174,7 +1205,8 @@ def main(): for stream in streamFilter: if stream.tmin >= stream.tmax: print( - f"Info: ignoring {stream.net}.{stream.sta}.{stream.loc}.{stream.cha} - " + "Info: " + f"ignoring {stream.net}.{stream.sta}.{stream.loc}.{stream.cha} - " f"start {stream.tmin} after end {stream.tmax}", file=sys.stderr, ) @@ -1228,8 +1260,9 @@ def main(): f"{stream.cha} {stream.tmin} - {stream.tmax}", file=sys.stderr, ) - stime = None - realTime = seiscomp.core.Time.GMT() + + firstRecordEndTime = None + startTime = seiscomp.core.Time.UTC() if sort: records = Sorter(archiveIterator) @@ -1245,36 +1278,34 @@ def main(): if ignoreRecords: continue - etime = seiscomp.core.Time(rec.endTime()) + etime = rec.endTime() - if stime is None: - stime = etime + if not firstRecordEndTime: + firstRecordEndTime = seiscomp.core.Time(etime) if verbose: - print(f"First record: {stime.iso()}", file=sys.stderr) + print( + f"First record end time: {firstRecordEndTime.iso()}", + file=sys.stderr, + ) - dt = etime - stime + if speed: + dt = (etime - firstRecordEndTime).length() + playTime = startTime + seiscomp.core.TimeSpan(dt / speed) - now = seiscomp.core.Time.GMT() + if modifyTime: + recLength = etime - rec.startTime() + rec.setStartTime(seiscomp.core.Time(playTime) - recLength) - if speed > 0: - playTime = (realTime + dt).toDouble() / speed - else: - playTime = now.toDouble() - - sleepTime = playTime - now.toDouble() - if sleepTime > 0: - time.sleep(sleepTime) - - if modifyTime: - recLength = etime - rec.startTime() - rec.setStartTime(seiscomp.core.Time(playTime) - recLength) + sleepSeconds = (playTime - seiscomp.core.Time.UTC()).length() + if sleepSeconds > 0: + time.sleep(sleepSeconds) if verbose: - etime = rec.endTime() print( - f"{rec.streamID()} time current: " - f"{seiscomp.core.Time.LocalTime().iso()} start: " - f"{rec.startTime().iso()} end: {etime.iso()}", + f"{rec.streamID()} " + f"current time: {seiscomp.core.Time.LocalTime().iso()}" + f", rec start: {rec.startTime().iso()}" + f", rec end: {rec.startTime().iso()}", file=sys.stderr, ) @@ -1529,7 +1560,8 @@ def main(): f = open(archiveDirectory + file, "ab") except BaseException: print( - f"File {archiveDirectory + file} could not be opened for writing", + f"File {archiveDirectory + file} could not be opened " + f"for writing", file=sys.stderr, ) return -1 @@ -1605,8 +1637,8 @@ def main(): print(fileName, file=sys.stderr) if printStreams and streamDict: - minTime = seiscomp.core.Time.GMT() - maxTime = str2time("1970-01-01 00:00:00") + minTime = None + maxTime = None totalRecs = 0 totalSamples = 0 totalChans = set() @@ -1624,8 +1656,12 @@ def main(): file=sys.stderr, ) - maxTime = max(maxTime, str2time(end)) - minTime = min(minTime, str2time(start)) + if minTime: + minTime = min(minTime, str2time(start)) + maxTime = max(maxTime, str2time(end)) + else: + minTime = str2time(start) + maxTime = str2time(end) totalChans.add(key) totalNetworks.add(key.split(".")[0]) @@ -1637,28 +1673,17 @@ def main(): "# Summary", file=sys.stderr, ) + if minTime and maxTime: + print( + f"# time range: {minTime.iso()} - {maxTime.iso()}", + file=sys.stderr, + ) print( - f"# time range: {minTime.iso()} - {maxTime.iso()}", - file=sys.stderr, - ) - print( - f"# networks: {len(totalNetworks)}", - file=sys.stderr, - ) - print( - f"# stations: {len(totalStations)}", - file=sys.stderr, - ) - print( - f"# streams: {len(totalChans)}", - file=sys.stderr, - ) - print( - f"# records: {totalRecs}", - file=sys.stderr, - ) - print( - f"# samples: {totalSamples}", + f""""# networks: {len(totalNetworks)} +# stations: {len(totalStations)} +# streams: {len(totalChans)} +# records: {totalRecs} +# samples: {totalSamples}""", file=sys.stderr, ) diff --git a/bin/scautoloc b/bin/scautoloc index 319096c..5a12902 100755 Binary files a/bin/scautoloc and b/bin/scautoloc differ diff --git a/bin/scautopick b/bin/scautopick index 76ac994..367afc1 100755 Binary files a/bin/scautopick and b/bin/scautopick differ diff --git a/bin/scchkcfg b/bin/scchkcfg index e39053e..e433fa6 100755 Binary files a/bin/scchkcfg and b/bin/scchkcfg differ diff --git a/bin/sccnv b/bin/sccnv index 296eec9..2760ad4 100755 Binary files a/bin/sccnv and b/bin/sccnv differ diff --git a/bin/scconfig b/bin/scconfig index 01ccb76..632edf4 100755 Binary files a/bin/scconfig and b/bin/scconfig differ diff --git a/bin/scdb b/bin/scdb index c5c95d3..e5363e1 100755 Binary files a/bin/scdb and b/bin/scdb differ diff --git a/bin/scdbstrip b/bin/scdbstrip index c6add30..cf2beb8 100755 --- a/bin/scdbstrip +++ b/bin/scdbstrip @@ -81,25 +81,20 @@ class MySQLDB(QueryInterface): return tmp_tables def deleteObjectQuery(self, *v): - if v[0]: - q = ( - "delete " - + v[0] - + " from " - + ", ".join(v) - + " where " - + v[0] - + "._oid=" - + v[1] - + "._oid and " - ) - else: - q = "delete " + v[1] + " from " + ", ".join(v[1:]) + " where " + q = ( + "delete " + + v[0] + + " from " + + ", ".join(v) + + " where " + + v[0] + + "._oid=" + + v[1] + + "._oid" + ) for i in range(1, len(v) - 1): - if i > 1: - q += " and " - q += v[i] + "._oid=" + v[i + 1] + "._oid" + q += " and " + v[i] + "._oid=" + v[i + 1] + "._oid" return q @@ -211,25 +206,20 @@ class PostgresDB(QueryInterface): return tmp_tables def deleteObjectQuery(self, *v): - if v[0]: - q = ( - "delete from " - + v[0] - + " using " - + ", ".join(v[1:]) - + " where " - + v[0] - + "._oid=" - + v[1] - + "._oid and " - ) - else: - q = "delete from " + v[1] + " using " + ", ".join(v[2:]) + " where " + q = ( + "delete from " + + v[0] + + " using " + + ", ".join(v[1:]) + + " where " + + v[0] + + "._oid=" + + v[1] + + "._oid" + ) for i in range(1, len(v) - 1): - if i > 1: - q += " and " - q += v[i] + "._oid=" + v[i + 1] + "._oid" + q += " and " + v[i] + "._oid=" + v[i + 1] + "._oid" return q @@ -333,6 +323,8 @@ class DBCleaner(seiscomp.client.Application): self._invertMode = False self._stripEP = True self._stripQC = True + self._keepModes = [] # Array with modes to keep + self._keepStatus = [] # Array with status to keep self._steps = 0 self._currentStep = 0 @@ -368,6 +360,18 @@ class DBCleaner(seiscomp.client.Application): "Event-IDs to keep in the database. Combining with 'qc-only' " "is invalid.", ) + self.commandline().addStringOption( + "Objects", + "keep-event-modes", + "Keep all events where is evaluation mode of the preferred origin is " + "one of the given modes." + ) + self.commandline().addStringOption( + "Objects", + "keep-event-status", + "Keep all events where is evaluation status of the preferred origin is " + "one of the given status." + ) self.commandline().addOption( "Objects", "qc-only,Q", @@ -473,7 +477,7 @@ class DBCleaner(seiscomp.client.Application): f"""Usage: {os.path.basename(__file__)} [options] -Remove event and waveform quality parameters from the database in a timespan. Use +Remove event and waveform quality parameters from the database in a timespan. Use scardac for removing data availability parameters.""" ) @@ -543,6 +547,19 @@ Remove all waveform QC paramters older than 30 days but do not effect event para except RuntimeError: pass + try: + status = self.commandline().optionString("keep-event-status") + self._keepStatus = [s.strip() for s in status.split(",")] + print(status, self._keepStatus) + except RuntimeError: + pass + + try: + modes = self.commandline().optionString("keep-event-modes") + self._keepModes = [m.strip() for m in modes.split(",")] + except RuntimeError: + pass + try: dateTime = self.commandline().optionString("datetime") except RuntimeError: @@ -694,6 +711,11 @@ Remove all waveform QC paramters older than 30 days but do not effect event para self.beginMessage("Search objects") if not self.runCommand(tmp_object): return False + + tmp_object = "create index idx_oid on tmp_object(_oid)" + if not self.runCommand(tmp_object): + return False + self.endMessage(self.globalCount("tmp_object")) for table in tables: @@ -783,6 +805,7 @@ Remove all waveform QC paramters older than 30 days but do not effect event para self.beginMessage("Deleting waveform quality parameters") if not self.runCommand( self._query.deleteObjectQuery("Object", "WaveformQuality") + + " and " + timeRangeSelection(f"WaveformQuality.{self.cnvCol('end')}") ): return False @@ -822,9 +845,28 @@ Remove all waveform QC paramters older than 30 days but do not effect event para + " not in ('%s')" % "','".join(self._keepEvents) ) + if len(self._keepModes) > 0: + old_events += ( + " and Origin." + + self.cnvCol("evaluationMode") + + " not in ('%s')" % "','".join(self._keepModes) + ) + + if len(self._keepStatus) > 0: + old_events += ( + " and Origin." + + self.cnvCol("evaluationStatus") + + " not in ('%s')" % "','".join(self._keepStatus) + ) + self.beginMessage("Find old events") if not self.runCommand(old_events): return False + + old_events = "create index idx_oid on old_events(_oid)" + if not self.runCommand(old_events): + return False + self.endMessage(self.globalCount("old_events")) # Delete OriginReferences of old events @@ -879,6 +921,10 @@ Remove all waveform QC paramters older than 30 days but do not effect event para self.beginMessage("Find unassociated focal mechanisms") + if not self.runCommand(tmp_fm): + return False + + tmp_fm = "create index idx_oid on tmp_fm(_oid)" if not self.runCommand(tmp_fm): return False @@ -990,6 +1036,10 @@ Remove all waveform QC paramters older than 30 days but do not effect event para self.beginMessage("Find unassociated origins") + if not self.runCommand(tmp_origin): + return False + + tmp_origin = "create index idx_oid on tmp_origin(_oid)" if not self.runCommand(tmp_origin): return False @@ -998,7 +1048,7 @@ Remove all waveform QC paramters older than 30 days but do not effect event para update tmp_origin set used=1 \ where (" + self.cnvCol("publicID") - + " in (select distinct " + + " in (select " + self.cnvCol("originID") + " from OriginReference)) \ or (" @@ -1093,6 +1143,10 @@ Remove all waveform QC paramters older than 30 days but do not effect event para ) ) + if not self.runCommand(tmp_pick): + return False + + tmp_pick = "create index idx_oid on tmp_pick(_oid)" if not self.runCommand(tmp_pick): return False @@ -1145,6 +1199,10 @@ Remove all waveform QC paramters older than 30 days but do not effect event para ) ) + if not self.runCommand(tmp_amp): + return False + + tmp_amp = "create index idx_oid on tmp_amp(_oid)" if not self.runCommand(tmp_amp): return False @@ -1287,27 +1345,27 @@ Remove all waveform QC paramters older than 30 days but do not effect event para self._query.deleteJournalQuery("PublicObject", *v) + " and used=0" ) self.runCommand( - self._query.deleteObjectQuery(None, "Object", *v) + " and used=0" + self._query.deleteObjectQuery("Object", *v) + " and used=0" ) self.runCommand( - self._query.deleteObjectQuery(None, "PublicObject", *v) + " and used=0" + self._query.deleteObjectQuery("PublicObject", *v) + " and used=0" ) def deleteObjects(self, *v): self.runCommand(self._query.deleteJournalQuery("PublicObject", *v)) - self.runCommand(self._query.deleteObjectQuery("Object", *v)) - self.runCommand(self._query.deleteObjectQuery("PublicObject", *v)) - self.runCommand(self._query.deleteObjectQuery(None, *v)) + self.runCommand(self._query.deleteObjectQuery(*v)) + self.runCommand(self._query.deleteObjectQuery("PublicObject", *v[1:])) + self.runCommand(self._query.deleteObjectQuery("Object", *v[1:])) def deleteUnusedObjects(self, *v): self.runCommand( self._query.deleteJournalQuery("PublicObject", *v) + " and used=0" ) - self.runCommand(self._query.deleteObjectQuery("Object", *v) + " and used=0") + self.runCommand(self._query.deleteObjectQuery(*v) + " and used=0") self.runCommand( - self._query.deleteObjectQuery("PublicObject", *v) + " and used=0" + self._query.deleteObjectQuery("PublicObject", *v[1:]) + " and used=0" ) - self.runCommand(self._query.deleteObjectQuery(None, *v) + " and used=0") + self.runCommand(self._query.deleteObjectQuery("Object", *v[1:]) + " and used=0") def delete(self, message, func, *v): self.beginMessage(message) diff --git a/bin/scdispatch b/bin/scdispatch index d96da46..85a865e 100755 Binary files a/bin/scdispatch and b/bin/scdispatch differ diff --git a/bin/scesv b/bin/scesv index c312469..8f3e2fe 100755 Binary files a/bin/scesv and b/bin/scesv differ diff --git a/bin/scevent b/bin/scevent index f27703a..695f0da 100755 Binary files a/bin/scevent and b/bin/scevent differ diff --git a/bin/scevtls b/bin/scevtls index f605f12..b9c0d5c 100755 --- a/bin/scevtls +++ b/bin/scevtls @@ -53,7 +53,7 @@ def readXML(self): if self._eventType: try: - eventType = seiscomp.datamodel.EEventTypeNames_name(evt.type()) + eventType = seiscomp.datamodel.EEventTypeNames.name(evt.type()) if eventType != self._eventType: continue except ValueError: @@ -108,10 +108,16 @@ class EventList(seiscomp.client.Application): ) self.commandline().addGroup("Events") self.commandline().addStringOption( - "Events", "begin", "Specify the lower bound of the time interval." + "Events", + "begin", + "Specify the lower bound of the time interval. Uses 1900-01-01T00:00:00 " + "unless given.", ) self.commandline().addStringOption( - "Events", "end", "Specify the upper bound of the time interval." + "Events", + "end", + "Specify the upper bound of the time interval Uses 2500-01-01T00:00:00 " + "unless given.", ) self.commandline().addStringOption( "Events", @@ -266,7 +272,7 @@ List event IDs available in a given time range and print to stdout.""" f"""Examples: Print all event IDs from year 2022 and thereafter {os.path.basename(__file__)} -d mysql://sysop:sysop@localhost/seiscomp \ ---begin "2022-01-01 00:00:00" +--begin 2022-01-01T00:00:00 Print all event IDs with event type 'quarry blast' {os.path.basename(__file__)} -d mysql://sysop:sysop@localhost/seiscomp --event-type 'quarry blast' @@ -303,7 +309,7 @@ Print IDs of all events in XML file if self._eventType: try: - eventType = seiscomp.datamodel.EEventTypeNames_name(evt.type()) + eventType = seiscomp.datamodel.EEventTypeNames.name(evt.type()) if eventType != self._eventType: continue except ValueError: diff --git a/bin/scevtstreams b/bin/scevtstreams index 479b3bc..e307e10 100755 --- a/bin/scevtstreams +++ b/bin/scevtstreams @@ -295,7 +295,7 @@ class EventStreams(client.Application): """Usage: scevtstreams [options] -Extract stream information and time windows from an event""" +Extract stream information and time windows from picks of an event or solitary picks.""" ) client.Application.printUsage(self) @@ -305,8 +305,8 @@ Extract stream information and time windows from an event""" Get the time windows for an event in the database: scevtstreams -E gfz2012abcd -d mysql://sysop:sysop@localhost/seiscomp -Create lists compatible with fdsnws: - scevtstreams -E gfz2012abcd -i event.xml -m 120,500 --fdsnws +Get the time windows for all picks given in an XML file without origins and events: + scevtstreams -i picks.xml -m 120,500 """ ) @@ -314,10 +314,14 @@ Create lists compatible with fdsnws: resolveWildcards = self.commandline().hasOption("resolve-wildcards") picks = [] - # read picks from input file if self.inputFile: - picks = self.readXML() + try: + picks = self.readXML() + except IOError as e: + print(f"Error: {e}", file=sys.stderr) + return False + if not picks: raise ValueError("Could not find picks in input file") @@ -327,6 +331,7 @@ Create lists compatible with fdsnws: pick = datamodel.Pick.Cast(obj) if pick is None: continue + picks.append(pick) if not picks: @@ -502,11 +507,18 @@ Create lists compatible with fdsnws: ep = datamodel.EventParameters.Cast(obj) if ep is None: - raise ValueError("no event parameters found in input file") + # pick may be provided as base object, only one can be read + pick = datamodel.Pick.Cast(obj) + if pick is None: + raise ValueError( + "Neither event parameters nor pick found in input file" + ) + else: + return [pick] # we require at least one origin which references to picks via arrivals - if ep.originCount() == 0: - raise ValueError("no origin found in input file") + if ep.originCount() == 0 and ep.pickCount() == 0: + raise ValueError("No origin found in input file") originIDs = [] @@ -524,7 +536,7 @@ Create lists compatible with fdsnws: # use first event/origin if no id was specified else: # no event, use first available origin - if ep.eventCount() == 0: + if ep.eventCount() == 0 and ep.originCount() > 0: if ep.originCount() > 1: print( "WARNING: Input file contains no event but more than " @@ -534,7 +546,7 @@ Create lists compatible with fdsnws: originIDs.append(ep.origin(0).publicID()) # use origin references of first available event - else: + elif ep.eventCount() > 0 and ep.originCount() > 0: if ep.eventCount() > 1: print( "WARNING: Input file contains more than 1 event. " @@ -546,10 +558,18 @@ Create lists compatible with fdsnws: ev.originReference(i).originID() for i in range(ev.originReferenceCount()) ] + else: + print("Found no origins, trying to continue with picks only.") + + if originIDs: + print( + f"Considering all arrivals from {len(originIDs)} origin(s).", + file=sys.stderr, + ) - # collect pickIDs pickIDs = set() for oID in originIDs: + # collect pickIDs from origins o = datamodel.Origin.Find(oID) if o is None: continue @@ -557,6 +577,11 @@ Create lists compatible with fdsnws: for i in range(o.arrivalCount()): pickIDs.add(o.arrival(i).pickID()) + if len(pickIDs) == 0: + # try reading picks only + for i in range(ep.pickCount()): + pickIDs.add(ep.pick(i).publicID()) + # lookup picks picks = [] for pickID in pickIDs: @@ -564,6 +589,9 @@ Create lists compatible with fdsnws: if pick: picks.append(pick) + if len(pickIDs) == 0: + print("Found no picks.", file=sys.stderr) + return picks diff --git a/bin/scheli b/bin/scheli index 3c243cb..9e0eed2 100755 Binary files a/bin/scheli and b/bin/scheli differ diff --git a/bin/scimex b/bin/scimex index daafe4e..c720cee 100755 Binary files a/bin/scimex and b/bin/scimex differ diff --git a/bin/scimport b/bin/scimport index 0cc809b..cf4a461 100755 Binary files a/bin/scimport and b/bin/scimport differ diff --git a/bin/scinv b/bin/scinv index 42d8cca..8af4aa8 100755 Binary files a/bin/scinv and b/bin/scinv differ diff --git a/bin/scm b/bin/scm index 920c6dd..fe4a1ef 100755 Binary files a/bin/scm and b/bin/scm differ diff --git a/bin/scmag b/bin/scmag index 687eea5..a61b1a9 100755 Binary files a/bin/scmag and b/bin/scmag differ diff --git a/bin/scmapcut b/bin/scmapcut index 6c35282..740e68a 100755 Binary files a/bin/scmapcut and b/bin/scmapcut differ diff --git a/bin/scmaster b/bin/scmaster index a5ea71a..43f83df 100755 Binary files a/bin/scmaster and b/bin/scmaster differ diff --git a/bin/scmm b/bin/scmm index d7b74eb..fc3812a 100755 Binary files a/bin/scmm and b/bin/scmm differ diff --git a/bin/scmsdemux b/bin/scmsdemux new file mode 100755 index 0000000..eae5815 --- /dev/null +++ b/bin/scmsdemux @@ -0,0 +1,144 @@ +#!/usr/bin/env seiscomp-python + +############################################################################ +# Copyright (C) gempa GmbH # +# All rights reserved. # +# Contact: gempa GmbH (seiscomp-dev@gempa.de) # +# # +# GNU Affero General Public License Usage # +# This file may be used under the terms of the GNU Affero # +# Public License version 3.0 as published by the Free Software Foundation # +# and appearing in the file LICENSE included in the packaging of this # +# file. Please review the following information to ensure the GNU Affero # +# Public License version 3.0 requirements will be met: # +# https://www.gnu.org/licenses/agpl-3.0.html. # +# # +# Other Usage # +# Alternatively, this file may be used in accordance with the terms and # +# conditions contained in a signed written agreement between you and # +# gempa GmbH. # +############################################################################ + +import os +import sys + +from getopt import gnu_getopt, GetoptError +from seiscomp import mseedlite as mseed + + +def usage(): + print( + f"""Usage: + {os.path.basename(__file__)} source + +Demultiplex all miniSEED records found in the given source by stream code writing them +into separate new files. The source can be files or stdin. One file per stream is +generated. File names are derived from stream codes and the begin time of the records. + +Verbosity: + -h, --help Display this help message. + -v, --verbose Verbose mode. + +Examples: +Demultiplex the miniSEED records contained in data.mseed and additionally print the +names of created files to stderr + {os.path.basename(__file__)} -v data.mseed + +Demultiplex the miniSEED records received from stdin + scmssort -u -E data.mseed | {os.path.basename(__file__)} - +""" + ) + + +def main(): + try: + opts, args = gnu_getopt( + sys.argv[1:], + "hv", + [ + "help", + "verbose", + ], + ) + except GetoptError: + print( + f"{os.path.basename(__file__)}: Unknown option", + file=sys.stderr, + ) + usage() + return False + + verbosity = False + for flag, arg in opts: + if flag in ("-h", "--help"): + usage() + return True + + if flag in ("-v", "--verbose"): + verbosity = True + + inFile = sys.stdin.buffer + try: + if len(args[0]) > 0: + openFiles = {} + except Exception: + print( + f"{os.path.basename(__file__)}: Missing source", + file=sys.stderr, + ) + usage() + sys.exit(1) + + if len(args) == 1: + if args[0] != "-": + try: + inFile = open(args[0], "rb") + except IOError as e: + print( + f"Could not open input file '{args[0]}' for reading: {e}", + file=sys.stderr, + ) + return False + else: + print( + "Waiting for miniSEED records on stdin. Use Ctrl + C to interrupt.", + file=sys.stderr, + ) + elif len(args) != 0: + usage() + sys.exit(1) + + try: + for rec in mseed.Input(inFile): + oName = "%s.%s.%s.%s" % (rec.sta, rec.net, rec.loc, rec.cha) + + if oName not in openFiles: + postfix = ".D.%04d.%03d.%02d%02d" % ( + rec.begin_time.year, + rec.begin_time.timetuple()[7], + rec.begin_time.hour, + rec.begin_time.minute, + ) + + openFiles[oName] = open(oName + postfix, "ab") + + oFile = openFiles[oName] + oFile.write(rec.header + rec.data) + + if verbosity: + print("Generated output files:", file=sys.stderr) + + for oName in openFiles: + if verbosity: + print(f" {oName}", file=sys.stderr) + + openFiles[oName].close() + + except KeyboardInterrupt: + return True + + return True + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/scmv b/bin/scmv index bb909ec..ef9b265 100755 Binary files a/bin/scmv and b/bin/scmv differ diff --git a/bin/scmvx b/bin/scmvx new file mode 100755 index 0000000..a36d68b Binary files /dev/null and b/bin/scmvx differ diff --git a/bin/scolv b/bin/scolv index c337dd9..3ead803 100755 Binary files a/bin/scolv and b/bin/scolv differ diff --git a/bin/scorg2nll b/bin/scorg2nll index a8508b9..17c5ed4 100755 Binary files a/bin/scorg2nll and b/bin/scorg2nll differ diff --git a/bin/scorgls b/bin/scorgls index c3936cb..ffa59d9 100755 --- a/bin/scorgls +++ b/bin/scorgls @@ -89,12 +89,14 @@ class OriginList(seiscomp.client.Application): self.commandline().addStringOption( "Origins", "begin", - "The lower bound of the time interval. Format: '1970-01-01 00:00:00'.", + "The lower bound of the time interval. Uses 1900-01-01T00:00:00 unless " + "given.", ) self.commandline().addStringOption( "Origins", "end", - "The upper bound of the time interval. Format: '1970-01-01 00:00:00'.", + "The upper bound of the time interval. Format: 1970-01-01T00:00:00. Uses " + "2500-01-01T00:00:00 unless given.", ) self.commandline().addStringOption( "Origins", "author", "The author of the origins." @@ -179,7 +181,7 @@ List origin IDs available in a given time range and print to stdout.""" f"""Examples: Print all origin IDs from year 2022 and thereafter {os.path.basename(__file__)} -d mysql://sysop:sysop@localhost/seiscomp \ ---begin "2022-01-01 00:00:00" +--begin 2022-01-01T00:00:00 Print IDs of all events in XML file {os.path.basename(__file__)} -i origins.xml diff --git a/bin/scplot b/bin/scplot index 4b3b506..d263e6d 100755 Binary files a/bin/scplot and b/bin/scplot differ diff --git a/bin/scproclat b/bin/scproclat index 345066d..88558be 100755 --- a/bin/scproclat +++ b/bin/scproclat @@ -13,19 +13,25 @@ # https://www.gnu.org/licenses/agpl-3.0.html. # ############################################################################ -import time, sys, os, traceback -import seiscomp.core, seiscomp.client, seiscomp.datamodel -import seiscomp.logging, seiscomp.system +import os +import sys +import traceback + +import seiscomp.core +import seiscomp.client +import seiscomp.datamodel +import seiscomp.logging +import seiscomp.system -def createDirectory(dir): - if os.access(dir, os.W_OK): +def createDirectory(directory): + if os.access(directory, os.W_OK): return True try: - os.makedirs(dir) + os.makedirs(directory) return True - except: + except OSError: return False @@ -46,8 +52,8 @@ def timeSpanToString(ts): if neg: return "-%.2d:%.2d:%.2d:%.2d.%06d" % (days, hours, mins, secs, usecs) - else: - return "%.2d:%.2d:%.2d:%.2d.%06d" % (days, hours, mins, secs, usecs) + + return "%.2d:%.2d:%.2d:%.2d.%06d" % (days, hours, mins, secs, usecs) class ProcLatency(seiscomp.client.Application): @@ -135,8 +141,6 @@ class ProcLatency(seiscomp.client.Application): def logObject(self, parentID, obj, update): now = seiscomp.core.Time.GMT() - time = None - pick = seiscomp.datamodel.Pick.Cast(obj) if pick: phase = "" @@ -199,7 +203,7 @@ class ProcLatency(seiscomp.client.Application): pass try: - status = seiscomp.datamodel.EOriginStatusNames.name(org.status()) + status = seiscomp.datamodel.EEvaluationStatusNames.name(org.status()) except: pass @@ -286,7 +290,7 @@ class ProcLatency(seiscomp.client.Application): sys.stdout.write(f"{timeToString(received)};{logEntry}\n") if nowDirectory != self._nowDirectory: - if createDirectory(nowDirectory) == False: + if not createDirectory(nowDirectory): seiscomp.logging.error(f"Unable to create directory {nowDirectory}") return False @@ -298,7 +302,7 @@ class ProcLatency(seiscomp.client.Application): ) if triggeredDirectory != self._triggeredDirectory: - if createDirectory(triggeredDirectory) == False: + if not createDirectory(triggeredDirectory): seiscomp.logging.error( f"Unable to create directory {triggeredDirectory}" ) @@ -321,7 +325,7 @@ class ProcLatency(seiscomp.client.Application): # logEntry = timeToString(received) logEntry = "" - if not triggered is None: + if triggered is not None: aTriggered = triggered.get() triggeredDirectory = ( self._directory + "/".join(["%.2d" % i for i in aTriggered[1:4]]) + "/" @@ -341,7 +345,7 @@ class ProcLatency(seiscomp.client.Application): sys.stdout.write(f"{timeToString(received)};{logEntry}\n") if nowDirectory != self._nowDirectory: - if createDirectory(nowDirectory) == False: + if not createDirectory(nowDirectory): seiscomp.logging.error(f"Unable to create directory {nowDirectory}") return False @@ -353,7 +357,7 @@ class ProcLatency(seiscomp.client.Application): if triggeredDirectory: if triggeredDirectory != self._triggeredDirectory: - if createDirectory(triggeredDirectory) == False: + if not createDirectory(triggeredDirectory): seiscomp.logging.error( f"Unable to create directory {triggeredDirectory}" ) @@ -369,11 +373,8 @@ class ProcLatency(seiscomp.client.Application): return True def writeLog(self, file, text): - of = open(file, "a") - if of: - of.write(text) - of.write("\n") - of.close() + with open(file, "a", encoding="utf8") as of: + of.print(text, file=of) app = ProcLatency(len(sys.argv), sys.argv) diff --git a/bin/scqc b/bin/scqc index 33cb21d..92fdd2e 100755 Binary files a/bin/scqc and b/bin/scqc differ diff --git a/bin/scqcv b/bin/scqcv index 7538fb3..7160d0c 100755 Binary files a/bin/scqcv and b/bin/scqcv differ diff --git a/bin/scquery b/bin/scquery index 4896c13..40efae3 100755 Binary files a/bin/scquery and b/bin/scquery differ diff --git a/bin/scqueryqc b/bin/scqueryqc index a0f908f..7b49976 100755 --- a/bin/scqueryqc +++ b/bin/scqueryqc @@ -105,10 +105,14 @@ class WfqQuery(seiscomp.client.Application): self.commandline().addGroup("Query") self.commandline().addStringOption( - "Query", "begin,b", "Begin time of query: 'YYYY-MM-DD hh:mm:ss'" + "Query", + "begin,b", + "Begin time of query. Uses 1900-01-01T00:00:00 unless given.", ) self.commandline().addStringOption( - "Query", "end,e", "End time of query: 'YYYY-MM-DD hh:mm:ss'" + "Query", + "end,e", + "End time of query. Uses current time unless given.", ) self.commandline().addStringOption( "Query", @@ -116,7 +120,7 @@ class WfqQuery(seiscomp.client.Application): "Waveform stream ID to search for QC parameters: net.sta.loc.cha -" " [networkCode].[stationCode].[sensorLocationCode].[channelCode]. " "Provide a single ID or a comma-separated list. Overrides " - "--streams-from-inventory", + "--streams-from-inventory.", ) self.commandline().addStringOption( "Query", @@ -151,8 +155,8 @@ Query a database for waveform quality control (QC) parameters.""", print( f"""Examples: Query rms and delay values for streams 'AU.AS18..SHZ' and 'AU.AS19..SHZ' from \ -'2021-11-20 00:00:00' until current - {os.path.basename(__file__)} -d localhost -b '2021-11-20 00:00:00' -p rms,delay \ +2021-11-20 00:00:00 until current + {os.path.basename(__file__)} -d localhost -b 2021-11-20T00:00:00 -p rms,delay \ -i AU.AS18..SHZ,AU.AS19..SHZ""", file=sys.stderr, ) diff --git a/bin/screloc b/bin/screloc index 1aecd09..d50b0fe 100755 Binary files a/bin/screloc and b/bin/screloc differ diff --git a/bin/screpick b/bin/screpick index 1b77019..cde5ace 100755 Binary files a/bin/screpick and b/bin/screpick differ diff --git a/bin/scrttv b/bin/scrttv index 2ebc813..f0a2afe 100755 Binary files a/bin/scrttv and b/bin/scrttv differ diff --git a/bin/scsendorigin b/bin/scsendorigin index 3c4d724..07771c8 100755 --- a/bin/scsendorigin +++ b/bin/scsendorigin @@ -69,8 +69,8 @@ class SendOrigin(seiscomp.client.Application): "Parameters", "coord", "Latitude,longitude,depth of origin" ) self.commandline().addStringOption("Parameters", "time", "time of origin") - except: - seiscomp.logging.warning(f"caught unexpected error {sys.exc_info()}") + except Exception: + seiscomp.logging.warning(f"Caught unexpected error {sys.exc_info()}") def printUsage(self): print( @@ -85,7 +85,7 @@ Create an artificial origin and send to the messaging""" print( """Examples: Send an artificial origin with hypocenter parameters to the messaging - scsendorigin --time "2022-05-01 10:00:00" --coord 52,12,10 + scsendorigin --time 2022-05-01T10:00:00 --coord 52,12,10 """ ) diff --git a/bin/scshowevent b/bin/scshowevent index 859e53c..342380a 100755 Binary files a/bin/scshowevent and b/bin/scshowevent differ diff --git a/bin/scsmdump b/bin/scsmdump new file mode 100755 index 0000000..643c264 Binary files /dev/null and b/bin/scsmdump differ diff --git a/bin/scsohlog b/bin/scsohlog index 4c8732e..c5967b4 100755 --- a/bin/scsohlog +++ b/bin/scsohlog @@ -359,9 +359,7 @@ Create an output XML file every 60 seconds and execute a custom script to proces try: f = open(self._outputFile, "w") except: - seiscomp.logging.error( - f"Unable to create output file: {self._outputFile}" - ) + seiscomp.logging.error(f"Unable to create output file: {self._outputFile}") return self.toXML(f) diff --git a/bin/scvoice b/bin/scvoice index 05d4797..0d70670 100755 --- a/bin/scvoice +++ b/bin/scvoice @@ -62,50 +62,52 @@ class VoiceAlert(client.Application): self.commandline().addOption( "Generic", "first-new", - "calls an event a new event when it is " "seen the first time", + "Calls an event a new event when it is seen the first time.", ) self.commandline().addGroup("Alert") self.commandline().addStringOption( "Alert", "amp-type", - "specify the amplitude type to listen to", + "Specify the amplitude type to listen to.", self._ampType, ) self.commandline().addStringOption( "Alert", "amp-script", - "specify the script to be called when a " + "Specify the script to be called when a " "stationamplitude arrived, network-, stationcode and amplitude are " - "passed as parameters $1, $2 and $3", + "passed as parameters $1, $2 and $3.", ) self.commandline().addStringOption( "Alert", "alert-script", - "specify the script to be called when a " + "Specify the script to be called when a " "preliminary origin arrived, latitude and longitude are passed as " - "parameters $1 and $2", + "parameters $1 and $2.", ) self.commandline().addStringOption( "Alert", "event-script", - "specify the script to be called when an " + "Specify the script to be called when an " "event has been declared; the message string, a flag (1=new event, " "0=update event), the EventID, the arrival count and the magnitude " - "(optional when set) are passed as parameter $1, $2, $3, $4 and $5", + "(optional when set) are passed as parameter $1, $2, $3, $4 and $5.", ) self.commandline().addGroup("Cities") self.commandline().addStringOption( "Cities", "max-dist", - "maximum distance for using the distance " "from a city to the earthquake", + "Maximum distance for using the distance from a city to the earthquake.", + str(self._citiesMaxDist), ) self.commandline().addStringOption( "Cities", "min-population", - "minimum population for a city to " "become a point of interest", + "Minimum population for a city to become a point of interest.", + str(self._citiesMinPopulation), ) self.commandline().addGroup("Debug") - self.commandline().addStringOption("Debug", "eventid,E", "specify Event ID") + self.commandline().addStringOption("Debug", "eventid,E", "Specify event ID.") return True def init(self): diff --git a/bin/scwfas b/bin/scwfas index acda6e9..e85f974 100755 Binary files a/bin/scwfas and b/bin/scwfas differ diff --git a/bin/scwfparam b/bin/scwfparam index a8131a2..f7eb9ef 100755 Binary files a/bin/scwfparam and b/bin/scwfparam differ diff --git a/bin/scxmldump b/bin/scxmldump index b70c200..13882b9 100755 Binary files a/bin/scxmldump and b/bin/scxmldump differ diff --git a/bin/scxmlmerge b/bin/scxmlmerge index 115972b..a9a94f0 100755 Binary files a/bin/scxmlmerge and b/bin/scxmlmerge differ diff --git a/bin/sczip b/bin/sczip index faf199b..a05e4a3 100755 Binary files a/bin/sczip and b/bin/sczip differ diff --git a/bin/seiscomp-control.py b/bin/seiscomp-control.py index 1b30acc..586a4f5 100755 --- a/bin/seiscomp-control.py +++ b/bin/seiscomp-control.py @@ -722,8 +722,8 @@ def on_status(args, _): if env.isModuleEnabled(mod.name) or isinstance( mod, seiscomp.kernel.CoreModule ): - mod.status(shouldModuleRun(mod.name)) - found += 1 + if mod.status(shouldModuleRun(mod.name)) == 0: + found += 1 if not useCSV: print(f"Summary: {found} modules enabled") @@ -733,8 +733,8 @@ def on_status(args, _): if len(args) > 0 and args[0] == "started": for mod in mods: if shouldModuleRun(mod.name): - mod.status(shouldModuleRun(mod.name)) - found += 1 + if mod.status(shouldModuleRun(mod.name)) == 0: + found += 1 if not useCSV: print(f"Summary: {found} modules started") @@ -743,8 +743,8 @@ def on_status(args, _): for mod in mods: if mod.name in args or len(args) == 0: - mod.status(shouldModuleRun(mod.name)) - found += 1 + if mod.status(shouldModuleRun(mod.name)) == 0: + found += 1 if not useCSV: print(f"Summary: {found} modules reported") diff --git a/bin/sh2proc b/bin/sh2proc index 8d1e43e..ec97108 100755 --- a/bin/sh2proc +++ b/bin/sh2proc @@ -86,7 +86,7 @@ class SH2Proc(seiscomp.client.Application): """Usage: sh2proc [options] -Convert Seismic Handler event data to SeisComP XML format""" +Convert Seismic Handler event data to SeisComP XML format which is sent to stdout.""" ) seiscomp.client.Application.printUsage(self) @@ -95,10 +95,10 @@ Convert Seismic Handler event data to SeisComP XML format""" """Examples: Convert the Seismic Handler file shm.evt to SCML. Receive the database connection to read inventory and configuration information from messaging - sh2proc shm.evt + sh2proc shm.evt > event.xml Read Seismic Handler data from stdin. Provide inventory and configuration in XML - cat shm.evt | sh2proc --inventory-db=inventory.xml --config-db=config.xml + cat shm.evt | sh2proc --inventory-db=inventory.xml --config-db=config.xml > event.xml """ ) @@ -489,7 +489,7 @@ Read Seismic Handler data from stdin. Provide inventory and configuration in XML seiscomp.datamodel.IMPULSIVE, seiscomp.datamodel.QUESTIONABLE, ]: - if value == seiscomp.datamodel.EPickOnsetNames_name(onset): + if value == seiscomp.datamodel.EPickOnsetNames.name(onset): pick.setOnset(onset) found = True break @@ -524,7 +524,7 @@ Read Seismic Handler data from stdin. Provide inventory and configuration in XML seiscomp.datamodel.AUTOMATIC, seiscomp.datamodel.MANUAL, ]: - if value == seiscomp.datamodel.EEvaluationModeNames_name(mode): + if value == seiscomp.datamodel.EEvaluationModeNames.name(mode): pick.setEvaluationMode(mode) found = True break diff --git a/bin/slarchive b/bin/slarchive index ef27982..e70a03c 100755 Binary files a/bin/slarchive and b/bin/slarchive differ diff --git a/bin/slinktool b/bin/slinktool index 331fa18..7d082c6 100755 Binary files a/bin/slinktool and b/bin/slinktool differ diff --git a/bin/slmon2 b/bin/slmon2 new file mode 100755 index 0000000..3951879 --- /dev/null +++ b/bin/slmon2 @@ -0,0 +1,3673 @@ +#!/usr/bin/env seiscomp-python + +from getopt import getopt, GetoptError +from time import time, gmtime +from datetime import datetime +import os +import sys +import signal +import glob +import re +import json + +from seiscomp.myconfig import MyConfig +import seiscomp.slclient +import seiscomp.kernel, seiscomp.config +from urllib.request import urlopen + +# A dictionary to store station coordinates +station_coordinates = {} + +def load_station_coordinates(config): + """Load station coordinates from FDSN web service""" + global station_coordinates + + # Get base URL from config or use default + base_url = config['setup'].get('fdsnws_url', 'http://localhost:8080/fdsnws/') + + # Create a dictionary in the format needed by data_fetcher + stations_config = {} + for key in config.station: + network = config.station[key]['net'] + station = config.station[key]['sta'] + station_id = f"{network}.{station}" + stations_config[station_id] = { + 'network': network, + 'station': station, + 'location': '', # Default location + 'stream': 'HHZ' # Default stream + } + + # Fetch coordinates for each station + for station_id, station_info in stations_config.items(): + network = station_info['network'] + station = station_info['station'] + + try: + with urlopen(base_url + f"station/1/query?net={network}&sta={station}&format=text") as fp: + fp.readline() + location_info = dict(zip(('lat', 'lon', 'elevation'), map(float, fp.readline().split(b'|')[2:5]))) + + if location_info: + station_coordinates[f"{network}_{station}"] = location_info + print(f"Loaded coordinates for {network}_{station}: {location_info}") + else: + print(f"Could not fetch coordinates for {network}_{station}") + except Exception as e: + print(f"Error fetching coordinates for {network}_{station}: {str(e)}") + + # Print summary + print(f"Loaded coordinates for {len(station_coordinates)} stations") + + + +usage_info = """ +Usage: + slmon [options] + +Enhanced SeedLink monitor creating modern, interactive web dashboards + +Options: + -h, --help display this help message + -c ini_setup = arg + -s ini_stations = arg + -t refresh = float(arg) # XXX not yet used + -v verbose = 1 + -g, --generate generate only template files and exit + +Examples: +Start slmon from the command line + slmon -c $SEISCOMP_ROOT/var/lib/slmon/config.ini + +Restart slmon in order to update the web pages. Use crontab entries for +automatic restart, e.g.: + */3 * * * * /home/sysop/seiscomp/bin/seiscomp check slmon >/dev/null 2>&1 +""" + +def usage(exitcode=0): + sys.stderr.write(usage_info) + exit(exitcode) + +try: + seiscompRoot = os.environ["SEISCOMP_ROOT"] +except: + print("\nSEISCOMP_ROOT must be defined - EXIT\n", file=sys.stderr) + usage(exitcode=2) + +ini_stations = os.path.join(seiscompRoot, 'var/lib/slmon2/stations.ini') +ini_setup = os.path.join(seiscompRoot, 'var/lib/slmon2/config.ini') + +regexStreams = re.compile("[SLBVEH][HNLGD][ZNE123ADHF]") +verbose = 0 +generate_only = False + + +class Module(seiscomp.kernel.Module): + def __init__(self, env): + seiscomp.kernel.Module.__init__(self, env, env.moduleName(__file__)) + + def printCrontab(self): + print("3 * * * * %s/bin/seiscomp check slmon >/dev/null 2>&1" % (self.env.SEISCOMP_ROOT)) + + +class Status: + def __repr__(self): + return "%2s %-5s %2s %3s %1s %s %s" % \ + (self.net, self.sta, self.loc, self.cha, self.typ, + str(self.last_data), str(self.last_feed)) + + +class StatusDict(dict): + def __init__(self, source=None): + if source: + self.read(source) + + def fromSlinkTool(self, server="", stations=["AU_ARMA", "AU_BLDU", "AU_YAPP"]): + # later this shall use XML + cmd = "slinktool -nd 10 -nt 10 -Q %s" % server + print(cmd) + f = os.popen(cmd) + # regex = re.compile("[SLBVEH][HNLG][ZNE123]") + regex = regexStreams + for line in f: + net_sta = line[:2].strip() + "_" + line[3:8].strip() + if not net_sta in stations: + continue + typ = line[16] + if typ != "D": + continue + cha = line[12:15].strip() + if not regex.match(cha): + continue + + d = Status() + d.net = line[0: 2].strip() + d.sta = line[3: 8].strip() + d.loc = line[9:11].strip() + d.cha = line[12:15] + d.typ = line[16] + d.last_data = seiscomp.slclient.timeparse(line[47:70]) + d.last_feed = d.last_data + sec = "%s_%s" % (d.net, d.sta) + sec = "%s.%s.%s.%s.%c" % (d.net, d.sta, d.loc, d.cha, d.typ) + self[sec] = d + + def read(self, source): + """ + Read status data from various source types (file path, file object, or list of lines) + Python 3 compatible version + """ + lines = [] + + # Handle different source types + if isinstance(source, str): + # String - treat as file path + with open(source, 'r', encoding='utf-8') as f: + lines = f.readlines() + elif hasattr(source, 'readlines'): + # File-like object + lines = source.readlines() + elif isinstance(source, list): + # Already a list of lines + lines = source + else: + raise TypeError(f'Cannot read from {type(source).__name__}') + + # Process each line + for line in lines: + line = str(line).rstrip('\n\r') + + # Skip lines that are too short + if len(line) < 65: + continue + + # Create status object and parse fields + d = Status() + d.net = line[0:2].strip() + d.sta = line[3:8].strip() + d.loc = line[9:11].strip() + d.cha = line[12:15].strip() + d.typ = line[16] + + # Parse timestamps with error handling + try: + d.last_data = seiscomp.slclient.timeparse(line[18:41]) + except: + d.last_data = None + + try: + d.last_feed = seiscomp.slclient.timeparse(line[42:65]) + except: + d.last_feed = None + + # Ensure last_feed is not earlier than last_data + if d.last_feed and d.last_data and d.last_feed < d.last_data: + d.last_feed = d.last_data + + # Create dictionary key and store + sec = f"{d.net}_{d.sta}:{d.loc}.{d.cha}.{d.typ}" + self[sec] = d + + def write(self, f): + """ + Write status data to file or file-like object + Python 3 compatible version + """ + should_close = False + + if isinstance(f, str): + # String - treat as file path + f = open(f, "w", encoding='utf-8') + should_close = True + + try: + # Prepare and write sorted lines + lines = [str(self[key]) for key in sorted(self.keys())] + f.write('\n'.join(lines) + '\n') + finally: + if should_close: + f.close() + + def to_json(self): + """Convert status dictionary to JSON for JavaScript use""" + global station_coordinates + stations_data = {} + + # Group by network and station + for key, value in self.items(): + net_sta = f"{value.net}_{value.sta}" + if net_sta not in stations_data: + stations_data[net_sta] = { + "network": value.net, + "station": value.sta, + "channels": [], + "channelGroups": { + "HH": [], # High-frequency, High-gain channels + "BH": [], # Broadband, High-gain channels + "LH": [], # Long-period, High-gain channels + "SH": [], # Short-period, High-gain channels + "EH": [], # Extremely Short-period, High-gain channels + "other": [] # All other channel types + } + } + + # Add coordinates if available + if net_sta in station_coordinates: + stations_data[net_sta]["coordinates"] = station_coordinates[net_sta] + + # Get latency information + now = datetime.utcnow() + latency_data = now - value.last_data + latency_seconds = total_seconds(latency_data) + + # Extract channel type (first two characters, e.g., 'LH', 'BH', 'HH', 'EH') + channel_type = value.cha[:2] if len(value.cha) >= 2 else "other" + + # Get status with channel-aware thresholds + status = get_status_from_seconds(latency_seconds, channel_type) + + # Create channel data + channel_data = { + "location": value.loc, + "channel": value.cha, + "type": value.typ, + "channelType": channel_type, + "last_data": value.last_data.isoformat() if value.last_data else None, + "last_feed": value.last_feed.isoformat() if value.last_feed else None, + "latency": latency_seconds, + "status": status + } + + # Add to main channels list + stations_data[net_sta]["channels"].append(channel_data) + + # Add to channel group for separated status calculation + if channel_type in stations_data[net_sta]["channelGroups"]: + stations_data[net_sta]["channelGroups"][channel_type].append(channel_data) + else: + stations_data[net_sta]["channelGroups"]["other"].append(channel_data) + + # Convert to list for easier JavaScript processing + stations_list = [] + for net_sta, data in stations_data.items(): + # Calculate overall station status based on priority channels (non-LH channels) + # First try HH channels + if data["channelGroups"]["HH"]: + worst_latency = max([ch["latency"] for ch in data["channelGroups"]["HH"]]) + data["status"] = get_status_from_seconds(worst_latency) + data["primaryChannelType"] = "HH" + # Then try BH channels + elif data["channelGroups"]["BH"]: + worst_latency = max([ch["latency"] for ch in data["channelGroups"]["BH"]]) + data["status"] = get_status_from_seconds(worst_latency) + data["primaryChannelType"] = "BH" + # Then try SH channels + elif data["channelGroups"]["SH"]: + worst_latency = max([ch["latency"] for ch in data["channelGroups"]["SH"]]) + data["status"] = get_status_from_seconds(worst_latency) + data["primaryChannelType"] = "SH" + # Then try EH channels + elif data["channelGroups"]["EH"]: + worst_latency = max([ch["latency"] for ch in data["channelGroups"]["EH"]]) + data["status"] = get_status_from_seconds(worst_latency) + data["primaryChannelType"] = "EH" + # Only use LH if nothing else is available + elif data["channelGroups"]["LH"]: + worst_latency = max([ch["latency"] for ch in data["channelGroups"]["LH"]]) + data["status"] = get_status_from_seconds(worst_latency, "LH") + data["primaryChannelType"] = "LH" + # Fall back to other channels + elif data["channelGroups"]["other"]: + worst_latency = max([ch["latency"] for ch in data["channelGroups"]["other"]]) + data["status"] = get_status_from_seconds(worst_latency) + data["primaryChannelType"] = "other" + else: + # Failsafe if no channels + data["status"] = "unavailable" + data["primaryChannelType"] = "none" + worst_latency = 0 + + data["latency"] = worst_latency + data["id"] = net_sta + stations_list.append(data) + + return json.dumps(stations_list) + +def get_map_settings(config): + """Extract map settings from config for JavaScript use""" + map_settings = { + 'center': { + 'lat': -25.6, # Default latitude + 'lon': 134.3, # Default longitude + 'zoom': 6 # Default zoom + }, + 'defaultLayer': 'street', + 'enableClustering': True, + 'showFullscreenControl': True, + 'showLayerControl': True, + 'showLocateControl': True, + 'darkModeLayer': 'dark', + 'lightModeLayer': 'street' + } + + # Extract center coordinates from config + if 'center_map' in config['setup']: + if 'lat' in config['setup']['center_map']: + map_settings['center']['lat'] = float(config['setup']['center_map']['lat']) + if 'lon' in config['setup']['center_map']: + map_settings['center']['lon'] = float(config['setup']['center_map']['lon']) + if 'zoom' in config['setup']['center_map']: + map_settings['center']['zoom'] = int(config['setup']['center_map']['zoom']) + + # Extract other map settings + if 'map_settings' in config['setup']: + map_config = config['setup']['map_settings'] + + if 'default_layer' in map_config: + map_settings['defaultLayer'] = map_config['default_layer'] + + if 'enable_clustering' in map_config: + map_settings['enableClustering'] = map_config['enable_clustering'] == 'true' or map_config['enable_clustering'] is True + + if 'show_fullscreen_control' in map_config: + map_settings['showFullscreenControl'] = map_config['show_fullscreen_control'] == 'true' or map_config['show_fullscreen_control'] is True + + if 'show_layer_control' in map_config: + map_settings['showLayerControl'] = map_config['show_layer_control'] == 'true' or map_config['show_layer_control'] is True + + if 'show_locate_control' in map_config: + map_settings['showLocateControl'] = map_config['show_locate_control'] == 'true' or map_config['show_locate_control'] is True + + if 'dark_mode_layer' in map_config: + map_settings['darkModeLayer'] = map_config['dark_mode_layer'] + + if 'light_mode_layer' in map_config: + map_settings['lightModeLayer'] = map_config['light_mode_layer'] + + return map_settings + +def get_status_from_seconds(seconds, channel_type=None): + """ + Get status code based on latency in seconds with channel-specific thresholds + + Args: + seconds (float): Latency in seconds + channel_type (str): Channel type (e.g., 'LH', 'BH', 'HH', 'EH') + + Returns: + str: Status code (good, delayed, etc.) + """ + # Special handling for LH channels - they're naturally delayed + if channel_type == 'LH': + # More lenient thresholds for LH channels + if seconds > 604800: # > 7 days + return "unavailable" + elif seconds > 518400: # > 6 days + return "four-day" + elif seconds > 432000: # > 5 days + return "three-day" + elif seconds > 345600: # > 4 days + return "multi-day" + elif seconds > 259200: # > 3 days + return "day-delayed" + elif seconds > 86400: # > 1 day + return "critical" + elif seconds > 43200: # > 12 hours + return "warning" + elif seconds > 21600: # > 6 hours + return "hour-delayed" + elif seconds > 10800: # > 3 hours + return "very-delayed" + elif seconds > 3600: # > 1 hour + return "long-delayed" + elif seconds > 1800: # > 30 minutes + return "delayed" + else: # <= 30 minutes (LH channels are considered good even with moderate delay) + return "good" + + # Standard thresholds for other channels + if seconds > 432000: # > 5 days + return "unavailable" + elif seconds > 345600: # > 4 days + return "four-day" + elif seconds > 259200: # > 3 days + return "three-day" + elif seconds > 172800: # > 2 days + return "multi-day" + elif seconds > 86400: # > 1 day + return "day-delayed" + elif seconds > 21600: # > 6 hours + return "critical" + elif seconds > 7200: # > 2 hours + return "warning" + elif seconds > 3600: # > 1 hour + return "hour-delayed" + elif seconds > 1800: # > 30 minutes + return "very-delayed" + elif seconds > 600: # > 10 minutes + return "long-delayed" + elif seconds > 60: # > 1 minute + return "delayed" + else: # <= 1 minute + return "good" + + +def getColor(delta): + delay = total_seconds(delta) + if delay > 432000: return '#666666' # > 5 days + elif delay > 345600: return '#999999' # > 4 days + elif delay > 259200: return '#CCCCCC' # > 3 days + elif delay > 172800: return '#FFB3B3' # > 2 days + elif delay > 86400: return '#FF3333' # > 1 day + elif delay > 21600: return '#FF9966' # > 6 hours + elif delay > 7200: return '#FFFF00' # > 2 hours + elif delay > 3600: return '#00FF00' # > 1 hour + elif delay > 1800: return '#3399FF' # > 30 minutes + elif delay > 600: return '#9470BB' # > 10 minutes + elif delay > 60: return '#EBD6FF' # > 1 minute + else: return '#FFFFFF' # <= 1 minute + + +def total_seconds(td): + return td.seconds + (td.days*86400) + + +def myrename(name1, name2): + # fault-tolerant rename that doesn't cause an exception if it fails, which + # may happen e.g. if the target is on a non-reachable NFS directory + try: + os.rename(name1, name2) + except OSError: + print("failed to rename(%s,%s)" % (name1, name2), file=sys.stderr) + + +def formatLatency(delta): + """Format latency for display""" + if delta is None: return 'n/a' + + t = total_seconds(delta) + + if t > 86400: return f"{t/86400:.1f} d" + elif t > 7200: return f"{t/3600:.1f} h" + elif t > 120: return f"{t/60:.1f} m" + else: return f"{t:.1f} s" + + +def generate_css_file(config): + """Generate the CSS file with theme support""" + css_content = """ +:root { + /* Light theme variables */ + --primary-color: #4f46e5; + --primary-hover: #4338ca; + --text-primary: #1f2937; + --text-secondary: #6b7280; + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f3f4f6; + --border-color: #e5e7eb; + --border-radius: 8px; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + + /* Status colors */ + --status-good: #ffffff; + --status-delayed: #c084fc; + --status-long-delayed: #8b5cf6; + --status-very-delayed: #3b82f6; + --status-hour-delayed: #10b981; + --status-warning: #fbbf24; + --status-critical: #f97316; + --status-day-delayed: #ef4444; + --status-multi-day: #f87171; + --status-three-day: #d1d5db; + --status-four-day: #9ca3af; + --status-unavailable: #6b7280; +} + +.dark-mode { + /* Dark theme variables */ + --primary-color: #818cf8; + --primary-hover: #a5b4fc; + --text-primary: #f9fafb; + --text-secondary: #9ca3af; + --bg-primary: #1f2937; + --bg-secondary: #111827; + --bg-tertiary: #374151; + --border-color: #374151; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.3); + + /* Dark theme status colors - background stays dark */ + --status-good: #1f2937; +} + +/* General Styles */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: var(--text-primary); + background-color: var(--bg-secondary); + padding: 0; + margin: 0; +} + +.container { + max-width: 1400px; + margin: 20px auto; + padding: 30px; + background-color: var(--bg-primary); + border-radius: var(--border-radius); + box-shadow: var(--shadow-md); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 15px; +} + +h1 { + font-size: 28px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.5px; +} + +.subtitle { + color: var(--text-secondary); + font-size: 16px; + margin-bottom: 20px; +} + +/* Navigation Tabs */ +.view-toggle { + display: flex; + gap: 10px; +} + +.view-toggle a { + padding: 8px 15px; + border-radius: 6px; + color: var(--text-secondary); + text-decoration: none; + transition: all 0.2s ease; + font-weight: 500; + font-size: 14px; +} + +.view-toggle a:hover { + background-color: var(--bg-tertiary); + color: var(--primary-color); +} + +.view-toggle a.active { + background-color: var(--primary-color); + color: white; +} + +/* Controls */ +.controls { + display: flex; + justify-content: space-between; + align-items: center; + margin: 20px 0; + flex-wrap: wrap; + gap: 15px; +} + +.actions { + display: flex; + gap: 10px; +} + +.action-button { + padding: 8px 15px; + display: flex; + align-items: center; + gap: 6px; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; +} + +.action-button:hover { + background-color: var(--bg-primary); + color: var(--primary-color); +} + +.action-button svg { + width: 16px; + height: 16px; +} + +.refresh-control { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 15px; + background-color: var(--bg-tertiary); + border-radius: 6px; +} + +.input-group { + display: flex; + align-items: center; + gap: 8px; +} + +.refresh-control input { + width: 60px; + padding: 6px 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + background-color: var(--bg-primary); + color: var(--text-primary); + text-align: center; +} + +.refresh-control button { + padding: 6px 12px; + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s ease; +} + +.refresh-control button:hover { + background-color: var(--primary-hover); +} + +.status-counter { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +#refresh-status { + font-size: 13px; + color: var(--text-secondary); +} + +.countdown { + font-size: 13px; + color: var(--text-secondary); +} + +#next-refresh { + color: var(--primary-color); + font-weight: 500; +} + +/* Filter and Search */ +.filters { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 20px; + padding: 15px; + background-color: var(--bg-tertiary); + border-radius: var(--border-radius); +} + +.filter-group { + display: flex; + align-items: center; + gap: 8px; +} + +.filter-group label { + font-size: 14px; + color: var(--text-secondary); + font-weight: 500; +} + +.filter-group select { + padding: 6px 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; +} + +.search-box { + padding: 6px 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + min-width: 200px; +} + +/* Table View */ +.table-container { + overflow-x: auto; + margin-bottom: 20px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +table th { + padding: 12px 15px; + background-color: var(--bg-tertiary); + color: var(--text-secondary); + font-weight: 600; + text-align: left; + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 10; +} + +table td { + padding: 10px 15px; + border-bottom: 1px solid var(--border-color); +} + +table tr:hover { + background-color: var(--bg-tertiary); +} + +/* Grid View */ +.grid-container { + display: table; + width: 100%; + border-collapse: collapse; + margin-top: 20px; +} + +.grid-row { + display: table-row; +} + +.network-label { + display: table-cell; + vertical-align: middle; + text-align: center; + font-weight: 600; + width: 60px; + min-width: 60px; + height: 34px; + background-color: var(--bg-tertiary); + border-radius: 6px; + color: var(--text-secondary); + box-shadow: var(--shadow-sm); + padding: 4px; + margin: 2px; + border: 1px solid var(--border-color); +} + +.stations-container { + display: table-cell; + padding-left: 6px; +} + +.stations-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 2px 0; +} + +.grid-cell { + width: 60px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + box-shadow: var(--shadow-sm); + text-decoration: none; + color: var(--text-primary); + transition: all 0.15s ease; + position: relative; + border: 1px solid var(--border-color); + background-color: var(--status-good); +} + +.grid-cell:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + z-index: 10; +} + +/* Map View */ +.map-container { + width: 100%; + height: 600px; + background-color: var(--bg-tertiary); + border-radius: var(--border-radius); + margin-bottom: 20px; + position: relative; +} + +/* Status Colors */ +.station-unavailable { + background-color: var(--status-unavailable); + color: white; + border-color: var(--status-unavailable); +} + +.station-warning { + background-color: var(--status-warning); + color: #7c2d12; + border-color: var(--status-warning); +} + +.station-critical { + background-color: var(--status-critical); + color: white; + border-color: var(--status-critical); +} + +.station-delayed { + background-color: var(--status-delayed); + color: #4a044e; + border-color: var(--status-delayed); +} + +.station-long-delayed { + background-color: var(--status-long-delayed); + color: white; + border-color: var(--status-long-delayed); +} + +.station-very-delayed { + background-color: var(--status-very-delayed); + color: white; + border-color: var(--status-very-delayed); +} + +.station-hour-delayed { + background-color: var(--status-hour-delayed); + color: white; + border-color: var(--status-hour-delayed); +} + +.station-day-delayed { + background-color: var(--status-day-delayed); + color: white; + border-color: var(--status-day-delayed); +} + +.station-multi-day { + background-color: var(--status-multi-day); + color: #7f1d1d; + border-color: var(--status-multi-day); +} + +.station-three-day { + background-color: var(--status-three-day); + color: #1f2937; + border-color: var(--status-three-day); +} + +.station-four-day { + background-color: var(--status-four-day); + color: white; + border-color: var(--status-four-day); +} + +.station-good { + background-color: var(--status-good); + color: var(--text-primary); + border-color: var(--border-color); +} + +/* Tooltip */ +.grid-cell::after { + content: attr(data-tooltip); + position: absolute; + bottom: 120%; + left: 50%; + transform: translateX(-50%); + background-color: #1f2937; + color: white; + text-align: center; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + white-space: nowrap; + z-index: 20; + opacity: 0; + visibility: hidden; + transition: all 0.2s ease; + pointer-events: none; + box-shadow: var(--shadow-md); +} + +.grid-cell::before { + content: ''; + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%); + border-width: 6px 6px 0; + border-style: solid; + border-color: #1f2937 transparent transparent; + z-index: 20; + opacity: 0; + visibility: hidden; + transition: all 0.2s ease; + pointer-events: none; +} + +.grid-cell:hover::after, +.grid-cell:hover::before { + opacity: 1; + visibility: visible; +} + +/* Stats */ +.stats-container { + margin: 20px 0; + padding: 20px; + background-color: var(--bg-tertiary); + border-radius: var(--border-radius); + display: none; +} + +.stats-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.stats-title { + font-weight: 600; + font-size: 16px; + color: var(--text-primary); +} + +.network-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 15px; +} + +.network-stat { + display: flex; + flex-direction: column; + gap: 8px; +} + +.network-name { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + display: flex; + justify-content: space-between; +} + +.progress-bar { + height: 8px; + background-color: var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +.progress { + height: 100%; + background-color: var(--primary-color); + border-radius: 4px; +} + +/* Legend */ +.legend { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 25px 0; + padding: 15px; + background-color: var(--bg-tertiary); + border-radius: var(--border-radius); + justify-content: center; +} + +.legend-item { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; + color: var(--text-secondary); +} + +.legend-color { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.1); +} + +/* Footer */ +.footer { + margin-top: 30px; + padding-top: 15px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + color: var(--text-secondary); + font-size: 14px; +} + +.footer a { + color: var(--primary-color); + text-decoration: none; +} + +.footer a:hover { + text-decoration: underline; +} + +/* Loading */ +#loading { + display: flex; + align-items: center; + justify-content: center; + margin: 30px 0; + color: var(--text-secondary); +} + +.loading-spinner { + width: 24px; + height: 24px; + border: 3px solid var(--bg-tertiary); + border-top: 3px solid var(--primary-color); + border-radius: 50%; + margin-right: 12px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Error Message */ +#error-message { + padding: 15px; + margin: 20px 0; + border-radius: var(--border-radius); + background-color: #fee2e2; + color: #b91c1c; + border-left: 4px solid #ef4444; + display: none; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .container { + margin: 10px; + padding: 15px; + border-radius: 6px; + } + + .header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .view-toggle { + align-self: flex-end; + } + + .controls { + flex-direction: column; + align-items: stretch; + } + + .actions { + justify-content: space-between; + } + + .refresh-control { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .filters { + flex-direction: column; + gap: 10px; + } + + .filter-group { + width: 100%; + } + + .search-box { + width: 100%; + } + + .network-stats { + grid-template-columns: 1fr; + } + + .map-container { + height: 400px; + } + } + /* Marker Cluster Styles */ + .marker-cluster { + background-clip: padding-box; + border-radius: 20px; + } + + .marker-cluster div { + width: 36px; + height: 36px; + margin-left: 2px; + margin-top: 2px; + text-align: center; + border-radius: 18px; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + } + + /* Map Controls */ + .leaflet-control-locate { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + .leaflet-control-locate a { + background-color: var(--bg-primary); + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + width: 30px; + height: 30px; + line-height: 30px; + color: var(--text-primary); + text-align: center; + } + + .leaflet-control-locate a:hover { + background-color: var(--bg-tertiary); + color: var(--primary-color); + } + + .leaflet-control-locate.active a { + color: var(--primary-color); + } + + .leaflet-control-fullscreen { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + .leaflet-control-fullscreen a { + background-color: var(--bg-primary); + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + width: 30px; + height: 30px; + line-height: 30px; + color: var(--text-primary); + text-align: center; + } + + .leaflet-control-fullscreen a:hover { + background-color: var(--bg-tertiary); + color: var(--primary-color); + } + + /* Map layers control */ + .leaflet-control-layers { + border-radius: var(--border-radius); + background-color: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); + } + + .dark-mode .leaflet-control-layers { + background-color: var(--bg-tertiary); + } + + .leaflet-control-layers-toggle { + width: 36px; + height: 36px; + background-size: 20px 20px; + } + + .leaflet-control-layers-expanded { + padding: 10px; + background-color: var(--bg-primary); + color: var(--text-primary); + border-radius: var(--border-radius); + } + + .dark-mode .leaflet-control-layers-expanded { + background-color: var(--bg-tertiary); + } + + .leaflet-control-layers-list { + margin-top: 8px; + } + + .leaflet-control-layers label { + margin-bottom: 5px; + display: block; + } + + /* Map layer selection buttons */ + .map-layers-control { + position: absolute; + top: 10px; + right: 10px; + z-index: 1000; + background: white; + padding: 5px; + border-radius: 4px; + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + } + + .map-layers-control button { + display: block; + margin: 5px 0; + padding: 5px; + width: 100%; + border: none; + background: #f8f8f8; + cursor: pointer; + } + + .map-layers-control button:hover { + background: #f0f0f0; + } + + .map-layers-control button.active { + background: #ddd; + font-weight: bold; + } + + /* Map tools control */ + .map-tools-control { + position: absolute; + bottom: 30px; + right: 10px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 5px; + } + + .map-tools-control button { + width: 34px; + height: 34px; + background: white; + border: 2px solid rgba(0,0,0,0.2); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #333; + } + + .map-tools-control button:hover { + background: #f4f4f4; + } + + .dark-mode .map-tools-control button { + background: #333; + color: #fff; + border-color: rgba(255,255,255,0.2); + } + + .dark-mode .map-tools-control button:hover { + background: #444; + } + + /* Map measurement widget */ + .leaflet-measure-path-measurement { + position: absolute; + font-size: 12px; + color: black; + text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white; + white-space: nowrap; + transform-origin: 0; + pointer-events: none; + } + + .dark-mode .leaflet-measure-path-measurement { + color: white; + text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; + } + + /* Popup styling */ + .leaflet-popup-content-wrapper { + border-radius: var(--border-radius); + background-color: var(--bg-primary); + color: var(--text-primary); + box-shadow: var(--shadow-md); + } + + .dark-mode .leaflet-popup-content-wrapper { + background-color: var(--bg-tertiary); + } + + .leaflet-popup-content { + margin: 12px; + line-height: 1.5; + } + + .leaflet-popup-tip { + background-color: var(--bg-primary); + } + + .dark-mode .leaflet-popup-tip { + background-color: var(--bg-tertiary); + } + + .leaflet-popup-content a { + color: var(--primary-color); + text-decoration: none; + } + + .leaflet-popup-content a:hover { + text-decoration: underline; + } + + /* Make the map more responsive on mobile */ + @media (max-width: 768px) { + .map-container { + height: 450px; + } + + .leaflet-control-layers, + .leaflet-control-zoom, + .leaflet-control-fullscreen, + .leaflet-control-locate { + margin-right: 10px !important; + } + + .leaflet-control-scale { + margin-bottom: 40px !important; + } + } + """ + + try: + css_path = os.path.join(config['setup']['wwwdir'], 'styles.css') + with open(css_path, 'w') as f: + f.write(css_content) + print(f"CSS file generated at {css_path}") + return css_path + except Exception as e: + print(f"Error generating CSS file: {str(e)}") + return None + + +def generate_js_file(config): + """Generate the JavaScript file with interactive features""" + js_content = """ +// Global variables +let refreshTimer = null; +let currentRefreshInterval = 60; +let lastRefreshTime = 0; +let isRefreshing = false; +let stationsData = []; +let viewMode = 'table'; // 'table', 'grid', or 'map' +let mapInitialized = false; +let map = null; +let markers = []; + +// Function to initialize the application +document.addEventListener('DOMContentLoaded', function() { + // Load saved preferences + loadPreferences(); + + // Set up event listeners + setupEventListeners(); + + //Add channel type filter + setupFilters(); + + // Set active view based on URL or default + setActiveView(); + + // Initial data load + fetchData(); +}); + +// Function to load user preferences from localStorage +function loadPreferences() { + // Load refresh interval + const savedInterval = parseInt(localStorage.getItem('seedlinkRefreshInterval')); + if (savedInterval && savedInterval >= 10) { + document.getElementById('refresh-interval').value = savedInterval; + currentRefreshInterval = savedInterval; + } + + // Load dark mode preference + const darkModeEnabled = localStorage.getItem('seedlink-dark-mode') === 'true'; + if (darkModeEnabled) { + document.body.classList.add('dark-mode'); + updateThemeToggleButton(true); + } + + // Load view mode + const savedViewMode = localStorage.getItem('seedlink-view-mode'); + if (savedViewMode) { + viewMode = savedViewMode; + } +} + +// Function to set up all event listeners +function setupEventListeners() { + // View toggle buttons + document.querySelectorAll('.view-toggle a').forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const view = this.getAttribute('data-view'); + switchView(view); + }); + }); + + // Refresh controls + document.getElementById('apply-refresh').addEventListener('click', function() { + const interval = parseInt(document.getElementById('refresh-interval').value); + if (interval && interval >= 10) { + updateRefreshInterval(interval); + } + }); + + document.getElementById('refresh-now').addEventListener('click', function() { + if (refreshTimer) { + clearTimeout(refreshTimer); + } + fetchData(); + }); + + // Theme toggle + document.getElementById('theme-toggle').addEventListener('click', toggleDarkMode); + + // Export CSV + document.getElementById('export-csv').addEventListener('click', exportToCsv); + + // Stats toggle + document.getElementById('stats-toggle').addEventListener('click', toggleStats); + document.getElementById('close-stats').addEventListener('click', function() { + document.getElementById('stats-container').style.display = 'none'; + }); + + // Filter inputs + document.getElementById('network-filter').addEventListener('change', applyFilters); + document.getElementById('status-filter').addEventListener('change', applyFilters); + document.getElementById('search-input').addEventListener('input', debounce(applyFilters, 300)); + + // Sort headers in table view + document.querySelectorAll('th[data-sort]').forEach(header => { + header.addEventListener('click', function() { + sortTable(this.getAttribute('data-sort')); + }); + }); + + // Handle visibility changes (tab switching) + document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'visible') { + // If data is stale (not refreshed in over half the interval) + const timeSinceLastRefresh = Date.now() - lastRefreshTime; + if (timeSinceLastRefresh > (currentRefreshInterval * 500)) { + if (refreshTimer) { + clearTimeout(refreshTimer); + } + fetchData(); + } + } + }); +} + +// Function to set active view based on URL or saved preference +function setActiveView() { + // Extract view from URL if present + const urlParams = new URLSearchParams(window.location.search); + const urlView = urlParams.get('view'); + + if (urlView && ['table', 'grid', 'map'].includes(urlView)) { + viewMode = urlView; + } + + // Set active class on the appropriate link + document.querySelectorAll('.view-toggle a').forEach(link => { + if (link.getAttribute('data-view') === viewMode) { + link.classList.add('active'); + } else { + link.classList.remove('active'); + } + }); + + // Show the appropriate view container + document.querySelectorAll('.view-container').forEach(container => { + if (container.id === `${viewMode}-view`) { + container.style.display = 'block'; + } else { + container.style.display = 'none'; + } + }); + + // Initialize map if needed + if (viewMode === 'map' && !mapInitialized && typeof L !== 'undefined') { + initializeMap(); + } + + // Save preference + localStorage.setItem('seedlink-view-mode', viewMode); +} + +// Function to switch between views +function switchView(view) { + viewMode = view; + + // Update URL without reloading the page + const url = new URL(window.location); + url.searchParams.set('view', view); + window.history.pushState({}, '', url); + + setActiveView(); + + // Refresh data display for the new view + renderData(); +} + +// Function to toggle dark mode +function toggleDarkMode() { + document.body.classList.toggle('dark-mode'); + const isDarkMode = document.body.classList.contains('dark-mode'); + localStorage.setItem('seedlink-dark-mode', isDarkMode ? 'true' : 'false'); + + updateThemeToggleButton(isDarkMode); + + // Update map tiles if map is initialized + if (mapInitialized && map) { + updateMapTiles(isDarkMode); + } +} + +// Function to update theme toggle button appearance +function updateThemeToggleButton(isDarkMode) { + const themeToggle = document.getElementById('theme-toggle'); + if (isDarkMode) { + themeToggle.innerHTML = ` + + Light Mode + `; + } else { + themeToggle.innerHTML = ` + + Dark Mode + `; + } +} + +// Function to initialize the map view +// 1. Enhanced map initialization function +// Updated initializeMap function with markerCluster safety checks +function initializeMap() { + // Check if Leaflet is available + if (typeof L === 'undefined') { + console.error('Leaflet library not loaded'); + document.getElementById('map-container').innerHTML = '
'; + return; + } + + // Initialize markerCluster as null so it's defined even if the plugin isn't available + markerCluster = null; + + // Read map settings from the page data if available + const mapSettings = window.mapSettings || { + center: { lat: 20, lon: 0, zoom: 2 }, + defaultLayer: 'street', + enableClustering: true, + showFullscreenControl: true, + showLayerControl: true, + showLocateControl: true + }; + + // Create map instance + map = L.map('map-container', { + center: [mapSettings.center.lat, mapSettings.center.lon], + zoom: mapSettings.center.zoom, + zoomControl: false // We'll add this separately for better positioning + }); + + // Define available base layers + const baseLayers = { + 'Street': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19 + }), + 'Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: 'Imagery © Esri © ArcGIS', + maxZoom: 19 + }), + 'Terrain': L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { + attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap', + maxZoom: 17 + }), + 'Dark': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 19 + }) + }; + + // Add appropriate layer based on settings or dark mode + const isDarkMode = document.body.classList.contains('dark-mode'); + let defaultLayer = isDarkMode ? 'Dark' : (mapSettings.defaultLayer || 'Street'); + defaultLayer = defaultLayer.charAt(0).toUpperCase() + defaultLayer.slice(1); // Capitalize + + // Add the default layer to the map + if (baseLayers[defaultLayer]) { + baseLayers[defaultLayer].addTo(map); + } else { + // Fallback to the first available layer + baseLayers[Object.keys(baseLayers)[0]].addTo(map); + } + + // Add layer control if enabled + if (mapSettings.showLayerControl !== false) { + L.control.layers(baseLayers, {}, { + position: 'topright', + collapsed: true + }).addTo(map); + } + + // Add zoom control in a better position + L.control.zoom({ + position: 'bottomright' + }).addTo(map); + + // Add scale control + L.control.scale().addTo(map); + + // Add fullscreen control if enabled and the plugin is available + if (mapSettings.showFullscreenControl !== false && typeof L.Control.Fullscreen !== 'undefined') { + L.control.fullscreen({ + position: 'topright', + title: { + 'false': 'View Fullscreen', + 'true': 'Exit Fullscreen' + } + }).addTo(map); + } + + // Add locate control if enabled and the plugin is available + if (mapSettings.showLocateControl !== false && typeof L.Control.Locate !== 'undefined') { + L.control.locate({ + position: 'bottomright', + icon: 'fa fa-location-arrow', + strings: { + title: 'Show my location' + }, + locateOptions: { + enableHighAccuracy: true, + maxZoom: 10 + } + }).addTo(map); + } + + // Initialize marker cluster group if enabled and the plugin is available + if (mapSettings.enableClustering !== false && typeof L.MarkerClusterGroup !== 'undefined') { + try { + markerCluster = L.markerClusterGroup({ + disableClusteringAtZoom: 10, + spiderfyOnMaxZoom: true, + showCoverageOnHover: false, + iconCreateFunction: function(cluster) { + const count = cluster.getChildCount(); + + // Determine color based on worst status in the cluster + let worstStatus = 'good'; + const markers = cluster.getAllChildMarkers(); + + for (const marker of markers) { + const status = marker.options.status || 'good'; + + // Simple ordering of statuses from least to most severe + const statusOrder = { + 'good': 0, + 'delayed': 1, + 'long-delayed': 2, + 'very-delayed': 3, + 'hour-delayed': 4, + 'warning': 5, + 'critical': 6, + 'day-delayed': 7, + 'multi-day': 8, + 'three-day': 9, + 'four-day': 10, + 'unavailable': 11 + }; + + if ((statusOrder[status] || 0) > (statusOrder[worstStatus] || 0)) { + worstStatus = status; + } + } + + // Get color for worst status + const color = getStatusColor(worstStatus); + + const textColor = getBestTextColor(color); + + return L.divIcon({ + html: `No station coordinates available
+Make sure your FDSNWS service is properly configured and accessible.
+ `; + document.getElementById('map-container').appendChild(noCoordinatesMsg); + } +} + +// Custom legend for the map +function addMapLegend() { + if (!mapInitialized || !map) return; + + // Remove existing legend if any + const existingLegend = document.querySelector('.map-legend'); + if (existingLegend) { + existingLegend.remove(); + } + + // Create a custom legend + const legend = L.control({position: 'bottomright'}); + + legend.onAdd = function() { + const div = L.DomUtil.create('div', 'map-legend'); + div.innerHTML = ` +Real-time seismic station monitoring dashboard
+ +| Station | +Status | +Latency | +Channels | +Last Updated | +
|---|
{config.station[net_sta]["text"]}
' + except: + pass + + # Station details table + html += """ +| Channel | +Last Sample | +Data Latency | +Last Received | +Feed Latency | +Diff | +
|---|---|---|---|---|---|
| {s} {c} | +{time1_str} | +{formatLatency(lat1)} | +{time2_str} | +{formatLatency(lat2)} | +{formatLatency(lat3)} | +
Click here to view in Grid View
\n'
+
+    if 'liveurl' in config['setup']:
+        # Substitute '%s' in live_url by station name
+        s = net_sta.split("_")[-1]
+        url = config['setup']['liveurl'] % s
+        html += f'View a live seismogram of station {s}