1+ """Reporting functionality for phys2bids."""
2+ import sys
3+ from distutils .dir_util import copy_tree
4+ from os .path import join
5+ from pathlib import Path
6+ from string import Template
7+ from bokeh .plotting import figure , ColumnDataSource
8+ from bokeh .embed import components
9+ from bokeh .layouts import gridplot
10+
11+ from phys2bids import _version
12+
13+
14+ def _save_as_html (log_html_path , log_content , qc_html_path ):
15+ """
16+ Save an HTML report out to a file.
17+
18+ Parameters
19+ ----------
20+ log_html_path : str
21+ Body for HTML report with embedded figures
22+ log_content: str
23+ String containing the logs generated by phys2bids
24+ qc_html_path : str
25+ Path to the quality check section of the report
26+
27+ Returns
28+ -------
29+ html: HTML code of the report
30+
31+ Outcome
32+ -------
33+ Saves the html file
34+ """
35+ resource_path = Path (__file__ ).resolve ().parent
36+ head_template_name = 'report_log_template.html'
37+ head_template_path = resource_path .joinpath (head_template_name )
38+ with open (str (head_template_path ), 'r' ) as head_file :
39+ head_tpl = Template (head_file .read ())
40+
41+ html = head_tpl .substitute (version = _version .get_versions ()['version' ],
42+ log_html_path = log_html_path , log_content = log_content ,
43+ qc_html_path = qc_html_path )
44+ return html
45+
46+
47+ def _update_fpage_template (tree_string , bokeh_id , bokeh_js , log_html_path , qc_html_path ):
48+ """
49+ Populate a report with content.
50+
51+ Parameters
52+ ----------
53+ tree_string: str
54+ Tree of files in directory.
55+ bokeh_id : str
56+ HTML div created by bokeh.embed.components
57+ bokeh_js : str
58+ Javascript created by bokeh.embed.components
59+ log_html_path : str
60+ Path to the log section of the report
61+ qc_html_path : str
62+ Path to the quality check section of the report
63+
64+ Returns
65+ -------
66+ body : Body for HTML report with embedded figures
67+ """
68+ resource_path = Path (__file__ ).resolve ().parent
69+
70+ body_template_name = 'report_plots_template.html'
71+ body_template_path = resource_path .joinpath (body_template_name )
72+ with open (str (body_template_path ), 'r' ) as body_file :
73+ body_tpl = Template (body_file .read ())
74+ body = body_tpl .substitute (tree = tree_string ,
75+ content = bokeh_id ,
76+ javascript = bokeh_js ,
77+ version = _version .get_versions ()['version' ],
78+ log_html_path = log_html_path ,
79+ qc_html_path = qc_html_path )
80+ return body
81+
82+
83+ def _generate_file_tree (out_dir ):
84+ """
85+ Populate a report with content.
86+
87+ Parameters
88+ ----------
89+ outdir : str
90+ Path to the output directory
91+
92+ Returns
93+ -------
94+ tree_string: String with the tree of files in directory
95+ """
96+ # prefix components:
97+ space = ' '
98+ branch = '│ '
99+ # pointers:
100+ tee = '├── '
101+ last = '└── '
102+
103+ def tree (dir_path : Path , prefix : str = '' ):
104+ """Generate tree structure.
105+
106+ Given a directory Path object
107+ will yield a visual tree structure line by line
108+ with each line prefixed by the same characters
109+
110+ from https://stackoverflow.com/questions/9727673/list-directory-tree-structure-in-python
111+ """
112+ contents = list (dir_path .iterdir ())
113+ # contents each get pointers that are ├── with a final └── :
114+ pointers = [tee ] * (len (contents ) - 1 ) + [last ]
115+ for pointer , path in zip (pointers , contents ):
116+ yield prefix + pointer + path .name
117+ if path .is_dir (): # extend the prefix and recurse:
118+ extension = branch if pointer == tee else space
119+ # i.e. space because last, └── , above so no more |
120+ yield from tree (path , prefix = prefix + extension )
121+
122+ tree_string = ''
123+ for line in tree (Path (out_dir )):
124+ tree_string += line + '<br>'
125+ return tree_string
126+
127+
128+ def _generate_bokeh_plots (phys_in , figsize = (250 , 500 )):
129+ """
130+ Plot all the channels for visualizations as linked line plots for dynamic report.
131+
132+ Parameters
133+ ----------
134+ phys_in: BlueprintInput object
135+ Object returned by BlueprintInput class
136+ figsize: tuple
137+ Size of the figure expressed as (size_x, size_y),
138+ Default is 250x750px
139+
140+ Outcome
141+ -------
142+ Creates new plot with path specified in outfile.
143+
144+ See Also
145+ --------
146+ https://phys2bids.readthedocs.io/en/latest/howto.html
147+ """
148+ colors = ['#ff7a3c' , '#008eba' , '#ff96d3' , '#3c376b' , '#ffd439' ]
149+
150+ time = phys_in .timeseries .T [0 ] # assumes first phys_in.timeseries is time
151+ ch_num = len (phys_in .ch_name )
152+ if ch_num > len (colors ):
153+ colors *= 2
154+
155+ downsample = int (phys_in .freq / 100 )
156+ plot_list = []
157+ for row , timeser in enumerate (phys_in .timeseries .T [1 :]):
158+ # build a data source for each plot, with only the data + index (time)
159+ # for the purpose of reporting, data is downsampled 10x
160+ # doesn't make much of a difference to the naked eye, fine for reports
161+ source = ColumnDataSource (data = dict (
162+ x = time [::downsample ],
163+ y = timeser [::downsample ]))
164+
165+ i = row + 1
166+
167+ tools = ['wheel_zoom,pan,reset' ]
168+ q = figure (plot_height = figsize [0 ], plot_width = figsize [1 ],
169+ tools = tools ,
170+ title = f' Channel { i } : { phys_in .ch_name [i ]} ' ,
171+ sizing_mode = 'stretch_both' )
172+ q .line ('x' , 'y' , color = colors [i - 1 ], alpha = 0.9 , source = source )
173+ q .xaxis .axis_label = 'Time (s)'
174+ # hovertool commented for posterity because I (KB) will be triumphant
175+ # eventually
176+ # q.add_tools(HoverTool(tooltips=[
177+ # (phys_in.ch_name[i], '@y{0.000} ' + phys_in.units[i]),
178+ # ('HELP', '100 :D')
179+ # ], mode='vline'))
180+ plot_list .append ([q ])
181+ p = gridplot (plot_list , toolbar_location = 'right' ,
182+ plot_height = 250 , plot_width = 750 ,
183+ merge_tools = True )
184+ script , div = components (p )
185+ return script , div
186+
187+
188+ def generate_report (out_dir , log_path , phys_in ):
189+ """
190+ Plot all the channels for visualizations as linked line plots for dynamic report.
191+
192+ Parameters
193+ ----------
194+ out_dir : str
195+ File path to a completed phys2bids output directory
196+ log_path: path
197+ Path to the logged output of phys2bids
198+ phys_in: BlueprintInput object
199+ Object returned by BlueprintInput class
200+
201+ Outcome
202+ -------
203+ Creates new plot with path specified in outfile.
204+
205+ See Also
206+ --------
207+ https://phys2bids.readthedocs.io/en/latest/howto.html
208+ """
209+ # Copy assets into output folder
210+ pkgdir = sys .modules ['phys2bids' ].__path__ [0 ]
211+ assets_path = join (pkgdir , 'reporting' , 'assets' )
212+ copy_tree (assets_path , join (out_dir , 'assets' ))
213+
214+ # Read log
215+ with open (log_path , 'r' ) as f :
216+ log_content = f .read ()
217+
218+ log_content = log_content .replace ('\n ' , '<br>' )
219+ log_html_path = join (out_dir , 'phys2bids_report_log.html' )
220+ qc_html_path = join (out_dir , 'phys2bids_report.html' )
221+
222+ html = _save_as_html (log_html_path , log_content , qc_html_path )
223+
224+ with open (log_html_path , 'wb' ) as f :
225+ f .write (html .encode ('utf-8' ))
226+
227+ # Read in output directory structure & create tree
228+ tree_string = _generate_file_tree (out_dir )
229+ bokeh_js , bokeh_div = _generate_bokeh_plots (phys_in , figsize = (250 , 750 ))
230+ html = _update_fpage_template (tree_string , bokeh_div , bokeh_js , log_html_path , qc_html_path )
231+
232+ with open (qc_html_path , 'wb' ) as f :
233+ f .write (html .encode ('utf-8' ))
0 commit comments