#
# BranchProDashApp Class
#
# This file is part of BRANCHPRO
# (https://github.com/SABS-R3-Epidemiology/branchpro.git) which is released
# under the BSD 3-clause license. See accompanying LICENSE.md for copyright
# notice and full license details.
#
import threading
import base64
import io
import os
import csv
import pandas as pd
import dash_defer_js_import as dji # For mathjax
import dash_bootstrap_components as dbc
from dash import html
# Import the mathjax
mathjax_script = dji.Import(
src='https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/latest.js'
'?config=TeX-AMS-MML_SVG')
# Write the mathjax index html
# https://chrisvoncsefalvay.com/2020/07/25/dash-latex/
index_str_math = """<!DOCTYPE html>
<html>
<head>
{%metas%}
<title>{%title%}</title>
{%favicon%}
{%css%}
</head>
<body>
{%app_entry%}
<footer>
{%config%}
{%scripts%}
<script type="text/x-mathjax-config">
MathJax.Hub.Config({
tex2jax: {
inlineMath: [ ['$','$'],],
processEscapes: true
}
});
</script>
{%renderer%}
</footer>
</body>
</html>
"""
# Head of the Incidence Data dataframe example
df = pd.DataFrame({
'Time': [1, 3, 4, 5],
'Incidence Number': [10, 50, 7, 50],
'Imported Cases': [1, None, 1, 1],
'R_t': [0.5, 2, 1, 2.5]
})
# Text for the modal explaining how uploaded Incidence Data
# files should look like
inc_modal = [
dbc.ModalHeader(html.H6(['Incidence Data'])),
dbc.ModalBody(
[
'The data for the incidences comes in either ',
html.Span(
'.csv',
id='csv_inc',
style={
'font-weight':
'bold'}
),
' or ',
html.Span(
'.txt',
id='txt_inc',
style={
'font-weight':
'bold'}
),
' (comma separated values) format and will be displayed as a' +
' table with the following column names:',
dbc.ListGroup(
[
dbc.ListGroupItem(
'Time (compulsory)',
style={
'font-weight':
'bold'}
),
dbc.ListGroupItem([
'Incidence Number (compulsory if no ',
html.Span(
'Imported Cases',
id='Imported Cases',
style={
'font-weight':
'lighter'}
),
' column is present)'],
style={
'font-weight':
'bold'}),
dbc.ListGroupItem(
'Imported Cases (optional)',
style={
'font-weight':
'bold'}),
dbc.ListGroupItem(
'R_t (optional).',
style={
'font-weight':
'bold'})
]),
html.P(['e.g.']),
dbc.Table.from_dataframe(df, bordered=True, hover=True),
'Missing days are filled in automatically. Missing data will \
be replaced with 0.']),
dbc.ModalFooter(
dbc.Button(
'Close', id='inc_modal_close', className='ml-auto')
),
]
# Head of the Serial Interval table example
row1 = html.Tr(
[html.Td('0'), html.Td('0'), html.Td('1'), html.Td('0.001')])
row2 = html.Tr(
[html.Td('0.233'), html.Td('2'), html.Td('0'), html.Td('0.003')])
row3 = html.Tr(
[html.Td('0.359'), html.Td('4'), html.Td('0'), html.Td('0.027')])
row4 = html.Tr([
html.Td('0.198'), html.Td('2'), html.Td('0'), html.Td('0.057')])
table_body = [html.Tbody([row1, row2, row3, row4])]
# Text for the modal explaining how uploaded Serial Interval
# files should look like
si_modal = [
dbc.ModalHeader(html.H6(['Serial Interval'])),
dbc.ModalBody(
[
'The data for the serial intervals comes in either ',
html.Span(
'.csv',
id='csv_si',
style={
'font-weight':
'bold'}
),
' or ',
html.Span(
'.txt',
id='txt_si',
style={
'font-weight':
'bold'}
),
' (comma separated values) format and will be displayed as a' +
' table with no columns names. ',
html.P([
'Each ',
html.Span(
'serial interval',
id='si',
style={
'font-weight':
'bold'}
),
' is displayed',
' as a ',
html.Span(
'column',
id='column',
style={
'font-weight':
'bold'}
),
' (as opposed to a row). ',
'For each serial interval, each ',
html.Span(
'row',
id='row',
style={
'font-weight':
'bold'}
),
' represents the ',
html.Span(
'value by day ',
id='daily',
style={
'font-weight':
'bold'}
),
' (i.e. daily serial interval).',
' Alternatively, each column could represent MCMC samples ',
'from a posterior distribution of the serial interval instead.'
]),
html.P(['e.g.']),
dbc.Table(table_body, bordered=True, hover=True)]),
dbc.ModalFooter(
dbc.Button(
'Close', id='si_modal_close', className='ml-auto')
),
]
[docs]
class BranchProDashApp:
"""Base class for dash apps for branching processes.
Notes
-----
When deploying objects of this class in a server environment, it is
recommended to use the lock to prevent interference between threads.
.. code-block:: python
@app.app.callback(...)
def callback(...):
with app.lock:
... # your callback code here
return ...
"""
def __init__(self):
# Default CSS style files
self.css = [dbc.themes.BOOTSTRAP,
'https://codepen.io/chriddyp/pen/bWLwgP.css']
self.session_data = {}
self.mathjax_html = index_str_math
self.mathjax_script = mathjax_script
self._inc_modal = inc_modal
self._si_modal = si_modal
self.lock = threading.Lock()
[docs]
def refresh_user_data_json(self, **kwargs):
"""Load the user's session data from JSON.
To be called at the beginning of a callback so that this object
contains the appropriate information for completing the request.
The inputs are translated from JSON to pandas dataframes, and saved in
the self.session_data dictionary. All previous entries in
self.session_data are cleared.
Parameters
----------
kwargs
Each key should be the id or name of a storage container recognized
by the particular app, and each value should be a string containing
the JSON data accessed from that storage.
"""
new_session_data = {}
for k, v in kwargs.items():
if v is not None:
v = pd.read_json(v)
new_session_data[k] = v
self.session_data = new_session_data
def _read_uploaded_file(self, contents, filename, is_si=False):
"""Load a text (csv) file into a pandas dataframe.
This method is for loading incidence number data. It expects files to
have at least two columns, the first with title ``Time`` and the second
with title ``Incidence Number``.
Parameters
----------
contents : str
File contents in binary encoding
filename : str
Name of the file
is_si : boolean
Function of the file in the context of the app
Returns
-------
html.Div
A div which contains a message for the user.
pandas.DataFrame
A dataframe with the loaded file. If the file load was not
successful, it will be None.
"""
content_type, content_string = contents.split(',')
_, extension = os.path.splitext(filename)
decoded = base64.b64decode(content_string)
try:
if extension in ['.csv', '.txt']:
# Assume that the user uploaded a CSV or TXT file
if is_si:
data = pd.read_csv(
io.StringIO(decoded.decode('utf-8')),
header=None)
data = data.fillna(0).values
for _ in range(data.shape[1]):
if isinstance(data[0, _], str) and \
not data[0, _].isnumeric():
return html.Div(['Incorrect format; file must not \
have a header.']), None
else:
if not csv.Sniffer().has_header(
io.StringIO(decoded.decode('utf-8')).getvalue()):
return html.Div(['Incorrect format; file must have a \
header.']), None
else:
data = pd.read_csv(
io.StringIO(decoded.decode('utf-8')))
time_key = data.columns[0]
data_times = data[time_key]
values = {
'Incidence Number': 0,
'Imported Cases': 0
}
data = data.set_index(time_key).reindex(
range(
min(data_times), max(data_times)+1)
).fillna(value=values).reset_index()
return None, data
else:
return html.Div(['File type must be CSV or TXT.']), None
except Exception as e:
print(e)
return html.Div([
'There was an error processing this file.'
]), None
[docs]
def parse_contents(self, contents, filename, is_si=False, sim_app=False):
"""Load a text (csv) file into a pandas dataframe.
This method is for loading:
* incidence number data. It expects files to have at least two
columns, the first with title ``Time`` and the second with title
``Incidence Number``.
* serial interval data. It expects files to have one column .
Parameters
----------
contents : str
File contents in binary encoding
filename : str
Name of the file
is_si : boolean
Function of the file in the context of the app, true if uploaded
data is a serial interval.
sim_app : boolean
Data to be read will be used for the simulation app.
Returns
-------
html.Div
A div which contains a message for the user.
pandas.DataFrame or numpy.array
A dataframe with the loaded data file. An array with the loaded
serial interval file. If the file load was not successful, it will
be None.
"""
message, data = self._read_uploaded_file(contents, filename, is_si)
if data is None:
return message, data
if not is_si:
if sim_app:
inc_col_cond = (
('Imported Cases' not in data.columns) and (
'Incidence Number' not in data.columns))
str_message = '`Incidence Number` and / or `Imported Cases`\
column'
else:
inc_col_cond = ('Incidence Number' not in data.columns)
str_message = '`Incidence Number` column'
if message is None:
if not is_si:
if ('Time' not in data.columns) or inc_col_cond:
message = html.Div(['Incorrect format; file must contain \
a `Time` and {}.'.format(str_message)])
data = None
else:
message = html.Div(
['Loaded data from: {}.'.format(filename)])
else:
num_cols = data.shape[1]
if num_cols > 1000:
message = html.Div(['Exceeded maximum number of serial \
intervals allowed (Max = 1000).'])
data = None
else:
message = html.Div(
['Loaded data ({} samples) from: {}.'.format(num_cols,
filename)]
)
return message, data
[docs]
def add_text(self, text):
"""Add a block of text at the top of the app.
This can be used to add introductory text that everyone looking at the
app will see right away.
Parameters
----------
text : str
The text to add to the html div
"""
if not hasattr(self, 'main_text'):
raise NotImplementedError(
'Child class must implement the self.main_text attribute to'
'use this method.')
text = html.Div([text])
self.main_text.append(text)
[docs]
def add_collapsed_text(self, text, title='More details...'):
"""Add a block of text at the top of the app.
By default, this text will be hidden. The user can click on a button
with the specified title in order to view the text.
Parameters
----------
text : str
The text to add to the html div
title : str
str which will be displayed on the show/hide button
"""
if not hasattr(self, 'collapsed_text'):
raise NotImplementedError(
'Child class must implement the self.collapsed_text attribute'
'to use this method.')
collapse = html.Div([
dbc.Button(
title,
id='showhidebutton',
color='primary',
),
dbc.Collapse(
dbc.Card(dbc.CardBody(text)),
id='collapsedtext',
),
])
self.collapsed_text.append(collapse)