Beispiel #1
0
def read_session_normaldata(session):
    """Read normal data according to patient info in current session"""
    info = sessionutils.load_info(session)
    if info is not None and 'hetu' in info:
        age = age_from_hetu(info['hetu'])
    else:
        age = None
    return read_default_normaldata(age)
Beispiel #2
0
def _read_session_normaldata(session):
    """Read model normal data according to patient in given session.

    This is a convenience that figures out the age of the patient
    and calls _read_default_normaldata"""
    info = sessionutils.load_info(session)
    if info is not None and 'hetu' in info:
        age = age_from_hetu(info['hetu'])
    else:
        age = None
    return _read_configured_model_normaldata(age)
Beispiel #3
0
def dash_report(
    sessions,
    info=None,
    max_cycles=None,
    tags=None,
    signals=None,
    recreate_plots=None,
    video_only=None,
):
    """Create a gait report dash app.

    Parameters
    ----------
    sessions : list
        List of session directories. For more than one session dirs, a
        comparison report will be created. Up to three sessions can be compared
        in the report.
    info : dict | None
        The patient info. If not None, some info will be shown in the report.
    max_cycles : dict | None
        Maximum number of gait cycles to plot for each variable type. If None,
        taken from config.
    tags : list | None
        Eclipse tags for finding dynamic gait trials. If None, will be taken from config.
    signals : ProgressSignals | None
        Instance of ProgressSignals, used to send progress updates across
        threads and track the cancel flag which aborts the creation of the
        report. If None, a dummy one will be created.
    recreate_plots : bool
        If True, force recreation of the report figures. Otherwise, cached
        figure data will be used, unless the report c3d files have changed (a
        checksum mechanism is used to verify this).
    video_only : bool
        If True, create a video-only report (no gait curves).

    Returns
    -------
    dash.Dash | None
        The dash (flask) app, or None if report creation failed.
    """
    sessions = [Path(s) for s in sessions]

    if recreate_plots is None:
        recreate_plots = False

    if video_only is None:
        video_only = False

    # relative width of left panel (1-12)
    # uncomment to use narrower video panel for 3-session comparison
    # LEFT_WIDTH = 8 if len(sessions) == 3 else 7
    LEFT_WIDTH = 8
    VIDS_TOTAL_HEIGHT = 88  # % of browser window height

    if len(sessions) < 1 or len(sessions) > 3:
        raise ValueError('Need a list of one to three sessions')
    is_comparison = len(sessions) > 1
    report_name = _report_name(sessions)
    info = info or sessionutils.default_info()

    # tags for dynamic trials
    if tags is None:
        dyn_tags = cfg.eclipse.tags
    else:
        dyn_tags = tags

    # signals is used to track progress across threads; if not given, create a dummy one
    if signals is None:
        signals = ProgressSignals()

    # this tag will be shown in the menu for static trials
    static_tag = 'Static'

    # get the camera labels
    # reduce to a set, since there may be several labels for given id
    camera_labels = set(cfg.general.camera_labels.values())
    # add camera labels for overlay videos
    # XXX: may cause trouble if camera labels already contain the string 'overlay'
    camera_labels_overlay = [lbl + ' overlay' for lbl in camera_labels]
    camera_labels.update(camera_labels_overlay)
    # build dict of videos for given tag / camera label
    # videos will be listed in session order
    vid_urls = dict()
    all_tags = dyn_tags + [static_tag] + cfg.eclipse.video_tags
    for tag in all_tags:
        vid_urls[tag] = dict()
        for camera_label in camera_labels:
            vid_urls[tag][camera_label] = list()

    # collect all session enfs into dict
    enfs = {session: dict() for session in sessions}
    data_enfs = list()  # enfs that are used for data
    signals.progress.emit('Collecting trials...', 0)
    for session in sessions:
        if signals.canceled:
            return None
        enfs[session] = dict(dynamic=dict(), static=dict(), vid_only=dict())
        # collect dynamic trials for each tag
        for tag in dyn_tags:
            dyns = sessionutils.get_enfs(session, tags=tag, trial_type='dynamic')
            if len(dyns) > 1:
                logger.warning(f'multiple tagged trials ({tag}) for {session}')
            dyn_trial = dyns[-1:]
            enfs[session]['dynamic'][tag] = dyn_trial  # may be empty list
            if dyn_trial:
                data_enfs.extend(dyn_trial)
        # require at least one dynamic trial for each session
        if not any(enfs[session]['dynamic'][tag] for tag in dyn_tags):
            raise GaitDataError(f'No tagged dynamic trials found for {session}')
        # collect static trial (at most 1 per session)
        # rules:
        # -prefer enfs that have a corresponding c3d file, even for a video-only report
        # (so that the same static gets used for both video-only and full reports)
        # -prefer the newest static trial
        sts = sessionutils.get_enfs(session, trial_type='static')
        for st in reversed(sts):  # newest first
            st_c3d = sessionutils.enf_to_trialfile(st, '.c3d')
            if st_c3d.is_file():
                static_trial = [st]
                break
        else:
            # no c3ds were found - just pick the latest static trial
            static_trial = sts[-1:]
        enfs[session]['static'][static_tag] = static_trial
        if static_trial:
            data_enfs.extend(static_trial)
        # collect video-only dynamic trials
        for tag in cfg.eclipse.video_tags:
            dyn_vids = sessionutils.get_enfs(session, tags=tag)
            if len(dyn_vids) > 1:
                logger.warning(
                    f'multiple tagged video-only trials ({tag}) for {session}'
                )
            enfs[session]['vid_only'][tag] = dyn_vids[-1:]

    # collect all videos for given tag and camera, listed in session order
    signals.progress.emit('Finding videos...', 0)
    for session in sessions:
        for trial_type in enfs[session]:
            for tag, enfs_this in enfs[session][trial_type].items():
                if enfs_this:
                    enf = enfs_this[0]  # only one enf per tag and session
                    for camera_label in camera_labels:
                        overlay = 'overlay' in camera_label
                        real_camera_label = (
                            camera_label[: camera_label.find(' overlay')]
                            if overlay
                            else camera_label
                        )
                        # need to convert filename, since get_trial_videos cannot
                        # deal with enf names
                        c3d = enf_to_trialfile(enf, 'c3d')
                        vids_this = videos.get_trial_videos(
                            c3d,
                            camera_label=real_camera_label,
                            vid_ext='.ogv',
                            overlay=overlay,
                        )
                        if vids_this:
                            vid = vids_this[0]
                            url = f'/static/{vid.name}'
                            vid_urls[tag][camera_label].append(url)

    # build dcc.Dropdown options list for cameras and tags
    # list cameras which have videos for any tag
    opts_cameras = list()
    for camera_label in sorted(camera_labels):
        if any(vid_urls[tag][camera_label] for tag in all_tags):
            opts_cameras.append({'label': camera_label, 'value': camera_label})
    # list tags which have videos for any camera
    opts_tags = list()
    for tag in all_tags:
        if any(vid_urls[tag][camera_label] for camera_label in camera_labels):
            opts_tags.append({'label': f'{tag}', 'value': tag})
    # add null entry in case we got no videos at all
    if not opts_tags:
        opts_tags.append({'label': 'No videos', 'value': 'no videos', 'disabled': True})

    # create (or load) the figures
    # this section is only run if we have c3d data
    if not video_only:
        data_c3ds = [enf_to_trialfile(enffile, 'c3d') for enffile in data_enfs]
        # at this point, all the c3ds need to exist
        missing = [fn for fn in data_c3ds if not fn.is_file()]
        if missing:
            missing_trials = ', '.join([fn.stem for fn in missing])
            raise GaitDataError(
                f'c3d files missing for following trials: {missing_trials}'
            )
        # see whether we can load report figures from disk
        digest = numutils._files_digest(data_c3ds)
        logger.debug(f'report data digest: {digest}')
        # the cached data is always saved into alphabetically first session
        data_dir = sorted(sessions)[0]
        data_fn = data_dir / f'web_report_{digest}.dat'
        if data_fn.is_file() and not recreate_plots:
            logger.info(f'loading saved report data from {data_fn}')
            signals.progress.emit('Loading saved report...', 0)
            try:
                with open(data_fn, 'rb') as f:
                    saved_report_data = pickle.load(f)
            except UnicodeDecodeError:
                logger.warning('cannot open report (probably made with legacy version)')
                logger.warning('recreating...')
                saved_report_data = dict()
        else:
            saved_report_data = dict()
            logger.info('no saved data found or recreate forced')

        # make Trial instances for all dynamic and static trials
        # this is currently necessary even if saved figures are used
        trials_dyn = list()
        trials_dyn_dict = dict()  # also organize dynamic trials by session
        trials_static = list()
        for session in sessions:
            trials_dyn_dict[session] = list()
            for tag in dyn_tags:
                if enfs[session]['dynamic'][tag]:
                    if signals.canceled:
                        return None
                    c3dfile = enf_to_trialfile(enfs[session]['dynamic'][tag][0], 'c3d')
                    tri = Trial(c3dfile)
                    trials_dyn.append(tri)
                    trials_dyn_dict[session].append(tri)
            if enfs[session]['static'][static_tag]:
                c3dfile = enf_to_trialfile(enfs[session]['static']['Static'][0], 'c3d')
                tri = Trial(c3dfile)
                trials_static.append(tri)

        emg_auto_layout = None

        # stuff that's needed to (re)create the figures
        if not saved_report_data:
            age = None
            if info['hetu'] is not None:
                # compute subject age at session time
                session_dates = [
                    sessionutils.get_session_date(session) for session in sessions
                ]
                ages = [age_from_hetu(info['hetu'], d) for d in session_dates]
                try:
                    age = max(ages)
                except TypeError:
                    age = None

            # create Markdown text for patient info
            patient_info_text = '##### %s ' % (
                info['fullname'] if info['fullname'] else 'Name unknown'
            )
            if info['hetu']:
                patient_info_text += f"({info['hetu']})"
            patient_info_text += '\n\n'
            # if age:
            #     patient_info_text += 'Age at measurement time: %d\n\n' % age

            # load normal data for gait models; we have to do it here instead of
            # leaving it up to plot_trials, since it's session (age) specific
            signals.progress.emit('Loading normal data...', 0)
            model_normaldata = normaldata._read_configured_model_normaldata(age)

            # make average trials for each session
            avg_trials = [
                AvgTrial.from_trials(trials_dyn_dict[session], sessionpath=session)
                for session in sessions
            ]

            # prepare for the curve-extracted value plots
            logger.debug('extracting values for curve-extracted plots...')
            vardefs_dict = dict(cfg.report.vardefs)
            allvars = [
                vardef[0] for vardefs in vardefs_dict.values() for vardef in vardefs
            ]
            from_models = set(models.model_from_var(var) for var in allvars)
            if None in from_models:
                raise GaitDataError(f'unknown variables in extract list: {allvars}')
            curve_vals = {
                session.name: _trials_extract_values(trials, from_models=from_models)
                for session, trials in trials_dyn_dict.items()
            }

            # in EMG layout, keep chs that are active in any of the trials
            signals.progress.emit('Reading EMG data', 0)
            try:
                emgs = [tr.emg for tr in trials_dyn]
                emg_auto_layout = layouts._rm_dead_channels(emgs, cfg.layouts.std_emg)
                if not emg_auto_layout:
                    emg_auto_layout = None
            except GaitDataError:
                emg_auto_layout = None

        # the layouts are specified as lists of tuples: (title, layout_spec)
        # where title is the page title, and layout_spec is either string or tuple.
        # if string, it denotes a special layout (e.g. 'patient_info')
        # if tuple, the first element should be the string 'layout_name' and the second
        # a gaitutils configured layout name;
        # alternatively the first element can be 'layout' and the second element a
        # valid gaitutils layout
        page_layouts = dict(cfg.web_report.page_layouts)

        # pick desired single variables from model and append
        pigvars = (
            models.pig_lowerbody.varlabels_nocontext
            | models.pig_lowerbody_kinetics.varlabels_nocontext
        )
        pigvars = sorted(pigvars.items(), key=lambda item: item[1])
        pigvars_louts = {varlabel: ('layout', [[var]]) for var, varlabel in pigvars}
        page_layouts.update(pigvars_louts)

        # add supplementary data for normal layouts
        supplementary_default = dict()

        dd_opts_multi_upper = list()
        dd_opts_multi_lower = list()

        # loop through the layouts, create or load figures
        report_data_new = dict()
        for k, (page_label, layout_spec) in enumerate(page_layouts.items()):
            signals.progress.emit(
                f'Creating plot: {page_label}', 100 * k / len(page_layouts)
            )
            if signals.canceled:
                return None
            # for comparison report, include session info in plot legends and
            # use session specific line style
            emg_mode = None
            if is_comparison:
                legend_type = cfg.report.comparison_legend_type
                style_by = cfg.report.comparison_style_by
                color_by = cfg.report.comparison_color_by
                if cfg.report.comparison_emg_as_envelope:
                    emg_mode = 'envelope'
            else:
                legend_type = cfg.report.legend_type
                style_by = cfg.report.style_by
                color_by = cfg.report.color_by

            try:
                if saved_report_data:
                    logger.debug(f'loading {page_label} from saved report data')
                    if page_label not in saved_report_data:
                        # will be caught, resulting in empty menu item
                        raise RuntimeError
                    else:
                        figdata = saved_report_data[page_label]
                else:
                    logger.debug(f'creating figure data for {page_label}')
                    # the 'special' layouts are indicated by a string
                    if isinstance(layout_spec, str):
                        if layout_spec == 'time_dist':
                            figdata = timedist.plot_comparison(
                                sessions, big_fonts=False, backend='plotly'
                            )
                        elif layout_spec == 'patient_info':
                            figdata = patient_info_text
                        elif layout_spec == 'static_kinematics':
                            layout_ = cfg.layouts.lb_kinematics
                            figdata = plot_trials(
                                trials_static,
                                layout_,
                                model_normaldata=False,
                                cycles='unnormalized',
                                legend_type='short_name_with_cyclename',
                                style_by=style_by,
                                color_by=color_by,
                                big_fonts=True,
                            )
                        elif layout_spec == 'static_emg':
                            layout_ = cfg.layouts.std_emg
                            figdata = plot_trials(
                                trials_static,
                                layout_,
                                model_normaldata=False,
                                cycles='unnormalized',
                                legend_type='short_name_with_cyclename',
                                style_by=style_by,
                                color_by=color_by,
                                big_fonts=True,
                            )
                        elif layout_spec == 'emg_auto':
                            if emg_auto_layout is None:  # no valid EMG channels
                                raise RuntimeError
                            else:
                                figdata = plot_trials(
                                    trials_dyn,
                                    emg_auto_layout,
                                    emg_mode=emg_mode,
                                    legend_type=legend_type,
                                    style_by=style_by,
                                    color_by=color_by,
                                    supplementary_data=supplementary_default,
                                    big_fonts=True,
                                )
                        elif layout_spec == 'kinematics_average':
                            layout_ = cfg.layouts.lb_kinematics
                            figdata = plot_trials(
                                avg_trials,
                                layout_,
                                style_by=style_by,
                                color_by=color_by,
                                model_normaldata=model_normaldata,
                                big_fonts=True,
                            )
                        elif layout_spec == 'disabled':
                            # exception will be caught in this loop, resulting in empty menu item
                            raise RuntimeError
                        else:  # unrecognized layout; this will cause an exception
                            raise Exception(f'Invalid page layout: {str(layout_spec)}')

                    # regular layouts and curve-extracted layouts are indicated by tuple
                    elif isinstance(layout_spec, tuple):
                        if layout_spec[0] in ['layout_name', 'layout']:
                            if layout_spec[0] == 'layout_name':
                                # get a configured layout by name
                                layout = layouts.get_layout(layout_spec[1])
                            else:
                                # it's already a valid layout
                                layout = layout_spec[1]
                            # plot according to layout
                            figdata = plot_trials(
                                trials_dyn,
                                layout,
                                model_normaldata=model_normaldata,
                                max_cycles=max_cycles,
                                emg_mode=emg_mode,
                                legend_type=legend_type,
                                style_by=style_by,
                                color_by=color_by,
                                supplementary_data=supplementary_default,
                                big_fonts=True,
                            )
                        elif layout_spec[0] == 'curve_extracted':
                            the_vardefs = vardefs_dict[layout_spec[1]]
                            figdata = plot_extracted_box(curve_vals, the_vardefs)
                        else:
                            raise Exception(f'Invalid page layout: {str(layout_spec)}')
                    else:
                        raise Exception(f'Invalid page layout: {str(layout_spec)}')

                # save the newly created data
                if not saved_report_data:
                    if isinstance(figdata, go.Figure):
                        # serialize go.Figures before saving
                        # this makes them much faster for pickle to handle
                        # apparently dcc.Graph can eat the serialized json directly,
                        # so no need to do anything on load
                        figdata_ = figdata.to_plotly_json()
                    else:
                        figdata_ = figdata
                    report_data_new[page_label] = figdata_

                # make the upper and lower panel graphs from figdata, depending
                # on data type
                def _is_base64(s):
                    """Test for valid base64 encoding"""
                    try:
                        return base64.b64encode(base64.b64decode(s)) == s
                    except Exception:
                        return False

                # this is for old style timedist figures that were in base64
                # encoded svg
                if layout_spec == 'time_dist' and _is_base64(figdata):
                    graph_upper = html.Img(
                        src=f'data:image/svg+xml;base64,{figdata}',
                        id='gaitgraph%d' % k,
                        style={'height': '100%'},
                    )
                    graph_lower = html.Img(
                        src=f'data:image/svg+xml;base64,{figdata}',
                        id='gaitgraph%d' % (len(page_layouts) + k),
                        style={'height': '100%'},
                    )
                elif layout_spec == 'patient_info':
                    graph_upper = dcc.Markdown(figdata)
                    graph_lower = graph_upper
                else:
                    # plotly fig -> dcc.Graph
                    graph_upper = dcc.Graph(
                        figure=figdata, id='gaitgraph%d' % k, style={'height': '100%'}
                    )
                    graph_lower = dcc.Graph(
                        figure=figdata,
                        id='gaitgraph%d' % (len(page_layouts) + k),
                        style={'height': '100%'},
                    )
                dd_opts_multi_upper.append({'label': page_label, 'value': graph_upper})
                dd_opts_multi_lower.append({'label': page_label, 'value': graph_lower})

            except (RuntimeError, GaitDataError) as e:  # could not create a figure
                logger.warning(f'failed to create figure for {page_label}: {e}')
                # insert the menu options but make them disabled
                dd_opts_multi_upper.append(
                    {'label': page_label, 'value': page_label, 'disabled': True}
                )
                dd_opts_multi_lower.append(
                    {'label': page_label, 'value': page_label, 'disabled': True}
                )
                continue

        opts_multi, mapper_multi_upper = _make_dropdown_lists(dd_opts_multi_upper)
        opts_multi, mapper_multi_lower = _make_dropdown_lists(dd_opts_multi_lower)

        # if plots were newly created, save them to disk
        if not saved_report_data:
            logger.debug(f'saving report data into {data_fn}')
            signals.progress.emit('Saving report data to disk...', 99)
            with open(data_fn, 'wb') as f:
                pickle.dump(report_data_new, f, protocol=-1)

    def make_left_panel(split=True, upper_value='Kinematics', lower_value='Kinematics'):
        """Helper to make the left graph panels. If split=True, make two stacked panels"""

        # the upper graph & dropdown
        items = [
            dcc.Dropdown(
                id='dd-vars-upper-multi',
                clearable=False,
                options=opts_multi,
                value=upper_value,
            ),
            html.Div(
                id='div-upper', style={'height': '50%'} if split else {'height': '100%'}
            ),
        ]

        if split:
            # add the lower one
            items.extend(
                [
                    dcc.Dropdown(
                        id='dd-vars-lower-multi',
                        clearable=False,
                        options=opts_multi,
                        value=lower_value,
                    ),
                    html.Div(id='div-lower', style={'height': '50%'}),
                ]
            )

        return html.Div(items, style={'height': '80vh'})

    # create the app
    app = dash.Dash('gaitutils')
    # use local packaged versions of JavaScript libs etc. (no internet needed)
    app.css.config.serve_locally = True
    app.scripts.config.serve_locally = True
    app.title = _report_name(sessions, long_name=False)

    # this is for generating the classnames in the CSS
    num2words = {
        1: 'one',
        2: 'two',
        3: 'three',
        4: 'four',
        5: 'five',
        6: 'six',
        7: 'seven',
        8: 'eight',
        9: 'nine',
        10: 'ten',
        11: 'eleven',
        12: 'twelve',
    }
    classname_left = f'{num2words[LEFT_WIDTH]} columns'
    classname_right = f'{num2words[12 - LEFT_WIDTH]} columns'

    if video_only:
        app.layout = html.Div(
            [  # row
                html.Div(
                    [  # single main div
                        dcc.Dropdown(
                            id='dd-camera',
                            clearable=False,
                            options=opts_cameras,
                            value='Front camera',
                        ),
                        dcc.Dropdown(
                            id='dd-video-tag',
                            clearable=False,
                            options=opts_tags,
                            value=opts_tags[0]['value'],
                        ),
                        html.Div(id='videos'),
                    ],
                    className='12 columns',
                ),
            ],
            className='row',
        )
    else:  # the two-panel layout with graphs and video
        app.layout = html.Div(
            [  # row
                html.Div(
                    [  # left main div
                        html.H6(report_name),
                        dcc.Checklist(
                            id='split-left',
                            options=[{'label': 'Two panels', 'value': 'split'}],
                            value=[],
                        ),
                        # need split=True so that both panels are in initial layout
                        html.Div(make_left_panel(split=True), id='div-left-main'),
                    ],
                    className=classname_left,
                ),
                html.Div(
                    [  # right main div
                        dcc.Dropdown(
                            id='dd-camera',
                            clearable=False,
                            options=opts_cameras,
                            value='Front camera',
                        ),
                        dcc.Dropdown(
                            id='dd-video-tag',
                            clearable=False,
                            options=opts_tags,
                            value=opts_tags[0]['value'],
                        ),
                        html.Div(id='videos'),
                    ],
                    className=classname_right,
                ),
            ],
            className='row',
        )

        @app.callback(
            Output('div-left-main', 'children'),
            [Input('split-left', 'value')],
            [State('dd-vars-upper-multi', 'value')],
        )
        def update_panel_layout(split_panels, upper_value):
            split = 'split' in split_panels
            return make_left_panel(split, upper_value=upper_value)

        @app.callback(
            Output('div-upper', 'children'), [Input('dd-vars-upper-multi', 'value')]
        )
        def update_contents_upper_multi(sel_var):
            return mapper_multi_upper[sel_var]

        @app.callback(
            Output('div-lower', 'children'), [Input('dd-vars-lower-multi', 'value')]
        )
        def update_contents_lower_multi(sel_var):
            return mapper_multi_lower[sel_var]

    def _video_elem(title, url, max_height):
        """Create a video element with title"""
        if not url:
            return 'No video found'
        vid_el = html.Video(
            src=url,
            controls=True,
            loop=True,
            preload='auto',
            title=title,
            style={'max-height': max_height, 'max-width': '100%'},
        )
        # return html.Div([title, vid_el])  # titles above videos
        return vid_el

    @app.callback(
        Output('videos', 'children'),
        [Input('dd-camera', 'value'), Input('dd-video-tag', 'value')],
    )
    def update_videos(camera_label, tag):
        """Create a list of video divs according to camera and tag selection"""
        if tag == 'no videos':
            return 'No videos found'
        vid_urls_ = vid_urls[tag][camera_label]
        if not vid_urls_:
            return 'No videos found'
        nvids = len(vid_urls_)
        max_height = str(int(VIDS_TOTAL_HEIGHT / nvids)) + 'vh'
        return [_video_elem('video', url, max_height) for url in vid_urls_]

    # add a static route to serve session data. be careful outside firewalls
    @app.server.route('/static/<resource>')
    def serve_file(resource):
        for session in sessions:
            filepath = session / resource
            if filepath.is_file():
                return flask.send_from_directory(str(session), resource)
        return None

    # add shutdown method - see http://flask.pocoo.org/snippets/67/
    @app.server.route('/shutdown')
    def shutdown():
        logger.debug('Received shutdown request...')
        _shutdown_server()
        return 'Server shutting down...'

    # inject some info of our own
    app._gaitutils_report_name = report_name

    # XXX: the Flask app ends up with a logger by the name of 'gaitutils', which has a default
    # stderr handler. since logger hierarchy corresponds to package hierarchy,
    # this creates a bug where all gaitutils package loggers propagate their messages into
    # the app logger and they get shown multiple times. as a dirty fix, we disable the
    # handlers for the app logger (they still get shown since they propagate to the root logger)
    app.logger.handlers = []

    return app
Beispiel #4
0
def create_report(
    sessionpath,
    info=None,
    pages=None,
    destdir=None,
    write_timedist=False,
    write_extracted=False,
):
    """Create a single-session pdf report.

    Parameters
    ----------
    sessionpath : str | Path
        Path to session.
    info : dict, optional
        Patient info dict.
    pages : dict, optional
        Which pages to include to include in report. Set value to True for
        desired pages. If None, do all pages. Currently supported keys:
        'TrialVelocity'
        'TimeDistAverage'
        'KinematicsCons'
        'TorsoKinematicsCons'
        'KineticsCons'
        'MuscleLenCons'
        'KinAverage'
    destdir : str, optional
        Destination directory for the pdf report. If None, write into sessionpath.
    write_timedist : bool
        If True, also write a text report of time-distance parameters into the
        same directory as the pdf.
    write_extracted : bool
        If True, also write a text report of curve extracted values into the
        same directory as the pdf.

    Returns
    -------
    str
        A status message.
    """
    sessionpath = Path(sessionpath)
    if info is None:
        info = defaultdict(lambda: '')
    fullname = info['fullname'] or ''
    hetu = info['hetu'] or ''
    session_description = info['session_description'] or ''
    if pages is None:
        pages = defaultdict(lambda: True)  # default: do all plots
    elif not any(pages.values()):
        pages = defaultdict(lambda: False)

    do_emg_consistency = True

    session_root, sessiondir = sessionpath.parent, sessionpath.name
    patient_code = session_root.name
    if destdir is None:
        destdir = sessionpath
    pdfname = sessiondir + '.pdf'
    pdfpath = destdir / pdfname

    tagged_trials = sessionutils._get_tagged_dynamic_c3ds_from_sessions(
        [sessionpath], tags=cfg.eclipse.tags)
    # XXX: do we unnecessarily load all trials twice (since plotting calls below don't
    # use the trials loaded here), or is it a non issue due to caching of c3d files?
    trials = (trial.Trial(t) for t in tagged_trials)
    has_kinetics = any(c.on_forceplate for t in trials for c in t.cycles)

    session_t = sessionutils.get_session_date(sessionpath)
    logger.debug('session timestamp: %s', session_t)
    age = age_from_hetu(hetu, session_t) if hetu else None

    model_normaldata = normaldata._read_session_normaldata(sessionpath)

    # make header page
    # timestr = time.strftime('%d.%m.%Y')  # current time, not currently used
    title_txt = f'{cfg.report.laboratory_name}\n'
    title_txt += f"{translate('Results of gait analysis')}\n"
    title_txt += '\n'
    title_txt += f"{translate('Name')}: {fullname}\n"
    title_txt += '%s: %s\n' % (
        translate('Social security number'),
        hetu if hetu else translate('unknown'),
    )
    age_str = '%d %s' % (age,
                         translate('years')) if age else translate('unknown')
    title_txt += f"{translate('Age at time of measurement')}: {age_str}\n"
    title_txt += f"{translate('Session')}: {sessiondir}\n"
    if session_description:
        title_txt += f"{translate('Description')}: {session_description}\n"
    title_txt += '%s: %s\n' % (
        translate('Session date'),
        session_t.strftime('%d.%m.%Y'),
    )
    title_txt += f"{translate('Patient code')}: {patient_code}\n"
    fig_title = _make_text_fig(title_txt)

    header = '%s: %s %s: %s' % (
        translate('Name'),
        fullname,
        translate('Social security number'),
        hetu,
    )
    musclelen_ndata = normaldata._find_normaldata_for_age(age)
    footer_musclelen = (f" {translate('Normal data')}: {musclelen_ndata}"
                        if musclelen_ndata else '')

    # make the figures
    legend_type = cfg.report.legend_type  # not currently used (legends disabled)
    style_by = cfg.report.style_by
    color_by = cfg.report.color_by

    # trial velocity plot
    fig_vel = None
    if pages['TrialVelocity']:
        logger.debug('creating velocity plot')
        fig_vel = plot_trial_velocities(sessionpath, backend=pdf_backend)

    # time-distance average
    fig_timedist_avg = None
    if pages['TimeDistAverage']:
        logger.debug('creating time-distance plot')
        fig_timedist_avg = timedist.plot_session_average(sessionpath,
                                                         backend=pdf_backend)

    # for next few plots, disable the legends (there are typically too many cycles)
    # kin consistency
    fig_kinematics_cons = None
    if pages['KinematicsCons']:
        logger.debug('creating kinematics consistency plot')
        fig_kinematics_cons = _plot_sessions(
            sessions=[sessionpath],
            layout='lb_kinematics',
            model_normaldata=model_normaldata,
            color_by=color_by,
            style_by=style_by,
            backend=pdf_backend,
            figtitle=f'Kinematics consistency for {sessiondir}',
            legend_type=legend_type,
            legend=False,
        )

    # kinetics consistency
    fig_kinetics_cons = None
    if pages['KineticsCons'] and has_kinetics:
        logger.debug('creating kinetics consistency plot')
        fig_kinetics_cons = _plot_sessions(
            sessions=[sessionpath],
            layout='lb_kinetics_web',
            model_normaldata=model_normaldata,
            color_by=color_by,
            style_by=style_by,
            backend=pdf_backend,
            figtitle=f'Kinetics consistency for {sessiondir}',
            legend_type=legend_type,
            legend=False,
        )

    # torso consistency
    fig_torso_kinematics_cons = None
    if pages['TorsoKinematicsCons']:
        logger.debug('creating torso kinematics consistency plot')
        fig_torso_kinematics_cons = _plot_sessions(
            sessions=[sessionpath],
            layout='torso',
            model_normaldata=model_normaldata,
            color_by=color_by,
            style_by=style_by,
            backend=pdf_backend,
            figtitle=f'Torso kinematics consistency for {sessiondir}',
            legend_type=legend_type,
            legend=False,
        )

    # musclelen consistency
    fig_musclelen_cons = None
    if pages['MuscleLenCons']:
        logger.debug('creating muscle length consistency plot')
        fig_musclelen_cons = _plot_sessions(
            sessions=[sessionpath],
            layout='musclelen',
            color_by=color_by,
            style_by=style_by,
            model_normaldata=model_normaldata,
            backend=pdf_backend,
            figtitle=f'Muscle length consistency for {sessiondir}',
            legend_type=legend_type,
            legend=False,
        )
    # EMG consistency
    fig_emg_cons = None
    if do_emg_consistency:
        logger.debug('creating EMG consistency plot')
        fig_emg_cons = _plot_sessions(
            sessions=[sessionpath],
            layout='std_emg',
            color_by=color_by,
            style_by=style_by,
            figtitle=f'EMG consistency for {sessiondir}',
            legend_type=legend_type,
            legend=False,
            backend=pdf_backend,
        )
    # EMG consistency, back muscles
    fig_back_emg_cons = None
    if pages['BackEMGCons']:
        logger.debug('creating EMG consistency plot')
        fig_back_emg_cons = _plot_sessions(
            sessions=[sessionpath],
            layout='back_emg',
            color_by=color_by,
            style_by=style_by,
            figtitle=f'EMG back muscles consistency for {sessiondir}',
            legend_type=legend_type,
            legend=False,
            backend=pdf_backend,
        )

    # average plots, R/L
    fig_kin_avg = None
    if pages['KinAverage']:
        fig_kin_avg = _plot_session_average(
            sessionpath,
            model_normaldata=model_normaldata,
            backend=pdf_backend,
        )

    # prep for extracted values if needed
    if pages['Extracted'] or write_extracted:
        vardefs_dict = dict(cfg.report.vardefs)
        allvars = [
            vardef[0] for vardefs in vardefs_dict.values()
            for vardef in vardefs
        ]
        from_models = set(models.model_from_var(var) for var in allvars)
        curve_vals = {
            sessionpath.name:
            stats._trials_extract_values(tagged_trials,
                                         from_models=from_models)
        }

    # tables of curve extracted values
    figs_extracted = list()
    if pages['Extracted']:
        logger.debug('plotting curve extracted values')
        for title, vardefs in vardefs_dict.items():
            fig = _plot_extracted_table_plotly(curve_vals, vardefs)
            fig.tight_layout()
            fig.set_dpi(300)
            fig.suptitle(f'Curve extracted values: {title}')
            figs_extracted.append(fig)

    # save the pdf file
    logger.debug(f'creating multipage pdf {pdfpath}')
    with PdfPages(pdfpath) as pdf:
        _savefig(pdf, fig_title)
        _savefig(pdf, fig_vel, header)
        _savefig(pdf, fig_timedist_avg, header)
        _savefig(pdf, fig_kinematics_cons, header)
        _savefig(pdf, fig_torso_kinematics_cons, header)
        _savefig(pdf, fig_kinetics_cons, header)
        _savefig(pdf, fig_musclelen_cons, header, footer_musclelen)
        _savefig(pdf, fig_emg_cons, header)
        _savefig(pdf, fig_back_emg_cons, header)
        _savefig(pdf, fig_kin_avg, header)
        for fig in figs_extracted:
            _savefig(pdf, fig, header)

    # save the time-distance parameters into a text file
    if write_timedist:
        _timedist_txt = _session_analysis_text(sessionpath)
        timedist_txt_file = sessiondir + '_time_distance.txt'
        timedist_txt_path = destdir / timedist_txt_file
        with io.open(timedist_txt_path, 'w', encoding='utf8') as f:
            logger.debug(
                f'writing timedist text data into {timedist_txt_path}')
            f.write(_timedist_txt)

    # save the curve extraced values into a text file
    if write_extracted:
        extracted_txt = '\n'.join(
            _curve_extracted_text(curve_vals, vardefs_dict))
        extracted_txt_file = sessiondir + '_curve_values.txt'
        extracted_txt_path = destdir / extracted_txt_file
        with io.open(extracted_txt_path, 'w', encoding='utf8') as f:
            logger.debug(
                f'writing extracted text data into {extracted_txt_path}')
            f.write(extracted_txt)

    return f'Created {pdfpath}'
Beispiel #5
0
def create_report(sessionpath, info=None, pages=None, destdir=None):
    """Create the pdf report and save in session directory"""

    if info is None:
        info = defaultdict(lambda: '')
    fullname = info['fullname'] or ''
    hetu = info['hetu'] or ''
    session_description = info['session_description'] or ''
    if pages is None:
        pages = defaultdict(lambda: True)  # default: do all plots
    elif not any(pages.values()):
        raise ValueError('No pages to print')

    do_emg_consistency = True

    session_root, sessiondir = op.split(sessionpath)
    patient_code = op.split(session_root)[1]
    if destdir is None:
        destdir = sessionpath
    pdfname = sessiondir + '.pdf'
    pdfpath = op.join(destdir, pdfname)

    tagged_trials = sessionutils.get_c3ds(
        sessionpath, tags=cfg.eclipse.tags, trial_type='dynamic'
    )
    if not tagged_trials:
        raise GaitDataError('No tagged trials found in %s' % sessiondir)

    trials = (trial.Trial(t) for t in tagged_trials)
    has_kinetics = any(c.on_forceplate for t in trials for c in t.cycles)

    session_t = sessionutils.get_session_date(sessionpath)
    logger.debug('session timestamp: %s', session_t)
    age = age_from_hetu(hetu, session_t) if hetu else None

    model_normaldata = normaldata.read_session_normaldata(sessionpath)

    # make header page
    # timestr = time.strftime('%d.%m.%Y')  # current time, not currently used
    title_txt = 'HUS Liikelaboratorio\n'
    title_txt += u'Kävelyanalyysin tulokset\n'
    title_txt += '\n'
    title_txt += u'Nimi: %s\n' % fullname
    title_txt += u'Henkilötunnus: %s\n' % (hetu if hetu else 'ei tiedossa')
    title_txt += u'Ikä mittaushetkellä: %s\n' % (
        '%d vuotta' % age if age else 'ei tiedossa'
    )
    title_txt += u'Mittaus: %s\n' % sessiondir
    if session_description:
        title_txt += u'Kuvaus: %s\n' % session_description
    title_txt += u'Mittauksen pvm: %s\n' % session_t.strftime('%d.%m.%Y')
    title_txt += u'Liikelaboratorion potilaskoodi: %s\n' % patient_code
    fig_title = _make_text_fig(title_txt)

    header = u'Nimi: %s Henkilötunnus: %s' % (fullname, hetu)
    musclelen_ndata = normaldata.normaldata_age(age)
    footer_musclelen = (
        u' Normaalidata: %s' % musclelen_ndata if musclelen_ndata else u''
    )

    color_by = {'model': 'context', 'emg': 'trial'}
    style_by = {'model': None}

    # trial velocity plot
    fig_vel = None
    if pages['TrialVelocity']:
        logger.debug('creating velocity plot')
        fig_vel = plot_trial_velocities(sessionpath, backend=pdf_backend)

    # time-distance average
    fig_timedist_avg = None
    if pages['TimeDistAverage']:
        logger.debug('creating time-distance plot')
        fig_timedist_avg = do_session_average_plot(sessionpath, backend=pdf_backend)

    # time-dist text
    _timedist_txt = session_analysis_text(sessionpath)

    # for next 2 plots, disable the legends (too many cycles)
    # kin consistency
    fig_kinematics_cons = None
    if pages['KinematicsCons']:
        logger.debug('creating kinematics consistency plot')
        fig_kinematics_cons = plot_sessions(
            sessions=[sessionpath],
            layout_name='lb_kinematics',
            model_normaldata=model_normaldata,
            color_by=color_by,
            style_by=style_by,
            backend=pdf_backend,
            figtitle='Kinematics consistency for %s' % sessiondir,
            legend=False,
        )

    # kinetics consistency
    fig_kinetics_cons = None
    if pages['KineticsCons'] and has_kinetics:
        logger.debug('creating kinetics consistency plot')
        fig_kinetics_cons = plot_sessions(
            sessions=[sessionpath],
            layout_name='lb_kinetics',
            model_normaldata=model_normaldata,
            color_by=color_by,
            style_by=style_by,
            backend=pdf_backend,
            figtitle='Kinetics consistency for %s' % sessiondir,
            legend=False,
        )

    # musclelen consistency
    fig_musclelen_cons = None
    if pages['MuscleLenCons']:
        logger.debug('creating muscle length consistency plot')
        fig_musclelen_cons = plot_sessions(
            sessions=[sessionpath],
            layout_name='musclelen',
            color_by=color_by,
            style_by=style_by,
            model_normaldata=model_normaldata,
            backend=pdf_backend,
            figtitle='Muscle length consistency for %s' % sessiondir,
            legend=False,
        )
    # EMG consistency
    fig_emg_cons = None
    if do_emg_consistency:
        logger.debug('creating EMG consistency plot')
        fig_emg_cons = plot_sessions(
            sessions=[sessionpath],
            layout_name='std_emg',
            color_by=color_by,
            style_by=style_by,
            figtitle='EMG consistency for %s' % sessiondir,
            legend=False,
            backend=pdf_backend,
        )
    # average plots, R/L
    fig_kin_avg = None
    if pages['KinAverage']:
        fig_kin_avg = plot_session_average(
            sessionpath, model_normaldata=model_normaldata, backend=pdf_backend
        )

    logger.debug('creating multipage pdf %s' % pdfpath)
    with PdfPages(pdfpath) as pdf:
        _savefig(pdf, fig_title)
        _savefig(pdf, fig_vel, header)
        _savefig(pdf, fig_timedist_avg, header)
        _savefig(pdf, fig_kinematics_cons, header)
        _savefig(pdf, fig_kinetics_cons, header)
        _savefig(pdf, fig_musclelen_cons, header, footer_musclelen)
        _savefig(pdf, fig_emg_cons, header)
        _savefig(pdf, fig_kin_avg, header)

    timedist_txt_file = sessiondir + '_time_distance.txt'
    timedist_txt_path = op.join(destdir, timedist_txt_file)
    with io.open(timedist_txt_path, 'w', encoding='utf8') as f:
        logger.debug('writing timedist text data into %s' % timedist_txt_path)
        f.write(_timedist_txt)
Beispiel #6
0
def dash_report(
    info=None,
    sessions=None,
    tags=None,
    signals=None,
    recreate_plots=None,
    video_only=None,
):
    """Create a web report dash app.

    Parameters
    ----------

    info : dict
        patient info
    sessions : list
        list of session dirs
    tags : list
        tags for dynamic gait trials
    signals : ProgressSignals
        instance of ProgressSignals, used to send progress updates across threads
    recreate_plots : bool
        force recreation of report
    video_only : bool
        Create a video-only report. C3D data will not be read.
    """

    if recreate_plots is None:
        recreate_plots = False

    if video_only is None:
        video_only = False

    # relative width of left panel (1-12)
    # 3-session comparison uses narrower video panel
    # LEFT_WIDTH = 8 if len(sessions) == 3 else 7
    LEFT_WIDTH = 8
    VIDS_TOTAL_HEIGHT = 88  # % of browser window height

    if len(sessions) < 1 or len(sessions) > 3:
        raise ValueError('Need a list of one to three sessions')
    is_comparison = len(sessions) > 1
    report_name = _report_name(sessions)
    info = info or sessionutils.default_info()

    # tags for dynamic trials
    # if doing a comparison, pick representative trials only
    dyn_tags = tags or (cfg.eclipse.repr_tags if is_comparison else cfg.eclipse.tags)
    # this tag will be shown in the menu for static trials
    static_tag = 'Static'

    # get the camera labels
    # reduce to a set, since there may be several labels for given id
    camera_labels = set(cfg.general.camera_labels.values())
    # add camera labels for overlay videos
    # XXX: may cause trouble if labels already contain the string 'overlay'
    camera_labels_overlay = [lbl + ' overlay' for lbl in camera_labels]
    camera_labels.update(camera_labels_overlay)
    # build dict of videos for given tag / camera label
    # videos will be listed in session order
    vid_urls = dict()
    all_tags = dyn_tags + [static_tag] + cfg.eclipse.video_tags
    for tag in all_tags:
        vid_urls[tag] = dict()
        for camera_label in camera_labels:
            vid_urls[tag][camera_label] = list()

    # collect all session enfs into dict
    enfs = {session: dict() for session in sessions}
    data_enfs = list()  # enfs that are used for data
    signals.progress.emit('Collecting trials...', 0)
    for session in sessions:
        if signals.canceled:
            return None
        enfs[session] = dict(dynamic=dict(), static=dict(), vid_only=dict())
        # collect dynamic trials for each tag
        for tag in dyn_tags:
            dyns = sessionutils.get_enfs(session, tags=tag, trial_type='dynamic')
            if len(dyns) > 1:
                logger.warning('multiple tagged trials (%s) for %s' % (tag, session))
            dyn_trial = dyns[-1:]
            enfs[session]['dynamic'][tag] = dyn_trial  # may be empty list
            if dyn_trial:
                data_enfs.extend(dyn_trial)
        # require at least one dynamic trial for each session
        if not any(enfs[session]['dynamic'][tag] for tag in dyn_tags):
            raise GaitDataError('No tagged dynamic trials found for %s' % (session))
        # collect static trial (at most 1 per session)
        # -prefer enfs that have a corresponding c3d file, even for a video-only report
        # (so that the same static gets used for both video-only and full reports)
        # -prefer the newest enf file
        sts = sessionutils.get_enfs(session, trial_type='static')
        for st in reversed(sts):  # newest first
            st_c3d = sessionutils.enf_to_trialfile(st, '.c3d')
            if op.isfile(st_c3d):
                static_trial = [st]
                break
        else:
            # no c3ds were found - just pick the latest static trial
            static_trial = sts[-1:]
        enfs[session]['static'][static_tag] = static_trial
        if static_trial:
            data_enfs.extend(static_trial)
        # collect video-only dynamic trials
        for tag in cfg.eclipse.video_tags:
            dyn_vids = sessionutils.get_enfs(session, tags=tag)
            if len(dyn_vids) > 1:
                logger.warning(
                    'multiple tagged video-only trials (%s) for %s' % (tag, session)
                )
            enfs[session]['vid_only'][tag] = dyn_vids[-1:]

    # collect all videos for given tag and camera, listed in session order
    signals.progress.emit('Finding videos...', 0)
    for session in sessions:
        for trial_type in enfs[session]:
            for tag, enfs_this in enfs[session][trial_type].items():
                if enfs_this:
                    enf = enfs_this[0]  # only one enf per tag and session
                    for camera_label in camera_labels:
                        overlay = 'overlay' in camera_label
                        real_camera_label = (
                            camera_label[: camera_label.find(' overlay')]
                            if overlay
                            else camera_label
                        )
                        c3d = enf_to_trialfile(enf, 'c3d')
                        vids_this = videos.get_trial_videos(
                            c3d,
                            camera_label=real_camera_label,
                            vid_ext='.ogv',
                            overlay=overlay,
                        )
                        if vids_this:
                            vid = vids_this[0]
                            url = '/static/%s' % op.split(vid)[1]
                            vid_urls[tag][camera_label].append(url)

    # build dcc.Dropdown options list for cameras and tags
    # list cameras which have videos for any tag
    opts_cameras = list()
    for camera_label in sorted(camera_labels):
        if any(vid_urls[tag][camera_label] for tag in all_tags):
            opts_cameras.append({'label': camera_label, 'value': camera_label})
    # list tags which have videos for any camera
    opts_tags = list()
    for tag in all_tags:
        if any(vid_urls[tag][camera_label] for camera_label in camera_labels):
            opts_tags.append({'label': '%s' % tag, 'value': tag})
    # add null entry in case we got no videos at all
    if not opts_tags:
        opts_tags.append({'label': 'No videos', 'value': 'no videos', 'disabled': True})

    # this whole section is only needed if we have c3d data
    if not video_only:
        # see whether we can load report figures from disk
        data_c3ds = [enf_to_trialfile(enffile, 'c3d') for enffile in data_enfs]
        digest = numutils.files_digest(data_c3ds)
        logger.debug('report data digest: %s' % digest)
        # data is always saved into alphabetically first session
        data_dir = sorted(sessions)[0]
        data_fn = op.join(data_dir, 'web_report_%s.dat' % digest)
        if op.isfile(data_fn) and not recreate_plots:
            logger.debug('loading saved report data from %s' % data_fn)
            signals.progress.emit('Loading saved report...', 0)
            with open(data_fn, 'rb') as f:
                saved_report_data = pickle.load(f)
        else:
            saved_report_data = dict()
            logger.debug('no saved data found or recreate forced')

        # make Trial instances for all dynamic and static trials
        # this is currently needed even if saved report is used
        trials_dyn = list()
        trials_static = list()
        _trials_avg = dict()
        for session in sessions:
            _trials_avg[session] = list()
            for tag in dyn_tags:
                if enfs[session]['dynamic'][tag]:
                    if signals.canceled:
                        return None
                    c3dfile = enf_to_trialfile(enfs[session]['dynamic'][tag][0], 'c3d')
                    tri = Trial(c3dfile)
                    trials_dyn.append(tri)
                    _trials_avg[session].append(tri)
            if enfs[session]['static'][static_tag]:
                c3dfile = enf_to_trialfile(enfs[session]['static']['Static'][0], 'c3d')
                tri = Trial(c3dfile)
                trials_static.append(tri)

        emg_layout = None
        tibial_torsion = dict()

        # stuff that's needed to (re)create the figures
        if not saved_report_data:
            age = None
            if info['hetu'] is not None:
                # compute subject age at session time
                session_dates = [
                    sessionutils.get_session_date(session) for session in sessions
                ]
                ages = [age_from_hetu(info['hetu'], d) for d in session_dates]
                age = max(ages)

            # create Markdown text for patient info
            patient_info_text = '##### %s ' % (
                info['fullname'] if info['fullname'] else 'Name unknown'
            )
            if info['hetu']:
                patient_info_text += '(%s)' % info['hetu']
            patient_info_text += '\n\n'
            # if age:
            #     patient_info_text += 'Age at measurement time: %d\n\n' % age
            if info['report_notes']:
                patient_info_text += info['report_notes']

            model_normaldata = dict()
            avg_trials = list()

            # load normal data for gait models
            signals.progress.emit('Loading normal data...', 0)
            for fn in cfg.general.normaldata_files:
                ndata = normaldata.read_normaldata(fn)
                model_normaldata.update(ndata)
            if age is not None:
                age_ndata_file = normaldata.normaldata_age(age)
                if age_ndata_file:
                    age_ndata = normaldata.read_normaldata(age_ndata_file)
                    model_normaldata.update(age_ndata)

            # make average trials for each session
            avg_trials = [
                AvgTrial.from_trials(_trials_avg[session], sessionpath=session)
                for session in sessions
            ]
            # read some extra data from trials and create supplementary data
            for tr in trials_dyn:
                # read tibial torsion for each trial and make supplementary traces
                # these will only be shown for KneeAnglesZ (knee rotation) variable
                tors = dict()
                tors['R'], tors['L'] = (
                    tr.subj_params['RTibialTorsion'],
                    tr.subj_params['LTibialTorsion'],
                )
                if tors['R'] is None or tors['L'] is None:
                    logger.warning(
                        'could not read tibial torsion values from %s' % tr.trialname
                    )
                    continue
                # include torsion info for all cycles; this is useful when plotting
                # isolated cycles
                max_cycles = cfg.plot.max_cycles['model']
                cycs = tr.get_cycles(cfg.plot.default_cycles['model'])[:max_cycles]

                for cyc in cycs:
                    tibial_torsion[cyc] = dict()
                    for ctxt in tors:
                        var_ = ctxt + 'KneeAnglesZ'
                        tibial_torsion[cyc][var_] = dict()
                        # x = % of gait cycle
                        tibial_torsion[cyc][var_]['t'] = np.arange(101)
                        # static tibial torsion value as function of x
                        # convert radians -> degrees
                        tibial_torsion[cyc][var_]['data'] = (
                            np.ones(101) * tors[ctxt] / np.pi * 180
                        )
                        tibial_torsion[cyc][var_]['label'] = 'Tib. tors. (%s) % s' % (
                            ctxt,
                            tr.trialname,
                        )

                # in EMG layout, keep chs that are active in any of the trials
                signals.progress.emit('Reading EMG data', 0)
                try:
                    emgs = [tr.emg for tr in trials_dyn]
                    emg_layout = layouts.rm_dead_channels_multitrial(
                        emgs, cfg.layouts.std_emg
                    )
                    if not emg_layout:
                        emg_layout = 'disabled'
                except GaitDataError:
                    emg_layout = 'disabled'

        # define layouts
        # FIXME: should be definable in config
        _layouts = OrderedDict(
            [
                ('Patient info', 'patient_info'),
                ('Kinematics', cfg.layouts.lb_kinematics),
                ('Kinematics average', 'kinematics_average'),
                ('Static kinematics', 'static_kinematics'),
                ('Static EMG', 'static_emg'),
                ('Kinematics + kinetics', cfg.layouts.lb_kin_web),
                ('Kinetics', cfg.layouts.lb_kinetics_web),
                ('EMG', emg_layout),
                ('Kinetics-EMG left', cfg.layouts.lb_kinetics_emg_l),
                ('Kinetics-EMG right', cfg.layouts.lb_kinetics_emg_r),
                ('Muscle length', cfg.layouts.musclelen),
                ('Torso kinematics', cfg.layouts.torso),
                ('Time-distance variables', 'time_dist'),
            ]
        )
        # pick desired single variables from model and append
        # Py2: dict merge below can be done more elegantly once Py2 is dropped
        pig_singlevars_ = models.pig_lowerbody.varlabels_noside.copy()
        pig_singlevars_.update(models.pig_lowerbody_kinetics.varlabels_noside)
        pig_singlevars = sorted(pig_singlevars_.items(), key=lambda item: item[1])
        singlevars = OrderedDict(
            [(varlabel, [[var]]) for var, varlabel in pig_singlevars]
        )
        _layouts.update(singlevars)

        # add supplementary data for normal layouts
        supplementary_default = dict()
        supplementary_default.update(tibial_torsion)

        dd_opts_multi_upper = list()
        dd_opts_multi_lower = list()

        # loop through the layouts, create or load figures
        report_data_new = dict()
        for k, (label, layout) in enumerate(_layouts.items()):
            signals.progress.emit('Creating plot: %s' % label, 100 * k / len(_layouts))
            if signals.canceled:
                return None
            # for comparison report, include session info in plot legends and
            # use session specific line style
            emg_mode = None
            if is_comparison:
                legend_type = cfg.web_report.comparison_legend_type
                style_by = cfg.web_report.comparison_style_by
                color_by = cfg.web_report.comparison_color_by
                if cfg.web_report.comparison_emg_rms:
                    emg_mode = 'rms'
            else:
                legend_type = cfg.web_report.legend_type
                style_by = cfg.web_report.style_by
                color_by = cfg.web_report.color_by

            try:
                if saved_report_data:
                    logger.debug('loading %s from saved report data' % label)
                    if label not in saved_report_data:
                        # will be caught, resulting in empty menu item
                        raise RuntimeError
                    else:
                        figdata = saved_report_data[label]
                else:
                    logger.debug('creating figure data for %s' % label)
                    if isinstance(layout, basestring):  # handle special layout codes
                        if layout == 'time_dist':
                            figdata = timedist.do_comparison_plot(
                                sessions, big_fonts=True, backend='plotly'
                            )
                        elif layout == 'patient_info':
                            figdata = patient_info_text
                        elif layout == 'static_kinematics':
                            layout_ = cfg.layouts.lb_kinematics
                            figdata = plot_trials(
                                trials_static,
                                layout_,
                                model_normaldata=False,
                                cycles='unnormalized',
                                legend_type='short_name_with_cyclename',
                                style_by=style_by,
                                color_by=color_by,
                                big_fonts=True,
                            )
                        elif layout == 'static_emg':
                            layout_ = cfg.layouts.std_emg
                            figdata = plot_trials(
                                trials_static,
                                layout_,
                                model_normaldata=False,
                                cycles='unnormalized',
                                legend_type='short_name_with_cyclename',
                                style_by=style_by,
                                color_by=color_by,
                                big_fonts=True,
                            )
                        elif layout == 'kinematics_average':
                            layout_ = cfg.layouts.lb_kinematics
                            figdata = plot_trials(
                                avg_trials,
                                layout_,
                                style_by=style_by,
                                color_by=color_by,
                                model_normaldata=model_normaldata,
                                big_fonts=True,
                            )
                        elif layout == 'disabled':
                            # will be caught, resulting in empty menu item
                            raise RuntimeError
                        else:  # unrecognized layout; this is not caught by us
                            raise Exception('Unrecognized layout: %s' % layout)

                    else:  # regular gaitutils layout
                        figdata = plot_trials(
                            trials_dyn,
                            layout,
                            model_normaldata=model_normaldata,
                            emg_mode=emg_mode,
                            legend_type=legend_type,
                            style_by=style_by,
                            color_by=color_by,
                            supplementary_data=supplementary_default,
                            big_fonts=True,
                        )
                # save newly created data
                if not saved_report_data:
                    if isinstance(figdata, go.Figure):
                        # serialize go.Figures before saving
                        # this makes them much faster for pickle to handle
                        # apparently dcc.Graph can eat the serialized json directly,
                        # so no need to do anything on load
                        figdata_ = figdata.to_plotly_json()
                    else:
                        figdata_ = figdata
                    report_data_new[label] = figdata_

                # make the upper and lower panel graphs from figdata, depending
                # on data type
                def _is_base64(s):
                    try:
                        return base64.b64encode(base64.b64decode(s)) == s
                    except Exception:
                        return False

                # this is for old style timedist figures that were in base64
                # encoded svg
                if layout == 'time_dist' and _is_base64(figdata):
                    graph_upper = html.Img(
                        src='data:image/svg+xml;base64,{}'.format(figdata),
                        id='gaitgraph%d' % k,
                        style={'height': '100%'},
                    )
                    graph_lower = html.Img(
                        src='data:image/svg+xml;base64,{}'.format(figdata),
                        id='gaitgraph%d' % (len(_layouts) + k),
                        style={'height': '100%'},
                    )
                elif layout == 'patient_info':
                    graph_upper = dcc.Markdown(figdata)
                    graph_lower = graph_upper
                else:
                    # plotly fig -> dcc.Graph
                    graph_upper = dcc.Graph(
                        figure=figdata, id='gaitgraph%d' % k, style={'height': '100%'}
                    )
                    graph_lower = dcc.Graph(
                        figure=figdata,
                        id='gaitgraph%d' % (len(_layouts) + k),
                        style={'height': '100%'},
                    )
                dd_opts_multi_upper.append({'label': label, 'value': graph_upper})
                dd_opts_multi_lower.append({'label': label, 'value': graph_lower})

            except (RuntimeError, GaitDataError) as e:  # could not create a figure
                logger.warning(u'failed to create figure for %s: %s' % (label, e))
                # insert the menu options but make them disabled
                dd_opts_multi_upper.append(
                    {'label': label, 'value': label, 'disabled': True}
                )
                dd_opts_multi_lower.append(
                    {'label': label, 'value': label, 'disabled': True}
                )
                continue

        opts_multi, mapper_multi_upper = _make_dropdown_lists(dd_opts_multi_upper)
        opts_multi, mapper_multi_lower = _make_dropdown_lists(dd_opts_multi_lower)

        # if plots were newly created, save them to disk
        if not saved_report_data:
            logger.debug('saving report data into %s' % data_fn)
            signals.progress.emit('Saving report data to disk...', 99)
            with open(data_fn, 'wb') as f:
                pickle.dump(report_data_new, f, protocol=-1)

    def make_left_panel(split=True, upper_value='Kinematics', lower_value='Kinematics'):
        """Helper to make the left graph panels. If split=True, make two stacked panels"""

        # the upper graph & dropdown
        items = [
            dcc.Dropdown(
                id='dd-vars-upper-multi',
                clearable=False,
                options=opts_multi,
                value=upper_value,
            ),
            html.Div(
                id='div-upper', style={'height': '50%'} if split else {'height': '100%'}
            ),
        ]

        if split:
            # add the lower one
            items.extend(
                [
                    dcc.Dropdown(
                        id='dd-vars-lower-multi',
                        clearable=False,
                        options=opts_multi,
                        value=lower_value,
                    ),
                    html.Div(id='div-lower', style={'height': '50%'}),
                ]
            )

        return html.Div(items, style={'height': '80vh'})

    # create the app
    app = dash.Dash('gaitutils')
    # use local packaged versions of JavaScript libs etc. (no internet needed)
    app.css.config.serve_locally = True
    app.scripts.config.serve_locally = True
    app.title = _report_name(sessions, long_name=False)

    # this is for generating the classnames in the CSS
    num2words = {
        1: 'one',
        2: 'two',
        3: 'three',
        4: 'four',
        5: 'five',
        6: 'six',
        7: 'seven',
        8: 'eight',
        9: 'nine',
        10: 'ten',
        11: 'eleven',
        12: 'twelve',
    }
    classname_left = '%s columns' % num2words[LEFT_WIDTH]
    classname_right = '%s columns' % num2words[12 - LEFT_WIDTH]

    if video_only:
        app.layout = html.Div(
            [  # row
                html.Div(
                    [  # single main div
                        dcc.Dropdown(
                            id='dd-camera',
                            clearable=False,
                            options=opts_cameras,
                            value='Front camera',
                        ),
                        dcc.Dropdown(
                            id='dd-video-tag',
                            clearable=False,
                            options=opts_tags,
                            value=opts_tags[0]['value'],
                        ),
                        html.Div(id='videos'),
                    ],
                    className='12 columns',
                ),
            ],
            className='row',
        )
    else:  # the two-panel layout with graphs and video
        app.layout = html.Div(
            [  # row
                html.Div(
                    [  # left main div
                        html.H6(report_name),
                        dcc.Checklist(
                            id='split-left',
                            options=[{'label': 'Two panels', 'value': 'split'}],
                            value=[],
                        ),
                        # need split=True so that both panels are in initial layout
                        html.Div(make_left_panel(split=True), id='div-left-main'),
                    ],
                    className=classname_left,
                ),
                html.Div(
                    [  # right main div
                        dcc.Dropdown(
                            id='dd-camera',
                            clearable=False,
                            options=opts_cameras,
                            value='Front camera',
                        ),
                        dcc.Dropdown(
                            id='dd-video-tag',
                            clearable=False,
                            options=opts_tags,
                            value=opts_tags[0]['value'],
                        ),
                        html.Div(id='videos'),
                    ],
                    className=classname_right,
                ),
            ],
            className='row',
        )

        @app.callback(
            Output('div-left-main', 'children'),
            [Input('split-left', 'value')],
            [State('dd-vars-upper-multi', 'value')],
        )
        def update_panel_layout(split_panels, upper_value):
            split = 'split' in split_panels
            return make_left_panel(split, upper_value=upper_value)

        @app.callback(
            Output('div-upper', 'children'), [Input('dd-vars-upper-multi', 'value')]
        )
        def update_contents_upper_multi(sel_var):
            return mapper_multi_upper[sel_var]

        @app.callback(
            Output('div-lower', 'children'), [Input('dd-vars-lower-multi', 'value')]
        )
        def update_contents_lower_multi(sel_var):
            return mapper_multi_lower[sel_var]

    def _video_elem(title, url, max_height):
        """Create a video element with title"""
        if not url:
            return 'No video found'
        vid_el = html.Video(
            src=url,
            controls=True,
            loop=True,
            preload='auto',
            title=title,
            style={'max-height': max_height, 'max-width': '100%'},
        )
        # return html.Div([title, vid_el])  # titles above videos
        return vid_el

    @app.callback(
        Output('videos', 'children'),
        [Input('dd-camera', 'value'), Input('dd-video-tag', 'value')],
    )
    def update_videos(camera_label, tag):
        """Create a list of video divs according to camera and tag selection"""
        if tag == 'no videos':
            return 'No videos found'
        vid_urls_ = vid_urls[tag][camera_label]
        if not vid_urls_:
            return 'No videos found'
        nvids = len(vid_urls_)
        max_height = str(int(VIDS_TOTAL_HEIGHT / nvids)) + 'vh'
        return [_video_elem('video', url, max_height) for url in vid_urls_]

    # add a static route to serve session data. be careful outside firewalls
    @app.server.route('/static/<resource>')
    def serve_file(resource):
        for session in sessions:
            filepath = op.join(session, resource)
            if op.isfile(filepath):
                return flask.send_from_directory(session, resource)
        return None

    # add shutdown method - see http://flask.pocoo.org/snippets/67/
    @app.server.route('/shutdown')
    def shutdown():
        logger.debug('Received shutdown request...')
        _shutdown_server()
        return 'Server shutting down...'

    # inject some info of our own
    app._gaitutils_report_name = report_name

    # XXX: the Flask app ends up with a logger by the name of 'gaitutils', which has a default
    # stderr handler. since logger hierarchy corresponds to package hierarchy,
    # this creates a bug where all gaitutils package loggers propagate their messages into
    # the app logger and they get shown multiple times. as a dirty fix, we disable the
    # handlers for the app logger (they still get shown since they propagate to the root logger)
    app.logger.handlers = []

    return app