Source code for sensortoolkit.evaluation_objs._performance_report

# -*- coding: utf-8 -*-
"""
This module contains the ``PerformanceReport`` class for constructing air
sensor performance evaluation reports.

.. important::

    At present, reports generated by this module utilize the **base testing**
    reporting templates included alongside U.S. EPA's documents detailing
    recommended performance protocols, metrics, and target values for PM2.5 or
    O3 air sensors.

    Future versions of sensortoolkit may allow the creation of reports for
    enhanced testing, however, currently programmatic creation of reports via
    this module is intendedly strictly for base testing at an ambient, outdoor
    monitoring site.

================================================================================

@Author:
  | Samuel Frederick, NSSC Contractor (ORAU)
  | U.S. EPA / ORD / CEMM / AMCD / SFSB

Created:
  Tue Dec 15 08:53:19 2020
Last Updated:
  Mon Jun 28 16:17:59 2021
"""
import pptx as ppt
import datetime as dt
import numpy as np
import math
import os
import sys
import warnings
from sensortoolkit.evaluation_objs import SensorEvaluation
from sensortoolkit import presets as _presets


[docs]class PerformanceReport(SensorEvaluation): """Generate air sensor performance evaluation reports. Reports are intended for evaluations following U.S. EPA's recommendations for base testing of air sensors at outdoor ambient air monitoring sites and collocated alongside FRM/FEM monitors for use in NSIM applications. In February 2021, U.S. EPA released two reports detailing recommended performance testing protocols, metrics, and target values for the evaluation of sensors measuring either fine particulate matter (PM2.5) or ozone (O3). More detail about EPA's sensor evaluation research as well as both reports can be found online at EPA's Air Sensor Toolbox (`<https://www.epa.gov/air-sensor-toolbox>`_) .. important:: ``PerformanceReport`` is an inherited class of ``SensorEvaluation``. As a result, it inherits all the class and instance attributes of ``SensorEvaluation``, including its numerous variables and data structures. Programmatically, ``PerformanceReport`` is intended as a direct extension of ``SensorEvaluation``; users can easily interact with all the attributes and data stuctures for sensor evaluations. However, whereas ``SensorEvaluation`` allows analysis of a wide number of pollutants and parameters, ``PerformanceReport`` is presently intended for constructing reports pertaining to sensors measuring either fine particulate matter (PM2.5) or ozone (O3) following U.S. EPA's recommended protocols and testing metrics for evaluating these sensors. Module will exit execution if parameters other than ``'PM25'`` or ``'O3'`` are specified. Args: sensor (sensortoolkit.AirSensor object): The air sensor object containing datasets with parameter measurements that will be evaluated. param (sensortoolkit.Parameter object): The parameter (measured environmental quantity) object containing parameter-specific attributes as well as metrics and targets for evaluating sensor performance. reference (sensortoolkit.ReferenceMethod object): The FRM/FEM reference instrument object containing datasets with parameter measurements against which air sensor data will be evaluated. write_to_file (bool, optional): If true, evaluation statistics will be written to the ``/data/eval_stats`` sensor subdirectory. Figures will also be written to the appropriate figures subdirectory. Defaults to False. figure_search (bool, optional): If true, PerformanceReport will search for figures in the ``/figures`` directory before attempting to create new figures. If false, PerformanceReport will create all new figures (may risk overwriting existing figures). Defaults to False. **kwargs (dict): - fmt_sensor_name """ # Evaluation parameters for which the PerformanceReport class can # constuct reports report_params = ['PM25', 'O3'] def __init__(self, sensor, param, reference, write_to_file=False, figure_search=False, **kwargs): # Add keyword arguments (testing_loc, testing_org, etc.) self.__dict__.update(**kwargs) self.kwargs = kwargs # Inherit the SensorEvaluation class instance attributes super().__init__(sensor, param, reference, write_to_file, **kwargs) if self._param_name not in self.report_params: sys.exit('Reporting template not configured for ' + self._param_name) self.figure_search = figure_search # Placeholder method for formatted sensor name, replace '_' with spaces self.fmt_sensor_name = self.kwargs.get('fmt_sensor_name', self.name.replace('_', ' ')) self.today = dt.datetime.now().strftime('%y%m%d') self.template_name = ('Reporting_Template_Base_' + self._param_name + '.pptx') # Path to reporting template self.template_path = os.path.abspath(os.path.join(__file__, '../templates', self._param_name, self.template_name)) # Details about testing and deployment site self.testing_loc = _presets.test_loc self.testing_org = _presets.test_org # Populate deployment dictionary with performance metric results self.calculate_metrics() # Sampling timeframe self.tframe = {grp: self.deploy_dict['Deployment Groups'][grp]['eval_start'] + ' to ' + self.deploy_dict['Deployment Groups'][grp]['eval_end'] for grp in list( self.deploy_dict['Deployment Groups'].keys()) } # Keys are sensor serial IDs, values are deployment group number # Useful if multiple evaluation groups deployed self.serial_grp_dict = {} for grp in self.deploy_dict['Deployment Groups']: grp_dict = self.deploy_dict['Deployment Groups'][grp] grp_sensors = grp_dict['sensors'] for sensor in grp_sensors: serial = grp_sensors[sensor]['serial_id'] self.serial_grp_dict[serial] = grp # Initialize report object self.rpt = ppt.Presentation(self.template_path) self.shapes = self.rpt.slides[0].shapes # Shape at backgroud around which to orient other figures self.cursor_sp = self.shapes[0]._element # The number of unique averaging intervals at which data will be # presented. Either param.averaging == ['1-hour'] (gases, # n_avg_intervals == 1) or param.averaging == ['1-hour', '24-hour'] # (PM, n_avg_intervals == 2). self.n_avg_intervals = len(self.param.averaging) # Initialize figure positions in report self.FigPositions() # Plotting: determine the max concentration for average of concurrent # sensor measurements and also the ref max concentration. Select the # upper limit for plots as 1.25x the larger of these values. sensor_avg_cmax = self.avg_hrly_df['mean_' + self._param_name + '_Value'].max() ref_cmax = self.hourly_ref_df[self._param_name + '_Value'].max() self.plot_cmax = 1.25*max(sensor_avg_cmax, ref_cmax)
[docs] def FigPositions(self): """Assign figure positions for reports. Values are in inches, specifying the left and top center location of each figure. Returns: None. """ self.fig_locs = {'SingleScatter': {'left': '', 'top': ''}, 'TripleScatter': {'left': '', 'top': ''}, 'Timeseries': {'left': '', 'top': ''}, 'MetricPlot': {'left': '', 'top': ''}, 'MetDist': {'left': '', 'top': ''}, 'MetInfl': {'left': '', 'top': ''} } if self.n_avg_intervals == 2: self.fig_locs['SingleScatter']['left'] = 11.11 self.fig_locs['SingleScatter']['top'] = 8.16 self.fig_locs['TripleScatter']['left'] = 2.35 self.fig_locs['TripleScatter']['top'] = 3.82 self.fig_locs['Timeseries']['left'] = 0.63 self.fig_locs['Timeseries']['top'] = 8.15 self.fig_locs['MetricPlot']['left'] = 0.65 self.fig_locs['MetricPlot']['top'] = 13.19 self.fig_locs['MetDist']['left'] = 1.91 self.fig_locs['MetDist']['top'] = 17.58 self.fig_locs['MetInfl']['left'] = 8.24 self.fig_locs['MetInfl']['top'] = 17.54 if self.n_avg_intervals == 1: self.fig_locs['SingleScatter']['left'] = 11.57 self.fig_locs['SingleScatter']['top'] = 8.14 self.fig_locs['TripleScatter']['left'] = 2.35 self.fig_locs['TripleScatter']['top'] = 3.82 self.fig_locs['Timeseries']['left'] = 0.63 self.fig_locs['Timeseries']['top'] = 8.19 self.fig_locs['MetricPlot']['left'] = 0.7 self.fig_locs['MetricPlot']['top'] = 12.78 self.fig_locs['MetDist']['left'] = 1.6 self.fig_locs['MetDist']['top'] = 17.3 self.fig_locs['MetInfl']['left'] = 8.28 self.fig_locs['MetInfl']['top'] = 17.31
[docs] def FigureSearch(self, figure_name, subfolder=None): """Indicate whether a figure exists and the full path to the figure. Args: figure_name (str): The filename for the figure. subfolder (str, optional): The subdirectory within the figure path where the file is located. Defaults to None. Returns: (tuple): Two-element tuple containing: - **bool**: True if the figure exists, false otherwise. - **full_figure_path** (*str*): The full directory path. """ if subfolder is None: subfolder = self._param_name # Search for figure created today figure_name += '_' + self.today + '.png' full_figure_path = os.path.join(self.figure_path, subfolder, figure_name) return os.path.exists(full_figure_path), full_figure_path
[docs] def AddFigure(self, fig_name, fig_path): """ Args: fig_name (TYPE): DESCRIPTION. fig_path (TYPE): DESCRIPTION. Returns: None. """ fig_loc = self.fig_locs[fig_name] figure = self.shapes.add_picture(fig_path, left=ppt.util.Inches(fig_loc['left']), top=ppt.util.Inches(fig_loc['top']) ) # Move image to 0 z-order (background) self.cursor_sp.addprevious(figure._element)
[docs] def AddSingleScatterPlot(self, **kwargs): """Add sensor vs reference scatter plots to report. Args: **kwargs (dict): Keyword arguments passed to ``plot_sensor_scatter()`` subroutine for drawing scatter plots. Returns: None. """ fig_name = self.name + '_vs_' + self.ref_name + '_report_fmt' fig_exists, fig_path = self.FigureSearch(fig_name) # Draw figure if no figure exists at path or if figure_search attrib # is false create_figure = False if self.figure_search is False: create_figure = True else: if fig_exists is False: create_figure = True if create_figure: self.write_to_file = True self.plot_sensor_scatter( plot_subset=kwargs.get('plot_subset', ['1']), # plot_limits=kwargs.get('plot_limits', (-1, self.plot_cmax)), # tick_spacing=kwargs.get('tick_spacing', 5), # text_pos=kwargs.get('text_pos', 'upper_left'), report_fmt=True) self.AddFigure(fig_name='SingleScatter', fig_path=fig_path)
[docs] def AddMultiScatter(self, **kwargs): """Add Sensor vs reference scatter plots for all sensors. Args: **kwargs (dict): Keyword arguments passed to ``plot_sensor_scatter()`` subroutine for drawing scatter plots. Returns: None. """ # Use slide layout for generating additional slides slide_layout_idx = 0 slide_layout = self.rpt.slide_layouts[slide_layout_idx] # Create new page slide = self.rpt.slides.add_slide(slide_layout) # Add Sensor-Sensor section header text label section_header = slide.shapes.add_textbox( ppt.util.Inches(0.82), # left ppt.util.Inches(2.81), # top ppt.util.Inches(3.16), # width ppt.util.Inches(0.47)) # height section_header_obj = section_header.text_frame.paragraphs[0] section_header_obj.text = 'Sensor-FRM/FEM Scatter Plots' self.FormatText(section_header_obj, alignment='left', font_name='Calibri Light', font_size=22) # A horizontal line separating the header text from the section body hline = slide.shapes.add_connector( ppt.enum.shapes.MSO_CONNECTOR.STRAIGHT, ppt.util.Inches(0.85), # start_x ppt.util.Inches(3.30), # start_y ppt.util.Inches(16.24), # end_x ppt.util.Inches(3.30)) # end_y hline.line.fill.solid() hline.line.fill.fore_color.rgb = ppt.dml.color.RGBColor(171, 171, 171) # Loop over averaging intervals specified for the parameter for i, averaging_interval in enumerate(self.param.averaging): if self.n_sensors > 1: plural = 's' else: plural = '' fig_name = (self.name + '_vs_' + self.ref_name + '_' + averaging_interval + '_' + str(self.n_sensors) + '_' + 'sensor' + plural) fig_exists, fig_path = self.FigureSearch(fig_name) # Draw figure if no figure exists at path or if figure_search # attrib is false create_figure = False if self.figure_search is False: create_figure = True else: if fig_exists is False: create_figure = True if create_figure: self.write_to_file = True self.plot_sensor_scatter( averaging_interval, plot_limits=kwargs.get('plot_limits', (-1, self.plot_cmax)), # tick_spacing=kwargs.get('tick_spacing', 5), # text_pos=kwargs.get('text_pos', 'upper_left') ) if self.n_sensors <= 3: scatter_loc = self.fig_locs['TripleScatter'] fig_height = 5.62 # height of triple scatter figure in inches else: # Add a page, place figure on new page # FIXME: Correct figure position needed for scatter plots with # more than three sensors. scatter_loc = self.fig_locs['TripleScatter'] # TODO: set correct figure height for figures with mult. rows fig_height = 5.62 # height of triple scatter figure in inches left = ppt.util.Inches(scatter_loc['left']) top = ppt.util.Inches(scatter_loc['top'] + i*fig_height) slide.shapes.add_picture(fig_path, left, top)
[docs] def AddTimeseriesPlot(self, **kwargs): """Add timeseries plots to report. Args: **kwargs (dict): Keyword arguments passed to ``sensor_timeplot()`` subroutine for drawing timeseries plots. Returns: None. """ fig_name = self.name + '_timeseries_' + self._param_name \ + '_report_fmt' fig_exists, fig_path = self.FigureSearch(fig_name) # Draw figure if no figure exists at path or if figure_search # attrib is false create_figure = False if self.figure_search is False: create_figure = True else: if fig_exists is False: create_figure = True if create_figure: self.write_to_file = True self.plot_timeseries(report_fmt=True) self.AddFigure(fig_name='Timeseries', fig_path=fig_path)
[docs] def AddMetricsPlot(self, **kwargs): """Add Performance target metric boxplots/dot plots to report. Args: **kwargs (dict): Keyword arguments passed to ``performance_metrics()`` subroutine for drawing performance metric plots. Returns: None. """ fig_name = self.name + '_regression_boxplot_' + self._param_name fig_exists, fig_path = self.FigureSearch(fig_name) # Draw figure if no figure exists at path or if figure_search # attrib is false create_figure = False if self.figure_search is False: create_figure = True else: if fig_exists is False: create_figure = True if create_figure: self.write_to_file = True self.plot_metrics() self.AddFigure(fig_name='MetricPlot', fig_path=fig_path)
[docs] def AddMetDistPlot(self, **kwargs): """Add meteorological distribution (Temp, RH) to report. Args: **kwargs (dict): Keyword arguments passed to ``met_distrib()`` subroutine for drawing distribution plots. Returns: None. """ fig_name = self.name + '_met_distplot_report_fmt' fig_exists, fig_path = self.FigureSearch(fig_name, subfolder='Met') # Draw figure if no figure exists at path or if figure_search # attrib is false create_figure = False if self.figure_search is False: create_figure = True else: if fig_exists is False: create_figure = True if create_figure: self.write_to_file = True self.plot_met_dist() self.AddFigure(fig_name='MetDist', fig_path=fig_path)
[docs] def AddMetInflPlot(self, **kwargs): """Add normalized met. influence scatter (Temp, RH) to report. Args: **kwargs (dict): Keyword arguments passed to ``normalized_met_scatter()`` subroutine for drawing distribution plots. Returns: None. """ fig_name = self.name + '_normalized_' + self._param_name \ + '_met_report_fmt' fig_exists, fig_path = self.FigureSearch(fig_name) # Draw figure if no figure exists at path or if figure_search # attrib is false create_figure = False if self.figure_search is False: create_figure = True else: if fig_exists is False: create_figure = True if create_figure: self.write_to_file = True self.plot_met_influence(report_fmt=True, plot_error_bars=False) self.AddFigure(fig_name='MetInfl', fig_path=fig_path)
[docs] def GetShape(self, slide_idx, shape_id=None, shape_loc=None): """Retrieve shape object for tables based on known shape ID. Allows for editing, modifying the table and its cells. Return either based on left and top location passed in inches to function (shape_loc=(left, top)), or by passing shape index to function. Args: slide_idx (int): The index position (beginning at zero) for the slide on which the shape is located. shape_id (int, optional): An integer assigned to the shape by the powerpoint API. If not known, can pass as none, but the shape_loc should be indicated. Defaults to None. shape_loc (Two-element tuple, optional): The x and y position of the top left-hand corner of the shape. The x-position is measured from the left-most part of the slide and the y-position is measured down (positive) from the topmost part of the slide. Defaults to None. Returns: shape (python-pptx.Presentation.slides[slide_idx].shapes.item): The slide shape object located at the location or ID specified. """ if shape_loc is not None: shp_l = shape_loc[0] shp_t = shape_loc[1] for shape in self.rpt.slides[slide_idx].shapes: if (math.isclose(shape.left.inches, shp_l, rel_tol=.05) and math.isclose(shape.top.inches, shp_t, rel_tol=.05)): return shape print('No shape within specified tolerance!') else: for shape in self.rpt.slides[slide_idx].shapes: if shape.shape_id == shape_id: return shape
[docs] def EditHeader(self): """Insert header description (title, contact info, photo, etc.). ======================== ============ =================== Shape name Slide Number Shape ID ======================== ============ =================== Report Title 1 35 (PM2.5), 9 (O3) Report Title 2 21 (PM2.5), 21 (O3) Report Title 3 13 (PM2.5), 15 (O3) Deployment, contact info 1 34 (PM2.5), 33 (O3) Deployment, contact info 2 20 (PM2.5), 22 (O3) Deployment, contact info 3 12 (PM2.5), 16 (O3) Photo placeholder 1 2 (PM2.5), 2 (O3) Photo placeholder 2 4 (PM2.5), 3 (O3) Photo placeholder 3 14 (PM2.5), 3 (O3) ======================== ============ =================== Returns: None """ # Title location for header title_left = 0.98 title_top = 0.51 text_vspace = 0 # Get pptx text shape for modifying cells for slide_n in np.arange(1, len(self.rpt.slides) + 1): slide_idx = int(slide_n) - 1 title = self.GetShape(slide_idx, shape_loc=(title_left, title_top)) # Set evaluation report title line 1: Parameter and test type title_line_1 = title.text_frame.paragraphs[0] run1 = title_line_1.add_run() run1.text = 'Testing Report - ' param_baseline = title_line_1.add_run() param_baseline.text = self.param.format_baseline param_subscript = title_line_1.add_run() param_subscript.text = self.param.format_subscript font = param_subscript.font self.SetSubscript(font) run4 = title_line_1.add_run() run4.text = ' Base Testing' self.FormatText(title_line_1, alignment='left', font_name='Calibri', font_size=30) title_line_1_font = title_line_1.font title_line_1_font.line_spacing = ppt.util.Pt(text_vspace) # Set evaluation report title line 2: Sensor name title_text_obj = title.text_frame.add_paragraph() title_text_obj.text = self.fmt_sensor_name self.FormatText(title_text_obj, alignment='left', font_name='Calibri Light', font_size=30) title_font_obj = title_text_obj.font title_font_obj.line_spacing = ppt.util.Pt(text_vspace) # Header testing information location tester_left = 7.80 tester_top = 0.36 text_vspace = 10 # Get pptx text shape for deployment and contact information for slide_n in np.arange(1, len(self.rpt.slides) + 1): slide_idx = int(slide_n) - 1 tester_info = self.GetShape(slide_idx, shape_loc=(tester_left, tester_top)) # Set deployment number tester_text = tester_info.text_frame.paragraphs[0] tester_text.text = self.testing_org['Deployment name'] self.FormatText(tester_text, alignment='left', font_name='Calibri', font_size=20, bold=True) tester_text.line_spacing = ppt.util.Pt(text_vspace) # Set testing organization name (line 2) tester_text = tester_info.text_frame.add_paragraph() tester_text.text = self.testing_org['Org name'][0] self.FormatText(tester_text, alignment='left', font_name='Calibri Light', font_size=20) tester_text.line_spacing = ppt.util.Pt(text_vspace) # Set testing organization name (line 2) tester_text = tester_info.text_frame.add_paragraph() tester_text.text = self.testing_org['Org name'][1] self.FormatText(tester_text, alignment='left', font_name='Calibri Light', font_size=20) tester_text.line_spacing = ppt.util.Pt(text_vspace) # Add contact email tester_text = tester_info.text_frame.add_paragraph() tester_text.text = (self.testing_org['Contact email']) self.FormatText(tester_text, alignment='left', font_name='Calibri Light', font_size=20) tester_text.line_spacing = ppt.util.Pt(text_vspace) # Add contact phone number tester_text = tester_info.text_frame.add_paragraph() tester_text.text = (' ' + self.testing_org['Contact phone']) self.FormatText(tester_text, alignment='left', font_name='Calibri Light', font_size=20) tester_text.line_spacing = ppt.util.Pt(text_vspace) # Add current month to header month_year = dt.datetime.now().strftime('%B %Y') tester_text = tester_info.text_frame.add_paragraph() tester_text.text = month_year self.FormatText(tester_text, alignment='left', font_name='Calibri Light', font_size=20) tester_text.line_spacing = ppt.util.Pt(text_vspace) # Edit header photo (edits the image placeholder) pic_left = 12.56 pic_top = 0.29 # Get pptx text shape for deployment and contact information for slide_n in np.arange(1, len(self.rpt.slides) + 1): slide_idx = int(slide_n) - 1 pic = self.GetShape(slide_idx, shape_loc=(pic_left, pic_top)) pic_path = os.path.join(self.figure_path, 'deployment', f'{self.name}.png') if not os.path.exists(pic_path): print('No deployment picture found at', pic_path) placeholder_path = os.path.join(__file__, '../templates', 'placeholder_image.png') placeholder_path = os.path.normpath(placeholder_path) try: pic.insert_picture(placeholder_path) except AttributeError: pass else: pic.insert_picture(pic_path)
[docs] def EditSiteTable(self): """Add details to testing organzation and site info table (page 1). ====================== ======= Table name TableID ====================== ======= Testing org, site info 18 ====================== ======= Returns: None. """ # Get pptx table shape for modifying cells shape = self.GetShape(slide_idx=0, shape_id=18) # ------------- Cell 1: Testing organization information -------------- cell = shape.table.cell(1, 1) # Add organization name text_obj = cell.text_frame.paragraphs[0] text_obj.text = (self.testing_org['Org name'][0] + ' - ' + self.testing_org['Org name'][1]) self.FormatText(text_obj, alignment='left', font_name='Calibri', font_size=14) # Add contact link text_obj = cell.text_frame.add_paragraph() run = text_obj.add_run() run.text = self.testing_org['Website']['website name'] link = self.testing_org['Website']['website link'] hlink = run.hyperlink hlink.address = link self.FormatText(text_obj, alignment='left', font_name='Calibri', font_size=11) # Add contact email, phone number text_obj = cell.text_frame.add_paragraph() text_obj.text = (self.testing_org['Contact email'] + '\n' + ' ' + self.testing_org['Contact phone']) self.FormatText(text_obj, alignment='left', font_name='Calibri', font_size=11) # ----------- Cell 2: Testing location information ---------------- cell = shape.table.cell(2, 1) # Add site name text_obj = cell.text_frame.paragraphs[0] text_obj.text = self.testing_loc['Site name'] self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=14) # Add site address text_obj = cell.text_frame.add_paragraph() text_obj.text = self.testing_loc['Site address'] self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=11) # Add site lat long text_obj = cell.text_frame.add_paragraph() text_obj.text = (self.testing_loc['Site lat'] + ', ' + self.testing_loc['Site long']) self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=11) # --------------- Cell 2: Testing location AQS ID --------------------- cell = shape.table.cell(3, 1) # Add site name text_obj = cell.text_frame.paragraphs[0] text_obj.text = self.testing_loc['Site AQS ID'] self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=14) # ------------------ Cell 3: Sampling timeframe ----------------------- cell = shape.table.cell(4, 1) # Add sampling timeframe for each group in deployment self.eval_grps = list(self.tframe.keys()) for i, grp in enumerate(self.tframe): if i == 0: text_obj = cell.text_frame.paragraphs[0] else: text_obj = cell.text_frame.add_paragraph() grp_name = self.eval_grps[i] grp_tframe = self.tframe[grp_name] if len(self.eval_grps) == 1: text_obj.text = grp_tframe else: text_obj.text = grp_name + ': ' + grp_tframe self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=11)
[docs] def EditSensorTable(self): """Add information to sensor information table (page 1). =========== =================== Table name TableID =========== =================== Sensor info 49 (PM2.5), 30 (O3) =========== =================== Returns: None. """ # Get pptx table shape for modifying cells if self.n_avg_intervals == 2: shape = self.GetShape(slide_idx=0, shape_id=49) if self.n_avg_intervals == 1: shape = self.GetShape(slide_idx=0, shape_id=30) # Populate list with configured sensor recording interval(s) rec_interval = [] for grp in self.deploy_dict['Deployment Groups']: grp_dict = self.deploy_dict['Deployment Groups'][grp] grp_sensors = grp_dict['sensors'] for sensor in grp_sensors: interval = grp_sensors[sensor]['recording_interval'] interval = interval.replace('.0', '') rec_interval.append(interval) rec_interval = list(set(rec_interval)) rec_str = ', '.join(string for string in rec_interval) # ------------- Cell 1: Sensor manufacturer and model ----------------- cell = shape.table.cell(1, 1) # Add sensor manufacturer, model name text_obj = cell.text_frame.paragraphs[0] text_obj.text = self.fmt_sensor_name self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=14) # --------------- Cell 3: Sensor recording interval ------------------- cell = shape.table.cell(3, 1) # Add recording interval text_obj = cell.text_frame.paragraphs[0] text_obj.text = rec_str self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=14) # -------------------- Sensor Serial ID cells ------------------------- rows = [4, 5, 6] # row indicies for sensor serial cells cols = [1, 3, 4] # column indicies for sensor serial cells for i, iloc in enumerate(rows): for j, jloc in enumerate(cols): cell_n = 3*i + (j + 1) # Only fill in ID info for number of sensors in evaluation. # If number of sensors is, say, 8, then the last cell for the # 3x3 grid for serial numbers is left empty. if len(self.serial_grp_dict) < cell_n: pass else: cell = shape.table.cell(iloc, jloc) serial_idx = cell_n - 1 sensor_serial = list(self.serial_grp_dict.keys() )[serial_idx] sensor_grp = list(self.serial_grp_dict.values() )[serial_idx] # Add group number if multiple groups if len(self.eval_grps) > 1: grp_obj = cell.text_frame.paragraphs[0] grp_obj.text = sensor_grp self.FormatText(grp_obj, alignment='center', font_name='Calibri', font_size=10.5) # Add sensor serial ID serial_obj = cell.text_frame.add_paragraph() else: serial_obj = cell.text_frame.paragraphs[0] serial_obj.text = sensor_serial self.FormatText(serial_obj, alignment='center', font_name='Calibri', font_size=10.5) # Check for issues with deployment for i, grp in enumerate(self.deploy_dict['Deployment Groups']): deploy_grp_data = (self.deploy_dict['Deployment Groups'] [grp]['sensors']) grp_sensors = list(deploy_grp_data.keys()) grp_status = [deploy_grp_data[str(j)]['deploy_issues'] for j in grp_sensors] cell = shape.table.cell(7, 2) if list(set(grp_status)) == ['False']: if i == 0: textobj = cell.text_frame.paragraphs[0] else: textobj = cell.text_frame.add_paragraph() if len(self.eval_grps) == 1: textobj.text = 'No Issues' else: textobj.text = grp + ': No Issues' self.FormatText(textobj, alignment='center', font_name='Calibri', font_size=12) else: if i == 0: textobj = cell.text_frame.paragraphs[0] else: textobj = cell.text_frame.add_paragraph() if len(self.eval_grps) == 1: textobj.text = 'Issues with deployment' else: textobj.text = grp + ': Issues with deployment' self.FormatText(textobj, alignment='center', font_name='Calibri', font_size=12)
[docs] def EditRefTable(self): """Add details to reference information table. ============== ======= Table name TableID ============== ======= Reference info 51 ============== ======= Returns: None. """ # Get pptx table shape for modifying cells shape = self.GetShape(slide_idx=0, shape_id=51) # ------------- Cell 1: Sensor manufacturer and model ----------------- cell = shape.table.cell(1, 1) # Add reference manufacturer and model text_obj = cell.text_frame.paragraphs[0] text_obj.text = self.ref_name self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=14)
[docs] def EditRefConcTable(self): """Add reference concentration tabular statistics (page 1). Located in different boxes based on the evaluation parameter type. Scatter plots box (PM2.5 only): =================== ======= Table name TableID =================== ======= Reference conc info 75 =================== ======= Time series box (O3 only): =================== ======= Table name TableID =================== ======= Reference conc info 56 =================== ======= Returns: None. """ # Get pptx table shape for modifying cells if self.n_avg_intervals == 2: shape = self.GetShape(slide_idx=0, shape_id=75) if self.n_avg_intervals == 1: shape = self.GetShape(slide_idx=0, shape_id=56) grp_info = self.deploy_dict['Deployment Groups'] # reference concentration range self.refconc = {} for grp in list(grp_info.keys()): ref = 'Reference' try: ref_hmin = grp_info[grp][self._param_name][ref][ 'conc_min_1-hour'] ref_hmax = grp_info[grp][self._param_name][ref][ 'conc_max_1-hour'] ref_dmin = grp_info[grp][self._param_name][ref][ 'conc_min_24-hour'] ref_dmax = grp_info[grp][self._param_name][ref][ 'conc_max_24-hour'] self.refconc[grp] = \ '{0:3.1f}-{1:3.1f} (1-hr), '\ '{2:3.1f}-{3:3.1f} (24-hr)'.format(ref_hmin, ref_hmax, ref_dmin, ref_dmax) # Raise when attributes are 'none' likely due to no data except TypeError: pass # Number of periods reference exceeded concentration target if self.n_avg_intervals == 2: exceed_str = 'n_exceed_conc_goal_24-hour' if self.n_avg_intervals == 1: exceed_str = 'n_exceed_conc_goal_1-hour' self.refexceed = {} for grp in list(grp_info.keys()): try: self.refexceed[grp] = \ '{0:d}'.format( grp_info[grp][self._param_name]['Reference'][exceed_str] ) # Raise when attributes are 'none' likely due to no data except TypeError: pass # ------------- Cell 1: Range of ref concentrations ----------------- cell = shape.table.cell(0, 1) for i, grp in enumerate(self.refconc): if i == 0: text_obj = cell.text_frame.paragraphs[0] else: text_obj = cell.text_frame.add_paragraph() grp_name = list(self.refconc.keys())[i] grp_refconc = self.refconc[grp_name] if len(self.eval_grps) == 1: text_obj.text = grp_refconc else: text_obj.text = grp_name + ': ' + grp_refconc self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=9) # ------------- Cell 2: N periods meeting conc. target----------------- cell = shape.table.cell(1, 1) for i, grp in enumerate(self.refexceed): if i == 0: text_obj = cell.text_frame.paragraphs[0] else: text_obj = cell.text_frame.add_paragraph() grp_name = list(self.refexceed.keys())[i] grp_refexceed = self.refexceed[grp_name] if len(self.eval_grps) == 1: text_obj.text = grp_refexceed else: text_obj.text = grp_name + ': ' + grp_refexceed self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=9)
[docs] def EditMetCondTable(self): """Add meteorological conditions table (page 1). ========================= ================== Table name TableID ========================= ================== N outside target criteria 45 (O3), 74 (PM25) ========================= ================== Returns: None. """ # Get pptx table shape for modifying cells if self.n_avg_intervals == 2: shape = self.GetShape(slide_idx=0, shape_id=74) if self.n_avg_intervals == 1: shape = self.GetShape(slide_idx=0, shape_id=32) grp_info = self.deploy_dict['Deployment Groups'] # Number of 24-hr periods temp exceeded target criteria self.tempexceed = {} exceed_str = 'n_exceed_target_criteria_24-hour' met_conds = 'Meteorological Conditions' # abbreviating temp = 'Temperature' # abbreviating for grp in list(grp_info.keys()): try: self.tempexceed[grp] = \ '{0:d}'.format( grp_info[grp][met_conds][temp][exceed_str]) # Raise when attributes are 'none' likely due to no data except TypeError: pass except ValueError: self.tempexceed[grp] = '0' # Number of 24-hr periods temp exceeded target criteria self.rhexceed = {} exceed_str = 'n_exceed_target_criteria_24-hour' met_conds = 'Meteorological Conditions' # abbreviating rh = 'Relative Humidity' # abbreviating for grp in list(grp_info.keys()): try: self.rhexceed[grp] = \ '{0:d}'.format( grp_info[grp][met_conds][rh][exceed_str]) # Raise when attributes are 'none' likely due to no data except TypeError: pass except ValueError: self.rhexceed[grp] = '0' # ----------- Cell 1: N periods outside temp target criteria ---------- cell = shape.table.cell(0, 1) for i, grp in enumerate(self.tempexceed): if i == 0: text_obj = cell.text_frame.paragraphs[0] else: text_obj = cell.text_frame.add_paragraph() grp_name = list(self.tempexceed.keys())[i] grp_tempexceed = self.tempexceed[grp_name] if len(self.eval_grps) == 1: text_obj.text = grp_tempexceed else: text_obj.text = grp_name + ': ' + grp_tempexceed self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=10) # ----------- Cell 2: N periods outside rh target criteria ------------ cell = shape.table.cell(1, 1) for i, grp in enumerate(self.rhexceed): if i == 0: text_obj = cell.text_frame.paragraphs[0] else: text_obj = cell.text_frame.add_paragraph() grp_name = list(self.rhexceed.keys())[i] grp_rhexceed = self.rhexceed[grp_name] if len(self.eval_grps) == 1: text_obj.text = grp_rhexceed else: text_obj.text = grp_name + ': ' + grp_rhexceed self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=10)
[docs] def EditMetInfTable(self): """Add meteorological influence table (page 1). ====================== ================== Table name TableID ====================== ================== N paired met conc vals 48 (O3), 76 (PM25) ====================== ================== Returns: None. """ # Get pptx table shape for modifying cells if self.n_avg_intervals == 2: shape = self.GetShape(slide_idx=0, shape_id=76) if self.n_avg_intervals == 1: shape = self.GetShape(slide_idx=0, shape_id=33) grp_info = self.deploy_dict['Deployment Groups'] # Number of 1-hr periods temp exceeded target criteria params = ['Temperature', 'Relative Humidity'] met_conds = 'Meteorological Conditions' pair_str = 'n_measurement_pairs_1-hour' dic = {} for i, param in enumerate(params): dic[param] = {grp: grp_info[grp][met_conds][param][pair_str] for grp in list(grp_info.keys())} for j, grp in enumerate(self.deploy_dict['Deployment Groups']): cell = shape.table.cell(i, 1) if j == 0: text_obj = cell.text_frame.paragraphs[0] else: text_obj = cell.text_frame.add_paragraph() grp_val = dic[param][grp] if grp_val == '': grp_val = 0 if len(self.eval_grps) == 1: text_obj.text = str(round(grp_val)) else: text_obj.text = grp + ': ' + str(round(grp_val)) self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=10)
[docs] def EditSensorRefTable(self, table): """Add sensor-reference tabular statistics (page 2). Args: table (pptx table object): A table object for sensor vs. reference regression statistics. Returns: None. """ if self.n_avg_intervals == 2: span_dict = {'Bias and Linearity': [1, 6], 'Data Quality': [7, 10], 'R^2': [12, 13], 'Slope': [14, 15], f'Intercept (b)\n({self.param.units})': [16, 17], 'Uptime (%)': [18, 19], 'N pairs': [20, 21]} table_categories = {'1': 'Bias and Linearity', '7': 'Data Quality'} metrics = {'12': 'R^2', '14': 'Slope', '16': f'Intercept\n({self.param.units})', '18': 'Uptime\n(%)', '20': 'Number of paired\nsensor and ' 'FRM/FEM\nconcentration pairs'} avg_intervals = {'23': '1-Hour', '24': '24-Hour', '25': '1-Hour', '26': '24-Hour', '27': '1-Hour', '28': '24-Hour', '29': '1-Hour', '30': '24-Hour', '31': '1-Hour', '32': '24-Hour'} rsqr_lbound = self.param.PerformanceTargets.get_metric( metrics['12'])['bounds'][0] slope_goal = self.param.PerformanceTargets.get_metric( metrics['14'])['goal'] slope_tol = self.param.PerformanceTargets.get_metric( metrics['14'])['bounds'][1] - slope_goal intcpt_lbound = self.param.PerformanceTargets.get_metric( metrics['16'].split('\n')[0])['bounds'][0] intcpt_ubound = self.param.PerformanceTargets.get_metric( metrics['16'].split('\n')[0])['bounds'][1] metric_targets = {'33': 'Metric Target Range', '34': '≥ {:3.2f}'.format(rsqr_lbound), '35': '≥ {:3.2f}'.format(rsqr_lbound), '36': '{:2.1f} ± {:3.2f}'.format(slope_goal, slope_tol), '37': '{:2.1f} ± {:3.2f}'.format(slope_goal, slope_tol), '38': '{:1.0f} ≤ b ≤ {:1.0f}'.format( intcpt_lbound, intcpt_ubound), '39': '{:1.0f} ≤ b ≤ {:1.0f}'.format( intcpt_lbound, intcpt_ubound), '40': '75%*', '41': '75%*', '42': '-', '43': '-'} if self.n_avg_intervals == 1: span_dict = {'Bias and Linearity': [1, 3], 'Data Quality': [4, 5]} table_categories = {'1': 'Bias and Linearity', '4': 'Data Quality'} metrics = {'7': 'R^2', '8': 'Slope', '9': f'Intercept\n({self.param.units})', '10': 'Uptime\n(%)', '11': 'Number of paired\nsensor and ' 'reference\nconcentration pairs'} avg_intervals = {'13': '1-Hour', '14': '1-Hour', '15': '1-Hour', '16': '1-Hour', '17': '1-Hour'} rsqr_lbound = self.param.PerformanceTargets.get_metric( metrics['7'])['bounds'][0] slope_goal = self.param.PerformanceTargets.get_metric( metrics['8'])['goal'] slope_tol = self.param.PerformanceTargets.get_metric( metrics['8'])['bounds'][1] - slope_goal intcpt_lbound = self.param.PerformanceTargets.get_metric( metrics['9'].split('\n')[0])['bounds'][0] intcpt_ubound = self.param.PerformanceTargets.get_metric( metrics['9'].split('\n')[0])['bounds'][1] metric_targets = {'18': 'Metric Target Range', '19': '≥ {:3.2f}'.format(rsqr_lbound), '20': '{:2.1f} ± {:3.2f}'.format(slope_goal, slope_tol), '21': '{:1.0f} ≤ b ≤ {:1.0f}'.format( intcpt_lbound, intcpt_ubound), '22': '75%*', '23': '-'} self.grp_stats = self.stats_df.dropna() cells = self.SetSpanningCells(table, span_dict) if self.n_avg_intervals == 1: c1_row, c2_row, c3_row, c4_row, c5_row = 0, 0, 0, 0, 0 h_stats = self.grp_stats.where( self.grp_stats['Averaging Interval'] == '1-hour' ).dropna().reset_index(drop=True) if self.n_avg_intervals == 2: (c1_row, c2_row, c3_row, c4_row, c5_row, c6_row, c7_row, c8_row, c9_row, c10_row) = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0) h_stats = self.grp_stats.where( self.grp_stats['Averaging Interval'] == '1-hour' ).dropna().reset_index(drop=True) d_stats = self.grp_stats.where( self.grp_stats['Averaging Interval'] == '24-hour' ).dropna().reset_index(drop=True) # Sensor group dictionary, reset group keys for indexing grp_df = (self.deploy_dict['Deployment Groups'] [self.grp_name]['sensors']) grp_df = {i: grp_df[k] for i, k in enumerate(sorted(grp_df.keys()))} # Lists for storing metric values to compute mean across sensors rsqr_h_vals, rsqr_d_vals = [], [] slope_h_vals, slope_d_vals = [], [] intcpt_h_vals, intcpt_d_vals = [], [] self.uptime_h_vals, self.uptime_d_vals = [], [] n_h_vals, n_d_vals = [], [] for i, cell in enumerate(cells): text_obj = cell.text_frame.paragraphs[0] n_header_rows = 4 if self.n_avg_intervals == 1: datacols = 6 if self.n_avg_intervals == 2: datacols = 11 datacell0 = datacols*n_header_rows datacellend = datacell0 + self.grp_n_sensors*datacols # Add table header info (rows 1-4) if str(i) in table_categories: text_obj.text = table_categories[str(i)] font_obj = text_obj.font font_obj.color.rgb = ppt.dml.color.RGBColor(0, 0, 0) if str(i) in metrics: metric = metrics[str(i)] # Format the superscript for R^2 if metric == 'R^2': baseline = text_obj.add_run() baseline.text = 'R' superscript = text_obj.add_run() superscript.text = '2' font = superscript.font self.SetSuperscript(font) # Format superscript for intercept if units in ug/m^3 elif metric == 'Intercept\n(μg/m^3)': lineone = text_obj.add_run() lineone.text = 'Intercept\n' linetwo_baseline_one = text_obj.add_run() linetwo_baseline_one.text = '(μg/m' superscript = text_obj.add_run() superscript.text = '3' font = superscript.font self.SetSuperscript(font) linetwo_baseline_two = text_obj.add_run() linetwo_baseline_two.text = ')' else: text_obj.text = metrics[str(i)] if str(i) in metric_targets: text_obj.text = metric_targets[str(i)] if str(i) in avg_intervals: text_obj.text = avg_intervals[str(i)] if i == datacell0 + self.grp_n_sensors*datacols: # Row of avg values at bottom of table text_obj.text = 'Mean' # Add Sensor Serial IDs to column 1 if (i % datacols == 0 and i >= datacell0 and c1_row < self.grp_n_sensors): try: text_obj.text = 'Sensor ' + grp_df[c1_row]['serial_id'] except KeyError: pass # Decision tree for adding sensor data to table if (i > datacell0 and i < datacell0 + (datacols)*self.grp_n_sensors and i % datacols != 0): j = i - (datacell0 + 1) # Metric column 1 if j % datacols == 0: try: # 1-hr R^2 val = h_stats.loc[c1_row, 'R$^2$'] fmt_precision = '3.2f' text_obj.text = format(val, fmt_precision) rsqr_h_vals.append(val) except KeyError: # Condition for empty grp stats pass c1_row += 1 # Metric column 2 if (j-1) % datacols == 0: try: if self.n_avg_intervals == 1: # 1-hr slope val = h_stats.loc[c2_row, 'Slope'] fmt_precision = '3.2f' slope_h_vals.append(val) if self.n_avg_intervals == 2: # 24-hr R^2 val = d_stats.loc[c2_row, 'R$^2$'] fmt_precision = '3.2f' rsqr_d_vals.append(val) text_obj.text = format(val, fmt_precision) except KeyError: # Condition for empty grp stats pass c2_row += 1 # Metric column 3 if (j-2) % datacols == 0: try: if self.n_avg_intervals == 1: # 1-hr Intercept val = h_stats.loc[c3_row, 'Intercept'] fmt_precision = '3.2f' intcpt_h_vals.append(val) if self.n_avg_intervals == 2: # 1-hr Slope val = h_stats.loc[c3_row, 'Slope'] fmt_precision = '3.2f' slope_h_vals.append(val) text_obj.text = format(val, fmt_precision) except KeyError: # Condition for empty grp stats pass c3_row += 1 # Metric column 4 if (j-3) % datacols == 0: # insert Uptime try: if self.n_avg_intervals == 1: # 1-hr uptime val = grp_df[c4_row]['uptime_1-hour'] fmt_precision = '.0f' self.uptime_h_vals.append(val) if self.n_avg_intervals == 2: # 24-hr Slope val = d_stats.loc[c4_row, 'Slope'] fmt_precision = '3.2f' slope_d_vals.append(val) text_obj.text = format(val, fmt_precision) except KeyError: # Condition for empty grp stats pass c4_row += 1 # Metric column 5 if (j-4) % datacols == 0: # insert N paired sensor/reference values try: if self.n_avg_intervals == 1: # 1-hr N val = h_stats.loc[c5_row, 'N'] fmt_precision = '.0f' n_h_vals.append(val) if self.n_avg_intervals == 2: # 1-hr Intercept val = h_stats.loc[c5_row, 'Intercept'] intcpt_h_vals.append(val) fmt_precision = '3.2f' text_obj.text = format(val, fmt_precision) except KeyError: # Condition for empty grp stats pass c5_row += 1 # Columns 6-10 only in PM2.5 sensor-reference table if self.n_avg_intervals == 2: # Metric column 6 if (j-5) % datacols == 0: try: # 24-hr Intercept val = d_stats.loc[c6_row, 'Intercept'] intcpt_d_vals.append(val) fmt_precision = '3.2f' text_obj.text = format(val, fmt_precision) except KeyError: # Condition for empty grp stats pass c6_row += 1 # Metric column 7 if (j-6) % datacols == 0: try: # 1-hr Uptime val = grp_df[c7_row]['uptime_1-hour'] self.uptime_h_vals.append(val) fmt_precision = '.0f' text_obj.text = format(val, fmt_precision) except KeyError: # Condition for empty grp stats pass c7_row += 1 # Metric column 8 if (j-7) % datacols == 0: try: # 24-hr Uptime val = grp_df[c8_row]['uptime_24-hour'] self.uptime_d_vals.append(val) fmt_precision = '.0f' text_obj.text = format(val, fmt_precision) except KeyError: # Condition for empty grp stats pass c8_row += 1 # Metric column 9 if (j-8) % datacols == 0: try: # 1-hr N val = h_stats.loc[c9_row, 'N'] n_h_vals.append(val) fmt_precision = '.0f' text_obj.text = format(val, fmt_precision) except KeyError: # Condition for empty grp stats pass c9_row += 1 # Metric column 10 if (j-9) % datacols == 0: try: # 24-hr N val = d_stats.loc[c10_row, 'N'] n_d_vals.append(val) fmt_precision = '.0f' text_obj.text = format(val, fmt_precision) except KeyError: # Condition for empty grp stats pass c10_row += 1 if i > datacellend: j = i - (datacellend + 1) # Metric Mean column 1 if j % datacols == 0: # Mean 1-hr R^2 if self.n_avg_intervals == 1: val = np.mean(rsqr_h_vals) txt = self.CheckTargets(rsqr_h_vals, metric='R^2') fmt_precision = '3.2f' # Mean 1-hr R^2 if self.n_avg_intervals == 2: val = np.mean(rsqr_h_vals) txt = self.CheckTargets(rsqr_h_vals, metric='R^2') fmt_precision = '3.2f' # Metric Mean column 2 if (j - 1) % datacols == 0: # Mean 1-hr Slope if self.n_avg_intervals == 1: val = np.mean(slope_h_vals) txt = self.CheckTargets(slope_h_vals, metric='Slope') fmt_precision = '3.2f' # Mean 24-hr R^2 if self.n_avg_intervals == 2: val = np.mean(rsqr_d_vals) txt = self.CheckTargets(rsqr_d_vals, metric='R^2') fmt_precision = '3.2f' # Metric Mean column 3 if (j - 2) % datacols == 0: # Mean 1-hr Intercept if self.n_avg_intervals == 1: val = np.mean(intcpt_h_vals) txt = self.CheckTargets(intcpt_h_vals, metric='Intercept') fmt_precision = '3.2f' # Mean 1-hr Slope if self.n_avg_intervals == 2: val = np.mean(slope_h_vals) txt = self.CheckTargets(slope_h_vals, metric='Slope') fmt_precision = '3.2f' # Metric Mean column 4 if (j - 3) % datacols == 0: # Mean 1-hr Uptime if self.n_avg_intervals == 1: val = np.mean(self.uptime_h_vals) txt = self.CheckTargets(self.uptime_h_vals, metric='Uptime') fmt_precision = '3.2f' # Mean 24-hr Slope if self.n_avg_intervals == 2: val = np.mean(slope_d_vals) txt = self.CheckTargets(slope_d_vals, metric='Slope') fmt_precision = '3.2f' # Metric Mean column 5 if (j - 4) % datacols == 0: # Mean 1-hr N paired measurements if self.n_avg_intervals == 1: val = np.mean(n_h_vals) # Mean 1-hr Intercept if self.n_avg_intervals == 2: val = np.mean(intcpt_h_vals) txt = self.CheckTargets(intcpt_h_vals, metric='Intercept') fmt_precision = '3.2f' if self.n_avg_intervals == 2: # Metric Mean column 6 if (j - 5) % datacols == 0: # Mean 24-hr Intercept val = np.mean(intcpt_d_vals) txt = self.CheckTargets(intcpt_d_vals, metric='Intercept') fmt_precision = '3.2f' # Metric Mean column 7 if (j - 6) % datacols == 0: # Mean 1-hr Uptime val = np.mean(self.uptime_h_vals) txt = self.CheckTargets(self.uptime_h_vals, metric='Uptime') fmt_precision = '.0f' # Metric Mean column 8 if (j - 7) % datacols == 0: # Mean 24-hr Uptime val = np.mean(self.uptime_d_vals) txt = self.CheckTargets(self.uptime_d_vals, metric='Uptime') fmt_precision = '.0f' # Metric Mean column 9 if (j - 8) % datacols == 0: # Mean 1-hr N paired measurements val = np.mean(n_h_vals) fmt_precision = '.0f' # Metric Mean column 10 if (j - 9) % datacols == 0: # Mean 24-hr N paired measurements val = np.mean(n_d_vals) fmt_precision = '.0f' # Indicate number of sensors meeting performance metric target if txt is not None: trgt_cell = cells[i - datacols*(self.grp_n_sensors + 2)] trgt_cell_text = trgt_cell.text_frame.add_paragraph() trgt_cell_text.text = txt self.FormatText(trgt_cell_text, alignment='center', font_name='Calibri Light', font_size=12) text_obj.text = format(val, fmt_precision) # Configure text formatting self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=14) txt = None
[docs] def EditErrorTable(self, table): """Add error tabular statistics (page 2). Args: table (pptx table object): A table object for sensor vs. reference error (RMSE, NRMSE). Returns: None. """ error_stats = (self.deploy_dict['Deployment Groups'] [self.grp_name] [self._param_name] ['Error']) for key, value in error_stats.items(): if value is None: error_stats[key] = -999 if self.n_avg_intervals == 2: datacols = 4 nheaderrows = 4 headercellend = nheaderrows*(datacols + 1) span_dict = {'Error': [1, 4], f'RMSE\n({self.param.units})': [6, 7], 'NRMSE\n(%)': [8, 9]} table_categories = {'1': 'Error'} metrics = {'6': f'RMSE\n({self.param.units})', '8': 'NRMSE\n(%)'} avg_intervals = {'11': '1-Hour', '12': '24-Hour', '13': '1-Hour', '14': '24-Hour'} rmse_ubound = self.param.PerformanceTargets.get_metric( metrics['6'].split('\n')[0])['bounds'][1] nrmse_ubound = self.param.PerformanceTargets.get_metric( metrics['8'].split('\n')[0])['bounds'][1] metric_targets = {'15': 'Metric Target Range', '16': '≤ {:2.1f}'.format(rmse_ubound), '17': '≤ {:2.1f}'.format(rmse_ubound), '18': '≤ {:3.1f}'.format(nrmse_ubound), '19': '≤ {:3.1f}'.format(nrmse_ubound), '20': 'Deployment Value'} metric_vals = {'21': format(error_stats['rmse_1-hour'], '3.1f'), '22': format(error_stats['rmse_24-hour'], '3.1f'), '23': format(error_stats['nrmse_1-hour'], '3.1f'), '24': format(error_stats['nrmse_24-hour'], '3.1f')} for key, value in metric_vals.items(): if value == '-999.00': metric_vals[key] = '' if self.n_avg_intervals == 1: datacols = 1 nheaderrows = 4 headercellend = nheaderrows*(datacols + 1) span_dict = {'Error': [1, 1]} table_categories = {'1': 'Error'} metrics = {'3': f'RMSE\n({self.param.units})'} avg_intervals = {'5': '1-Hour'} rmse_ubound = self.param.PerformanceTargets.get_metric( metrics['3'].split('\n')[0])['bounds'][1] metric_targets = {'6': 'Metric Target Range', '7': '≤ {:2.1f}'.format(rmse_ubound), '8': 'Deployment Value'} metric_vals = {'9': format(error_stats['rmse_1-hour'], '3.1f')} for key, value in metric_vals.items(): if value == '-999.00': metric_vals[key] = '' cells = self.SetSpanningCells(table, span_dict) for i, cell in enumerate(cells): text_obj = cell.text_frame.paragraphs[0] if str(i) in table_categories: text_obj.text = table_categories[str(i)] font_obj = text_obj.font font_obj.color.rgb = ppt.dml.color.RGBColor(0, 0, 0) if str(i) in metrics: metric = metrics[str(i)] # TODO: Possibly remove since using unicode chars? if metric == 'RMSE\n(μg/m^3)': lineone = text_obj.add_run() lineone.text = 'RMSE\n' linetwo_baseline_one = text_obj.add_run() linetwo_baseline_one.text = '(μg/m' superscript = text_obj.add_run() superscript.text = '3' font = superscript.font self.SetSuperscript(font) linetwo_baseline_two = text_obj.add_run() linetwo_baseline_two.text = ')' else: text_obj.text = metrics[str(i)] if str(i) in metric_targets: text_obj.text = metric_targets[str(i)] if str(i) in avg_intervals: text_obj.text = avg_intervals[str(i)] if str(i) in metric_vals: text_obj.text = metric_vals[str(i)] # Metric column 1 if i > headercellend: if (i - 1) % datacols == 0: # Mean 1-hr RMSE if self.n_avg_intervals == 1: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='RMSE') # Mean 1-hr RMSE if self.n_avg_intervals == 2: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='RMSE') # Metric column 2 if (i - 1) % datacols == 1: # 1-hr NRMSE if self.n_avg_intervals == 1: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='NRMSE') # 24-hr RMSE if self.n_avg_intervals == 2: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='RMSE') # Metric column 3 if self.n_avg_intervals == 2: if (i - 1) % datacols == 2: # 1-hr NRMSE val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='NRMSE') # Metric column 4 if (i - 1) % datacols == 3: # 24-hr NRMSE val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='NRMSE') # Indicate whether sensors meet performance metric target if txt is not None: trgt_cell = cells[i - 2*(datacols + 1)] trgt_cell_text = trgt_cell.text_frame.add_paragraph() trgt_cell_text.text = txt self.FormatText(trgt_cell_text, alignment='center', font_name='Calibri Light', font_size=12) self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=14) txt = None
[docs] def EditSensorSensorTable(self, table): """Add intersensor (sensor-sensor) precision tabular stats (page 2). Args: table (pptx table object): A table object for intersensor precision statistics. Returns: None. """ grp_stats = (self.deploy_dict['Deployment Groups'] [self.grp_name] [self._param_name] ['Precision']) if self.n_avg_intervals == 2: datacols = 8 nheaderrows = 4 headercellend = nheaderrows*(datacols + 1) span_dict = {'Precision (between collocated sensors)': [1, 4], 'Data Quality': [5, 8], 'CV\n(%)': [10, 11], f'SD\n({self.param.units})': [12, 13], 'Uptime\n(%)': [14, 15], 'Number concurrent sensor values': [16, 17]} table_categories = {'1': 'Precision (between collocated sensors)', '5': 'Data Quality'} metrics = {'10': 'CV\n(%)', '12': f'SD\n({self.param.units})', '14': 'Uptime\n(%)', '16': 'Number of concurrent\nsensor concentration pairs'} avg_intervals = {'19': '1-Hour', '20': '24-Hour', '21': '1-Hour', '22': '24-Hour', '23': '1-Hour', '24': '24-Hour', '25': '1-Hour', '26': '24-Hour'} cv_ubound = self.param.PerformanceTargets.get_metric( metrics['10'].split('\n')[0])['bounds'][1] sd_ubound = self.param.PerformanceTargets.get_metric( metrics['12'].split('\n')[0])['bounds'][1] metric_targets = {'27': 'Metric Target Range', '28': '≤ {:3.1f}'.format(cv_ubound), '29': '≤ {:3.1f}'.format(cv_ubound), '30': '≤ {:2.1f}'.format(sd_ubound), '31': '≤ {:2.1f}'.format(sd_ubound), '32': '75%*', '33': '75%*', '34': '-', '35': '-', '36': 'Deployment Value'} metric_vals = {'37': format(grp_stats['cv_1-hour'], '3.1f'), '38': format(grp_stats['cv_24-hour'], '3.1f'), '39': format(grp_stats['std_1-hour'], '3.1f'), '40': format(grp_stats['std_24-hour'], '3.1f'), '41': format(np.mean(self.uptime_h_vals), '.0f'), '42': format(np.mean(self.uptime_d_vals), '.0f'), '43': format(grp_stats['n_1-hour'], '.0f'), '44': format(grp_stats['n_24-hour'], '.0f')} if self.n_avg_intervals == 1: datacols = 4 nheaderrows = 4 headercellend = nheaderrows*(datacols + 1) span_dict = {'Precision (between collocated sensors)': [1, 2], 'Data Quality': [3, 4]} table_categories = {'1': 'Precision (between collocated sensors)', '3': 'Data Quality'} metrics = {'6': 'CV\n(%)', '7': f'SD\n({self.param.units})', '8': 'Uptime\n(%)', '9': 'Number of paired\nsensor and ' 'reference\nconcentration pairs'} avg_intervals = {'11': '1-Hour', '12': '1-Hour', '13': '1-Hour', '14': '1-Hour'} cv_ubound = self.param.PerformanceTargets.get_metric( metrics['6'].split('\n')[0])['bounds'][1] sd_ubound = self.param.PerformanceTargets.get_metric( metrics['7'].split('\n')[0])['bounds'][1] metric_targets = {'15': 'Metric Target Range', '16': '≤ {:3.1f}'.format(cv_ubound), '17': '≤ {:2.1f}'.format(sd_ubound), '18': '75%*', '19': '-', '20': 'Deployment Value'} metric_vals = {'21': format(grp_stats['cv_1-hour'], '3.1f'), '22': format(grp_stats['std_1-hour'], '3.1f'), '23': format(np.mean(self.uptime_h_vals), '.0f'), '24': format(grp_stats['n_1-hour'], '.0f')} cells = self.SetSpanningCells(table, span_dict) for i, cell in enumerate(cells): text_obj = cell.text_frame.paragraphs[0] if str(i) in table_categories: text_obj.text = table_categories[str(i)] font_obj = text_obj.font font_obj.color.rgb = ppt.dml.color.RGBColor(0, 0, 0) if str(i) in metrics: metric = metrics[str(i)] # TODO: Possibly remove since using unicode chars? if metric == 'SD\n(μg/m^3)': lineone = text_obj.add_run() lineone.text = 'SD\n' linetwo_baseline_one = text_obj.add_run() linetwo_baseline_one.text = '(μg/m' superscript = text_obj.add_run() superscript.text = '3' font = superscript.font self.SetSuperscript(font) linetwo_baseline_two = text_obj.add_run() linetwo_baseline_two.text = ')' else: text_obj.text = metrics[str(i)] if str(i) in metric_targets: text_obj.text = metric_targets[str(i)] if str(i) in avg_intervals: text_obj.text = avg_intervals[str(i)] if str(i) in metric_vals: text_obj.text = metric_vals[str(i)] # Metric column 1 if i > headercellend: j = i - headercellend if (j - 1) % datacols == 0: # Mean 1-hr CV if self.n_avg_intervals == 1: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='CV') # Mean 1-hr CV if self.n_avg_intervals == 2: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='CV') # Metric column 2 if (j - 1) % datacols == 1: # 1-hr Std dev if self.n_avg_intervals == 1: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='SD') # 24-hr CV if self.n_avg_intervals == 2: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='CV') # Metric column 3 if (j - 1) % datacols == 2: # 1-hr Uptime if self.n_avg_intervals == 1: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='Uptime') # 1-hr Std dev if self.n_avg_intervals == 2: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='CV') # Metric column 4 if (j - 1) % datacols == 3: # 24-hr Std dev if self.n_avg_intervals == 2: val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='SD') if self.n_avg_intervals == 2: # Metric column 5 if (j - 1) % datacols == 4: # 1-hr uptime val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='Uptime') # Metric column 6 if (j - 1) % datacols == 5: # 24-hr uptime val = float(metric_vals[str(i)]) txt = self.CheckTargets(val, metric='Uptime') # Indicate whether sensors meet performance metric target if txt is not None: trgt_cell = cells[i - 2*(datacols + 1)] trgt_cell_text = trgt_cell.text_frame.add_paragraph() trgt_cell_text.text = txt self.FormatText(trgt_cell_text, alignment='center', font_name='Calibri Light', font_size=12) self.FormatText(text_obj, alignment='center', font_name='Calibri', font_size=14) txt = None
[docs] def SetSpanningCells(self, table, span_dict): """Merge tabular cells to form cells spanning multiple rows/columns. Args: table (pptx.table.Table): pptx table object to modify. span_dict (dict): Dictionary where each entry contains list of consecutive cell indicies in the table that will be spanned. Example: Say you have a table with three rows and two columns for a total of 4 cells. Let's say we want to make the first row of cells into a single cell that spans the row. The cells in the table are accessed by the index position starting at zero in the top left corner and incrementing from left to right. The table and the indicies for each cell can be visualized in the following way: +---+---+---+ | 0 | 1 | 2 | +---+---+---+ | 3 | 4 | 5 | +---+---+---+ Since we want to span the columns of the first row, we need to indicate in the span_dict that the starting cell for spanning the table is the cell at index position zero and the ending cell for spanning will be the cell at index position two. >>>span_dict = {'name_of_spanned_cells': [0, 2]} The spanned table will then be returned as: +---+---+---+ | | +---+---+---+ | | | | +---+---+---+ Returns: cells (collection of pptx.table.Table.cell objects): Table cells that have been spanned. """ cells = [cell for cell in table.iter_cells()] if span_dict is not None: for entry in span_dict: merge_start_idx = span_dict[entry][0] merge_end_idx = span_dict[entry][1] merge_start_cell = cells[merge_start_idx] merge_end_cell = cells[merge_end_idx] merge_start_cell.merge(merge_end_cell) return cells
[docs] def EditTabularStats(self): """Wrapper for constructing tables and adding entries (page 2). Returns: None. """ self.n_grps = len(set(self.serial_grp_dict.values())) self.n_sensors = len(set(self.serial_grp_dict)) # Use slide layout for generating additional slides tabular_layout_idx = 1 tabular_layout = self.rpt.slide_layouts[tabular_layout_idx] legend_txt = { 'line1': 'Device-specific metrics (computed for each ' 'sensor in evaluation)', 'line2': '○○○ Metric value for none of devices tested ' 'falls within the target range', 'line3': '●○○ Metric value for one of devices tested ' 'falls within the target range', 'line4': '●●○ Metric value for two of devices tested ' 'falls within the target range', 'line5': '●●● Metric value for three of devices tested ' 'falls within the target range', 'line6': '', 'line7': 'Single-valued metrics ' '(computed via entire evaluation dataset)', 'line8': '○ Indicates that the metric value is not ' 'within the target range', 'line9': '● Indicates that the metric value is within ' 'the target range', } # List of unique testing groups grps = sorted(list(set(self.serial_grp_dict.values()))) for grp_n, grp_name in enumerate(grps, 1): self.grp_n_sensors = list( self.serial_grp_dict.values()).count(grp_name) self.grp_name = grp_name # Create new tabular stats page tabular_slide = self.rpt.slides.add_slide( tabular_layout) # Sensor Reference Table sr_frame, sr_table = self.ConstructTable( tabular_slide, table_type='sensor_reference') self.EditSensorRefTable(sr_table) # Error Table e_frame, e_table = self.ConstructTable( tabular_slide, table_type='error') self.EditErrorTable(e_table) # Intersensor (sensor-sensor) table ss_frame, ss_table = self.ConstructTable( tabular_slide, table_type='sensor_sensor') self.EditSensorSensorTable(ss_table) # Adjust sensor-reference table vertical position EMU_to_in = 1/914400.0 sr_top = 4.03 # in sr_height = EMU_to_in*sr_frame.height sr_frame.top = ppt.util.Inches(sr_top) # Adjust error table vertical position e_t = 4.03 + sr_height + 0.4 # in e_h = EMU_to_in*e_frame.height e_frame.top = ppt.util.Inches(e_t) # Adjust Sensor-Sensor table vertical position ss_top = e_t + e_h + 2*0.4 # in ss_frame.top = ppt.util.Inches(ss_top) # Add section header tabular_header = tabular_slide.shapes.add_textbox( ppt.util.Inches(0.82), # left ppt.util.Inches(2.81), # top ppt.util.Inches(3.16), # width ppt.util.Inches(0.47)) # height tabular_header_obj = tabular_header.text_frame.paragraphs[0] tabular_header_obj.text = 'Tabular Statistics' if self.n_grps > 1: tabular_header_obj.text += ' - ' + self.grp_name self.FormatText(tabular_header_obj, alignment='left', font_name='Calibri Light', font_size=22) # Add Sensor-Reference section header text label sr_header = tabular_slide.shapes.add_textbox( ppt.util.Inches(0.82), # left ppt.util.Inches(3.30), # top ppt.util.Inches(12.81), # width ppt.util.Inches(0.44)) # height sr_header_obj = sr_header.text_frame.paragraphs[0] sr_header_obj.text = 'Sensor-FRM/FEM Correlation' self.FormatText(sr_header_obj, alignment='left', font_name='Calibri Light', font_size=20) # Add Sensor-Sensor section header text label ss_header = tabular_slide.shapes.add_textbox( ppt.util.Inches(0.82), # left ppt.util.Inches(e_t + e_h + 0.12), # top ppt.util.Inches(12.81), # width ppt.util.Inches(0.44)) # height ss_header_obj = ss_header.text_frame.paragraphs[0] ss_header_obj.text = 'Sensor-Sensor Precision' self.FormatText(ss_header_obj, alignment='left', font_name='Calibri Light', font_size=20) connector1 = tabular_slide.shapes.add_connector( ppt.enum.shapes.MSO_CONNECTOR.STRAIGHT, ppt.util.Inches(0.85), # start_x ppt.util.Inches(3.30), # start_y ppt.util.Inches(16.24), # end_x ppt.util.Inches(3.30)) # end_y connector1.line.fill.solid() connector1.line.fill.fore_color.rgb = ppt.dml.color.RGBColor(171, 171, 171) connector2 = tabular_slide.shapes.add_connector( ppt.enum.shapes.MSO_CONNECTOR.STRAIGHT, ppt.util.Inches(0.85), # start_x ppt.util.Inches(e_t + e_h + 0.56), # start_y ppt.util.Inches(16.24), # end_x ppt.util.Inches(e_t + e_h + 0.56)) # end_y connector2.line.fill.solid() connector2.line.fill.fore_color.rgb = ppt.dml.color.RGBColor(171, 171, 171) # Add Sensor-Sensor text label legend_h = 2.7 legend = tabular_slide.shapes.add_shape( ppt.enum.shapes.MSO_SHAPE.ROUNDED_RECTANGLE, ppt.util.Inches(8.83), # left ppt.util.Inches(e_t + 0.5*(e_h + - legend_h)), ppt.util.Inches(6.69), # width ppt.util.Inches(legend_h)) legend.fill.solid() legend.fill.fore_color.rgb = ppt.dml.color.RGBColor(236, 236, 240) legend.line.fill.solid() legend.line.fill.fore_color.rgb = ppt.dml.color.RGBColor(214, 216, 226) legend.line.width = ppt.util.Pt(2.5) legend_obj = legend.text_frame.paragraphs[0] legend_obj.text = legend_txt['line1'] self.FormatText(legend_obj, alignment='left', font_name='Calibri', font_size=16, bold=False) for i, line in enumerate(legend_txt): if i > 0: legend_obj = legend.text_frame.add_paragraph() legend_obj.text = legend_txt[line] font = legend_obj.font font.name = 'Calibri' font.Bold = False font.color.rgb = ppt.dml.color.RGBColor(0, 0, 0) if line == 'line6': font.size = ppt.util.Pt(8) else: font.size = ppt.util.Pt(15)
[docs] def ConstructTable(self, slide, table_type='sensor_reference'): """Select and construct tables on report page 2. Presets are set for constructing each table type (number of rows and columns, dimensions of tables, shading of cells and fill color, etc.) Args: slide (pptx slide object): The report slide on which the tabular statistics will be placed. This will likely be slide #2 (i.e., ``self.rpt.slides[1]``). table_type (str): Name of the type of table to construct. Options include the following: - ``'sensor_reference'`` - ``'error'`` - ``'sensor_sensor'`` Returns: (tuple): Two-element tuple containing: - **frame** (*pptx GraphicFrame*): Object in which the table is contained. - **table** (*pptx table shape*): Table shape formatted for the selected table type. """ cell_margin = 0.001 table_spacing = 0.4 shapes = slide.shapes # Create Sensor-Reference Correlation Table --------------------------- if table_type == 'sensor_reference': if self.n_avg_intervals == 2: nrows = 5 + self.grp_n_sensors ncols = 11 col_width = ppt.util.Inches(1.2) width = ppt.util.Inches(14.4) height = ppt.util.Inches(3.07 + 0.45*self.grp_n_sensors) left = ppt.util.Inches(1.32) top = ppt.util.Inches(3.69) # grey out cells at index (from l-r where top left is 0) greyed_cells = [42, 43] if self.n_avg_intervals == 1: nrows = 5 + self.grp_n_sensors ncols = 6 col_width = ppt.util.Inches(2.4) width = ppt.util.Inches(14.4) height = ppt.util.Inches(3.07 + 0.45*self.grp_n_sensors) left = ppt.util.Inches(1.32) top = ppt.util.Inches(3.69) greyed_cells = [23] if table_type == 'error': if self.n_avg_intervals == 2: nrows = 5 ncols = 5 col_width = ppt.util.Inches(1.2) width = ppt.util.Inches(7.2) height = ppt.util.Inches(3.23) left = ppt.util.Inches(1.32) top = ppt.util.Inches(3.69 + 7.83 + table_spacing) greyed_cells = None if self.n_avg_intervals == 1: nrows = 5 ncols = 2 col_width = ppt.util.Inches(2.4) width = ppt.util.Inches(7.2) height = ppt.util.Inches(3.23) left = ppt.util.Inches(1.32) top = ppt.util.Inches(3.69 + 7.83 + table_spacing) greyed_cells = None if table_type == 'sensor_sensor': if self.n_avg_intervals == 2: nrows = 5 ncols = 9 col_width = ppt.util.Inches(1.2) width = ppt.util.Inches(12.0) height = ppt.util.Inches(3.23) left = ppt.util.Inches(1.32) top = ppt.util.Inches(3.69 + 7.83 + 2*table_spacing + 3.23) greyed_cells = [34, 35] if self.n_avg_intervals == 1: nrows = 5 ncols = 5 col_width = ppt.util.Inches(2.4) width = ppt.util.Inches(12.0) height = ppt.util.Inches(3.23) left = ppt.util.Inches(1.32) top = ppt.util.Inches(3.69 + 7.83 + 2*table_spacing + 3.23) greyed_cells = [19] # Construct the table based on selected presets frame = shapes.add_table(nrows, ncols, left, top, width, height) table = frame.table table.horz_banding = False # Loop over the cells in the table, configure fill color, cell margins for cell_idx, cell in enumerate(table.iter_cells()): # Set cell border width self.SetCellBorder(cell) # Set dark blue cell fill color if cell_idx < 3*ncols + 1 or cell_idx % ncols == 0: cell.fill.solid() cell.fill.fore_color.rgb = ppt.dml.color.RGBColor(191, 208, 235) # Set transparent (background) fill for top 3 cells if cell_idx % ncols == 0 and cell_idx <= 2*ncols: cell.fill.background() # Grey out cells where no target range specified if greyed_cells is not None: if cell_idx in greyed_cells: cell.fill.solid() cell.fill.fore_color.rgb = ppt.dml.color.RGBColor(214, 216, 226) # Set cell margins to near zero cell.margin_left = ppt.util.Inches(cell_margin) cell.margin_right = ppt.util.Inches(cell_margin) cell.margin_top = ppt.util.Inches(cell_margin) cell.margin_bottom = ppt.util.Inches(cell_margin) # Vertical position text in middle of cells cell.vertical_anchor = ppt.enum.text.MSO_ANCHOR.MIDDLE for paragraph in cell.text_frame.paragraphs: for run in paragraph.runs: run.font.size = ppt.util.Pt(14) # Modify table column width table.columns[0].width = ppt.util.Inches(2.2) for col_idx in np.arange(1, len(table.columns)): table.columns[col_idx].width = col_width # Modify table header row height table.rows[0].height = ppt.util.Inches(0.47) # Category table.rows[1].height = ppt.util.Inches(0.97) # Metric label table.rows[2].height = ppt.util.Inches(0.72) # Averaging interval for row_idx in np.arange(3, len(table.rows)): table.rows[row_idx].height = ppt.util.Inches(0.45) return frame, table
[docs] def PrintpptxShapes(self, slide_number=1, shape_type='all'): """Diagnostic tool for indicating shape ids and locations on reporting template slides. Args: slide number (int): The number of the slide (starting at 1) for which shape ids and locations will be printed. shape_type (str) {'all', ...?}: The types of shapes on the slide to print out. 'all' will return all shapes regardless of type, however, selecting a particular type (e.g., 'table') will only return shapes on the page corresponding to the specified type. Returns: None """ print("{:^6s}{:^18s}{:^12s}{:^10s}".format('ID', 'Type', 'Left loc', 'Top loc')) print("{:^6s}{:^18s}{:^12s}{:^10s}".format('', '', '(in)', '(in)')) print('{:^45s}'.format(45*'-')) if shape_type == 'all': for shape in self.rpt.slides[slide_number - 1].shapes: print("%4s %16s %10.2f %10.2f" % (shape.shape_id, shape.shape_type, shape.left.inches, shape.top.inches)) else: for shape in self.rpt.slides[slide_number - 1].shapes: if str(shape.shape_type).startswith(shape_type): print("%4s %16s %10.2f %10.2f" % (shape.shape_id, shape.shape_type, shape.left.inches, shape.top.inches))
[docs] def SubElement(self, parent, tagname, **kwargs): """Modify an XML element by adding a sub-element entry and assign attributes. **Reference:** Based on Steve Canny's code at the following link: https://groups.google.com/g/python-pptx/c/UTkdemIZICw Args: parent (XML element): An XML element. tagname (str): XML tagname for the sub-element to add to the parent attribute. **kwargs (dict): Attributes to assign to the sub-element. Returns: element (XML): Updated element with attributes added. """ # Create a new sub-element entry for the passed tagname element = ppt.oxml.xmlchemy.OxmlElement(tagname) # Add attributes to the new element entry element.attrib.update(kwargs) # Join the XML sub-element to the parent element parent.append(element) return element
[docs] def SetCellBorder(self, cell, border_color="ffffff", border_width='20000'): """Edit tabular cell boarder attributes (border width, fill color, etc.). **Reference:** Based on Steve Canny's code at the following links: - https://groups.google.com/g/python-pptx/c/UTkdemIZICw - https://stackoverflow.com/questions/42610829/python-pptx-changing-table-style-or-adding-borders-to-cells Args: cell (pptx table._cell object): The cell object within a pptx.table object that will be edited. border_color (str, optional): Cell border color in hex color code. Defaults to "ffffff" (white). border_width (str, optional): The width of the cell border (in english metric units). Defaults to '20000'. Returns: None. """ # Table cell object tc = cell._tc # Table cell properties tcPr = tc.get_or_add_tcPr() for lines in ['a:lnL', 'a:lnR', 'a:lnT', 'a:lnB']: ln = self.SubElement(tcPr, lines, w=border_width, cap='flat', mpd='sng', algn='ctr') solidFill = self.SubElement(ln, 'a:solidFill') srgbClr = self.SubElement(solidFill, 'a:srgbClr', val=border_color) prstDash = self.SubElement(ln, 'a:prstDash', val='solid') round_ = self.SubElement(ln, 'a:round') headEnd = self.SubElement(ln, 'a:headEnd', type='none', w='med', len='med') tailEnd = self.SubElement(ln, 'a:tailEnd', type='none', w='med', len='med')
[docs] def SetSubscript(self, font): """Workaround for making font object text subscript (not included in python-pptx as of v0.6.19) **Reference:** Code via Martin Packer: https://stackoverflow.com/questions/61329224/how-do-i-add-superscript-subscript-text-to-powerpoint-using-python-pptx Args: font (pptx text run object): Font object containing various character properies. Returns: None. """ font._element.set('baseline', '-25000')
[docs] def SetSuperscript(self, font): """Workaround for making font object text superscript (not included in python-pptx as of v0.6.19) **Reference:** Code via Martin Packer: https://stackoverflow.com/questions/61329224/how-do-i-add-superscript-subscript-text-to-powerpoint-using-python-pptx Args: font (pptx text run object): Font object containing various character properies. Returns: None. """ font._element.set('baseline', '30000')
[docs] def MoveSlide(self, slides, slide, new_idx): """Move the supplemental info table to the last slide position. **Reference:** Code via github user Amazinzay (Feb 17 2021): https://github.com/scanny/python-pptx/issues/68 Args: slides (pptx.slide.Slides): The collection of presentation slide objects. slide (pptx.slide.Slide): The slide object that will be reordered. new_idx (int): The integer position indicating where the slide will be relocated. Returns: None. """ slides._sldIdLst.insert(new_idx, slides._sldIdLst[slides.index(slide)])
[docs] def AddSlideNumbers(self): """Add slide numbers to slides generated during report construction. For some reason, the python pptx module can't assign the footer page number to slides that are created by the library. While slides that are imported via the template (the first and last page of the report) have page number placeholders already assigned, the pptx library doesnt do this without explicity copying and pasting the page number placeholder from the layout to the slides that are created by the module. **Reference:** This code follows the basic outline Steve Canny (scanny) suggests in response to this GitHub post: https://github.com/scanny/python-pptx/issues/64 Returns: None. """ layout = self.rpt.slide_layouts[1] placeholders = layout.placeholders for i, placeholder in enumerate(placeholders): if layout.placeholders[i].name == 'Slide Number Placeholder': break # add slide numbers to the 2nd through the 2nd to last slide # (slides that are generated by PerformanceReport) for idx in np.arange(1, len(self.rpt.slides)-1, 1): slide = self.rpt.slides[idx] slide.shapes.clone_placeholder(placeholder) slide_placeholder = slide.shapes[-1] slide_placeholder.text = str(idx + 1)
[docs] def FormatText(self, text_obj, alignment='center', font_name='Calibri', font_size=24, bold=False, italic=False): """Set text attributes (font, size, bold, italic, alignment). Args: text_obj (pptx.text.text Subshape): Object containing the text attributes. alignment (str, optional): Text alignment. Options are 'center' or 'left'. Defaults to 'center'. font_name (str, optional): The name of the font typeface. Defaults to 'Calibri'. font_size (int or float, optional): The font size. Defaults to 24. bold (bool, optional): If true, text will be formatted in bold. Defaults to False. italic (bool, optional): If true, text will be formatted in italics. Defaults to False. Returns: None. """ if alignment == 'center': text_obj.alignment = ppt.enum.text.PP_ALIGN.CENTER if alignment == 'left': text_obj.alignment = ppt.enum.text.PP_ALIGN.LEFT font_obj = text_obj.font font_obj.name = font_name font_obj.size = ppt.util.Pt(font_size) font_obj.bold = bold font_obj.italic = italic
[docs] def CheckTargets(self, metric_vals, metric): """Evaluate how many sensors met a metric target, return textual depiction. For a passed metric name 'metric', determine the number of sensors with metric values within the specified metric target range. **Example:** Say the 'metric' argument is 'CV' and the 'metric_vals' argument is ``[20.2, 43.6, 26.5]`` (values are percentages). Given that the target range for 'CV' is from 0% to 30%, two our of three sensors fall within the target range. Textually, this can be represented by a series of three dots, where two dots are closed and one is empty. Text returned by ``CheckTargets()``: '●●○' Args: metric_vals (float, int, or list): Evaluation results for the indicated performance metric. metric (str): The name of the performance metric. Returns: text (str): A textual representation of the number of sensors meeting the target range criteria for the performance metric. """ # Place float / int values (single-valued intersensor stats) into # list for parsing if type(metric_vals) != list: metric_vals = [metric_vals] if metric != 'Uptime': metric_info = self.param.PerformanceTargets.get_metric(metric) metric_bounds = metric_info['bounds'] metric_min, metric_max = metric_bounds else: Uptime_min, Uptime_max = 75, 100 metric_min, metric_max = Uptime_min, Uptime_max # Number of sensors meeting target n_sensors = len(metric_vals) n_passed = sum(metric_min <= val <= metric_max for val in metric_vals) open_dot = '○' closed_dot = '●' if n_sensors != 0: pcnt_passed = 100*(n_passed / n_sensors) if metric in ['R^2', 'Slope', 'Intercept', 'Uptime']: text = n_passed*closed_dot + (n_sensors - n_passed)*open_dot if n_sensors == 1 and metric == 'Uptime': if pcnt_passed == 100: text = closed_dot else: text = open_dot if metric in ['CV', 'SD', 'RMSE', 'NRMSE']: if pcnt_passed == 100: text = closed_dot elif n_sensors > 0: text = open_dot else: # Metric values empty? text = '' return text
[docs] def CreateReport(self): """Wrapper for running the various methods that construct reports. Existing figures are assumed to have been created on the same day of class instantiation. If a figure filename is not found, sensor data are loaded via the SensorEvaluation class and the figure is generated. Returns: None. """ print('Creating Testing Report for', self.name) ref_source = self.hourly_ref_df.Data_Source.mode()[0] MET_DATA = True if self.met_hourly_ref_df.select_dtypes(exclude='object').dropna(how='all', axis=1).empty: print('') warnings.warn('Warning: No Meteorological data for reference' ' source. Met plots will not be added to the report') MET_DATA = False # Set figure positions self.FigPositions() print('..Adding figures to report') # Add figures to report self.AddSingleScatterPlot() self.AddTimeseriesPlot() self.AddMetricsPlot() print('..Adding tabular data') # Modify report tables self.EditSiteTable() self.EditSensorTable() self.EditRefTable() self.EditRefConcTable() self.EditTabularStats() # Reference dependent details. Only add met plots if reference data # come from AirNowTech or another source where met data are provided if MET_DATA: self.AddMetDistPlot() self.AddMetInflPlot() self.EditMetCondTable() self.EditMetInfTable() self.AddMultiScatter() self.EditHeader() # Move the supplemental info slide to the last slide position slides = self.rpt.slides slide = slides[1] new_idx = len(slides) self.MoveSlide(slides, slide, new_idx) self.AddSlideNumbers() self.SaveReport()
[docs] def SaveReport(self): """Save the report to the ``/reports`` directory as a pptx file. Returns: None. """ print('..Saving report') self.rpt_name = f'Base_Testing_Report_{self._param_name}_{self.name}_{self.today}.pptx' save_dir = os.path.join(self.path, 'reports', self.name, self._param_name) save_path = os.path.join(save_dir, self.rpt_name) if not os.path.exists(save_dir): os.makedirs(save_dir) print('..Creating directory:') print('....' + save_dir) print('....' + save_path.replace(self.path, '')) self.rpt.save(save_path)