diff --git a/MANIFEST.in b/MANIFEST.in index 049d4498..b51a99d7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,3 +6,5 @@ include bin/*.py include versioneer.py include sourcespec/_version.py include sourcespec/configspec.conf +include sourcespec/html_report_template/style.css +include sourcespec/html_report_template/*.html diff --git a/sourcespec/configspec.conf b/sourcespec/configspec.conf index 4cbc6b34..faee1f5b 100644 --- a/sourcespec/configspec.conf +++ b/sourcespec/configspec.conf @@ -261,3 +261,9 @@ plot_station_text_size = float(default=8) # based on the map size) plot_map_tiles_zoom_level = integer(default=None) # -------- PLOT PARAMETERS + + +# HTML REPORT -------- +# Generate an HTML page summarizing the results of this run +html_report = boolean(default=False) +# -------- HTML REPORT diff --git a/sourcespec/html_report_template/index.html b/sourcespec/html_report_template/index.html new file mode 100644 index 00000000..add2405d --- /dev/null +++ b/sourcespec/html_report_template/index.html @@ -0,0 +1,301 @@ + + + + + + + SourceSpec: {EVENTID} + + + + + + + + + + + + + + + +
+ + + + + +
+
+ +
+ +

Event Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Event ID:{EVENTID}
Longitude:{EVENT_LONGITUDE} °E
Latitude:{EVENT_LATITUDE} °N
Depth:{EVENT_DEPTH} km
Origin Time:{ORIGIN_TIME}
Moment Magnitude:{MW_WEIGHT} ± {MW_WEIGHT_ERR}
+ +
+
+

Moment Magnitude

+
+ +
+ +
+
+

Corner Frequency

+
+ +
+ +
+ {TRACES_PLOTS} + +
+ {SPECTRA_PLOTS} + +
+
+

Average Source Parameters

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Mw{MW} +/- {MW_ERR}
Mw (weighted):{MW_WEIGHT} +/- {MW_WEIGHT_ERR}
Mo:{M0} /- {M0_ERR_MINUS} /+ {M0_ERR_PLUS} N.m
Mo (weighted):{M0_WEIGHT} /- {M0_WEIGHT_ERR_MINUS} /+ {M0_WEIGHT_ERR_PLUS} N.m
fc:{FC} /- {FC_ERR_MINUS} /+ {FC_ERR_PLUS} Hz
fc (weighted):{FC_WEIGHT} /- {FC_WEIGHT_ERR_MINUS} /+ {FC_WEIGHT_ERR_PLUS} Hz
t_star:{TSTAR} +/- {TSTAR_ERR} s
t_star (weighted):{TSTAR_WEIGHT} +/- {TSTAR_WEIGHT_ERR} s
Source Radius:{RADIUS} /- {RADIUS_ERR_MINUS} /+ {RADIUS_ERR_PLUS} m
Brune Stress Drop:{BSD} /- {BSD_ERR_MINUS} /+ {BSD_ERR_PLUS} MPa
Radiated Energy:{ER} /- {ER_ERR_MINUS} /+ {ER_ERR_PLUS} N.m
+ +
+
+

Station Source Parameters

+ + + + + + + + + + + + + + + + {STATION_TABLE_ROWS} + +
StationTypeMwCorner Frequency (Hz)t_star (s)Seismic Moment (N.m)Hypocentral Distance (km)Azimuth (°)Radiated Energy (N.m)
+ +
+
+

Files

+ + + + + + + + + + + + + + + +
Configuration: + + {CONF_FILE_BNAME} + +
Output: + + {OUT_FILE_BNAME} + +
Log: + + {LOG_FILE_BNAME} + +
+ +
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/sourcespec/html_report_template/spectra_plot.html b/sourcespec/html_report_template/spectra_plot.html new file mode 100644 index 00000000..68a9026a --- /dev/null +++ b/sourcespec/html_report_template/spectra_plot.html @@ -0,0 +1,5 @@ +
+ Spectra +
+ +
diff --git a/sourcespec/html_report_template/station_table_row.html b/sourcespec/html_report_template/station_table_row.html new file mode 100644 index 00000000..cc3896a6 --- /dev/null +++ b/sourcespec/html_report_template/station_table_row.html @@ -0,0 +1,11 @@ + + {STATION_ID} + {STATION_TYPE} + {STATION_MW}
±{STATION_MW_ERR} + {STATION_FC}
±{STATION_FC_ERR} + {STATION_TSTAR}
±{STATION_TSTAR_ERR} + {STATION_M0} + {STATION_DIST} + {STATION_AZ} + {STATION_ER} + diff --git a/sourcespec/html_report_template/style.css b/sourcespec/html_report_template/style.css new file mode 100644 index 00000000..ba598fb5 --- /dev/null +++ b/sourcespec/html_report_template/style.css @@ -0,0 +1,279 @@ +@import "https://fonts.googleapis.com/css?family=Poppins:300,400,500,600,700"; + +body { + font-family: 'Poppins', sans-serif; + background: #fff; +} + +p { + font-family: 'Poppins', sans-serif; + font-size: 1.1em; + font-weight: 300; + line-height: 1.7em; + color: #999; +} + +a, +a:hover, +a:focus { + color: inherit; + text-decoration: none; + transition: all 0.3s; +} + +table.stations { + font-size: 0.8em; +} + +.navbar { + padding: 15px 10px; + background: #fff; + border: none; + border-radius: 0; + margin-bottom: 40px; + box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.1); +} + +.navbar-btn { + box-shadow: none; + outline: none !important; + border: none; +} + +.line { + width: 100%; + height: 1px; + border-bottom: 1px dashed #ddd; + margin: 40px 0; +} + +/* --------------------------------------------------- + SIDEBAR STYLE +----------------------------------------------------- */ + +.wrapper { + display: flex; + width: 100%; +} + +#sidebar { + width: 250px; + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: 999; + background: #212121; + color: #fff; + transition: all 0.3s; +} + +#sidebar.active { + margin-left: -250px; +} + +#sidebar .sidebar-header { + padding: 5px; + background: #17054F; + border-bottom: 4px solid #444; + font-size: 1.3em; + font-weight: bold; +} + +#sidebar .sidebar-footer { + padding-left: 20px; + font-size: 0.9em; + color: #94AFAB; +} + +#sidebar ul.components { + padding: 20px 0; +} + +#sidebar ul p { + color: #fff; + padding: 10px; +} + +#sidebar ul li a { + padding-top: 15px; + padding-bottom: 15px; + padding-left: 20px; + padding-right: 10px; + font-size: 1.0em; + display: block; + border-bottom: 1px solid #444; + color: #aaa; +} + +#sidebar ul li a:hover { + color: #e14d5b; + background: #fff; +} + +#sidebar ul li.active>a, +a[aria-expanded="true"] { + color: #fff; + background: #6d7fcc; +} + +a[data-toggle="collapse"] { + position: relative; +} + +.dropdown-toggle::after { + display: block; + position: absolute; + top: 50%; + right: 20px; + transform: translateY(-50%); +} + +ul ul a { + font-size: 0.9em !important; + padding-left: 30px !important; + background: #6d7fcc; +} + +ul.CTAs { + padding: 20px; +} + +ul.CTAs a { + text-align: center; + font-size: 0.9em !important; + display: block; + border-radius: 5px; + margin-bottom: 5px; +} + +a.download { + background: #fff; + color: #7386D5; +} + +a.article, +a.article:hover { + background: #6d7fcc !important; + color: #fff !important; +} + +/* --------------------------------------------------- + SIDEBAR COLLAPSE BUTTON +----------------------------------------------------- */ + +#sidebarCollapse { + position: fixed; + top: 0; + left: 250px; + transition: all 0.3s; +} +#sidebarCollapse.active { + left: 0; +} +#sidebarCollapse .btn.btn-info { + font-size: 24px; + color: black; + background: transparent; + border-color: transparent; + box-shadow: none; +} + +/* --------------------------------------------------- + CONTENT STYLE +----------------------------------------------------- */ + +#content { + width: calc(100% - 250px); + padding: 40px; + min-height: 100vh; + transition: all 0.3s; + position: absolute; + top: 0; + right: 0; +} + +#content.active { + width: 100%; +} + +/* --------------------------------------------------- + LIGHTGALLERY ITEMS +----------------------------------------------------- */ + +.item { + cursor: zoom-in; +} + +/* --------------------------------------------------- + MEDIAQUERIES +----------------------------------------------------- */ + +@media (max-width: 768px) { + #sidebar { + margin-left: -250px; + } + #sidebar.active { + margin-left: 0; + } + #content { + width: 100%; + } + /* Uncomment the following if you do not want sidebar hovering content + on small window widths */ + /* #content.active { + width: calc(100% - 250px); + } */ + #sidebarCollapse span { + display: none; + } + #sidebarCollapse { + left: 0; + } + #sidebarCollapse.active { + left: 246px; + } + #sidebarCollapse.active .btn.btn-info { + font-size: 27.4px; + background: #17054f; + color: white; + } +} + + +/* --------------------------------------------------- + PRINTING +----------------------------------------------------- */ + +#print { + display: none; +} +.page-break { + display: none; +} + +@page { + size: A4 landscape; +} + +@media print { + #sidebarCollapse { + display: none; + } + #sidebar { + margin-left: -250px; + } + #content { + width: 100%; + } + #print { + display: block; + } + .line { + display: none; + } + .page-break { + display: block; + page-break-before: always; + } +} \ No newline at end of file diff --git a/sourcespec/html_report_template/traces_plot.html b/sourcespec/html_report_template/traces_plot.html new file mode 100644 index 00000000..06446a92 --- /dev/null +++ b/sourcespec/html_report_template/traces_plot.html @@ -0,0 +1,5 @@ +
+ Traces +
+ +
diff --git a/sourcespec/ssp_html_report.py b/sourcespec/ssp_html_report.py new file mode 100644 index 00000000..5b3257b7 --- /dev/null +++ b/sourcespec/ssp_html_report.py @@ -0,0 +1,266 @@ + +# -*- coding: utf-8 -*- +""" +Generate an HTML report for source_spec. + +:copyright: + 2021 Claudio Satriano +:license: + CeCILL Free Software License Agreement, Version 2.1 + (http://www.cecill.info/index.en.html) +""" +from __future__ import (absolute_import, division, print_function, + unicode_literals) + +import os +import logging +import shutil +import re +import numpy as np +from glob import glob +from sourcespec._version import get_versions +logger = logging.getLogger(__name__.split('.')[-1]) + + +def _multireplace(string, replacements, ignore_case=False): + """ + Given a string and a replacement map, it returns the replaced string. + + :param str string: string to execute replacements on + :param dict replacements: replacement dictionary + {value to find: value to replace} + :param bool ignore_case: whether the match should be case insensitive + :rtype: str + + Source: https://gist.github.com/bgusach/a967e0587d6e01e889fd1d776c5f3729 + """ + if not replacements: + # Edge case that'd produce a funny regex and cause a KeyError + return string + + # If case insensitive, we need to normalize the old string so that later a + # replacement can be found. For instance with {"HEY": "lol"} we should + # match and find a replacement for "hey", + # "HEY", "hEy", etc. + if ignore_case: + def normalize_old(s): + return s.lower() + + re_mode = re.IGNORECASE + + else: + def normalize_old(s): + return s + + re_mode = 0 + + replacements = { + normalize_old(key): val for key, val in replacements.items() + } + + # Place longer ones first to keep shorter substrings from matching where + # the longer ones should take place For instance given the replacements + # {'ab': 'AB', 'abc': 'ABC'} against the string 'hey abc', it should + # produce 'hey ABC' and not 'hey ABc' + rep_sorted = sorted(replacements, key=len, reverse=True) + rep_escaped = map(re.escape, rep_sorted) + + # Create a big OR regex that matches any of the substrings to replace + pattern = re.compile("|".join(rep_escaped), re_mode) + + # For each match, look up the new string in the replacements, being the key + # the normalized old string + return pattern.sub( + lambda match: replacements[normalize_old(match.group(0))], string) + + +def _format_exponent(value, reference): + """Format `value` to a string having the same exponent than `reference`.""" + # get the exponent of reference value + xp = np.int(np.floor(np.log10(np.abs(reference)))) + # format value to print it with the same exponent of reference value + return '{:5.3f}e{:+03d}'.format(value/10**xp, xp) + + +def html_report(config, sourcepar, sourcepar_err): + """Generate an HTML report.""" + # Read template files + template_dir = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'html_report_template' + ) + style_css = os.path.join(template_dir, 'style.css') + index_html = os.path.join(template_dir, 'index.html') + index_html_out = os.path.join(config.options.outdir, 'index.html') + traces_plot_html = os.path.join(template_dir, 'traces_plot.html') + spectra_plot_html = os.path.join(template_dir, 'spectra_plot.html') + station_table_row_html = os.path.join( + template_dir, 'station_table_row.html') + + # Copy CSS to output dir + shutil.copy(style_css, config.options.outdir) + + # Version and run completed + ssp_version = get_versions()['version'] + run_completed = '{} {}'.format( + config.end_of_run.strftime('%Y-%m-%d %H:%M:%S'), + config.end_of_run_tz + ) + + # Output files and maps + hypo = config.hypo + evid = hypo.evid + config_file = '{}.ssp.conf'.format(evid) + out_file = '{}.ssp.out'.format(evid) + log_file = '{}.ssp.log'.format(evid) + map_mag = '{}.map_mag.png'.format(evid) + map_fc = '{}.map_fc.png'.format(evid) + + # Trace plot files + traces_plot = open(traces_plot_html).read() + traces_plot_files = glob( + os.path.join(config.options.outdir, '*.traces.png')) + traces_plot_files += glob( + os.path.join(config.options.outdir, '*.traces.??.png')) + traces_plots = '' + traces_plot_id = '' + for traces_plot_file in sorted(traces_plot_files): + traces_plot_file = os.path.basename(traces_plot_file) + traces_plots += traces_plot.replace( + '{TRACES_PLOT_ID}', traces_plot_id).replace( + '{TRACES_PLOT_FILE}', traces_plot_file) + traces_plot_id = ' id="print"' + + # Spectral plot files + spectra_plot = open(spectra_plot_html).read() + spectra_plot_files = glob( + os.path.join(config.options.outdir, '*.ssp.png')) + spectra_plot_files += glob( + os.path.join(config.options.outdir, '*.ssp.??.png')) + spectra_plots = '' + spectra_plot_id = '' + for spectra_plot_file in sorted(spectra_plot_files): + spectra_plot_file = os.path.basename(spectra_plot_file) + spectra_plots += spectra_plot.replace( + '{SPECTRA_PLOT_ID}', spectra_plot_id).replace( + '{SPECTRA_PLOT_FILE}', spectra_plot_file) + spectra_plot_id = ' id="print"' + + # Station table + station_table_row = open(station_table_row_html).read() + station_table_rows = '' + for statId in sorted(sourcepar.keys()): + if statId in ['means', 'errors', 'means_weight', 'errors_weight']: + continue + par = sourcepar[statId] + err = sourcepar_err[statId] + id, type = statId.split() + Mw = par['Mw'] + Mw_err = err['Mw'] + fc = par['fc'] + fc_err = err['fc'] + t_star = par['t_star'] + t_star_err = err['t_star'] + Mo = par['Mo'] + hyp_dist = par['hyp_dist'] + az = par['az'] + Er = par['Er'] + replacements = { + '{STATION_ID}': id, + '{STATION_TYPE}': type, + '{STATION_MW}': '{:.3f}'.format(Mw), + '{STATION_MW_ERR}': '{:.3f}'.format(Mw_err), + '{STATION_FC}': '{:.3f}'.format(fc), + '{STATION_FC_ERR}': '{:.3f}'.format(fc_err), + '{STATION_TSTAR}': '{:.3f}'.format(t_star), + '{STATION_TSTAR_ERR}': '{:.3f}'.format(t_star_err), + '{STATION_M0}': '{:.3e}'.format(Mo), + '{STATION_DIST}': '{:.3f}'.format(hyp_dist), + '{STATION_AZ}': '{:.3f}'.format(az), + '{STATION_ER}': '{:.3e}'.format(Er), + } + station_table_rows += _multireplace(station_table_row, replacements) + + # Main HTML page + means = sourcepar['means'] + errors = sourcepar['errors'] + means_weight = sourcepar['means_weight'] + errors_weight = sourcepar['errors_weight'] + Mw_mean = means['Mw'] + Mw_error = errors['Mw'] + Mw_mean_weight = means_weight['Mw'] + Mw_error_weight = errors_weight['Mw'] + Mo_mean = means['Mo'] + Mo_minus, Mo_plus = errors['Mo'] + Mo_mean_weight = means_weight['Mo'] + Mo_minus_weight, Mo_plus_weight = errors_weight['Mo'] + fc_mean = means['fc'] + fc_minus, fc_plus = errors['fc'] + fc_mean_weight = means_weight['fc'] + fc_minus_weight, fc_plus_weight = errors_weight['fc'] + t_star_mean = means['t_star'] + t_star_error = errors['t_star'] + t_star_mean_weight = means_weight['t_star'] + t_star_error_weight = errors_weight['t_star'] + ra_mean = means['ra'] + ra_minus, ra_plus = errors['ra'] + bsd_mean = means['bsd'] + bsd_minus, bsd_plus = errors['bsd'] + Er_mean = means['Er'] + Er_minus, Er_plus = errors['Er'] + replacements = { + '{VERSION}': ssp_version, + '{RUN_COMPLETED}': run_completed, + '{EVENTID}': evid, + '{EVENT_LONGITUDE}': '{:8.3f}'.format(hypo.longitude), + '{EVENT_LATITUDE}': '{:7.3f}'.format(hypo.latitude), + '{EVENT_DEPTH}': '{:5.1f}'.format(hypo.depth), + '{ORIGIN_TIME}': '{}'.format(hypo.origin_time), + '{MW}': '{:.2f}'.format(Mw_mean), + '{MW_ERR}': '{:.2f}'.format(Mw_error), + '{MW_WEIGHT}': '{:.2f}'.format(Mw_mean_weight), + '{MW_WEIGHT_ERR}': '{:.2f}'.format(Mw_error_weight), + '{M0}': '{:.3e}'.format(Mo_mean), + '{M0_ERR_MINUS}': '{}'.format(_format_exponent(Mo_minus, Mo_mean)), + '{M0_ERR_PLUS}': '{}'.format(_format_exponent(Mo_plus, Mo_mean)), + '{M0_WEIGHT}': '{:.3e}'.format(Mo_mean_weight), + '{M0_WEIGHT_ERR_MINUS}': '{}'.format( + _format_exponent(Mo_minus_weight, Mo_mean_weight)), + '{M0_WEIGHT_ERR_PLUS}': '{}'.format( + _format_exponent(Mo_plus_weight, Mo_mean_weight)), + '{FC}': '{:.3f}'.format(fc_mean), + '{FC_ERR_MINUS}': '{:.3f}'.format(fc_minus), + '{FC_ERR_PLUS}': '{:.3f}'.format(fc_plus), + '{FC_WEIGHT}': '{:.3f}'.format(fc_mean_weight), + '{FC_WEIGHT_ERR_MINUS}': '{:.3f}'.format(fc_minus_weight), + '{FC_WEIGHT_ERR_PLUS}': '{:.3f}'.format(fc_plus_weight), + '{TSTAR}': '{:.3f}'.format(t_star_mean), + '{TSTAR_ERR}': '{:.3f}'.format(t_star_error), + '{TSTAR_WEIGHT}': '{:.3f}'.format(t_star_mean_weight), + '{TSTAR_WEIGHT_ERR}': '{:.3f}'.format(t_star_error_weight), + '{RADIUS}': '{:.3f}'.format(ra_mean), + '{RADIUS_ERR_MINUS}': '{:.3f}'.format(ra_minus), + '{RADIUS_ERR_PLUS}': '{:.3f}'.format(ra_plus), + '{BSD}': '{:.3e}'.format(bsd_mean), + '{BSD_ERR_MINUS}': '{}'.format(_format_exponent(bsd_minus, bsd_mean)), + '{BSD_ERR_PLUS}': '{}'.format(_format_exponent(bsd_plus, bsd_mean)), + '{ER}': '{:.3e}'.format(Er_mean), + '{ER_ERR_MINUS}': '{}'.format(_format_exponent(Er_minus, Er_mean)), + '{ER_ERR_PLUS}': '{}'.format(_format_exponent(Er_plus, Er_mean)), + '{CONF_FILE_BNAME}': config_file, + '{CONF_FILE}': config_file, + '{OUT_FILE_BNAME}': out_file, + '{OUT_FILE}': out_file, + '{LOG_FILE_BNAME}': log_file, + '{LOG_FILE}': log_file, + '{MAP_MAG}': map_mag, + '{MAP_FC}': map_fc, + '{TRACES_PLOTS}': traces_plots, + '{SPECTRA_PLOTS}': spectra_plots, + '{STATION_TABLE_ROWS}': station_table_rows, + } + index = open(index_html).read() + index = _multireplace(index, replacements) + with open(index_html_out, 'w') as fp: + fp.write(index) + logger.info('HTML report written to file: ' + index_html_out) diff --git a/sourcespec/ssp_output.py b/sourcespec/ssp_output.py index 44674a1d..5c09dd60 100644 --- a/sourcespec/ssp_output.py +++ b/sourcespec/ssp_output.py @@ -24,6 +24,7 @@ from pytz import reference from sourcespec.ssp_setup import ssp_exit from sourcespec.ssp_util import mag_to_moment +from sourcespec.ssp_html_report import html_report logger = logging.getLogger(__name__.split('.')[-1]) @@ -377,6 +378,10 @@ def write_output(config, sourcepar, sourcepar_err): # Write to hypo file, if requested _write_hypo(config, sourcepar) + # Write html_report, if requested + if config.html_report: + html_report(config, sourcepar, sourcepar_err) + params_name = ('Mw', 'fc', 't_star') sourcepar_mean = dict( zip(params_name, [means['Mw'], means['fc'], means['t_star']])) diff --git a/sourcespec/ssp_setup.py b/sourcespec/ssp_setup.py index 53a7a944..ea6d5d4c 100644 --- a/sourcespec/ssp_setup.py +++ b/sourcespec/ssp_setup.py @@ -595,6 +595,18 @@ def setup_logging(config, basename=None, progname='source_spec'): def init_plotting(config): + if config.html_report: + if not config.PLOT_SAVE: + logger.warning( + 'The html_report option is selected but PLOT_SAVE is False. ' + 'Setting PLOT_SAVE to True.') + config.PLOT_SAVE = True + if config.PLOT_SAVE_FORMAT != 'png': + logger.warning( + 'The html_report option is selected but PLOT_SAVE_FORMAT is ' + 'not png. Setting PLOT_SAVE_FORMAT to png.') + config.PLOT_SAVE_FORMAT = 'png' + import matplotlib.pyplot as plt if not config.PLOT_SHOW: plt.switch_backend('Agg')