diff --git a/backend/routers/projects.py b/backend/routers/projects.py index d719ab9..2ef20eb 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -327,7 +327,7 @@ def _build_combined_location_data( try: dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S') date_str = dt.strftime('%Y-%m-%d') - time_str = dt.strftime('%H:%M:%S') + time_str = dt.strftime('%H:%M') except ValueError: date_str = start_time_str @@ -2177,236 +2177,229 @@ async def generate_excel_report( ws = wb.active ws.title = "Sound Level Data" - # Define styles - title_font = Font(name='Arial', bold=True, size=12) - subtitle_font = Font(name='Arial', bold=True, size=12) - client_font = Font(name='Arial', italic=True, size=10) - filter_font = Font(name='Arial', italic=True, size=10, color="666666") - header_font = Font(name='Arial', bold=True, size=10) - data_font = Font(name='Arial', size=10) - summary_title_font = Font(name='Arial', bold=True, size=12) - thin_border = Border( - left=Side(style='thin'), - right=Side(style='thin'), - top=Side(style='thin'), - bottom=Side(style='thin') - ) - header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid") - center_align = Alignment(horizontal='center', vertical='center') - left_align = Alignment(horizontal='left', vertical='center') - right_align = Alignment(horizontal='right', vertical='center') + # --- Styles --- + f_title = Font(name='Arial', bold=True, size=12) + f_data = Font(name='Arial', size=10) + f_bold = Font(name='Arial', bold=True, size=10) - # Row 1: Report title + thin = Side(style='thin') + dbl = Side(style='double') + + # Header row: double top border; leftmost/rightmost cells get double outer edge + hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin) + hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin) + hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin) + # Last data row: double bottom border + last_inner = Border(left=thin, right=thin, top=thin, bottom=dbl) + last_left = Border(left=dbl, right=thin, top=thin, bottom=dbl) + last_right = Border(left=thin, right=dbl, top=thin, bottom=dbl) + # Normal data rows + data_inner = Border(left=thin, right=thin, top=thin, bottom=thin) + data_left = Border(left=dbl, right=thin, top=thin, bottom=thin) + data_right = Border(left=thin, right=dbl, top=thin, bottom=thin) + + hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") + center = Alignment(horizontal='center', vertical='center', wrap_text=True) + left = Alignment(horizontal='left', vertical='center') + right = Alignment(horizontal='right', vertical='center') + + # Column widths from Soundstudyexample.xlsx NRL_1 (sheet2) + # A B C D E F G H I J K L M N O P + for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]): + ws.column_dimensions[get_column_letter(col_i)].width = col_w + + # --- Header rows 1-6 --- final_project_name = project_name if project_name else (project.name if project else "") - final_title = report_title - if final_project_name: - final_title = f"{report_title} - {final_project_name}" - ws['A1'] = final_title - ws['A1'].font = title_font - ws['A1'].alignment = center_align + final_location = location_name if location_name else (location.name if location else "") + final_title = f"{report_title} - {final_project_name}" if final_project_name else report_title + ws.merge_cells('A1:G1') - ws.row_dimensions[1].height = 20 + ws['A1'] = final_title + ws['A1'].font = f_title; ws['A1'].alignment = center + ws.row_dimensions[1].height = 15.75 - # Row 2: Client name (if provided) - if client_name: - ws['A2'] = f"Client: {client_name}" - ws['A2'].font = client_font - ws['A2'].alignment = center_align - ws.merge_cells('A2:G2') - ws.row_dimensions[2].height = 16 + ws.row_dimensions[2].height = 15 - # Row 3: Location name - final_location = location_name - if not final_location and location: - final_location = location.name - if final_location: - ws['A3'] = final_location - ws['A3'].font = subtitle_font - ws['A3'].alignment = center_align - ws.merge_cells('A3:G3') - ws.row_dimensions[3].height = 20 + ws.merge_cells('A3:G3') + ws['A3'] = final_location + ws['A3'].font = f_title; ws['A3'].alignment = center + ws.row_dimensions[3].height = 15.75 - # Row 4: Time filter info (if applied) - if start_time and end_time: - filter_info = f"Time Filter: {start_time} - {end_time}" - if start_date or end_date: - filter_info += f" | Date Range: {start_date or 'start'} to {end_date or 'end'}" - filter_info += f" | {len(rnd_rows)} of {original_count} rows" - ws['A4'] = filter_info - ws['A4'].font = filter_font - ws['A4'].alignment = center_align - ws.merge_cells('A4:G4') - ws.row_dimensions[4].height = 14 + ws.row_dimensions[4].height = 15 - # Row 7: Headers - headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments'] - for col, header in enumerate(headers, 1): - cell = ws.cell(row=7, column=col, value=header) - cell.font = header_font - cell.border = thin_border - cell.fill = header_fill - cell.alignment = center_align - ws.row_dimensions[7].height = 16 + date_range_str = '' + if start_date or end_date: + date_range_str = f"{start_date or ''} to {end_date or ''}" + elif start_time and end_time: + date_range_str = f"{start_time} - {end_time}" + ws.merge_cells('A5:G5') + ws['A5'] = date_range_str + ws['A5'].font = f_data; ws['A5'].alignment = center + ws.row_dimensions[5].height = 15.75 - # Set column widths — A-G sized to fit one printed page width - column_widths = [12, 11, 9, 11, 11, 11, 20] - for i, width in enumerate(column_widths, 1): - ws.column_dimensions[get_column_letter(i)].width = width + hdr_labels = ['Interval #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments'] + for col, label in enumerate(hdr_labels, 1): + cell = ws.cell(row=6, column=col, value=label) + cell.font = f_bold; cell.fill = hdr_fill; cell.alignment = center + cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner) + ws.row_dimensions[6].height = 39 - # Data rows starting at row 8 - data_start_row = 8 + # --- Data rows starting at row 7 --- + data_start_row = 7 + parsed_rows = [] for idx, row in enumerate(rnd_rows, 1): - data_row = data_start_row + idx - 1 + dr = data_start_row + idx - 1 + is_last = (idx == len(rnd_rows)) + b_left = last_left if is_last else data_left + b_inner = last_inner if is_last else data_inner + b_right = last_right if is_last else data_right - # Test Increment # - c = ws.cell(row=data_row, column=1, value=idx) - c.border = thin_border; c.font = data_font; c.alignment = center_align + c = ws.cell(row=dr, column=1, value=idx) + c.font = f_data; c.alignment = center; c.border = b_left - # Parse the Start Time to get Date and Time start_time_str = row.get('Start Time', '') + row_dt = None if start_time_str: try: - dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S') - c2 = ws.cell(row=data_row, column=2, value=dt.strftime('%m/%d/%y')) - c3 = ws.cell(row=data_row, column=3, value=dt.strftime('%H:%M')) + row_dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S') + c2 = ws.cell(row=dr, column=2, value=row_dt.strftime('%m/%d/%y')) + c3 = ws.cell(row=dr, column=3, value=row_dt.strftime('%H:%M')) except ValueError: - c2 = ws.cell(row=data_row, column=2, value=start_time_str) - c3 = ws.cell(row=data_row, column=3, value='') + c2 = ws.cell(row=dr, column=2, value=start_time_str) + c3 = ws.cell(row=dr, column=3, value='') else: - c2 = ws.cell(row=data_row, column=2, value='') - c3 = ws.cell(row=data_row, column=3, value='') - for c in (c2, c3): - c.border = thin_border; c.font = data_font; c.alignment = center_align + c2 = ws.cell(row=dr, column=2, value='') + c3 = ws.cell(row=dr, column=3, value='') + c2.font = f_data; c2.alignment = center; c2.border = b_inner + c3.font = f_data; c3.alignment = center; c3.border = b_inner - # LAmax, LA01, LA10 - for col_idx, key in [(4, 'Lmax(Main)'), (5, 'LN1(Main)'), (6, 'LN2(Main)')]: - val = row.get(key) - c = ws.cell(row=data_row, column=col_idx, value=val if val else '') - c.border = thin_border; c.font = data_font; c.alignment = right_align + lmax = row.get('Lmax(Main)') + ln1 = row.get('LN1(Main)') + ln2 = row.get('LN2(Main)') + for col_idx, val in [(4, lmax), (5, ln1), (6, ln2)]: + c = ws.cell(row=dr, column=col_idx, value=val if isinstance(val, (int, float)) else '') + c.font = f_data; c.alignment = center; c.border = b_inner - # Comments - c = ws.cell(row=data_row, column=7, value='') - c.border = thin_border; c.font = data_font; c.alignment = left_align + c = ws.cell(row=dr, column=7, value='') + c.font = f_data; c.alignment = left; c.border = b_right + ws.row_dimensions[dr].height = 15 + + if row_dt and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)): + parsed_rows.append((row_dt, lmax, ln1, ln2)) data_end_row = data_start_row + len(rnd_rows) - 1 - # Add Line Chart + # --- Chart anchored at H4, spanning H4:P29 --- chart = LineChart() - chart.title = f"{final_location or 'Sound Level Data'} - Background Noise Study" - chart.style = 10 + chart.title = f"{final_location} - {final_title}" if final_location else final_title + chart.style = 2 chart.y_axis.title = "Sound Level (dBA)" - chart.x_axis.title = "Time" - chart.height = 18 - chart.width = 22 + chart.x_axis.title = "Time Period (15 Minute Intervals)" + # 9 cols × 0.70" = 6.3" wide; H4:P29 = 25 rows at ~15pt ≈ 16.5cm tall + chart.height = 12.7 + chart.width = 15.7 - # Data references (LAmax, LA01, LA10 are columns D, E, F) - data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row) + data_ref = Reference(ws, min_col=4, min_row=6, max_col=6, max_row=data_end_row) categories = Reference(ws, min_col=3, min_row=data_start_row, max_row=data_end_row) - chart.add_data(data_ref, titles_from_data=True) chart.set_categories(categories) - # Style the series if len(chart.series) >= 3: - chart.series[0].graphicalProperties.line.solidFill = "FF0000" # LAmax - Red - chart.series[1].graphicalProperties.line.solidFill = "00B050" # LA01 - Green - chart.series[2].graphicalProperties.line.solidFill = "0070C0" # LA10 - Blue + chart.series[0].graphicalProperties.line.solidFill = "C00000" + chart.series[0].graphicalProperties.line.width = 15875 + chart.series[1].graphicalProperties.line.solidFill = "00B050" + chart.series[1].graphicalProperties.line.width = 19050 + chart.series[2].graphicalProperties.line.solidFill = "0070C0" + chart.series[2].graphicalProperties.line.width = 19050 - # Position chart starting at column H - ws.add_chart(chart, "H3") + ws.add_chart(chart, "H4") - # Print layout: A-G fits one page width, landscape + # --- Stats table: note at I28-I29, headers at I31, data rows 32-34 --- + note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ") + note1.font = f_data; note1.alignment = left + ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14) + note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.") + note2.font = f_data; note2.alignment = left + ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14) + + # Table header row 31 + med = Side(style='medium') + tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin')) + tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin')) + tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med) + # No vertical divider between value and dBA cells + tbl_top_val = Border(left=Side(style='thin'), right=Side(), top=med, bottom=Side(style='thin')) + tbl_top_unit = Border(left=Side(), right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_rval = Border(left=Side(style='thin'), right=Side(), top=med, bottom=Side(style='thin')) + tbl_top_runit = Border(left=Side(), right=med, top=med, bottom=Side(style='thin')) + tbl_mid_val = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_unit = Border(left=Side(), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_rval = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_runit = Border(left=Side(), right=med, top=Side(style='thin'), bottom=Side(style='thin')) + tbl_bot_val = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=med) + tbl_bot_unit = Border(left=Side(), right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_rval = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=med) + tbl_bot_runit = Border(left=Side(), right=med, top=Side(style='thin'), bottom=med) + + hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") + + # Header row: blank | Evening | Nighttime + c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold + c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)") + c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + c.border = tbl_top_mid; c.fill = hdr_fill_tbl + ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11) + c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)") + c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + c.border = tbl_top_right; c.fill = hdr_fill_tbl + ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13) + ws.row_dimensions[31].height = 15 + + evening = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows if 19 <= dt.hour < 22] + nighttime = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows if dt.hour >= 22 or dt.hour < 7] + + def _avg(vals): return round(sum(vals) / len(vals), 1) if vals else None + def _max(vals): return round(max(vals), 1) if vals else None + + def write_stat(row_num, label, eve_val, night_val, is_last=False): + bl = tbl_bot_left if is_last else tbl_mid_left + bm = tbl_bot_mid if is_last else tbl_mid_mid + br = tbl_bot_right if is_last else tbl_mid_right + lbl = ws.cell(row=row_num, column=9, value=label) + lbl.font = f_data; lbl.border = bl + lbl.alignment = Alignment(horizontal='left', vertical='center') + ev_str = f"{eve_val} dBA" if eve_val is not None else "" + ev = ws.cell(row=row_num, column=10, value=ev_str) + ev.font = f_bold; ev.border = bm + ev.alignment = Alignment(horizontal='center', vertical='center') + ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11) + ni_str = f"{night_val} dBA" if night_val is not None else "" + ni = ws.cell(row=row_num, column=12, value=ni_str) + ni.font = f_bold; ni.border = br + ni.alignment = Alignment(horizontal='center', vertical='center') + ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13) + + write_stat(32, "LAmax", _max([v[0] for v in evening]), _max([v[0] for v in nighttime])) + write_stat(33, "LA01 Average",_avg([v[1] for v in evening]), _avg([v[1] for v in nighttime])) + write_stat(34, "LA10 Average",_avg([v[2] for v in evening]), _avg([v[2] for v in nighttime]), is_last=True) + + # --- Page setup: portrait, letter, template margins --- from openpyxl.worksheet.properties import PageSetupProperties - ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True) - ws.page_setup.orientation = 'landscape' - ws.page_setup.fitToWidth = 1 - ws.page_setup.fitToHeight = 0 - - # Add summary statistics section below the data - summary_row = data_end_row + 3 - c = ws.cell(row=summary_row, column=1, value="Summary Statistics") - c.font = summary_title_font - - # Calculate time-period statistics - time_periods = { - 'Evening (7PM-10PM)': [], - 'Nighttime (10PM-7AM)': [], - 'Morning (7AM-12PM)': [], - 'Daytime (12PM-7PM)': [] - } - - for row in rnd_rows: - start_time_str = row.get('Start Time', '') - if start_time_str: - try: - dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S') - hour = dt.hour - - lmax = row.get('Lmax(Main)') - ln1 = row.get('LN1(Main)') - ln2 = row.get('LN2(Main)') - - if isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)): - data_point = {'lmax': lmax, 'ln1': ln1, 'ln2': ln2} - - if 19 <= hour < 22: - time_periods['Evening (7PM-10PM)'].append(data_point) - elif hour >= 22 or hour < 7: - time_periods['Nighttime (10PM-7AM)'].append(data_point) - elif 7 <= hour < 12: - time_periods['Morning (7AM-12PM)'].append(data_point) - else: # 12-19 - time_periods['Daytime (12PM-7PM)'].append(data_point) - except ValueError: - continue - - # Summary table headers - summary_row += 2 - summary_headers = ['Time Period', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg'] - for col, header in enumerate(summary_headers, 1): - cell = ws.cell(row=summary_row, column=col, value=header) - cell.font = header_font - cell.fill = header_fill - cell.border = thin_border - cell.alignment = center_align - - # Summary data - summary_row += 1 - for period_name, samples in time_periods.items(): - c = ws.cell(row=summary_row, column=1, value=period_name) - c.border = thin_border; c.font = data_font; c.alignment = left_align - c = ws.cell(row=summary_row, column=2, value=len(samples)) - c.border = thin_border; c.font = data_font; c.alignment = center_align - - if samples: - avg_lmax = sum(s['lmax'] for s in samples) / len(samples) - avg_ln1 = sum(s['ln1'] for s in samples) / len(samples) - avg_ln2 = sum(s['ln2'] for s in samples) / len(samples) - for col_idx, val in [(3, avg_lmax), (4, avg_ln1), (5, avg_ln2)]: - c = ws.cell(row=summary_row, column=col_idx, value=round(val, 1)) - c.border = thin_border; c.font = data_font; c.alignment = right_align - else: - for col_idx in [3, 4, 5]: - c = ws.cell(row=summary_row, column=col_idx, value='-') - c.border = thin_border; c.font = data_font; c.alignment = center_align - - summary_row += 1 - - # Overall summary - summary_row += 1 - c = ws.cell(row=summary_row, column=1, value='Overall') - c.font = Font(name='Arial', bold=True, size=10); c.border = thin_border; c.alignment = left_align - c = ws.cell(row=summary_row, column=2, value=len(rnd_rows)) - c.border = thin_border; c.font = data_font; c.alignment = center_align - - all_lmax = [r.get('Lmax(Main)') for r in rnd_rows if isinstance(r.get('Lmax(Main)'), (int, float))] - all_ln1 = [r.get('LN1(Main)') for r in rnd_rows if isinstance(r.get('LN1(Main)'), (int, float))] - all_ln2 = [r.get('LN2(Main)') for r in rnd_rows if isinstance(r.get('LN2(Main)'), (int, float))] - - for col_idx, vals in [(3, all_lmax), (4, all_ln1), (5, all_ln2)]: - if vals: - c = ws.cell(row=summary_row, column=col_idx, value=round(sum(vals) / len(vals), 1)) - c.border = thin_border; c.font = data_font; c.alignment = right_align + ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False) + ws.page_setup.orientation = 'portrait' + ws.page_setup.paperSize = 1 # Letter + ws.page_margins.left = 0.75 + ws.page_margins.right = 0.75 + ws.page_margins.top = 1.0 + ws.page_margins.bottom = 1.0 + ws.page_margins.header = 0.5 + ws.page_margins.footer = 0.5 # Save to buffer output = io.BytesIO() @@ -2593,7 +2586,7 @@ async def preview_report_data( try: dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S') date_str = dt.strftime('%Y-%m-%d') - time_str = dt.strftime('%H:%M:%S') + time_str = dt.strftime('%H:%M') except ValueError: date_str = start_time_str time_str = '' @@ -2691,113 +2684,204 @@ async def generate_report_from_preview( ws = wb.active ws.title = "Sound Level Data" - # Styles - title_font = Font(name='Arial', bold=True, size=12) - subtitle_font = Font(name='Arial', bold=True, size=12) - client_font = Font(name='Arial', italic=True, size=10) - filter_font = Font(name='Arial', italic=True, size=10, color="666666") - header_font = Font(name='Arial', bold=True, size=10) - data_font = Font(name='Arial', size=10) - thin_border = Border( - left=Side(style='thin'), - right=Side(style='thin'), - top=Side(style='thin'), - bottom=Side(style='thin') - ) - header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid") - center_align = Alignment(horizontal='center', vertical='center') - left_align = Alignment(horizontal='left', vertical='center') - right_align = Alignment(horizontal='right', vertical='center') + # --- Styles --- + f_title = Font(name='Arial', bold=True, size=12) + f_data = Font(name='Arial', size=10) + f_bold = Font(name='Arial', bold=True, size=10) - # Row 1: Title + thin = Side(style='thin') + dbl = Side(style='double') + + hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin) + hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin) + hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin) + last_inner = Border(left=thin, right=thin, top=thin, bottom=dbl) + last_left = Border(left=dbl, right=thin, top=thin, bottom=dbl) + last_right = Border(left=thin, right=dbl, top=thin, bottom=dbl) + data_inner = Border(left=thin, right=thin, top=thin, bottom=thin) + data_left = Border(left=dbl, right=thin, top=thin, bottom=thin) + data_right = Border(left=thin, right=dbl, top=thin, bottom=thin) + + hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") + center = Alignment(horizontal='center', vertical='center', wrap_text=True) + left = Alignment(horizontal='left', vertical='center') + right = Alignment(horizontal='right', vertical='center') + + # Column widths from Soundstudyexample.xlsx NRL_1 (sheet2) + # A B C D E F G H I J K L M N O P + for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]): + ws.column_dimensions[get_column_letter(col_i)].width = col_w + + # --- Header rows 1-6 --- final_title = f"{report_title} - {project_name}" if project_name else report_title - ws['A1'] = final_title - ws['A1'].font = title_font - ws['A1'].alignment = center_align + ws.merge_cells('A1:G1') - ws.row_dimensions[1].height = 20 + ws['A1'] = final_title + ws['A1'].font = f_title; ws['A1'].alignment = center + ws.row_dimensions[1].height = 15.75 + ws.row_dimensions[2].height = 15 - # Row 2: Client - if client_name: - ws['A2'] = f"Client: {client_name}" - ws['A2'].font = client_font - ws['A2'].alignment = center_align - ws.merge_cells('A2:G2') - ws.row_dimensions[2].height = 16 + ws.merge_cells('A3:G3') + ws['A3'] = location_name + ws['A3'].font = f_title; ws['A3'].alignment = center + ws.row_dimensions[3].height = 15.75 + ws.row_dimensions[4].height = 15 - # Row 3: Location - if location_name: - ws['A3'] = location_name - ws['A3'].font = subtitle_font - ws['A3'].alignment = center_align - ws.merge_cells('A3:G3') - ws.row_dimensions[3].height = 20 + ws.merge_cells('A5:G5') + ws['A5'] = time_filter + ws['A5'].font = f_data; ws['A5'].alignment = center + ws.row_dimensions[5].height = 15.75 - # Row 4: Time filter info - if time_filter: - ws['A4'] = time_filter - ws['A4'].font = filter_font - ws['A4'].alignment = center_align - ws.merge_cells('A4:G4') - ws.row_dimensions[4].height = 14 + hdr_labels = ['Interval #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments'] + for col, label in enumerate(hdr_labels, 1): + cell = ws.cell(row=6, column=col, value=label) + cell.font = f_bold; cell.fill = hdr_fill; cell.alignment = center + cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner) + ws.row_dimensions[6].height = 39 - # Row 7: Headers - headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments'] - for col, header in enumerate(headers, 1): - cell = ws.cell(row=7, column=col, value=header) - cell.font = header_font - cell.border = thin_border - cell.fill = header_fill - cell.alignment = center_align - ws.row_dimensions[7].height = 16 - - # Column widths — A-G sized to fit one printed page width - column_widths = [12, 11, 9, 11, 11, 11, 20] - for i, width in enumerate(column_widths, 1): - ws.column_dimensions[get_column_letter(i)].width = width - - # Data rows - data_start_row = 8 - col_aligns = [center_align, center_align, center_align, right_align, right_align, right_align, left_align] - for idx, row_data in enumerate(spreadsheet_data): - data_row = data_start_row + idx + # --- Data rows starting at row 7 --- + data_start_row = 7 + parsed_rows = [] + for idx, row_data in enumerate(spreadsheet_data, 1): + dr = data_start_row + idx - 1 + is_last = (idx == len(spreadsheet_data)) + b_left = last_left if is_last else data_left + b_inner = last_inner if is_last else data_inner + b_right = last_right if is_last else data_right + col_borders = [b_left] + [b_inner] * 5 + [b_right] + col_aligns = [center, center, center, center, center, center, left] for col, value in enumerate(row_data, 1): - cell = ws.cell(row=data_row, column=col, value=value if value != '' else None) - cell.border = thin_border - cell.font = data_font - cell.alignment = col_aligns[col - 1] if col <= len(col_aligns) else left_align + cell = ws.cell(row=dr, column=col, value=value if value != '' else None) + cell.font = f_data + cell.border = col_borders[col - 1] if col <= 7 else b_inner + cell.alignment = col_aligns[col - 1] if col <= 7 else left + ws.row_dimensions[dr].height = 15 + + try: + time_str = row_data[2] if len(row_data) > 2 else '' + lmax_v = row_data[3] if len(row_data) > 3 else '' + ln1_v = row_data[4] if len(row_data) > 4 else '' + ln2_v = row_data[5] if len(row_data) > 5 else '' + if time_str and isinstance(lmax_v, (int, float)): + try: + row_dt = datetime.strptime(time_str, '%H:%M') + except ValueError: + row_dt = datetime.strptime(time_str, '%H:%M:%S') + parsed_rows.append((row_dt, float(lmax_v), float(ln1_v), float(ln2_v))) + except (ValueError, TypeError): + pass data_end_row = data_start_row + len(spreadsheet_data) - 1 - # Add chart if we have data - if len(spreadsheet_data) > 0: + # --- Chart anchored at H4, spanning H4:P29 --- + if spreadsheet_data: chart = LineChart() - chart.title = f"{location_name or 'Sound Level Data'} - Background Noise Study" - chart.style = 10 + chart.title = f"{location_name} - {final_title}" if location_name else final_title + chart.style = 2 chart.y_axis.title = "Sound Level (dBA)" - chart.x_axis.title = "Time" - chart.height = 18 - chart.width = 22 - - data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row) + chart.x_axis.title = "Time Period (15 Minute Intervals)" + chart.height = 12.7 + chart.width = 15.7 + data_ref = Reference(ws, min_col=4, min_row=6, max_col=6, max_row=data_end_row) categories = Reference(ws, min_col=3, min_row=data_start_row, max_row=data_end_row) - chart.add_data(data_ref, titles_from_data=True) chart.set_categories(categories) - if len(chart.series) >= 3: - chart.series[0].graphicalProperties.line.solidFill = "FF0000" + chart.series[0].graphicalProperties.line.solidFill = "C00000" + chart.series[0].graphicalProperties.line.width = 15875 chart.series[1].graphicalProperties.line.solidFill = "00B050" + chart.series[1].graphicalProperties.line.width = 19050 chart.series[2].graphicalProperties.line.solidFill = "0070C0" + chart.series[2].graphicalProperties.line.width = 19050 + ws.add_chart(chart, "H4") - ws.add_chart(chart, "H3") + # --- Stats block starting at I28 --- + # Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35 + note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ") + note1.font = f_data; note1.alignment = left + ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14) + note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.") + note2.font = f_data; note2.alignment = left + ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14) - # Print layout: A-G fits one page width, landscape + # Table header row 31 + med = Side(style='medium') + tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin')) + tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin')) + tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med) + # No vertical divider between value and dBA cells + tbl_top_val = Border(left=Side(style='thin'), right=Side(), top=med, bottom=Side(style='thin')) + tbl_top_unit = Border(left=Side(), right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_rval = Border(left=Side(style='thin'), right=Side(), top=med, bottom=Side(style='thin')) + tbl_top_runit = Border(left=Side(), right=med, top=med, bottom=Side(style='thin')) + tbl_mid_val = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_unit = Border(left=Side(), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_rval = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_runit = Border(left=Side(), right=med, top=Side(style='thin'), bottom=Side(style='thin')) + tbl_bot_val = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=med) + tbl_bot_unit = Border(left=Side(), right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_rval = Border(left=Side(style='thin'), right=Side(), top=Side(style='thin'), bottom=med) + tbl_bot_runit = Border(left=Side(), right=med, top=Side(style='thin'), bottom=med) + + hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") + + # Header row: blank | Evening | Nighttime + c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold + c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)") + c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + c.border = tbl_top_mid; c.fill = hdr_fill_tbl + ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11) + c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)") + c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + c.border = tbl_top_right; c.fill = hdr_fill_tbl + ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13) + ws.row_dimensions[31].height = 15 + + evening2 = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows if 19 <= dt.hour < 22] + nighttime2 = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows if dt.hour >= 22 or dt.hour < 7] + + def _avg2(vals): return round(sum(vals) / len(vals), 1) if vals else None + def _max2(vals): return round(max(vals), 1) if vals else None + + def write_stat2(row_num, label, eve_val, night_val, is_last=False): + bl = tbl_bot_left if is_last else tbl_mid_left + bm = tbl_bot_mid if is_last else tbl_mid_mid + br = tbl_bot_right if is_last else tbl_mid_right + lbl = ws.cell(row=row_num, column=9, value=label) + lbl.font = f_data; lbl.border = bl + lbl.alignment = Alignment(horizontal='left', vertical='center') + ev_str = f"{eve_val} dBA" if eve_val is not None else "" + ev = ws.cell(row=row_num, column=10, value=ev_str) + ev.font = f_bold; ev.border = bm + ev.alignment = Alignment(horizontal='center', vertical='center') + ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11) + ni_str = f"{night_val} dBA" if night_val is not None else "" + ni = ws.cell(row=row_num, column=12, value=ni_str) + ni.font = f_bold; ni.border = br + ni.alignment = Alignment(horizontal='center', vertical='center') + ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13) + + write_stat2(32, "LAmax", _max2([v[0] for v in evening2]), _max2([v[0] for v in nighttime2])) + write_stat2(33, "LA01 Average",_avg2([v[1] for v in evening2]), _avg2([v[1] for v in nighttime2])) + write_stat2(34, "LA10 Average",_avg2([v[2] for v in evening2]), _avg2([v[2] for v in nighttime2]), is_last=True) + + # Page setup: portrait, letter, template margins from openpyxl.worksheet.properties import PageSetupProperties - ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True) - ws.page_setup.orientation = 'landscape' - ws.page_setup.fitToWidth = 1 - ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False) + ws.page_setup.orientation = 'portrait' + ws.page_setup.paperSize = 1 + ws.page_margins.left = 0.75 + ws.page_margins.right = 0.75 + ws.page_margins.top = 1.0 + ws.page_margins.bottom = 1.0 + ws.page_margins.header = 0.5 + ws.page_margins.footer = 0.5 # Save to buffer output = io.BytesIO() @@ -2888,25 +2972,30 @@ async def generate_combined_excel_report( if not location_files: raise HTTPException(status_code=404, detail="No Leq measurement files found in project. Reports require Leq data (files with '_Leq_' in the name).") - # Define styles - title_font = Font(name='Arial', bold=True, size=12) - header_font = Font(name='Arial', bold=True, size=10) - data_font = Font(name='Arial', size=10) - thin_border = Border( - left=Side(style='thin'), - right=Side(style='thin'), - top=Side(style='thin'), - bottom=Side(style='thin') - ) - header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid") - center_align = Alignment(horizontal='center', vertical='center') - left_align = Alignment(horizontal='left', vertical='center') - right_align = Alignment(horizontal='right', vertical='center') + # --- Shared styles --- + f_title = Font(name='Arial', bold=True, size=12) + f_data = Font(name='Arial', size=10) + f_bold = Font(name='Arial', bold=True, size=10) + + thin = Side(style='thin') + dbl = Side(style='double') + + hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin) + hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin) + hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin) + last_inner = Border(left=thin, right=thin, top=thin, bottom=dbl) + last_left = Border(left=dbl, right=thin, top=thin, bottom=dbl) + last_right = Border(left=thin, right=dbl, top=thin, bottom=dbl) + data_inner = Border(left=thin, right=thin, top=thin, bottom=thin) + data_left = Border(left=dbl, right=thin, top=thin, bottom=thin) + data_right = Border(left=thin, right=dbl, top=thin, bottom=thin) + + hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") + center = Alignment(horizontal='center', vertical='center', wrap_text=True) + left_a = Alignment(horizontal='left', vertical='center') # Create Excel workbook wb = openpyxl.Workbook() - - # Remove default sheet wb.remove(wb.active) # Track all data for summary @@ -2914,39 +3003,34 @@ async def generate_combined_excel_report( # Create a sheet for each location for location_name, file_list in location_files.items(): - # Sanitize sheet name (max 31 chars, no special chars) safe_sheet_name = "".join(c for c in location_name if c.isalnum() or c in (' ', '-', '_'))[:31] ws = wb.create_sheet(title=safe_sheet_name) - # Row 1: Report title + # Column widths from Soundstudyexample.xlsx NRL_1 (sheet2) + # A B C D E F G H I J K L M N O P + for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]): + ws.column_dimensions[get_column_letter(col_i)].width = col_w + final_title = f"{report_title} - {project.name}" - ws['A1'] = final_title - ws['A1'].font = title_font - ws['A1'].alignment = center_align ws.merge_cells('A1:G1') - ws.row_dimensions[1].height = 20 + ws['A1'] = final_title + ws['A1'].font = f_title; ws['A1'].alignment = center + ws.row_dimensions[1].height = 15.75 + ws.row_dimensions[2].height = 15 - # Row 3: Location name - ws['A3'] = location_name - ws['A3'].font = Font(name='Arial', bold=True, size=12) - ws['A3'].alignment = center_align ws.merge_cells('A3:G3') - ws.row_dimensions[3].height = 20 + ws['A3'] = location_name + ws['A3'].font = f_title; ws['A3'].alignment = center + ws.row_dimensions[3].height = 15.75 + ws.row_dimensions[4].height = 15 + ws.row_dimensions[5].height = 15.75 - # Row 7: Headers - headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments'] - for col, header in enumerate(headers, 1): - cell = ws.cell(row=7, column=col, value=header) - cell.font = header_font - cell.border = thin_border - cell.fill = header_fill - cell.alignment = center_align - ws.row_dimensions[7].height = 16 - - # Set column widths — A-G sized to fit one printed page width - column_widths = [12, 11, 9, 11, 11, 11, 20] - for i, width in enumerate(column_widths, 1): - ws.column_dimensions[get_column_letter(i)].width = width + hdr_labels = ['Interval #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments'] + for col, label in enumerate(hdr_labels, 1): + cell = ws.cell(row=6, column=col, value=label) + cell.font = f_bold; cell.fill = hdr_fill; cell.alignment = center + cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner) + ws.row_dimensions[6].height = 39 # Combine data from all files for this location all_rnd_rows = [] @@ -2959,9 +3043,9 @@ async def generate_combined_excel_report( try: with open(file_path, 'r', encoding='utf-8', errors='replace') as f: - content = f.read() + content_f = f.read() - reader = csv.DictReader(io.StringIO(content)) + reader = csv.DictReader(io.StringIO(content_f)) for row in reader: cleaned_row = {} for key, value in row.items(): @@ -2984,73 +3068,149 @@ async def generate_combined_excel_report( if not all_rnd_rows: continue - # Sort by start time all_rnd_rows.sort(key=lambda r: r.get('Start Time', '')) - # Data rows starting at row 8 - data_start_row = 8 + data_start_row = 7 + parsed_rows_c = [] for idx, row in enumerate(all_rnd_rows, 1): - data_row = data_start_row + idx - 1 + dr = data_start_row + idx - 1 + is_last = (idx == len(all_rnd_rows)) + b_left = last_left if is_last else data_left + b_inner = last_inner if is_last else data_inner + b_right = last_right if is_last else data_right - ws.cell(row=data_row, column=1, value=idx).border = thin_border + c = ws.cell(row=dr, column=1, value=idx) + c.font = f_data; c.alignment = center; c.border = b_left start_time_str = row.get('Start Time', '') + row_dt = None if start_time_str: try: - dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S') - ws.cell(row=data_row, column=2, value=dt.date()) - ws.cell(row=data_row, column=3, value=dt.time()) + row_dt = datetime.strptime(start_time_str, '%Y/%m/%d %H:%M:%S') + c2 = ws.cell(row=dr, column=2, value=row_dt.strftime('%m/%d/%y')) + c3 = ws.cell(row=dr, column=3, value=row_dt.strftime('%H:%M')) except ValueError: - ws.cell(row=data_row, column=2, value=start_time_str) - ws.cell(row=data_row, column=3, value='') + c2 = ws.cell(row=dr, column=2, value=start_time_str) + c3 = ws.cell(row=dr, column=3, value='') else: - ws.cell(row=data_row, column=2, value='') - ws.cell(row=data_row, column=3, value='') + c2 = ws.cell(row=dr, column=2, value='') + c3 = ws.cell(row=dr, column=3, value='') + c2.font = f_data; c2.alignment = center; c2.border = b_inner + c3.font = f_data; c3.alignment = center; c3.border = b_inner lmax = row.get('Lmax(Main)') - ws.cell(row=data_row, column=4, value=lmax if lmax else '').border = thin_border + ln1 = row.get('LN1(Main)') + ln2 = row.get('LN2(Main)') + for col_idx, val in [(4, lmax), (5, ln1), (6, ln2)]: + c = ws.cell(row=dr, column=col_idx, value=val if isinstance(val, (int, float)) else '') + c.font = f_data; c.alignment = center; c.border = b_inner - ln1 = row.get('LN1(Main)') - ws.cell(row=data_row, column=5, value=ln1 if ln1 else '').border = thin_border + c = ws.cell(row=dr, column=7, value='') + c.font = f_data; c.alignment = left_a; c.border = b_right + ws.row_dimensions[dr].height = 15 - ln2 = row.get('LN2(Main)') - ws.cell(row=data_row, column=6, value=ln2 if ln2 else '').border = thin_border - - ws.cell(row=data_row, column=7, value='').border = thin_border - ws.cell(row=data_row, column=2).border = thin_border - ws.cell(row=data_row, column=3).border = thin_border + if row_dt and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)): + parsed_rows_c.append((row_dt, lmax, ln1, ln2)) data_end_row = data_start_row + len(all_rnd_rows) - 1 - # Add Line Chart chart = LineChart() - chart.title = f"{location_name}" - chart.style = 10 + chart.title = f"{location_name} - {final_title}" + chart.style = 2 chart.y_axis.title = "Sound Level (dBA)" - chart.x_axis.title = "Time" - chart.height = 18 - chart.width = 22 + chart.x_axis.title = "Time Period (15 Minute Intervals)" + chart.height = 12.7 + chart.width = 15.7 - data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row) + data_ref = Reference(ws, min_col=4, min_row=6, max_col=6, max_row=data_end_row) categories = Reference(ws, min_col=3, min_row=data_start_row, max_row=data_end_row) - chart.add_data(data_ref, titles_from_data=True) chart.set_categories(categories) if len(chart.series) >= 3: - chart.series[0].graphicalProperties.line.solidFill = "FF0000" + chart.series[0].graphicalProperties.line.solidFill = "C00000" + chart.series[0].graphicalProperties.line.width = 15875 chart.series[1].graphicalProperties.line.solidFill = "00B050" + chart.series[1].graphicalProperties.line.width = 19050 chart.series[2].graphicalProperties.line.solidFill = "0070C0" + chart.series[2].graphicalProperties.line.width = 19050 - ws.add_chart(chart, "H3") + ws.add_chart(chart, "H4") + + # Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35 + note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ") + note1.font = f_data; note1.alignment = left_a + ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14) + note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.") + note2.font = f_data; note2.alignment = left_a + ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14) + + # Table header row 31 + med = Side(style='medium') + tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin')) + tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin')) + tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med) + + hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") + + # Header row: blank | Evening | Nighttime + c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold + c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)") + c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + c.border = tbl_top_mid; c.fill = hdr_fill_tbl + ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11) + c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)") + c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + c.border = tbl_top_right; c.fill = hdr_fill_tbl + ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13) + ws.row_dimensions[31].height = 15 + + evening_c = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_c if 19 <= dt.hour < 22] + nighttime_c = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_c if dt.hour >= 22 or dt.hour < 7] + + def _avg_c(vals): return round(sum(vals) / len(vals), 1) if vals else None + def _max_c(vals): return round(max(vals), 1) if vals else None + + def write_stat_c(row_num, label, eve_val, night_val, is_last=False): + bl = tbl_bot_left if is_last else tbl_mid_left + bm = tbl_bot_mid if is_last else tbl_mid_mid + br = tbl_bot_right if is_last else tbl_mid_right + lbl = ws.cell(row=row_num, column=9, value=label) + lbl.font = f_data; lbl.border = bl + lbl.alignment = Alignment(horizontal='left', vertical='center') + ev_str = f"{eve_val} dBA" if eve_val is not None else "" + ev = ws.cell(row=row_num, column=10, value=ev_str) + ev.font = f_bold; ev.border = bm + ev.alignment = Alignment(horizontal='center', vertical='center') + ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11) + ni_str = f"{night_val} dBA" if night_val is not None else "" + ni = ws.cell(row=row_num, column=12, value=ni_str) + ni.font = f_bold; ni.border = br + ni.alignment = Alignment(horizontal='center', vertical='center') + ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13) + + write_stat_c(32, "LAmax", _max_c([v[0] for v in evening_c]), _max_c([v[0] for v in nighttime_c])) + write_stat_c(33, "LA01 Average",_avg_c([v[1] for v in evening_c]), _avg_c([v[1] for v in nighttime_c])) + write_stat_c(34, "LA10 Average",_avg_c([v[2] for v in evening_c]), _avg_c([v[2] for v in nighttime_c]), is_last=True) - # Print layout: A-G fits one page width, landscape from openpyxl.worksheet.properties import PageSetupProperties - ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True) - ws.page_setup.orientation = 'landscape' - ws.page_setup.fitToWidth = 1 - ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False) + ws.page_setup.orientation = 'portrait' + ws.page_setup.paperSize = 1 + ws.page_margins.left = 0.75 + ws.page_margins.right = 0.75 + ws.page_margins.top = 1.0 + ws.page_margins.bottom = 1.0 + ws.page_margins.header = 0.5 + ws.page_margins.footer = 0.5 + # Calculate summary for this location # Calculate summary for this location all_lmax = [r.get('Lmax(Main)') for r in all_rnd_rows if isinstance(r.get('Lmax(Main)'), (int, float))] all_ln1 = [r.get('LN1(Main)') for r in all_rnd_rows if isinstance(r.get('LN1(Main)'), (int, float))] @@ -3241,15 +3401,26 @@ async def generate_combined_from_preview( raise HTTPException(status_code=400, detail="No location data provided") # Styles - title_font = Font(name='Arial', bold=True, size=12) - header_font = Font(name='Arial', bold=True, size=10) - data_font = Font(name='Arial', size=10) - thin_border = Border( - left=Side(style='thin'), right=Side(style='thin'), - top=Side(style='thin'), bottom=Side(style='thin') - ) - header_fill = PatternFill(start_color="DAEEF3", end_color="DAEEF3", fill_type="solid") - center_align = Alignment(horizontal='center', vertical='center') + f_title = Font(name='Arial', bold=True, size=12) + f_bold = Font(name='Arial', bold=True, size=10) + f_data = Font(name='Arial', size=10) + thin = Side(style='thin') + dbl = Side(style='double') + hdr_inner = Border(left=thin, right=thin, top=dbl, bottom=thin) + hdr_left = Border(left=dbl, right=thin, top=dbl, bottom=thin) + hdr_right = Border(left=thin, right=dbl, top=dbl, bottom=thin) + last_inner = Border(left=thin, right=thin, top=thin, bottom=dbl) + last_left = Border(left=dbl, right=thin, top=thin, bottom=dbl) + last_right = Border(left=thin, right=dbl, top=thin, bottom=dbl) + data_inner = Border(left=thin, right=thin, top=thin, bottom=thin) + data_left = Border(left=dbl, right=thin, top=thin, bottom=thin) + data_right = Border(left=thin, right=dbl, top=thin, bottom=thin) + hdr_fill = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") + center_a = Alignment(horizontal='center', vertical='center', wrap_text=True) + left_a = Alignment(horizontal='left', vertical='center') + right_a = Alignment(horizontal='right', vertical='center') + + from openpyxl.worksheet.properties import PageSetupProperties wb = openpyxl.Workbook() wb.remove(wb.active) @@ -3266,66 +3437,69 @@ async def generate_combined_from_preview( safe_sheet_name = "".join(c for c in loc_name if c.isalnum() or c in (' ', '-', '_'))[:31] ws = wb.create_sheet(title=safe_sheet_name) - # Title row + # Column widths from Soundstudyexample.xlsx NRL_1 (sheet2) + # A B C D E F G H I J K L M N O P + for col_i, col_w in zip(range(1, 17), [9.43, 10.14, 8.14, 12.86, 10.86, 10.86, 25.0, 6.43, 12.43, 12.43, 10.0, 14.71, 8.0, 6.43, 6.43, 6.43]): + ws.column_dimensions[get_column_letter(col_i)].width = col_w + final_title = f"{report_title} - {project_name}" - ws['A1'] = final_title - ws['A1'].font = title_font - ws['A1'].alignment = center_align ws.merge_cells('A1:G1') - ws.row_dimensions[1].height = 20 + ws['A1'] = final_title + ws['A1'].font = f_title; ws['A1'].alignment = center_a + ws.row_dimensions[1].height = 15.75 + ws.row_dimensions[2].height = 15 - # Client row (row 2) if provided - if client_name: - ws['A2'] = client_name - ws['A2'].font = Font(name='Arial', italic=True, size=10) - ws['A2'].alignment = center_align - ws.merge_cells('A2:G2') - - # Location row - ws['A3'] = loc_name - ws['A3'].font = Font(name='Arial', bold=True, size=12) - ws['A3'].alignment = center_align ws.merge_cells('A3:G3') - ws.row_dimensions[3].height = 20 + ws['A3'] = loc_name + ws['A3'].font = f_title; ws['A3'].alignment = center_a + ws.row_dimensions[3].height = 15.75 + ws.row_dimensions[4].height = 15 + ws.row_dimensions[5].height = 15.75 - # Column headers at row 7 - headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments'] - for col, header in enumerate(headers, 1): - cell = ws.cell(row=7, column=col, value=header) - cell.font = header_font - cell.border = thin_border - cell.fill = header_fill - cell.alignment = center_align - ws.row_dimensions[7].height = 16 + hdr_labels = ['Interval #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments'] + for col, label in enumerate(hdr_labels, 1): + cell = ws.cell(row=6, column=col, value=label) + cell.font = f_bold; cell.fill = hdr_fill; cell.alignment = center_a + cell.border = hdr_left if col == 1 else (hdr_right if col == 7 else hdr_inner) + ws.row_dimensions[6].height = 39 - column_widths = [12, 11, 9, 11, 11, 11, 20] - for i, width in enumerate(column_widths, 1): - ws.column_dimensions[get_column_letter(i)].width = width - - # Data rows starting at row 8 - data_start_row = 8 + # Data rows starting at row 7 + data_start_row = 7 + parsed_rows_p = [] lmax_vals = [] ln1_vals = [] ln2_vals = [] for row_idx, row in enumerate(rows): - data_row = data_start_row + row_idx - # row is [test#, date, time, lmax, ln1, ln2, comment] + dr = data_start_row + row_idx + is_last = (row_idx == len(rows) - 1) + b_left = last_left if is_last else data_left + b_inner = last_inner if is_last else data_inner + b_right = last_right if is_last else data_right + test_num = row[0] if len(row) > 0 else row_idx + 1 date_val = row[1] if len(row) > 1 else '' time_val = row[2] if len(row) > 2 else '' - lmax = row[3] if len(row) > 3 else '' - ln1 = row[4] if len(row) > 4 else '' - ln2 = row[5] if len(row) > 5 else '' - comment = row[6] if len(row) > 6 else '' + lmax = row[3] if len(row) > 3 else '' + ln1 = row[4] if len(row) > 4 else '' + ln2 = row[5] if len(row) > 5 else '' + comment = row[6] if len(row) > 6 else '' - ws.cell(row=data_row, column=1, value=test_num).border = thin_border - ws.cell(row=data_row, column=2, value=date_val).border = thin_border - ws.cell(row=data_row, column=3, value=time_val).border = thin_border - ws.cell(row=data_row, column=4, value=lmax if lmax != '' else None).border = thin_border - ws.cell(row=data_row, column=5, value=ln1 if ln1 != '' else None).border = thin_border - ws.cell(row=data_row, column=6, value=ln2 if ln2 != '' else None).border = thin_border - ws.cell(row=data_row, column=7, value=comment).border = thin_border + c = ws.cell(row=dr, column=1, value=test_num) + c.font = f_data; c.alignment = center_a; c.border = b_left + c = ws.cell(row=dr, column=2, value=date_val) + c.font = f_data; c.alignment = center_a; c.border = b_inner + c = ws.cell(row=dr, column=3, value=time_val) + c.font = f_data; c.alignment = center_a; c.border = b_inner + c = ws.cell(row=dr, column=4, value=lmax if lmax != '' else None) + c.font = f_data; c.alignment = center_a; c.border = b_inner + c = ws.cell(row=dr, column=5, value=ln1 if ln1 != '' else None) + c.font = f_data; c.alignment = center_a; c.border = b_inner + c = ws.cell(row=dr, column=6, value=ln2 if ln2 != '' else None) + c.font = f_data; c.alignment = center_a; c.border = b_inner + c = ws.cell(row=dr, column=7, value=comment) + c.font = f_data; c.alignment = left_a; c.border = b_right + ws.row_dimensions[dr].height = 15 if isinstance(lmax, (int, float)): lmax_vals.append(lmax) @@ -3334,34 +3508,115 @@ async def generate_combined_from_preview( if isinstance(ln2, (int, float)): ln2_vals.append(ln2) + # Parse time for evening/nighttime stats + if time_val and isinstance(lmax, (int, float)) and isinstance(ln1, (int, float)) and isinstance(ln2, (int, float)): + try: + try: + row_dt = datetime.strptime(str(time_val), '%H:%M') + except ValueError: + row_dt = datetime.strptime(str(time_val), '%H:%M:%S') + parsed_rows_p.append((row_dt, float(lmax), float(ln1), float(ln2))) + except (ValueError, TypeError): + pass + data_end_row = data_start_row + len(rows) - 1 - # Line chart + # Chart anchored at H4 chart = LineChart() - chart.title = loc_name - chart.style = 10 + chart.title = f"{loc_name} - {final_title}" + chart.style = 2 chart.y_axis.title = "Sound Level (dBA)" - chart.x_axis.title = "Time" - chart.height = 18 - chart.width = 22 + chart.x_axis.title = "Time Period (15 Minute Intervals)" + chart.height = 12.7 + chart.width = 15.7 - data_ref = Reference(ws, min_col=4, min_row=7, max_col=6, max_row=data_end_row) + data_ref = Reference(ws, min_col=4, min_row=6, max_col=6, max_row=data_end_row) categories = Reference(ws, min_col=3, min_row=data_start_row, max_row=data_end_row) chart.add_data(data_ref, titles_from_data=True) chart.set_categories(categories) if len(chart.series) >= 3: - chart.series[0].graphicalProperties.line.solidFill = "FF0000" + chart.series[0].graphicalProperties.line.solidFill = "C00000" + chart.series[0].graphicalProperties.line.width = 15875 chart.series[1].graphicalProperties.line.solidFill = "00B050" + chart.series[1].graphicalProperties.line.width = 19050 chart.series[2].graphicalProperties.line.solidFill = "0070C0" + chart.series[2].graphicalProperties.line.width = 19050 - ws.add_chart(chart, "H3") + ws.add_chart(chart, "H4") - from openpyxl.worksheet.properties import PageSetupProperties - ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True) - ws.page_setup.orientation = 'landscape' - ws.page_setup.fitToWidth = 1 - ws.page_setup.fitToHeight = 0 + # Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35 + note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ") + note1.font = f_data; note1.alignment = left_a + ws.merge_cells(start_row=28, start_column=9, end_row=28, end_column=14) + note2 = ws.cell(row=29, column=9, value="for each specified range of time intervals.") + note2.font = f_data; note2.alignment = left_a + ws.merge_cells(start_row=29, start_column=9, end_row=29, end_column=14) + + # Table header row 31 + med = Side(style='medium') + tbl_top_left = Border(left=med, right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=med, bottom=Side(style='thin')) + tbl_top_right = Border(left=Side(style='thin'), right=med, top=med, bottom=Side(style='thin')) + tbl_mid_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) + tbl_mid_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=Side(style='thin')) + tbl_bot_left = Border(left=med, right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_mid = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=med) + tbl_bot_right = Border(left=Side(style='thin'), right=med, top=Side(style='thin'), bottom=med) + + hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") + + # Header row: blank | Evening | Nighttime + c = ws.cell(row=31, column=9, value=""); c.border = tbl_top_left; c.font = f_bold + c = ws.cell(row=31, column=10, value="Evening (7PM to 10PM)") + c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + c.border = tbl_top_mid; c.fill = hdr_fill_tbl + ws.merge_cells(start_row=31, start_column=10, end_row=31, end_column=11) + c = ws.cell(row=31, column=12, value="Nighttime (10PM to 7AM)") + c.font = f_bold; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + c.border = tbl_top_right; c.fill = hdr_fill_tbl + ws.merge_cells(start_row=31, start_column=12, end_row=31, end_column=13) + ws.row_dimensions[31].height = 15 + + evening_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if 19 <= dt.hour < 22] + nighttime_p = [(lmax, ln1, ln2) for dt, lmax, ln1, ln2 in parsed_rows_p if dt.hour >= 22 or dt.hour < 7] + + def _avg_p(vals): return round(sum(vals) / len(vals), 1) if vals else None + def _max_p(vals): return round(max(vals), 1) if vals else None + + def write_stat_p(row_num, label, eve_val, night_val, is_last=False): + bl = tbl_bot_left if is_last else tbl_mid_left + bm = tbl_bot_mid if is_last else tbl_mid_mid + br = tbl_bot_right if is_last else tbl_mid_right + lbl = ws.cell(row=row_num, column=9, value=label) + lbl.font = f_data; lbl.border = bl + lbl.alignment = Alignment(horizontal='left', vertical='center') + ev_str = f"{eve_val} dBA" if eve_val is not None else "" + ev = ws.cell(row=row_num, column=10, value=ev_str) + ev.font = f_bold; ev.border = bm + ev.alignment = Alignment(horizontal='center', vertical='center') + ws.merge_cells(start_row=row_num, start_column=10, end_row=row_num, end_column=11) + ni_str = f"{night_val} dBA" if night_val is not None else "" + ni = ws.cell(row=row_num, column=12, value=ni_str) + ni.font = f_bold; ni.border = br + ni.alignment = Alignment(horizontal='center', vertical='center') + ws.merge_cells(start_row=row_num, start_column=12, end_row=row_num, end_column=13) + + write_stat_p(32, "LAmax", _max_p([v[0] for v in evening_p]), _max_p([v[0] for v in nighttime_p])) + write_stat_p(33, "LA01 Average",_avg_p([v[1] for v in evening_p]), _avg_p([v[1] for v in nighttime_p])) + write_stat_p(34, "LA10 Average",_avg_p([v[2] for v in evening_p]), _avg_p([v[2] for v in nighttime_p]), is_last=True) + + # Page setup: portrait, letter + ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=False) + ws.page_setup.orientation = 'portrait' + ws.page_setup.paperSize = 1 + ws.page_margins.left = 0.75 + ws.page_margins.right = 0.75 + ws.page_margins.top = 1.0 + ws.page_margins.bottom = 1.0 + ws.page_margins.header = 0.5 + ws.page_margins.footer = 0.5 all_location_summaries.append({ 'location': loc_name, @@ -3372,16 +3627,18 @@ async def generate_combined_from_preview( }) # Summary sheet + thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), + top=Side(style='thin'), bottom=Side(style='thin')) summary_ws = wb.create_sheet(title="Summary", index=0) summary_ws['A1'] = f"{report_title} - {project_name} - Summary" - summary_ws['A1'].font = title_font + summary_ws['A1'].font = f_title summary_ws.merge_cells('A1:E1') summary_headers = ['Location', 'Samples', 'LAmax Avg', 'LA01 Avg', 'LA10 Avg'] for col, header in enumerate(summary_headers, 1): cell = summary_ws.cell(row=3, column=col, value=header) - cell.font = header_font - cell.fill = header_fill + cell.font = f_bold + cell.fill = hdr_fill cell.border = thin_border for i, width in enumerate([30, 10, 12, 12, 12], 1): diff --git a/templates/combined_report_wizard.html b/templates/combined_report_wizard.html index 04551d7..8470ae9 100644 --- a/templates/combined_report_wizard.html +++ b/templates/combined_report_wizard.html @@ -12,7 +12,7 @@
{{ project.name }}
- ← Back to Project