# -*- coding: utf-8 -*-
# SPDX-License-Identifier: CECILL-2.1
"""
Generate an HTML report for source_spec.
:copyright:
2021-2026 Claudio Satriano <satriano@ipgp.fr>
:license:
CeCILL Free Software License Agreement v2.1
(http://www.cecill.info/licences.en.html)
"""
import os
import logging
import shutil
import re
import contextlib
from urllib.parse import urlparse
import numpy as np
from sourcespec._version import get_versions
from sourcespec.ssp_data_types import SpectralParameter
logger = logging.getLogger(__name__.rsplit('.', maxsplit=1)[-1])
VALID_FIGURE_FORMATS = ('.png', '.svg')
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 _agency_logo_path(config):
agency_logo_path = config.agency_logo
if agency_logo_path is None:
return None
# check if agency_logo is a URL
parsed_url = urlparse(agency_logo_path)
if not parsed_url.scheme:
# check if it is a file
if not os.path.exists(agency_logo_path):
logger.warning(
f'Cannot find the agency logo file: {agency_logo_path}')
return None
bname = os.path.basename(agency_logo_path)
dest = os.path.join(config.options.outdir, bname)
if not os.path.exists(dest):
shutil.copy(agency_logo_path, config.options.outdir)
agency_logo_path = bname
return agency_logo_path
def _agency_logo(config):
agency_logo_path = _agency_logo_path(config)
if agency_logo_path is None:
return ''
agency_logo_img = f'<img class="logo" src="{agency_logo_path}"/>'
indent5 = 5 * ' '
indent6 = 6 * ' '
if config.agency_url is not None:
agency_logo_html = (
f'{indent5}<a href="{config.agency_url}" target="_blank">\n'
f'{indent6}{agency_logo_img}\n'
f'{indent5}</a>'
)
else:
agency_logo_html = indent5 + agency_logo_img
agency_logo_html = f'{agency_logo_html}\n{indent5}<hr class="solid">'
return agency_logo_html
def _logo_file_url():
cdn_baseurl = 'https://cdn.jsdelivr.net/gh/SeismicSource/sourcespec@1.6'
return f'{cdn_baseurl}/imgs/SourceSpec_logo.svg'
def _version_and_run_completed(config):
ssp_version = get_versions()['version']
run_completed = (
f'{config.end_of_run.strftime("%Y-%m-%d %H:%M:%S")} '
f'{config.end_of_run_tz}'
)
return ssp_version, run_completed
def _author_html(config):
author = ''
if config.author_name is not None:
author = config.author_name
elif config.author_email is not None:
author = config.author_email
if config.author_email is not None:
author = f'<a href="mailto:{config.author_email}">{author}</a>'
return author
def _agency_html(config):
agency = ''
if config.agency_full_name is not None:
agency = config.agency_full_name
if config.agency_short_name is not None:
agency += f' ({config.agency_short_name})'
elif config.agency_short_name is not None:
agency = config.agency_short_name
elif config.agency_url is not None:
agency = config.agency_url
if config.agency_url is not None:
agency = f'<a href="{config.agency_url}" target="_blank">{agency}</a>'
return agency
def _author_and_agency_html(author, agency):
if author != '':
author = f'<br/><br/>{author}'
if author == '' and agency != '':
agency = f'<br/><br/>{agency}'
if author != '' and agency != '':
agency = f'<br/>{agency}'
return author + agency
def _page_footer(config):
footer_html = ''
indent3 = 3 * ' '
indent4 = 4 * ' '
footer_html += f'{indent3}<div class="text_footer">\n'
author = _author_html(config)
agency = _agency_html(config)
auth_agen_text = ''
if author != '':
if agency != '':
auth_agen_text +=\
f'{indent4}{author}\n{indent4}-\n{indent4}{agency}\n'
if agency == '':
auth_agen_text += f'{indent4}{author}\n'
if author == '' and agency != '':
auth_agen_text += f'{indent4}{agency}\n'
run_completed = config.end_of_run.strftime('%Y-%m-%d')
footer_html += (
f'{auth_agen_text}\n{indent4}-\n{indent4}{run_completed}\n'
if auth_agen_text
else f'{indent4}{run_completed}\n'
)
footer_html += f'{indent3}</div>\n'
agency_logo_path = _agency_logo_path(config)
if agency_logo_path is not None:
if config.agency_url is not None:
a_agency = f'<a href="{config.agency_url}" target="_blank">'
a_agency_close = '</a>'
else:
a_agency = a_agency_close = ''
footer_html += (
f'{indent3}<div class="logo_footer">\n{indent4}{a_agency}'
f'<img src="{agency_logo_path}"/>{a_agency_close}\n'
f'{indent3}</div>\n'
)
return footer_html
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
base = 10**xp
return f'{value/base:5.3f}e{xp:+03d}'
def _summary_value_and_err_text(value, error, fmt):
"""Format summary value and error text."""
if error[0] == error[1]:
text = f'{fmt}<br>±{fmt}'
text = text.format(value, error[0])
else:
text = f'{fmt}<br>-{fmt}<br>+{fmt}'
text = text.format(value, error[0], error[1])
return text
def _station_value_and_err_text(par, key, fmt):
"""Format station value and error text."""
# par[key] can be None even if key is in par (e.g., if key is 'Ml' and
# local magnitude is not computed)
_par = par[key] if key in par else None
if _par is None:
return '', ''
if isinstance(_par, SpectralParameter):
value = _par.value
outlier = _par.outlier
else:
value = _par
outlier = False
value_text = f'<nobr>{fmt.format(value)}</nobr>'
err_text = None
if isinstance(_par, SpectralParameter):
if _par.uncertainty is not None:
# use HTML code for ±, for compatibility with Edge
err_text = f'<nobr>±{fmt.format(_par.uncertainty)}</nobr>'
elif _par.lower_uncertainty is not None:
err_text = (
f'<nobr>-{fmt.format(_par.lower_uncertainty)}</nobr><br/>'
f'<nobr>+{fmt.format(_par.upper_uncertainty)}</nobr>'
)
if outlier:
value_text = f'<span style="color:#979A9A">{value_text}</span>'
if err_text is not None:
err_text = f'<span style="color:#979A9A">{err_text}</span>'
return value_text, err_text
def _misfit_table_rows(misfit_plot_files):
template_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'html_report_template'
)
misfit_table_column_html = os.path.join(
template_dir, 'misfit_table_column.html')
with open(
misfit_table_column_html, encoding='utf-8'
) as fp:
misfit_table_column = fp.read()
misfit_table_rows = ''
for n, misfit_plot_file in enumerate(sorted(misfit_plot_files)):
if n % 3 == 0:
misfit_table_rows += '<tr>\n'
misfit_plot_file = os.path.join(
'misfit', os.path.basename(misfit_plot_file))
misfit_table_rows += misfit_table_column.replace(
'{MISFIT_PLOT}', misfit_plot_file)
misfit_table_rows += '\n'
if n % 3 == 2:
misfit_table_rows += 10 * ' '
misfit_table_rows += '</tr>\n'
misfit_table_rows += 10 * ' '
misfit_table_rows += 10 * ' '
misfit_table_rows += '</tr>'
return misfit_table_rows
def _misfit_page(config):
"""Generate an HTML page with misfit plots."""
# Read template files
template_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'html_report_template'
)
misfit_html = os.path.join(template_dir, 'misfit.html')
misfit_html_out = os.path.join(config.options.outdir, 'misfit.html')
# Logo file
logo_file = _logo_file_url()
# Version and run completed
ssp_version, run_completed = _version_and_run_completed(config)
# Author and agency
author = _author_html(config)
agency = _agency_html(config)
author_and_agency = _author_and_agency_html(author, agency)
# 1d conditional misfit plots
misfit_plot_files = config.figures['misfit_1d']
one_d_misfit_table_rows = _misfit_table_rows(misfit_plot_files)
# 2d conditional misfit plots: fc-Mw
misfit_plot_files = config.figures['misfit_fc-Mw']
two_d_misfit_table_rows_fc_mw = _misfit_table_rows(misfit_plot_files)
# Plots with t-star might be missing if Q model is fixed
# 2d conditional misfit plots: fc-tstar
misfit_plot_files = config.figures['misfit_fc-t_star']
if misfit_plot_files:
two_d_misfit_table_rows_fc_tstar =\
_misfit_table_rows(misfit_plot_files)
two_d_misfit_fc_tstar_comment_begin = ''
two_d_misfit_fc_tstar_comment_end = ''
else:
two_d_misfit_table_rows_fc_tstar = ''
two_d_misfit_fc_tstar_comment_begin = '<!--'
two_d_misfit_fc_tstar_comment_end = '-->'
# 2d conditional misfit plots: tstar-Mw
misfit_plot_files = config.figures['misfit_t_star-Mw']
if misfit_plot_files:
two_d_misfit_table_rows_tstar_mw =\
_misfit_table_rows(misfit_plot_files)
two_d_misfit_tstar_mw_comment_begin = ''
two_d_misfit_tstar_mw_comment_end = ''
else:
two_d_misfit_table_rows_tstar_mw = ''
two_d_misfit_tstar_mw_comment_begin = '<!--'
two_d_misfit_tstar_mw_comment_end = '-->'
# Main HTML page
replacements = {
'{LOGO_FILE}': logo_file,
'{VERSION}': ssp_version,
'{RUN_COMPLETED}': run_completed,
'{AUTHOR_AND_AGENCY}': author_and_agency,
'{EVENTID}': config.event.event_id,
'{1D_MISFIT_TABLE_ROWS}': one_d_misfit_table_rows,
'{2D_MISFIT_TABLE_ROWS_FC_MW}': two_d_misfit_table_rows_fc_mw,
'{2D_MISFIT_TABLE_ROWS_FC_TSTAR}': two_d_misfit_table_rows_fc_tstar,
'{2D_MISFIT_FC_TSTAR_COMMENT_BEGIN}':
two_d_misfit_fc_tstar_comment_begin,
'{2D_MISFIT_FC_TSTAR_COMMENT_END}':
two_d_misfit_fc_tstar_comment_end,
'{2D_MISFIT_TABLE_ROWS_TSTAR_MW}': two_d_misfit_table_rows_tstar_mw,
'{2D_MISFIT_TSTAR_MW_COMMENT_BEGIN}':
two_d_misfit_tstar_mw_comment_begin,
'{2D_MISFIT_TSTAR_MW_COMMENT_END}':
two_d_misfit_tstar_mw_comment_end
}
with open(misfit_html, encoding='utf-8') as fp:
misfit = fp.read()
misfit = _multireplace(misfit, replacements)
with open(misfit_html_out, 'w', encoding='utf-8') as fp:
fp.write(misfit)
def _add_run_info_to_html(config, replacements):
"""Add run info to HTML report."""
ssp_url = 'https://sourcespec.seismicsource.org'
logo_file = _logo_file_url()
agency_logo = _agency_logo(config)
ssp_version, run_completed = _version_and_run_completed(config)
author = _author_html(config)
if not author:
author_comment_begin = '<!--'
author_comment_end = '-->'
else:
author_comment_begin = ''
author_comment_end = ''
agency = _agency_html(config)
if not agency:
agency_comment_begin = '<!--'
agency_comment_end = '-->'
else:
agency_comment_begin = ''
agency_comment_end = ''
author_and_agency = _author_and_agency_html(author, agency)
page_footer = _page_footer(config)
replacements |= {
'{AGENCY_LOGO}': agency_logo,
'{LOGO_FILE}': logo_file,
'{VERSION}': ssp_version,
'{SSP_URL}': ssp_url,
'{RUN_COMPLETED}': run_completed,
'{AUTHOR}': author,
'{AUTHOR_COMMENT_BEGIN}': author_comment_begin,
'{AUTHOR_COMMENT_END}': author_comment_end,
'{AGENCY}': agency,
'{AGENCY_COMMENT_BEGIN}': agency_comment_begin,
'{AGENCY_COMMENT_END}': agency_comment_end,
'{AUTHOR_AND_AGENCY}': author_and_agency,
'{PAGE_FOOTER}': page_footer,
}
def _add_event_info_to_html(config, replacements):
"""Add event info to HTML report."""
evid = config.event.event_id
evname = config.event.name
hypo = config.event.hypocenter
run_id = config.options.run_id
replacements |= {
'{EVENTID}': evid,
'{EVENT_NAME}': evname,
'{RUNID}': run_id,
'{EVENT_LONGITUDE}': f'{hypo.longitude.value_in_deg:8.3f}',
'{EVENT_LATITUDE}': f'{hypo.latitude.value_in_deg:7.3f}',
'{EVENT_DEPTH}': f'{hypo.depth.value_in_km:5.1f}',
'{ORIGIN_TIME}': f'{hypo.origin_time}',
}
# Link to event page, if defined
event_url = config.event_url
if event_url is not None:
event_url = event_url.replace('$EVENTID', evid)
event_year = hypo.origin_time.year
event_month = hypo.origin_time.month
event_day = hypo.origin_time.day
event_url = event_url.replace('$YEAR', f'{event_year:04d}')
event_url = event_url.replace('$MONTH', f'{event_month:02d}')
event_url = event_url.replace('$DAY', f'{event_day:02d}')
parsed_url = urlparse(event_url)
if not parsed_url.scheme:
logger.warning(
f'{event_url} is not a valid URL and will not be used')
event_url = None
if event_url is not None:
event_url_comment_begin = ''
event_url_comment_end = ''
else:
event_url = ''
event_url_comment_begin = '<!--'
event_url_comment_end = '-->'
replacements |= {
'{EVENT_URL}': event_url,
'{EVENT_URL_COMMENT_BEGIN}': event_url_comment_begin,
'{EVENT_URL_COMMENT_END}': event_url_comment_end
}
# Only show Event Name if it is not empty
if evname:
evname_comment_begin = evname_comment_end = ''
else:
evname_comment_begin = '<!--'
evname_comment_end = '-->'
replacements |= {
'{EVENT_NAME_COMMENT_BEGIN}': evname_comment_begin,
'{EVENT_NAME_COMMENT_END}': evname_comment_end
}
# Only show Run ID if it is not empty
if run_id:
run_id_comment_begin = run_id_comment_end = ''
else:
run_id_comment_begin = '<!--'
run_id_comment_end = '-->'
replacements |= {
'{RUNID_COMMENT_BEGIN}': run_id_comment_begin,
'{RUNID_COMMENT_END}': run_id_comment_end
}
def _add_maps_to_html(config, replacements):
"""Add maps to HTML report."""
try:
station_maps = [
m for m in config.figures['station_maps']
if m.endswith(VALID_FIGURE_FORMATS)]
except KeyError:
station_maps = []
map_mag = ''
map_fc = ''
with contextlib.suppress(IndexError):
map_mag = [
mapfile for mapfile in station_maps if 'map_mag' in mapfile][0]
map_mag = os.path.basename(map_mag)
with contextlib.suppress(IndexError):
map_fc = [
mapfile for mapfile in station_maps if 'map_fc' in mapfile][0]
map_fc = os.path.basename(map_fc)
if map_mag:
map_mag_comment_begin = map_mag_comment_end = ''
else:
map_mag_comment_begin = '<!--'
map_mag_comment_end = '-->'
if map_fc:
map_fc_comment_begin = map_fc_comment_end = ''
else:
map_fc_comment_begin = '<!--'
map_fc_comment_end = '-->'
replacements |= {
'{MAP_MAG}': map_mag,
'{MAP_MAG_COMMENT_BEGIN}': map_mag_comment_begin,
'{MAP_MAG_COMMENT_END}': map_mag_comment_end,
'{MAP_FC}': map_fc,
'{MAP_FC_COMMENT_BEGIN}': map_fc_comment_begin,
'{MAP_FC_COMMENT_END}': map_fc_comment_end
}
def _add_traces_plots_to_html(config, templates, replacements):
"""Add trace plots to HTML report."""
with open(templates.traces_plot_html, encoding='utf-8') as fp:
traces_plot = fp.read()
traces_plot_files = [
t for t in config.figures['traces']
if t.endswith(VALID_FIGURE_FORMATS)]
traces_plots = ''
traces_plot_class = ''
n_traces_plot_files = len(traces_plot_files)
for n, traces_plot_file in enumerate(sorted(traces_plot_files)):
if n_traces_plot_files > 1:
traces_plot_counter = (
f'<span class="print_inline"> ({n+1} of '
f'{n_traces_plot_files})</span>'
)
else:
traces_plot_counter = ''
traces_plot_file = os.path.basename(traces_plot_file)
traces_plots += traces_plot.\
replace('{TRACES_PLOT_CLASS}', traces_plot_class).\
replace('{TRACES_PLOT_COUNTER}', traces_plot_counter).\
replace('{TRACES_PLOT_FILE}', traces_plot_file)
traces_plot_class = ' class="print"'
if traces_plots:
traces_plots_comment_begin = traces_plots_comment_end = ''
else:
traces_plots_comment_begin = '<!--'
traces_plots_comment_end = '-->'
replacements |= {
'{TRACES_PLOTS}': traces_plots,
'{TRACES_PLOTS_COMMENT_BEGIN}': traces_plots_comment_begin,
'{TRACES_PLOTS_COMMENT_END}': traces_plots_comment_end
}
def _add_spectra_plots_to_html(config, templates, replacements):
"""Add spectra plots to HTML report."""
with open(templates.spectra_plot_html, encoding='utf-8') as fp:
spectra_plot = fp.read()
spectra_plot_files = [
s for s in config.figures['spectra_regular']
if s.endswith(VALID_FIGURE_FORMATS)]
spectra_plots = ''
spectra_plot_class = ''
n_spectra_plot_files = len(spectra_plot_files)
for n, spectra_plot_file in enumerate(sorted(spectra_plot_files)):
if n_spectra_plot_files > 1:
spectra_plot_counter = (
f'<span class="print_inline"> ({n+1} of '
f'{n_spectra_plot_files})</span>'
)
else:
spectra_plot_counter = ''
spectra_plot_file = os.path.basename(spectra_plot_file)
spectra_plots += spectra_plot.\
replace('{SPECTRA_PLOT_CLASS}', spectra_plot_class).\
replace('{SPECTRA_PLOT_COUNTER}', spectra_plot_counter).\
replace('{SPECTRA_PLOT_FILE}', spectra_plot_file)
spectra_plot_class = ' class="print"'
if spectra_plots:
spectra_plots_comment_begin = spectra_plots_comment_end = ''
else:
spectra_plots_comment_begin = '<!--'
spectra_plots_comment_end = '-->'
replacements |= {
'{SPECTRA_PLOTS}': spectra_plots,
'{SPECTRA_PLOTS_COMMENT_BEGIN}': spectra_plots_comment_begin,
'{SPECTRA_PLOTS_COMMENT_END}': spectra_plots_comment_end
}
def _add_inversion_info_to_html(sspec_output, replacements):
# Inversion information
inversion_algorithms = {
'TNC': 'Truncated Newton',
'LM': 'Levenberg-Marquardt',
'BH': 'Basin-hopping',
'GS': 'Grid search',
'IS': 'K-d tree importance sampling',
}
weightings = {
'noise': 'Noise weighting',
'frequency': 'Frequency weighting',
'inv_frequency': 'Inverse frequency weighting',
'no_weight': 'No weighting',
}
inversion_algorithm = inversion_algorithms[
sspec_output.inversion_info.algorithm]
inversion_wave_type = sspec_output.inversion_info.wave_type
inversion_weighting = weightings[sspec_output.inversion_info.weighting]
inversion_t_star_0 = f'{sspec_output.inversion_info.t_star_0} s'
inversion_invert_t_star_0 =\
str(sspec_output.inversion_info.invert_t_star_0)
inversion_t_star_0_variability =\
f'{sspec_output.inversion_info.t_star_0_variability * 100:.1f} %'
if sspec_output.inversion_info.t_star_min_max == 'null':
inversion_t_star_min_max = '-'
else:
inversion_t_star_min_max =\
f'{sspec_output.inversion_info.t_star_min_max} s'
if sspec_output.inversion_info.fc_min_max == 'null':
inversion_fc_min_max = '-'
else:
inversion_fc_min_max =\
f'{sspec_output.inversion_info.fc_min_max} Hz'
if sspec_output.inversion_info.Qo_min_max == 'null':
inversion_Qo_min_max = '-'
else:
inversion_Qo_min_max =\
str(sspec_output.inversion_info.Qo_min_max)
replacements |= {
'{INVERSION_ALGORITHM}': inversion_algorithm,
'{INVERSION_WAVE_TYPE}': inversion_wave_type,
'{INVERSION_WEIGHTING}': inversion_weighting,
'{INVERSION_T_STAR_0}': inversion_t_star_0,
'{INVERSION_INVERT_T_STAR_0}': inversion_invert_t_star_0,
'{INVERSION_T_STAR_0_VARIABILITY}': inversion_t_star_0_variability,
'{INVERSION_T_STAR_MIN_MAX}': inversion_t_star_min_max,
'{INVERSION_FC_MIN_MAX}': inversion_fc_min_max,
'{INVERSION_Q0_MIN_MAX}': inversion_Qo_min_max,
}
def _add_inversion_quality_to_html(sspec_output, replacements):
"""Add inversion quality info to HTML report."""
quality_info = sspec_output.quality_info
n_input_stations = (
f'{quality_info.n_input_stations}'
if quality_info.n_input_stations is not None else '-'
)
n_input_spectra = (
f'{quality_info.n_input_spectra}'
if quality_info.n_input_spectra is not None else '-'
)
n_spectra_inverted = (
f'{quality_info.n_spectra_inverted}'
if quality_info.n_spectra_inverted is not None else '-'
)
azimuthal_gap_primary = (
f'{quality_info.azimuthal_gap_primary:.1f}°'
if quality_info.azimuthal_gap_primary is not None else '-'
)
azimuthal_gap_secondary = (
f'{quality_info.azimuthal_gap_secondary:.1f}°'
if quality_info.azimuthal_gap_secondary is not None else '-'
)
rmsn_mean = (
f'{quality_info.rmsn_mean:.3f}'
if quality_info.rmsn_mean is not None else '-'
)
quality_of_fit_mean = (
f'{quality_info.quality_of_fit_mean:.1f}%'
if quality_info.quality_of_fit_mean is not None else '-'
)
spectral_dispersion_rmsn = (
f'{quality_info.spectral_dispersion_rmsn:.3f}'
if quality_info.spectral_dispersion_rmsn is not None else '-'
)
spectral_dispersion_score = (
f'{quality_info.spectral_dispersion_score:.1f}%'
if quality_info.spectral_dispersion_score is not None else '-'
)
replacements |= {
'{N_INPUT_STATIONS}': n_input_stations,
'{N_INPUT_SPECTRA}': n_input_spectra,
'{N_SPECTRA_INVERTED}': n_spectra_inverted,
'{AZIMUTHAL_GAP_PRIMARY}': azimuthal_gap_primary,
'{AZIMUTHAL_GAP_SECONDARY}': azimuthal_gap_secondary,
'{RMSN_MEAN}': rmsn_mean,
'{QUALITY_OF_FIT_MEAN}': quality_of_fit_mean,
'{SPECTRAL_DISPERSION_RMSN}': spectral_dispersion_rmsn,
'{SPECTRAL_DISPERSION_SCORE}': spectral_dispersion_score,
}
def _add_summary_spectral_params_to_html(config, sspec_output, replacements):
"""Add summary spectral parameters to HTML report."""
ref_stat = sspec_output.summary_spectral_parameters.reference_statistics
col_mean_highlighted = col_wmean_highlighted = col_perc_highlighted = ''
if ref_stat == 'mean':
col_mean_highlighted = 'class="highlighted_column"'
elif ref_stat == 'weighted_mean':
col_wmean_highlighted = 'class="highlighted_column"'
elif ref_stat == 'percentiles':
col_perc_highlighted = 'class="highlighted_column"'
replacements |= {
'{COL_MEAN_HIGHLIGHTED}': col_mean_highlighted,
'{COL_WMEAN_HIGHLIGHTED}': col_wmean_highlighted,
'{COL_PERC_HIGHLIGHTED}': col_perc_highlighted,
}
summary_values = sspec_output.reference_values()
summary_uncertainties = sspec_output.reference_uncertainties()
Mw_summary = summary_values['Mw']
try:
Mw_summary_error_minus, Mw_summary_error_plus =\
summary_uncertainties['Mw']
Mw_summary_str = (
f'{Mw_summary:.2f} '
f'[- {Mw_summary_error_minus:.2f}, + {Mw_summary_error_plus:.2f}]'
)
except TypeError:
Mw_summary_error = summary_uncertainties['Mw']
Mw_summary_str = f'{Mw_summary:.2f} ± {Mw_summary_error:.2f}'
replacements |= {'{MW_SUMMARY}': Mw_summary_str}
fc_summary = summary_values['fc']
try:
fc_summary_error_minus, fc_summary_error_plus =\
summary_uncertainties['fc']
fc_summary_str = (
f'{fc_summary:.3f} '
f'[- {fc_summary_error_minus:.3f}, + {fc_summary_error_plus:.3f}]'
)
except TypeError:
fc_summary_error = summary_uncertainties['fc']
fc_summary_str = f'{fc_summary:.3f} ± {fc_summary_error:.3f}'
replacements |= {'{FC_SUMMARY}': fc_summary_str}
means = sspec_output.mean_values()
mean_errors = sspec_output.mean_uncertainties()
wmeans = sspec_output.weighted_mean_values()
wmean_errors = sspec_output.weighted_mean_uncertainties()
percentiles = sspec_output.percentiles_values()
percentile_errors = sspec_output.percentiles_uncertainties()
n_sigma = config.n_sigma
n_sigma = int(n_sigma) if float(n_sigma).is_integer() else n_sigma
n_sigma = f'{n_sigma} sigma'
mid_pct, lower_pct, upper_pct =\
config.mid_percentage, config.lower_percentage, config.upper_percentage
mid_pct = int(mid_pct) if float(mid_pct).is_integer() else mid_pct
lower_pct = int(lower_pct) if float(lower_pct).is_integer() else lower_pct
upper_pct = int(upper_pct) if float(upper_pct).is_integer() else upper_pct
percentages = f'{mid_pct}%, [{lower_pct}%, {upper_pct}%]'
replacements |= {
'{N_SIGMA}': n_sigma,
'{PERCENTAGES}': percentages
}
Mw_mean = means['Mw']
Mw_mean_error = mean_errors['Mw']
Mw_wmean = wmeans['Mw']
Mw_wmean_error = wmean_errors['Mw']
Mw_perc = percentiles['Mw']
Mw_perc_error = percentile_errors['Mw']
replacements |= {
'{MW_MEAN_AND_ERR}': _summary_value_and_err_text(
Mw_mean, Mw_mean_error, '{:.2f}'),
'{MW_WMEAN_AND_ERR}': _summary_value_and_err_text(
Mw_wmean, Mw_wmean_error, '{:.2f}'),
'{MW_PERC_AND_ERR}': _summary_value_and_err_text(
Mw_perc, Mw_perc_error, '{:.2f}'),
}
Mo_mean = means['Mo']
Mo_mean_error = mean_errors['Mo']
Mo_wmean = wmeans['Mo']
Mo_wmean_error = wmean_errors['Mo']
Mo_perc = percentiles['Mo']
Mo_perc_error = percentile_errors['Mo']
replacements |= {
'{M0_MEAN_AND_ERR}': _summary_value_and_err_text(
Mo_mean, Mo_mean_error, '{:.3e}'),
'{M0_WMEAN_AND_ERR}': _summary_value_and_err_text(
Mo_wmean, Mo_wmean_error, '{:.3e}'),
'{M0_PERC_AND_ERR}': _summary_value_and_err_text(
Mo_perc, Mo_perc_error, '{:.3e}'),
}
fc_mean = means['fc']
fc_mean_error = mean_errors['fc']
fc_wmean = wmeans['fc']
fc_wmean_error = wmean_errors['fc']
fc_perc = percentiles['fc']
fc_perc_error = percentile_errors['fc']
replacements |= {
'{FC_MEAN_AND_ERR}': _summary_value_and_err_text(
fc_mean, fc_mean_error, '{:.3f}'),
'{FC_WMEAN_AND_ERR}': _summary_value_and_err_text(
fc_wmean, fc_wmean_error, '{:.3f}'),
'{FC_PERC_AND_ERR}': _summary_value_and_err_text(
fc_perc, fc_perc_error, '{:.3f}'),
}
t_star_mean = means['t_star']
t_star_mean_error = mean_errors['t_star']
t_star_wmean = wmeans['t_star']
t_star_wmean_error = wmean_errors['t_star']
t_star_perc = percentiles['t_star']
t_star_perc_error = percentile_errors['t_star']
replacements |= {
'{TSTAR_MEAN_AND_ERR}': _summary_value_and_err_text(
t_star_mean, t_star_mean_error, '{:.3f}'),
'{TSTAR_WMEAN_AND_ERR}': _summary_value_and_err_text(
t_star_wmean, t_star_wmean_error, '{:.3f}'),
'{TSTAR_PERC_AND_ERR}': _summary_value_and_err_text(
t_star_perc, t_star_perc_error, '{:.3f}'),
}
Qo_mean = means['Qo']
Qo_mean_error = mean_errors['Qo']
Qo_wmean = wmeans['Qo']
Qo_wmean_error = wmean_errors['Qo']
Qo_perc = percentiles['Qo']
Qo_perc_error = percentile_errors['Qo']
replacements |= {
'{Q0_MEAN_AND_ERR}': _summary_value_and_err_text(
Qo_mean, Qo_mean_error, '{:.1f}'),
'{Q0_WMEAN_AND_ERR}': _summary_value_and_err_text(
Qo_wmean, Qo_wmean_error, '{:.1f}'),
'{Q0_PERC_AND_ERR}': _summary_value_and_err_text(
Qo_perc, Qo_perc_error, '{:.1f}'),
}
ra_mean = means['radius']
ra_mean_error = mean_errors['radius']
ra_wmean = wmeans['radius']
ra_wmean_error = wmean_errors['radius']
ra_perc = percentiles['radius']
ra_perc_error = percentile_errors['radius']
replacements |= {
'{RADIUS_MEAN_AND_ERR}': _summary_value_and_err_text(
ra_mean, ra_mean_error, '{:.3f}'),
'{RADIUS_WMEAN_AND_ERR}': _summary_value_and_err_text(
ra_wmean, ra_wmean_error, '{:.3f}'),
'{RADIUS_PERC_AND_ERR}': _summary_value_and_err_text(
ra_perc, ra_perc_error, '{:.3f}'),
}
ssd_mean = means['ssd']
ssd_mean_error = mean_errors['ssd']
ssd_wmean = wmeans['ssd']
ssd_wmean_error = wmean_errors['ssd']
ssd_perc = percentiles['ssd']
ssd_perc_error = percentile_errors['ssd']
replacements |= {
'{SSD_MEAN_AND_ERR}': _summary_value_and_err_text(
ssd_mean, ssd_mean_error, '{:.3e}'),
'{SSD_WMEAN_AND_ERR}': _summary_value_and_err_text(
ssd_wmean, ssd_wmean_error, '{:.3e}'),
'{SSD_PERC_AND_ERR}': _summary_value_and_err_text(
ssd_perc, ssd_perc_error, '{:.3e}'),
}
Er_mean = means['Er']
Er_mean_error = mean_errors['Er']
Er_wmean = wmeans['Er']
Er_wmean_error = wmean_errors['Er']
Er_perc = percentiles['Er']
Er_perc_error = percentile_errors['Er']
replacements |= {
'{ER_MEAN_AND_ERR}': _summary_value_and_err_text(
Er_mean, Er_mean_error, '{:.3e}'),
'{ER_WMEAN_AND_ERR}': _summary_value_and_err_text(
Er_wmean, Er_wmean_error, '{:.3e}'),
'{ER_PERC_AND_ERR}': _summary_value_and_err_text(
Er_perc, Er_perc_error, '{:.3e}'),
}
sigma_a_mean = means['sigma_a']
sigma_a_mean_error = mean_errors['sigma_a']
sigma_a_wmean = wmeans['sigma_a']
sigma_a_wmean_error = wmean_errors['sigma_a']
sigma_a_perc = percentiles['sigma_a']
sigma_a_perc_error = percentile_errors['sigma_a']
replacements |= {
'{SIGMA_A_MEAN_AND_ERR}': _summary_value_and_err_text(
sigma_a_mean, sigma_a_mean_error, '{:.3e}'),
'{SIGMA_A_WMEAN_AND_ERR}': _summary_value_and_err_text(
sigma_a_wmean, sigma_a_wmean_error, '{:.3e}'),
'{SIGMA_A_PERC_AND_ERR}': _summary_value_and_err_text(
sigma_a_perc, sigma_a_perc_error, '{:.3e}'),
}
# Local magnitude, if computed
if config.compute_local_magnitude:
Ml_mean = means['Ml']
Ml_mean_error = mean_errors['Ml']
Ml_wmean = wmeans['Ml']
Ml_wmean_error = wmean_errors['Ml']
Ml_perc = percentiles['Ml']
Ml_perc_error = percentile_errors['Ml']
Ml_comment_begin = ''
Ml_comment_end = ''
else:
Ml_mean = Ml_wmean = Ml_perc = np.nan
Ml_mean_error = Ml_wmean_error = Ml_perc_error = (np.nan, np.nan)
Ml_comment_begin = '<!--'
Ml_comment_end = '-->'
replacements |= {
'{ML_MEAN_AND_ERR}': _summary_value_and_err_text(
Ml_mean, Ml_mean_error, '{:.2f}'),
'{ML_WMEAN_AND_ERR}': _summary_value_and_err_text(
Ml_wmean, Ml_wmean_error, '{:.2f}'),
'{ML_PERC_AND_ERR}': _summary_value_and_err_text(
Ml_perc, Ml_perc_error, '{:.2f}'),
'{ML_COMMENT_BEGIN}': Ml_comment_begin,
'{ML_COMMENT_END}': Ml_comment_end,
}
def _add_box_plots_to_html(config, replacements):
"""Add box plots to HTML report."""
box_plots = ''
with contextlib.suppress(KeyError, IndexError):
box_plots = [
b for b in config.figures['boxplots']
if b.endswith(VALID_FIGURE_FORMATS)][0]
box_plots = os.path.basename(box_plots)
if box_plots:
box_plots_comment_begin = box_plots_comment_end = ''
else:
box_plots_comment_begin = '<!--'
box_plots_comment_end = '-->'
replacements |= {
'{BOX_PLOTS}': box_plots,
'{BOX_PLOTS_COMMENT_BEGIN}': box_plots_comment_begin,
'{BOX_PLOTS_COMMENT_END}': box_plots_comment_end,
}
def _add_stacked_spectra_to_html(config, replacements):
"""Add stacked spectra to HTML report."""
stacked_spectra = ''
with contextlib.suppress(KeyError, IndexError):
stacked_spectra = [
s for s in config.figures['stacked_spectra']
if s.endswith(VALID_FIGURE_FORMATS)][0]
stacked_spectra = os.path.basename(stacked_spectra)
if stacked_spectra:
stacked_spectra_comment_begin = stacked_spectra_comment_end = ''
else:
stacked_spectra_comment_begin = '<!--'
stacked_spectra_comment_end = '-->'
replacements |= {
'{STACKED_SPECTRA}': stacked_spectra,
'{STACKED_SPECTRA_COMMENT_BEGIN}': stacked_spectra_comment_begin,
'{STACKED_SPECTRA_COMMENT_END}': stacked_spectra_comment_end,
}
def _add_station_table_to_html(config, sspec_output, templates, replacements):
"""Add station table to HTML report."""
with open(templates.station_table_row_html, encoding='utf-8') as fp:
station_table_row = fp.read()
station_table_rows = ''
stationpar = sspec_output.station_parameters
for statId in sorted(stationpar.keys()):
par = stationpar[statId]
if par.ignored:
continue
instrument_type = par.instrument_type
Mw_text, Mw_err_text = _station_value_and_err_text(par, 'Mw', '{:.3f}')
fc_text, fc_err_text = _station_value_and_err_text(par, 'fc', '{:.3f}')
t_star_text, t_star_err_text =\
_station_value_and_err_text(par, 't_star', '{:.3f}')
Qo_text, Qo_err_text = _station_value_and_err_text(par, 'Qo', '{:.1f}')
Mo_text, Mo_err_text = _station_value_and_err_text(par, 'Mo', '{:.3e}')
ssd_text, ssd_err_text =\
_station_value_and_err_text(par, 'ssd', '{:.3e}')
ra_text, ra_err_text =\
_station_value_and_err_text(par, 'radius', '{:.3f}')
Er_text, _ = _station_value_and_err_text(par, 'Er', '{:.3e}')
sigma_a_text, sigma_a_err_text =\
_station_value_and_err_text(par, 'sigma_a', '{:.3e}')
Ml_text, _ = _station_value_and_err_text(par, 'Ml', '{:.3f}')
hyp_dist_text, _ =\
_station_value_and_err_text(par, 'hypo_dist_in_km', '{:.3f}')
az_text, _ = _station_value_and_err_text(par, 'azimuth', '{:.3f}')
row_replacements = {
'{STATION_ID}': statId,
'{STATION_TYPE}': instrument_type,
'{STATION_MW}': Mw_text,
'{STATION_MW_ERR}': Mw_err_text,
'{STATION_FC}': fc_text,
'{STATION_FC_ERR}': fc_err_text,
'{STATION_TSTAR}': t_star_text,
'{STATION_TSTAR_ERR}': t_star_err_text,
'{STATION_Q0}': Qo_text,
'{STATION_Q0_ERR}': Qo_err_text,
'{STATION_M0}': Mo_text,
'{STATION_M0_ERR}': Mo_err_text,
'{STATION_SSD}': ssd_text,
'{STATION_SSD_ERR}': ssd_err_text,
'{STATION_RA}': ra_text,
'{STATION_RA_ERR}': ra_err_text,
'{STATION_SIGMA_A}': sigma_a_text,
'{STATION_SIGMA_A_ERR}': sigma_a_err_text,
'{STATION_ER}': Er_text,
'{STATION_ML}': Ml_text,
'{STATION_DIST}': hyp_dist_text,
'{STATION_AZ}': az_text,
}
# Local magnitude, if computed
if config.compute_local_magnitude:
Ml_comment_begin = ''
Ml_comment_end = ''
else:
Ml_comment_begin = '<!--'
Ml_comment_end = '-->'
row_replacements |= {
'{ML_COMMENT_BEGIN}': Ml_comment_begin,
'{ML_COMMENT_END}': Ml_comment_end
}
station_table_rows += _multireplace(
station_table_row, row_replacements)
replacements |= {
'{STATION_TABLE_ROWS}': station_table_rows,
}
def _add_misfit_plots_to_html(config, replacements):
"""Add misfit plots to HTML report."""
if 'misfit_1d' in config.figures:
misfit_plot_comment_begin = ''
misfit_plot_comment_end = ''
_misfit_page(config)
else:
misfit_plot_comment_begin = '<!--'
misfit_plot_comment_end = '-->'
replacements |= {
'{MISFIT_PLOT_COMMENT_BEGIN}': misfit_plot_comment_begin,
'{MISFIT_PLOT_COMMENT_END}': misfit_plot_comment_end
}
def _add_downloadable_files_to_html(config, templates, replacements):
"""Add links to downloadable files to HTML report."""
# symlink to input files (not supported on Windows)
input_files = '' if os.name == 'nt' else 'input_files'
input_files_text = '' if os.name == 'nt'\
else 'Click to navigate to input files'
evid = config.event.event_id
config_file = f'{evid}.ssp.conf'
yaml_file = f'{evid}.ssp.yaml'
log_file = f'{evid}.ssp.log'
replacements |= {
'{INPUT_FILES}': input_files,
'{INPUT_FILES_TEXT}': input_files_text,
'{CONF_FILE}': config_file,
'{YAML_FILE}': yaml_file,
'{LOG_FILE}': log_file,
}
# QuakeML file (if produced)
if config.qml_file_out is not None:
quakeml_file = os.path.basename(config.qml_file_out)
with open(templates.quakeml_file_link_html, encoding='utf-8') as fp:
quakeml_file_link = fp.read()
quakeml_file_link = quakeml_file_link\
.replace('{QUAKEML_FILE}', quakeml_file)
else:
quakeml_file_link = ''
replacements |= {
'{QUAKEML_FILE_LINK}': quakeml_file_link
}
suppl_file_list = [
os.path.basename(fig) for fig in config.figures['traces_raw']
]
suppl_file_list += [
os.path.basename(fig) for fig in config.figures['spectra_weight']
]
supplementary_files = ''.join(
f'<a href="{suppl_file}">{suppl_file}</a></br>\n'
for suppl_file in suppl_file_list
)
if supplementary_files:
with open(
templates.supplementary_file_links_html, encoding='utf-8'
) as fp:
supplementary_file_links = fp.read()
supplementary_file_links = supplementary_file_links\
.replace('{SUPPLEMENTARY_FILES}', supplementary_files)
else:
supplementary_file_links = ''
replacements |= {
'{SUPPLEMENTARY_FILE_LINKS}': supplementary_file_links
}
[docs]
class HTMLtemplates:
"""Class to hold paths to HTML templates."""
def __init__(self):
template_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
'html_report_template'
)
self.style_css = os.path.join(template_dir, 'style.css')
self.index_html = os.path.join(template_dir, 'index.html')
self.traces_plot_html = os.path.join(
template_dir, 'traces_plot.html')
self.spectra_plot_html = os.path.join(
template_dir, 'spectra_plot.html')
self.station_table_row_html = os.path.join(
template_dir, 'station_table_row.html')
self.quakeml_file_link_html = os.path.join(
template_dir, 'quakeml_file_link.html')
self.supplementary_file_links_html = os.path.join(
template_dir, 'supplementary_file_links.html')
def _cleanup_html(text):
"""Remove unnecessary comments and whitespace from HTML."""
# remove HTML-style comments
text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL)
# strip spaces at the end of lines
text = re.sub(r' +$', '', text, flags=re.MULTILINE)
# replace multiple empty lines with a single empty line
text = re.sub(r'\n\s*\n', '\n\n', text)
return text
[docs]
def html_report(config, sspec_output):
"""Generate an HTML report."""
templates = HTMLtemplates()
replacements = {}
_add_run_info_to_html(config, replacements)
_add_event_info_to_html(config, replacements)
_add_maps_to_html(config, replacements)
_add_traces_plots_to_html(config, templates, replacements)
_add_spectra_plots_to_html(config, templates, replacements)
_add_inversion_info_to_html(sspec_output, replacements)
_add_inversion_quality_to_html(sspec_output, replacements)
_add_summary_spectral_params_to_html(config, sspec_output, replacements)
_add_box_plots_to_html(config, replacements)
_add_stacked_spectra_to_html(config, replacements)
_add_station_table_to_html(config, sspec_output, templates, replacements)
_add_misfit_plots_to_html(config, replacements)
_add_downloadable_files_to_html(config, templates, replacements)
with open(templates.index_html, encoding='utf-8') as fp:
index = fp.read()
index = _multireplace(index, replacements)
index = _cleanup_html(index)
shutil.copy(templates.style_css, config.options.outdir)
index_html_out = os.path.join(config.options.outdir, 'index.html')
with open(index_html_out, 'w', encoding='utf-8') as fp:
fp.write(index)
logger.info(f'HTML report written to file: {index_html_out}')