#!/usr/bin/env seiscomp-python # -*- coding: utf-8 -*- ############################################################################ # Copyright (C) GFZ Potsdam # # All rights reserved. # # # # 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. # # # # Author: Alexander Jaeger, Stephan Herrnkind, # # Lukas Lehmann, Dirk Roessler# # # Email: herrnkind@gempa.de # ############################################################################ # from time import strptime import sys import traceback import seiscomp.client import seiscomp.core import seiscomp.datamodel import seiscomp.io import seiscomp.logging import seiscomp.math TimeFormats = ["%d-%b-%Y_%H:%M:%S.%f", "%d-%b-%Y_%H:%M:%S"] # SC3 has more event types available in the datamodel EventTypes = { "teleseismic quake": seiscomp.datamodel.EARTHQUAKE, "local quake": seiscomp.datamodel.EARTHQUAKE, "regional quake": seiscomp.datamodel.EARTHQUAKE, "quarry blast": seiscomp.datamodel.QUARRY_BLAST, "nuclear explosion": seiscomp.datamodel.NUCLEAR_EXPLOSION, "mining event": seiscomp.datamodel.MINING_EXPLOSION, } def wfs2Str(wfsID): return f"{wfsID.networkCode()}.{wfsID.stationCode()}.{wfsID.locationCode()}.{wfsID.channelCode()}" ############################################################################### class SH2Proc(seiscomp.client.Application): ########################################################################### def __init__(self): seiscomp.client.Application.__init__(self, len(sys.argv), sys.argv) self.setMessagingEnabled(True) self.setDatabaseEnabled(True, True) self.setLoadInventoryEnabled(True) self.setLoadConfigModuleEnabled(True) self.setDaemonEnabled(False) self.inputFile = "-" self.streams = None ########################################################################### def initConfiguration(self): if not seiscomp.client.Application.initConfiguration(self): return False # 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. if self.databaseURI() != "": self.setMessagingEnabled(False) else: # A database connection is not required if the inventory is loaded # from file if not self.isInventoryDatabaseEnabled(): self.setMessagingEnabled(False) self.setDatabaseEnabled(False, False) return True ########################################################################## def printUsage(self): print( """Usage: sh2proc [options] Convert Seismic Handler event data to SeisComP XML format which is sent to stdout.""" ) seiscomp.client.Application.printUsage(self) print( """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 > 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 > event.xml """ ) ########################################################################## def validateParameters(self): if not seiscomp.client.Application.validateParameters(self): return False for opt in self.commandline().unrecognizedOptions(): if len(opt) > 1 and opt.startswith("-"): continue self.inputFile = opt break return True ########################################################################### def loadStreams(self): now = seiscomp.core.Time.GMT() inv = seiscomp.client.Inventory.Instance() self.streams = {} # try to load streams by detecLocid and detecStream mod = self.configModule() if mod is not None and mod.configStationCount() > 0: seiscomp.logging.info("loading streams using detecLocid and detecStream") for i in range(mod.configStationCount()): cfg = mod.configStation(i) net = cfg.networkCode() sta = cfg.stationCode() if sta in self.streams: seiscomp.logging.warning( f"ambiguous stream id found for station {net}.{sta}" ) continue setup = seiscomp.datamodel.findSetup(cfg, self.name(), True) if not setup: seiscomp.logging.warning( f"could not find station setup for {net}.{sta}" ) continue params = seiscomp.datamodel.ParameterSet.Find(setup.parameterSetID()) if not params: seiscomp.logging.warning( f"could not find station parameters for {net}.{sta}" ) continue detecLocid = "" detecStream = None for j in range(params.parameterCount()): param = params.parameter(j) if param.name() == "detecStream": detecStream = param.value() elif param.name() == "detecLocid": detecLocid = param.value() if detecStream is None: seiscomp.logging.warning( f"could not find detecStream for {net}.{sta}" ) continue loc = inv.getSensorLocation(net, sta, detecLocid, now) if loc is None: seiscomp.logging.warning( f"could not find preferred location for {net}.{sta}" ) continue components = {} tc = seiscomp.datamodel.ThreeComponents() seiscomp.datamodel.getThreeComponents(tc, loc, detecStream[:2], now) if tc.vertical(): cha = tc.vertical() wfsID = seiscomp.datamodel.WaveformStreamID( net, sta, loc.code(), cha.code(), "" ) components[cha.code()[-1]] = wfsID seiscomp.logging.debug(f"add stream {wfs2Str(wfsID)} (vertical)") if tc.firstHorizontal(): cha = tc.firstHorizontal() wfsID = seiscomp.datamodel.WaveformStreamID( net, sta, loc.code(), cha.code(), "" ) components[cha.code()[-1]] = wfsID seiscomp.logging.debug( f"add stream {wfs2Str(wfsID)} (first horizontal)" ) if tc.secondHorizontal(): cha = tc.secondHorizontal() wfsID = seiscomp.datamodel.WaveformStreamID( net, sta, loc.code(), cha.code(), "" ) components[cha.code()[-1]] = wfsID seiscomp.logging.debug( f"add stream {wfs2Str(wfsID)} (second horizontal)" ) if len(components) > 0: self.streams[sta] = components return # fallback loading streams from inventory seiscomp.logging.warning( "no configuration module available, loading streams " "from inventory and selecting first available stream " "matching epoch" ) for iNet in range(inv.inventory().networkCount()): net = inv.inventory().network(iNet) seiscomp.logging.debug( f"network {net.code()}: loaded {net.stationCount()} stations" ) for iSta in range(net.stationCount()): sta = net.station(iSta) try: start = sta.start() if not start <= now: continue except: continue try: end = sta.end() if not now <= end: continue except: pass for iLoc in range(sta.sensorLocationCount()): loc = sta.sensorLocation(iLoc) for iCha in range(loc.streamCount()): cha = loc.stream(iCha) wfsID = seiscomp.datamodel.WaveformStreamID( net.code(), sta.code(), loc.code(), cha.code(), "" ) comp = cha.code()[2] if sta.code() not in self.streams: components = {} components[comp] = wfsID self.streams[sta.code()] = components else: # Seismic Handler does not support network, # location and channel code: make sure network and # location codes match first item in station # specific steam list oldWfsID = list(self.streams[sta.code()].values())[0] if ( net.code() != oldWfsID.networkCode() or loc.code() != oldWfsID.locationCode() or cha.code()[:2] != oldWfsID.channelCode()[:2] ): seiscomp.logging.warning( f"ambiguous stream id found for station\ {sta.code()}, ignoring {wfs2Str(wfsID)}" ) continue self.streams[sta.code()][comp] = wfsID seiscomp.logging.debug(f"add stream {wfs2Str(wfsID)}") ########################################################################### def parseTime(self, timeStr): time = seiscomp.core.Time() for fmt in TimeFormats: if time.fromString(timeStr, fmt): break return time ########################################################################### def parseMagType(self, value): if value == "m": return "M" if value == "ml": return "ML" if value == "mb": return "mb" if value == "ms": return "Ms(BB)" if value == "mw": return "Mw" if value == "bb": return "mB" return "" ########################################################################### def sh2proc(self, file): ep = seiscomp.datamodel.EventParameters() origin = seiscomp.datamodel.Origin.Create() event = seiscomp.datamodel.Event.Create() origin.setCreationInfo(seiscomp.datamodel.CreationInfo()) origin.creationInfo().setCreationTime(seiscomp.core.Time.GMT()) originQuality = None originCE = None latFound = False lonFound = False depthError = None originComments = {} # variables, reset after 'end of phase' pick = None stationMag = None staCode = None compCode = None stationMagBB = None ampPeriod = None ampBBPeriod = None amplitudeDisp = None amplitudeVel = None amplitudeSNR = None amplitudeBB = None magnitudeMB = None magnitudeML = None magnitudeMS = None magnitudeBB = None # To avoid undefined warning arrival = None phase = None km2degFac = 1.0 / seiscomp.math.deg2km(1.0) # read file line by line, split key and value at colon iLine = 0 for line in file: iLine += 1 a = line.split(":", 1) key = a[0].strip() keyLower = key.lower() value = None # empty line if len(keyLower) == 0: continue # end of phase if keyLower == "--- end of phase ---": if pick is None: seiscomp.logging.warning(f"Line {iLine}: found empty phase block") continue if staCode is None or compCode is None: seiscomp.logging.warning( f"Line {iLine}: end of phase, stream code incomplete" ) continue if not staCode in self.streams: seiscomp.logging.warning( f"Line {iLine}: end of phase, station code {staCode} not found in inventory" ) continue if not compCode in self.streams[staCode]: seiscomp.logging.warning( f"Line {iLine}: end of phase, component\ {compCode} of station {staCode} not found in inventory" ) continue streamID = self.streams[staCode][compCode] pick.setWaveformID(streamID) ep.add(pick) arrival.setPickID(pick.publicID()) arrival.setPhase(phase) origin.add(arrival) if amplitudeSNR is not None: amplitudeSNR.setPickID(pick.publicID()) amplitudeSNR.setWaveformID(streamID) ep.add(amplitudeSNR) if amplitudeBB is not None: amplitudeBB.setPickID(pick.publicID()) amplitudeBB.setWaveformID(streamID) ep.add(amplitudeBB) if stationMagBB is not None: stationMagBB.setWaveformID(streamID) origin.add(stationMagBB) stationMagContrib = ( seiscomp.datamodel.StationMagnitudeContribution() ) stationMagContrib.setStationMagnitudeID(stationMagBB.publicID()) if magnitudeBB is None: magnitudeBB = seiscomp.datamodel.Magnitude.Create() magnitudeBB.add(stationMagContrib) if stationMag is not None: if stationMag.type() in ["mb", "ML"] and amplitudeDisp is not None: amplitudeDisp.setPickID(pick.publicID()) amplitudeDisp.setWaveformID(streamID) amplitudeDisp.setPeriod( seiscomp.datamodel.RealQuantity(ampPeriod) ) amplitudeDisp.setType(stationMag.type()) ep.add(amplitudeDisp) if stationMag.type() in ["Ms(BB)"] and amplitudeVel is not None: amplitudeVel.setPickID(pick.publicID()) amplitudeVel.setWaveformID(streamID) amplitudeVel.setPeriod( seiscomp.datamodel.RealQuantity(ampPeriod) ) amplitudeVel.setType(stationMag.type()) ep.add(amplitudeVel) stationMag.setWaveformID(streamID) origin.add(stationMag) stationMagContrib = ( seiscomp.datamodel.StationMagnitudeContribution() ) stationMagContrib.setStationMagnitudeID(stationMag.publicID()) magType = stationMag.type() if magType == "ML": if magnitudeML is None: magnitudeML = seiscomp.datamodel.Magnitude.Create() magnitudeML.add(stationMagContrib) elif magType == "Ms(BB)": if magnitudeMS is None: magnitudeMS = seiscomp.datamodel.Magnitude.Create() magnitudeMS.add(stationMagContrib) elif magType == "mb": if magnitudeMB is None: magnitudeMB = seiscomp.datamodel.Magnitude.Create() magnitudeMB.add(stationMagContrib) pick = None staCode = None compCode = None stationMag = None stationMagBB = None ampPeriod = None ampBBPeriod = None amplitudeDisp = None amplitudeVel = None amplitudeSNR = None amplitudeBB = None continue # empty key if len(a) == 1: seiscomp.logging.warning(f"Line {iLine}: key without value") continue value = a[1].strip() if pick is None: pick = seiscomp.datamodel.Pick.Create() arrival = seiscomp.datamodel.Arrival() try: ############################################################## # station parameters # station code if keyLower == "station code": staCode = value # pick time elif keyLower == "onset time": pick.setTime(seiscomp.datamodel.TimeQuantity(self.parseTime(value))) # pick onset type elif keyLower == "onset type": found = False for onset in [ seiscomp.datamodel.EMERGENT, seiscomp.datamodel.IMPULSIVE, seiscomp.datamodel.QUESTIONABLE, ]: if value == seiscomp.datamodel.EPickOnsetNames.name(onset): pick.setOnset(onset) found = True break if not found: raise Exception("Unsupported onset value") # phase code elif keyLower == "phase name": phase = seiscomp.datamodel.Phase() phase.setCode(value) pick.setPhaseHint(phase) # event type elif keyLower == "event type": evttype = EventTypes[value] event.setType(evttype) originComments[key] = value # filter ID elif keyLower == "applied filter": pick.setFilterID(value) # channel code, prepended by configured Channel prefix if only # one character is found elif keyLower == "component": compCode = value # pick evaluation mode elif keyLower == "pick type": found = False for mode in [ seiscomp.datamodel.AUTOMATIC, seiscomp.datamodel.MANUAL, ]: if value == seiscomp.datamodel.EEvaluationModeNames.name(mode): pick.setEvaluationMode(mode) found = True break if not found: raise Exception("Unsupported evaluation mode value") # pick author elif keyLower == "analyst": creationInfo = seiscomp.datamodel.CreationInfo() creationInfo.setAuthor(value) pick.setCreationInfo(creationInfo) # pick polarity # isn't tested elif keyLower == "sign": if value == "positive": sign = "0" # positive elif value == "negative": sign = "1" # negative else: sign = "2" # unknown pick.setPolarity(float(sign)) # arrival weight elif keyLower == "weight": arrival.setWeight(float(value)) # arrival azimuth elif keyLower == "theo. azimuth (deg)": arrival.setAzimuth(float(value)) # pick theo backazimuth elif keyLower == "theo. backazimuth (deg)": if pick.slownessMethodID() == "corrected": seiscomp.logging.debug( f"Line {iLine}: ignoring parameter: {key}" ) else: pick.setBackazimuth( seiscomp.datamodel.RealQuantity(float(value)) ) pick.setSlownessMethodID("theoretical") # pick beam slowness elif keyLower == "beam-slowness (sec/deg)": if pick.slownessMethodID() == "corrected": seiscomp.logging.debug( f"Line {iLine}: ignoring parameter: {key}" ) else: pick.setHorizontalSlowness( seiscomp.datamodel.RealQuantity(float(value)) ) pick.setSlownessMethodID("Array Beam") # pick beam backazimuth elif keyLower == "beam-azimuth (deg)": if pick.slownessMethodID() == "corrected": seiscomp.logging.debug( f"Line {iLine}: ignoring parameter: {key}" ) else: pick.setBackazimuth( seiscomp.datamodel.RealQuantity(float(value)) ) # pick epi slowness elif keyLower == "epi-slowness (sec/deg)": pick.setHorizontalSlowness( seiscomp.datamodel.RealQuantity(float(value)) ) pick.setSlownessMethodID("corrected") # pick epi backazimuth elif keyLower == "epi-azimuth (deg)": pick.setBackazimuth(seiscomp.datamodel.RealQuantity(float(value))) # arrival distance degree elif keyLower == "distance (deg)": arrival.setDistance(float(value)) # arrival distance km, recalculates for degree elif keyLower == "distance (km)": if isinstance(arrival.distance(), float): seiscomp.logging.debug( f"Line {iLine - 1}: ignoring parameter: distance (deg)" ) arrival.setDistance(float(value) * km2degFac) # arrival time residual elif keyLower == "residual time": arrival.setTimeResidual(float(value)) # amplitude snr elif keyLower == "signal/noise": amplitudeSNR = seiscomp.datamodel.Amplitude.Create() amplitudeSNR.setType("SNR") amplitudeSNR.setAmplitude( seiscomp.datamodel.RealQuantity(float(value)) ) # amplitude period elif keyLower.startswith("period"): ampPeriod = float(value) # amplitude value for displacement elif keyLower == "amplitude (nm)": amplitudeDisp = seiscomp.datamodel.Amplitude.Create() amplitudeDisp.setAmplitude( seiscomp.datamodel.RealQuantity(float(value)) ) amplitudeDisp.setUnit("nm") # amplitude value for velocity elif keyLower.startswith("vel. amplitude"): amplitudeVel = seiscomp.datamodel.Amplitude.Create() amplitudeVel.setAmplitude( seiscomp.datamodel.RealQuantity(float(value)) ) amplitudeVel.setUnit("nm/s") elif keyLower == "bb amplitude (nm/sec)": amplitudeBB = seiscomp.datamodel.Amplitude.Create() amplitudeBB.setAmplitude( seiscomp.datamodel.RealQuantity(float(value)) ) amplitudeBB.setType("mB") amplitudeBB.setUnit("nm/s") amplitudeBB.setPeriod(seiscomp.datamodel.RealQuantity(ampBBPeriod)) elif keyLower == "bb period (sec)": ampBBPeriod = float(value) elif keyLower == "broadband magnitude": magType = self.parseMagType("bb") stationMagBB = seiscomp.datamodel.StationMagnitude.Create() stationMagBB.setMagnitude( seiscomp.datamodel.RealQuantity(float(value)) ) stationMagBB.setType(magType) stationMagBB.setAmplitudeID(amplitudeBB.publicID()) # ignored elif keyLower == "quality number": seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") # station magnitude value and type elif keyLower.startswith("magnitude "): magType = self.parseMagType(key[10:]) stationMag = seiscomp.datamodel.StationMagnitude.Create() stationMag.setMagnitude( seiscomp.datamodel.RealQuantity(float(value)) ) if len(magType) > 0: stationMag.setType(magType) if magType == "mb": stationMag.setAmplitudeID(amplitudeDisp.publicID()) elif magType == "MS(BB)": stationMag.setAmplitudeID(amplitudeVel.publicID()) else: seiscomp.logging.debug( f"Line {iLine}: Magnitude Type not known {magType}." ) ############################################################### # origin parameters # event ID, added as origin comment later on elif keyLower == "event id": originComments[key] = value # magnitude value and type elif keyLower == "mean bb magnitude": magType = self.parseMagType("bb") if magnitudeBB is None: magnitudeBB = seiscomp.datamodel.Magnitude.Create() magnitudeBB.setMagnitude( seiscomp.datamodel.RealQuantity(float(value)) ) magnitudeBB.setType(magType) elif keyLower.startswith("mean magnitude "): magType = self.parseMagType(key[15:]) if magType == "ML": if magnitudeML is None: magnitudeML = seiscomp.datamodel.Magnitude.Create() magnitudeML.setMagnitude( seiscomp.datamodel.RealQuantity(float(value)) ) magnitudeML.setType(magType) elif magType == "Ms(BB)": if magnitudeMS is None: magnitudeMS = seiscomp.datamodel.Magnitude.Create() magnitudeMS.setMagnitude( seiscomp.datamodel.RealQuantity(float(value)) ) magnitudeMS.setType(magType) elif magType == "mb": if magnitudeMB is None: magnitudeMB = seiscomp.datamodel.Magnitude.Create() magnitudeMB.setMagnitude( seiscomp.datamodel.RealQuantity(float(value)) ) magnitudeMB.setType(magType) else: seiscomp.logging.warning( f"Line {iLine}: Magnitude type {magType} not defined yet." ) # latitude elif keyLower == "latitude": origin.latitude().setValue(float(value)) latFound = True elif keyLower == "error in latitude (km)": origin.latitude().setUncertainty(float(value)) # longitude elif keyLower == "longitude": origin.longitude().setValue(float(value)) lonFound = True elif keyLower == "error in longitude (km)": origin.longitude().setUncertainty(float(value)) # depth elif keyLower == "depth (km)": origin.setDepth(seiscomp.datamodel.RealQuantity(float(value))) if depthError is not None: origin.depth().setUncertainty(depthError) elif keyLower == "depth type": seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") elif keyLower == "error in depth (km)": depthError = float(value) try: origin.depth().setUncertainty(depthError) except seiscomp.core.ValueException: pass # time elif keyLower == "origin time": origin.time().setValue(self.parseTime(value)) elif keyLower == "error in origin time": origin.time().setUncertainty(float(value)) # location method elif keyLower == "location method": origin.setMethodID(str(value)) # region table, added as origin comment later on elif keyLower == "region table": originComments[key] = value # region table, added as origin comment later on elif keyLower == "region id": originComments[key] = value # source region, added as origin comment later on elif keyLower == "source region": originComments[key] = value # used station count elif keyLower == "no. of stations used": if originQuality is None: originQuality = seiscomp.datamodel.OriginQuality() originQuality.setUsedStationCount(int(value)) # ignored elif keyLower == "reference location name": seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") # confidence ellipsoid major axis elif keyLower == "error ellipse major": if originCE is None: originCE = seiscomp.datamodel.ConfidenceEllipsoid() originCE.setSemiMajorAxisLength(float(value)) # confidence ellipsoid minor axis elif keyLower == "error ellipse minor": if originCE is None: originCE = seiscomp.datamodel.ConfidenceEllipsoid() originCE.setSemiMinorAxisLength(float(value)) # confidence ellipsoid rotation elif keyLower == "error ellipse strike": if originCE is None: originCE = seiscomp.datamodel.ConfidenceEllipsoid() originCE.setMajorAxisRotation(float(value)) # azimuthal gap elif keyLower == "max azimuthal gap (deg)": if originQuality is None: originQuality = seiscomp.datamodel.OriginQuality() originQuality.setAzimuthalGap(float(value)) # creation info author elif keyLower == "author": origin.creationInfo().setAuthor(value) # creation info agency elif keyLower == "source of information": origin.creationInfo().setAgencyID(value) # earth model id elif keyLower == "velocity model": origin.setEarthModelID(value) # standard error elif keyLower == "rms of residuals (sec)": if originQuality is None: originQuality = seiscomp.datamodel.OriginQuality() originQuality.setStandardError(float(value)) # ignored elif keyLower == "phase flags": seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") # ignored elif keyLower == "location input params": seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") # missing keys elif keyLower == "ampl&period source": seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") elif keyLower == "location quality": seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") elif keyLower == "reference latitude": seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") elif keyLower == "reference longitude": seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") elif keyLower.startswith("amplitude time"): seiscomp.logging.debug(f"Line {iLine}: ignoring parameter: {key}") # unknown key else: seiscomp.logging.warning( "Line {iLine}: ignoring unknown parameter: {key}" ) except ValueError: seiscomp.logging.warning(f"Line {iLine}: can not parse {key} value") except Exception: seiscomp.logging.error("Line {iLine}: {str(traceback.format_exc())}") return None # check if not latFound: seiscomp.logging.warning("could not add origin, missing latitude parameter") elif not lonFound: seiscomp.logging.warning( "could not add origin, missing longitude parameter" ) elif not origin.time().value().valid(): seiscomp.logging.warning( "could not add origin, missing origin time parameter" ) else: if magnitudeMB is not None: origin.add(magnitudeMB) if magnitudeML is not None: origin.add(magnitudeML) if magnitudeMS is not None: origin.add(magnitudeMS) if magnitudeBB is not None: origin.add(magnitudeBB) ep.add(event) ep.add(origin) if originQuality is not None: origin.setQuality(originQuality) if originCE is not None: uncertainty = seiscomp.datamodel.OriginUncertainty() uncertainty.setConfidenceEllipsoid(originCE) origin.setUncertainty(uncertainty) for k, v in originComments.items(): comment = seiscomp.datamodel.Comment() comment.setId(k) comment.setText(v) origin.add(comment) return ep ########################################################################### def run(self): self.loadStreams() try: if self.inputFile == "-": f = sys.stdin else: f = open(self.inputFile) except IOError as e: seiscomp.logging.error(str(e)) return False ep = self.sh2proc(f) if ep is None: return False ar = seiscomp.io.XMLArchive() ar.create("-") ar.setFormattedOutput(True) ar.writeObject(ep) ar.close() return True ############################################################################### def main(): try: app = SH2Proc() return app() except: sys.stderr.write(str(traceback.format_exc())) return 1 if __name__ == "__main__": sys.exit(main()) # vim: ts=4 et