Channel Plots
+Hold on... It might take a few seconds to load the plots depending on how big your data is.
+ $content +phys2BIDS Output Directory
+$tree
+diff --git a/phys2bids/cli/run.py b/phys2bids/cli/run.py
index 0527d6155..f2afa7e23 100644
--- a/phys2bids/cli/run.py
+++ b/phys2bids/cli/run.py
@@ -134,6 +134,12 @@ def _get_parser():
help='full path to file with info needed to generate '
'participant.tsv file ',
default='')
+ optional.add_argument('-report', '--report',
+ dest='make_report',
+ action='store_true',
+ help='Generate a report with the data and generated folder structure. '
+ 'Default is False.',
+ default=False)
optional.add_argument('-debug', '--debug',
dest='debug',
action='store_true',
diff --git a/phys2bids/phys2bids.py b/phys2bids/phys2bids.py
index f97323e74..8a95327d6 100644
--- a/phys2bids/phys2bids.py
+++ b/phys2bids/phys2bids.py
@@ -38,6 +38,7 @@
from phys2bids import utils, viz, _version, bids
from phys2bids.cli.run import _get_parser
from phys2bids.physio_obj import BlueprintOutput
+from phys2bids.reporting.html_report import generate_report
from phys2bids.slice4phys import slice4phys
from . import __version__
@@ -129,7 +130,8 @@ def print_json(outfile, samp_freq, time_offset, ch_name):
cite_module=True)
def phys2bids(filename, info=False, indir='.', outdir='.', heur_file=None,
sub=None, ses=None, chtrig=0, chsel=None, num_timepoints_expected=None,
- tr=None, thr=None, pad=9, ch_name=[], yml='', debug=False, quiet=False):
+ tr=None, thr=None, pad=9, ch_name=[], yml='', make_report=False,
+ debug=False, quiet=False):
"""
Run main workflow of phys2bids.
@@ -438,6 +440,9 @@ def phys2bids(filename, info=False, indir='.', outdir='.', heur_file=None,
os.path.join(conversion_path,
os.path.splitext(os.path.basename(phys_out[key].filename)
)[0]))
+ # Only generate report if specified by the user
+ if make_report:
+ generate_report(conversion_path, logname, phys_out[key])
def _main(argv=None):
diff --git a/phys2bids/reporting/__init__.py b/phys2bids/reporting/__init__.py
new file mode 100644
index 000000000..107d3aba2
--- /dev/null
+++ b/phys2bids/reporting/__init__.py
@@ -0,0 +1 @@
+"""Visual reporting tools for inspecting phys2bids workflow outputs."""
\ No newline at end of file
diff --git a/phys2bids/reporting/assets/apple-icon-180x180.png b/phys2bids/reporting/assets/apple-icon-180x180.png
new file mode 100644
index 000000000..4b6e2e4bf
Binary files /dev/null and b/phys2bids/reporting/assets/apple-icon-180x180.png differ
diff --git a/phys2bids/reporting/assets/main.css b/phys2bids/reporting/assets/main.css
new file mode 100644
index 000000000..426336395
--- /dev/null
+++ b/phys2bids/reporting/assets/main.css
@@ -0,0 +1,102 @@
+html, body {
+ margin: 0;
+ padding: 0;
+ font-family: 'Lato', sans-serif;
+ overflow-x: hidden;
+ overflow-y: scroll;
+}
+* {
+ box-sizing: border-box;
+ }
+
+.header {
+ background: linear-gradient(90deg, rgba(0,240,141,1) 0%, rgba(0,73,133,1) 100%);
+ height: 70px;
+ width: 100%;
+ position: fixed;
+ overflow: hidden;
+ margin: 0;
+ z-index: 100;
+}
+
+.header a, span {
+ color: white;
+ text-decoration: none;
+ font-weight: 700;
+}
+
+.header_logo {
+ display: inline-block;
+ float: left;
+}
+
+.header_logo img{
+ height: 50px;
+ top: 0;
+ left: 0;
+ padding-top: 15px;
+}
+
+.header_links {
+ top: 0;
+ left: 0;
+ padding-top: 25px;
+ margin-left: 20px;
+ margin-right: 20px;
+ float: left;
+ display: inline-block;
+}
+.clear {
+ clear: both;
+}
+
+.content {
+ margin-top: 100px;
+ display: flex;
+ width: 100%;
+}
+
+.tree {
+ margin-left: 50px;
+ margin-right: 50px;
+ flex: 0.5;
+ min-width: 300px;
+ float: left;
+}
+
+.tree_text {
+ margin-top: 10px;
+ margin-bottom: 70px;
+ width: 100%;
+}
+
+.bk-root {
+ display: inline-block;
+ margin-top: 10px;
+ width: 100%;
+}
+
+.bokeh_plots {
+ margin-left: 50px;
+ margin-right: 50px;
+ flex: 1;
+ min-width: 500px;
+ float: left;
+}
+
+@media screen and (max-width: 600px) {
+ .content {
+ flex-wrap: wrap;
+ }
+ .tree {
+ flex-basis: 100%;
+ }
+ .bokeh_plots {
+ flex-basis: 100%;
+ }
+}
+
+.main{
+ margin-top: 100px;
+ margin-left: 100px;
+}
\ No newline at end of file
diff --git a/phys2bids/reporting/assets/phys2bids_negativeReadTheDocs_500x150.png b/phys2bids/reporting/assets/phys2bids_negativeReadTheDocs_500x150.png
new file mode 100644
index 000000000..815be7e98
Binary files /dev/null and b/phys2bids/reporting/assets/phys2bids_negativeReadTheDocs_500x150.png differ
diff --git a/phys2bids/reporting/html_report.py b/phys2bids/reporting/html_report.py
new file mode 100644
index 000000000..12cde4a91
--- /dev/null
+++ b/phys2bids/reporting/html_report.py
@@ -0,0 +1,234 @@
+"""Reporting functionality for phys2bids."""
+import sys
+from distutils.dir_util import copy_tree
+from os.path import join
+from pathlib import Path
+from string import Template
+from bokeh.plotting import figure, ColumnDataSource
+from bokeh.embed import components
+from bokeh.layouts import gridplot
+
+from phys2bids import _version
+
+
+def _save_as_html(log_html_path, log_content, qc_html_path):
+ """
+ Save an HTML report out to a file.
+
+ Parameters
+ ----------
+ log_html_path : str
+ Body for HTML report with embedded figures
+ log_content: str
+ String containing the logs generated by phys2bids
+ qc_html_path : str
+ Path to the quality check section of the report
+
+ Returns
+ -------
+ html: HTML code of the report
+
+ Outcome
+ -------
+ Saves the html file
+ """
+ resource_path = Path(__file__).resolve().parent
+ head_template_name = 'report_log_template.html'
+ head_template_path = resource_path.joinpath(head_template_name)
+ with open(str(head_template_path), 'r') as head_file:
+ head_tpl = Template(head_file.read())
+
+ html = head_tpl.substitute(version=_version.get_versions()['version'],
+ log_html_path=log_html_path, log_content=log_content,
+ qc_html_path=qc_html_path)
+ return html
+
+
+def _update_fpage_template(tree_string, bokeh_id, bokeh_js, log_html_path, qc_html_path):
+ """
+ Populate a report with content.
+
+ Parameters
+ ----------
+ tree_string: str
+ Tree of files in directory.
+ bokeh_id : str
+ HTML div created by bokeh.embed.components
+ bokeh_js : str
+ Javascript created by bokeh.embed.components
+ log_html_path : str
+ Path to the log section of the report
+ qc_html_path : str
+ Path to the quality check section of the report
+
+ Returns
+ -------
+ body : Body for HTML report with embedded figures
+ """
+ resource_path = Path(__file__).resolve().parent
+
+ body_template_name = 'report_plots_template.html'
+ body_template_path = resource_path.joinpath(body_template_name)
+ with open(str(body_template_path), 'r') as body_file:
+ body_tpl = Template(body_file.read())
+ body = body_tpl.substitute(tree=tree_string,
+ content=bokeh_id,
+ javascript=bokeh_js,
+ version=_version.get_versions()['version'],
+ log_html_path=log_html_path,
+ qc_html_path=qc_html_path)
+ return body
+
+
+def _generate_file_tree(out_dir):
+ """
+ Populate a report with content.
+
+ Parameters
+ ----------
+ outdir : str
+ Path to the output directory
+
+ Returns
+ -------
+ tree_string: String with the tree of files in directory
+ """
+ # prefix components:
+ space = ' '
+ branch = '│ '
+ # pointers:
+ tee = '├── '
+ last = '└── '
+
+ def tree(dir_path: Path, prefix: str = ''):
+ """Generate tree structure.
+
+ Given a directory Path object
+ will yield a visual tree structure line by line
+ with each line prefixed by the same characters
+
+ from https://stackoverflow.com/questions/9727673/list-directory-tree-structure-in-python
+ """
+ contents = list(dir_path.iterdir())
+ # contents each get pointers that are ├── with a final └── :
+ pointers = [tee] * (len(contents) - 1) + [last]
+ for pointer, path in zip(pointers, contents):
+ yield prefix + pointer + path.name
+ if path.is_dir(): # extend the prefix and recurse:
+ extension = branch if pointer == tee else space
+ # i.e. space because last, └── , above so no more |
+ yield from tree(path, prefix=prefix + extension)
+
+ tree_string = ''
+ for line in tree(Path(out_dir)):
+ tree_string += line + '
'
+ return tree_string
+
+
+def _generate_bokeh_plots(phys_in, figsize=(250, 500)):
+ """
+ Plot all the channels for visualizations as linked line plots for dynamic report.
+
+ Parameters
+ ----------
+ phys_in: BlueprintInput object
+ Object returned by BlueprintInput class
+ figsize: tuple
+ Size of the figure expressed as (size_x, size_y),
+ Default is 250x750px
+
+ Outcome
+ -------
+ Creates new plot with path specified in outfile.
+
+ See Also
+ --------
+ https://phys2bids.readthedocs.io/en/latest/howto.html
+ """
+ colors = ['#ff7a3c', '#008eba', '#ff96d3', '#3c376b', '#ffd439']
+
+ time = phys_in.timeseries.T[0] # assumes first phys_in.timeseries is time
+ ch_num = len(phys_in.ch_name)
+ if ch_num > len(colors):
+ colors *= 2
+
+ downsample = int(phys_in.freq / 100)
+ plot_list = []
+ for row, timeser in enumerate(phys_in.timeseries.T[1:]):
+ # build a data source for each plot, with only the data + index (time)
+ # for the purpose of reporting, data is downsampled 10x
+ # doesn't make much of a difference to the naked eye, fine for reports
+ source = ColumnDataSource(data=dict(
+ x=time[::downsample],
+ y=timeser[::downsample]))
+
+ i = row + 1
+
+ tools = ['wheel_zoom,pan,reset']
+ q = figure(plot_height=figsize[0], plot_width=figsize[1],
+ tools=tools,
+ title=f' Channel {i}: {phys_in.ch_name[i]}',
+ sizing_mode='stretch_both',
+ x_range=(0, 100))
+ q.line('x', 'y', color=colors[i - 1], alpha=0.9, source=source)
+ q.xaxis.axis_label = 'Time (s)'
+ # hovertool commented for posterity because I (KB) will be triumphant
+ # eventually
+ # q.add_tools(HoverTool(tooltips=[
+ # (phys_in.ch_name[i], '@y{0.000} ' + phys_in.units[i]),
+ # ('HELP', '100 :D')
+ # ], mode='vline'))
+ plot_list.append([q])
+ p = gridplot(plot_list, toolbar_location='right',
+ plot_height=250, plot_width=750,
+ merge_tools=True)
+ script, div = components(p)
+ return script, div
+
+
+def generate_report(out_dir, log_path, phys_in):
+ """
+ Plot all the channels for visualizations as linked line plots for dynamic report.
+
+ Parameters
+ ----------
+ out_dir : str
+ File path to a completed phys2bids output directory
+ log_path: path
+ Path to the logged output of phys2bids
+ phys_in: BlueprintInput object
+ Object returned by BlueprintInput class
+
+ Outcome
+ -------
+ Creates new plot with path specified in outfile.
+
+ See Also
+ --------
+ https://phys2bids.readthedocs.io/en/latest/howto.html
+ """
+ # Copy assets into output folder
+ pkgdir = sys.modules['phys2bids'].__path__[0]
+ assets_path = join(pkgdir, 'reporting', 'assets')
+ copy_tree(assets_path, join(out_dir, 'assets'))
+
+ # Read log
+ with open(log_path, 'r') as f:
+ log_content = f.read()
+
+ log_content = log_content.replace('\n', '
')
+ log_html_path = join(out_dir, 'phys2bids_report_log.html')
+ qc_html_path = join(out_dir, 'phys2bids_report.html')
+
+ html = _save_as_html(log_html_path, log_content, qc_html_path)
+
+ with open(log_html_path, 'wb') as f:
+ f.write(html.encode('utf-8'))
+
+ # Read in output directory structure & create tree
+ tree_string = _generate_file_tree(out_dir)
+ bokeh_js, bokeh_div = _generate_bokeh_plots(phys_in, figsize=(250, 750))
+ html = _update_fpage_template(tree_string, bokeh_div, bokeh_js, log_html_path, qc_html_path)
+
+ with open(qc_html_path, 'wb') as f:
+ f.write(html.encode('utf-8'))
\ No newline at end of file
diff --git a/phys2bids/reporting/report_log_template.html b/phys2bids/reporting/report_log_template.html
new file mode 100644
index 000000000..64182d820
--- /dev/null
+++ b/phys2bids/reporting/report_log_template.html
@@ -0,0 +1,33 @@
+
+
+
$log_content
+Hold on... It might take a few seconds to load the plots depending on how big your data is.
+ $content +$tree
+