|
|
|
|
@@ -45,6 +45,73 @@ router = APIRouter(prefix="/api/projects", tags=["projects"])
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# RND file normalization — maps AU2 (older Rion) column names to the NL-43
|
|
|
|
|
# equivalents so report generation and the web viewer work for both formats.
|
|
|
|
|
# AU2 files: LAeq, LAmax, LAmin, LA01, LA10, LA50, LA90, LA95, LCpeak
|
|
|
|
|
# NL-43 files: Leq(Main), Lmax(Main), Lmin(Main), LN1(Main) … Lpeak(Main)
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
_AU2_TO_NL43 = {
|
|
|
|
|
"LAeq": "Leq(Main)",
|
|
|
|
|
"LAmax": "Lmax(Main)",
|
|
|
|
|
"LAmin": "Lmin(Main)",
|
|
|
|
|
"LCpeak": "Lpeak(Main)",
|
|
|
|
|
"LA01": "LN1(Main)",
|
|
|
|
|
"LA10": "LN2(Main)",
|
|
|
|
|
"LA50": "LN3(Main)",
|
|
|
|
|
"LA90": "LN4(Main)",
|
|
|
|
|
"LA95": "LN5(Main)",
|
|
|
|
|
# Time column differs too
|
|
|
|
|
"Time": "Start Time",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_rnd_rows(rows: list[dict]) -> tuple[list[dict], bool]:
|
|
|
|
|
"""
|
|
|
|
|
Detect AU2-format RND rows (by presence of 'LAeq' key) and remap column
|
|
|
|
|
names to NL-43 equivalents. Returns (normalized_rows, was_au2_format).
|
|
|
|
|
If already NL-43 format the rows are returned unchanged.
|
|
|
|
|
"""
|
|
|
|
|
if not rows:
|
|
|
|
|
return rows, False
|
|
|
|
|
if "LAeq" not in rows[0]:
|
|
|
|
|
return rows, False # already NL-43 format
|
|
|
|
|
|
|
|
|
|
normalized = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
new_row = {}
|
|
|
|
|
for k, v in row.items():
|
|
|
|
|
new_row[_AU2_TO_NL43.get(k, k)] = v
|
|
|
|
|
normalized.append(new_row)
|
|
|
|
|
return normalized, True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _peek_rnd_headers(file_path) -> list[dict]:
|
|
|
|
|
"""Read just the first data row of an RND file to check column names cheaply."""
|
|
|
|
|
import csv as _csv
|
|
|
|
|
try:
|
|
|
|
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
|
|
|
reader = _csv.DictReader(f)
|
|
|
|
|
row = next(reader, None)
|
|
|
|
|
return [row] if row else []
|
|
|
|
|
except Exception:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_leq_file(file_path: str, rows: list[dict]) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Return True if this RND file contains Leq (15-min averaged) data.
|
|
|
|
|
Accepts NL-43 Leq files (_Leq_ in path) and AU2 files (LAeq column or
|
|
|
|
|
Leq(Main) column after normalisation).
|
|
|
|
|
"""
|
|
|
|
|
if "_Leq_" in file_path:
|
|
|
|
|
return True
|
|
|
|
|
if rows and ("LAeq" in rows[0] or "Leq(Main)" in rows[0]):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Project List & Overview
|
|
|
|
|
# ============================================================================
|
|
|
|
|
@@ -1539,6 +1606,7 @@ async def view_rnd_file(
|
|
|
|
|
"unit": unit,
|
|
|
|
|
"metadata": metadata,
|
|
|
|
|
"filename": file_path.name,
|
|
|
|
|
"is_leq": _is_leq_file(str(file_record.file_path), _peek_rnd_headers(file_path)),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1603,11 +1671,16 @@ async def get_rnd_data(
|
|
|
|
|
cleaned_row[cleaned_key] = cleaned_value
|
|
|
|
|
rows.append(cleaned_row)
|
|
|
|
|
|
|
|
|
|
# Normalise AU2-format columns to NL-43 names
|
|
|
|
|
rows, _was_au2 = _normalize_rnd_rows(rows)
|
|
|
|
|
if _was_au2:
|
|
|
|
|
headers = list(rows[0].keys()) if rows else headers
|
|
|
|
|
|
|
|
|
|
# Detect file type (Leq vs Lp) based on columns
|
|
|
|
|
file_type = 'unknown'
|
|
|
|
|
if headers:
|
|
|
|
|
header_str = ','.join(headers).lower()
|
|
|
|
|
if 'leq' in header_str:
|
|
|
|
|
if 'leq(main)' in header_str or 'laeq' in header_str:
|
|
|
|
|
file_type = 'leq' # Time-averaged data
|
|
|
|
|
elif 'lp(main)' in header_str or 'lp (main)' in header_str:
|
|
|
|
|
file_type = 'lp' # Instantaneous data
|
|
|
|
|
@@ -1714,15 +1787,7 @@ async def generate_excel_report(
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
|
|
|
|
|
|
|
|
|
# Validate this is a Leq file (contains '_Leq_' in path)
|
|
|
|
|
# Lp files (instantaneous 100ms readings) don't have the LN percentile data needed for reports
|
|
|
|
|
if '_Leq_' not in file_record.file_path:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Reports can only be generated from Leq files (15-minute averaged data). This appears to be an Lp (instantaneous) file."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Read and parse the Leq RND file
|
|
|
|
|
# Read and parse the RND file
|
|
|
|
|
try:
|
|
|
|
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
|
|
|
content = f.read()
|
|
|
|
|
@@ -1748,6 +1813,18 @@ async def generate_excel_report(
|
|
|
|
|
if not rnd_rows:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No data found in RND file")
|
|
|
|
|
|
|
|
|
|
# Normalise AU2-format columns to NL-43 names
|
|
|
|
|
rnd_rows, _ = _normalize_rnd_rows(rnd_rows)
|
|
|
|
|
|
|
|
|
|
# Validate this is a Leq file — Lp files lack the LN percentile data
|
|
|
|
|
if not _is_leq_file(file_record.file_path, rnd_rows):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Reports can only be generated from Leq files (15-minute averaged data). This appears to be an Lp (instantaneous) file."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error reading RND file: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
|
|
|
|
|
@@ -1851,8 +1928,13 @@ async def generate_excel_report(
|
|
|
|
|
ws.title = "Sound Level Data"
|
|
|
|
|
|
|
|
|
|
# Define styles
|
|
|
|
|
title_font = Font(bold=True, size=14)
|
|
|
|
|
header_font = Font(bold=True, size=10)
|
|
|
|
|
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'),
|
|
|
|
|
@@ -1860,6 +1942,9 @@ async def generate_excel_report(
|
|
|
|
|
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')
|
|
|
|
|
|
|
|
|
|
# Row 1: Report title
|
|
|
|
|
final_project_name = project_name if project_name else (project.name if project else "")
|
|
|
|
|
@@ -1868,12 +1953,17 @@ async def generate_excel_report(
|
|
|
|
|
final_title = f"{report_title} - {final_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
|
|
|
|
|
|
|
|
|
|
# Row 2: Client name (if provided)
|
|
|
|
|
if client_name:
|
|
|
|
|
ws['A2'] = f"Client: {client_name}"
|
|
|
|
|
ws['A2'].font = Font(italic=True, size=10)
|
|
|
|
|
ws['A2'].font = client_font
|
|
|
|
|
ws['A2'].alignment = center_align
|
|
|
|
|
ws.merge_cells('A2:G2')
|
|
|
|
|
ws.row_dimensions[2].height = 16
|
|
|
|
|
|
|
|
|
|
# Row 3: Location name
|
|
|
|
|
final_location = location_name
|
|
|
|
|
@@ -1881,7 +1971,10 @@ async def generate_excel_report(
|
|
|
|
|
final_location = location.name
|
|
|
|
|
if final_location:
|
|
|
|
|
ws['A3'] = final_location
|
|
|
|
|
ws['A3'].font = Font(bold=True, size=11)
|
|
|
|
|
ws['A3'].font = subtitle_font
|
|
|
|
|
ws['A3'].alignment = center_align
|
|
|
|
|
ws.merge_cells('A3:G3')
|
|
|
|
|
ws.row_dimensions[3].height = 20
|
|
|
|
|
|
|
|
|
|
# Row 4: Time filter info (if applied)
|
|
|
|
|
if start_time and end_time:
|
|
|
|
|
@@ -1890,7 +1983,10 @@ async def generate_excel_report(
|
|
|
|
|
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 = Font(italic=True, size=9, color="666666")
|
|
|
|
|
ws['A4'].font = filter_font
|
|
|
|
|
ws['A4'].alignment = center_align
|
|
|
|
|
ws.merge_cells('A4:G4')
|
|
|
|
|
ws.row_dimensions[4].height = 14
|
|
|
|
|
|
|
|
|
|
# Row 7: Headers
|
|
|
|
|
headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
|
|
|
|
|
@@ -1899,10 +1995,11 @@ async def generate_excel_report(
|
|
|
|
|
cell.font = header_font
|
|
|
|
|
cell.border = thin_border
|
|
|
|
|
cell.fill = header_fill
|
|
|
|
|
cell.alignment = Alignment(horizontal='center')
|
|
|
|
|
cell.alignment = center_align
|
|
|
|
|
ws.row_dimensions[7].height = 16
|
|
|
|
|
|
|
|
|
|
# Set column widths
|
|
|
|
|
column_widths = [16, 12, 10, 12, 12, 12, 40]
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
@@ -1912,41 +2009,34 @@ async def generate_excel_report(
|
|
|
|
|
data_row = data_start_row + idx - 1
|
|
|
|
|
|
|
|
|
|
# Test Increment #
|
|
|
|
|
ws.cell(row=data_row, column=1, value=idx).border = thin_border
|
|
|
|
|
c = ws.cell(row=data_row, column=1, value=idx)
|
|
|
|
|
c.border = thin_border; c.font = data_font; c.alignment = center_align
|
|
|
|
|
|
|
|
|
|
# Parse the Start Time to get Date and Time
|
|
|
|
|
start_time_str = row.get('Start Time', '')
|
|
|
|
|
if start_time_str:
|
|
|
|
|
try:
|
|
|
|
|
# Format: "2025/12/26 20:23:38"
|
|
|
|
|
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())
|
|
|
|
|
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'))
|
|
|
|
|
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=data_row, column=2, value=start_time_str)
|
|
|
|
|
c3 = ws.cell(row=data_row, column=3, value='')
|
|
|
|
|
else:
|
|
|
|
|
ws.cell(row=data_row, column=2, value='')
|
|
|
|
|
ws.cell(row=data_row, column=3, value='')
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# LAmax - from Lmax(Main)
|
|
|
|
|
lmax = row.get('Lmax(Main)')
|
|
|
|
|
ws.cell(row=data_row, column=4, value=lmax if lmax else '').border = thin_border
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# LA01 - from LN1(Main)
|
|
|
|
|
ln1 = row.get('LN1(Main)')
|
|
|
|
|
ws.cell(row=data_row, column=5, value=ln1 if ln1 else '').border = thin_border
|
|
|
|
|
|
|
|
|
|
# LA10 - from LN2(Main)
|
|
|
|
|
ln2 = row.get('LN2(Main)')
|
|
|
|
|
ws.cell(row=data_row, column=6, value=ln2 if ln2 else '').border = thin_border
|
|
|
|
|
|
|
|
|
|
# Comments (empty for now, can be populated)
|
|
|
|
|
ws.cell(row=data_row, column=7, value='').border = thin_border
|
|
|
|
|
|
|
|
|
|
# Apply borders to date/time cells
|
|
|
|
|
ws.cell(row=data_row, column=2).border = thin_border
|
|
|
|
|
ws.cell(row=data_row, column=3).border = thin_border
|
|
|
|
|
# Comments
|
|
|
|
|
c = ws.cell(row=data_row, column=7, value='')
|
|
|
|
|
c.border = thin_border; c.font = data_font; c.alignment = left_align
|
|
|
|
|
|
|
|
|
|
data_end_row = data_start_row + len(rnd_rows) - 1
|
|
|
|
|
|
|
|
|
|
@@ -1955,13 +2045,13 @@ async def generate_excel_report(
|
|
|
|
|
chart.title = f"{final_location or 'Sound Level Data'} - Background Noise Study"
|
|
|
|
|
chart.style = 10
|
|
|
|
|
chart.y_axis.title = "Sound Level (dBA)"
|
|
|
|
|
chart.x_axis.title = "Test Increment"
|
|
|
|
|
chart.height = 12
|
|
|
|
|
chart.width = 20
|
|
|
|
|
chart.x_axis.title = "Time"
|
|
|
|
|
chart.height = 18
|
|
|
|
|
chart.width = 22
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
categories = Reference(ws, min_col=1, min_row=data_start_row, 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)
|
|
|
|
|
@@ -1972,12 +2062,20 @@ async def generate_excel_report(
|
|
|
|
|
chart.series[1].graphicalProperties.line.solidFill = "00B050" # LA01 - Green
|
|
|
|
|
chart.series[2].graphicalProperties.line.solidFill = "0070C0" # LA10 - Blue
|
|
|
|
|
|
|
|
|
|
# Position chart to the right of data
|
|
|
|
|
ws.add_chart(chart, "I3")
|
|
|
|
|
# Position chart starting at column H
|
|
|
|
|
ws.add_chart(chart, "H3")
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# Add summary statistics section below the data
|
|
|
|
|
summary_row = data_end_row + 3
|
|
|
|
|
ws.cell(row=summary_row, column=1, value="Summary Statistics").font = Font(bold=True, size=12)
|
|
|
|
|
c = ws.cell(row=summary_row, column=1, value="Summary Statistics")
|
|
|
|
|
c.font = summary_title_font
|
|
|
|
|
|
|
|
|
|
# Calculate time-period statistics
|
|
|
|
|
time_periods = {
|
|
|
|
|
@@ -2020,43 +2118,45 @@ async def generate_excel_report(
|
|
|
|
|
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():
|
|
|
|
|
ws.cell(row=summary_row, column=1, value=period_name).border = thin_border
|
|
|
|
|
ws.cell(row=summary_row, column=2, value=len(samples)).border = thin_border
|
|
|
|
|
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)
|
|
|
|
|
ws.cell(row=summary_row, column=3, value=round(avg_lmax, 1)).border = thin_border
|
|
|
|
|
ws.cell(row=summary_row, column=4, value=round(avg_ln1, 1)).border = thin_border
|
|
|
|
|
ws.cell(row=summary_row, column=5, value=round(avg_ln2, 1)).border = thin_border
|
|
|
|
|
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:
|
|
|
|
|
ws.cell(row=summary_row, column=3, value='-').border = thin_border
|
|
|
|
|
ws.cell(row=summary_row, column=4, value='-').border = thin_border
|
|
|
|
|
ws.cell(row=summary_row, column=5, value='-').border = thin_border
|
|
|
|
|
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
|
|
|
|
|
ws.cell(row=summary_row, column=1, value='Overall').font = Font(bold=True)
|
|
|
|
|
ws.cell(row=summary_row, column=1).border = thin_border
|
|
|
|
|
ws.cell(row=summary_row, column=2, value=len(rnd_rows)).border = thin_border
|
|
|
|
|
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))]
|
|
|
|
|
|
|
|
|
|
if all_lmax:
|
|
|
|
|
ws.cell(row=summary_row, column=3, value=round(sum(all_lmax) / len(all_lmax), 1)).border = thin_border
|
|
|
|
|
if all_ln1:
|
|
|
|
|
ws.cell(row=summary_row, column=4, value=round(sum(all_ln1) / len(all_ln1), 1)).border = thin_border
|
|
|
|
|
if all_ln2:
|
|
|
|
|
ws.cell(row=summary_row, column=5, value=round(sum(all_ln2) / len(all_ln2), 1)).border = thin_border
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# Save to buffer
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
@@ -2120,14 +2220,7 @@ async def preview_report_data(
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
|
|
|
|
|
|
|
|
|
# Validate this is a Leq file
|
|
|
|
|
if '_Leq_' not in file_record.file_path:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Reports can only be generated from Leq files (15-minute averaged data)."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Read and parse the Leq RND file
|
|
|
|
|
# Read and parse the RND file
|
|
|
|
|
try:
|
|
|
|
|
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
|
|
|
content = f.read()
|
|
|
|
|
@@ -2153,6 +2246,16 @@ async def preview_report_data(
|
|
|
|
|
if not rnd_rows:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No data found in RND file")
|
|
|
|
|
|
|
|
|
|
rnd_rows, _ = _normalize_rnd_rows(rnd_rows)
|
|
|
|
|
|
|
|
|
|
if not _is_leq_file(file_record.file_path, rnd_rows):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Reports can only be generated from Leq files (15-minute averaged data)."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error reading RND file: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
|
|
|
|
|
@@ -2264,7 +2367,7 @@ async def preview_report_data(
|
|
|
|
|
final_location = location_name if location_name else (location.name if location else "")
|
|
|
|
|
|
|
|
|
|
# Get templates for the dropdown
|
|
|
|
|
templates = db.query(ReportTemplate).all()
|
|
|
|
|
report_templates = db.query(ReportTemplate).all()
|
|
|
|
|
|
|
|
|
|
return templates.TemplateResponse("report_preview.html", {
|
|
|
|
|
"request": request,
|
|
|
|
|
@@ -2284,7 +2387,7 @@ async def preview_report_data(
|
|
|
|
|
"end_date": end_date,
|
|
|
|
|
"original_count": original_count,
|
|
|
|
|
"filtered_count": len(rnd_rows),
|
|
|
|
|
"templates": templates,
|
|
|
|
|
"templates": report_templates,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -2339,8 +2442,12 @@ async def generate_report_from_preview(
|
|
|
|
|
ws.title = "Sound Level Data"
|
|
|
|
|
|
|
|
|
|
# Styles
|
|
|
|
|
title_font = Font(bold=True, size=14)
|
|
|
|
|
header_font = Font(bold=True, size=10)
|
|
|
|
|
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'),
|
|
|
|
|
@@ -2348,27 +2455,41 @@ async def generate_report_from_preview(
|
|
|
|
|
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')
|
|
|
|
|
|
|
|
|
|
# Row 1: Title
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# Row 2: Client
|
|
|
|
|
if client_name:
|
|
|
|
|
ws['A2'] = f"Client: {client_name}"
|
|
|
|
|
ws['A2'].font = Font(italic=True, size=10)
|
|
|
|
|
ws['A2'].font = client_font
|
|
|
|
|
ws['A2'].alignment = center_align
|
|
|
|
|
ws.merge_cells('A2:G2')
|
|
|
|
|
ws.row_dimensions[2].height = 16
|
|
|
|
|
|
|
|
|
|
# Row 3: Location
|
|
|
|
|
if location_name:
|
|
|
|
|
ws['A3'] = location_name
|
|
|
|
|
ws['A3'].font = Font(bold=True, size=11)
|
|
|
|
|
ws['A3'].font = subtitle_font
|
|
|
|
|
ws['A3'].alignment = center_align
|
|
|
|
|
ws.merge_cells('A3:G3')
|
|
|
|
|
ws.row_dimensions[3].height = 20
|
|
|
|
|
|
|
|
|
|
# Row 4: Time filter info
|
|
|
|
|
if time_filter:
|
|
|
|
|
ws['A4'] = time_filter
|
|
|
|
|
ws['A4'].font = Font(italic=True, size=9, color="666666")
|
|
|
|
|
ws['A4'].font = filter_font
|
|
|
|
|
ws['A4'].alignment = center_align
|
|
|
|
|
ws.merge_cells('A4:G4')
|
|
|
|
|
ws.row_dimensions[4].height = 14
|
|
|
|
|
|
|
|
|
|
# Row 7: Headers
|
|
|
|
|
headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
|
|
|
|
|
@@ -2377,20 +2498,24 @@ async def generate_report_from_preview(
|
|
|
|
|
cell.font = header_font
|
|
|
|
|
cell.border = thin_border
|
|
|
|
|
cell.fill = header_fill
|
|
|
|
|
cell.alignment = Alignment(horizontal='center')
|
|
|
|
|
cell.alignment = center_align
|
|
|
|
|
ws.row_dimensions[7].height = 16
|
|
|
|
|
|
|
|
|
|
# Column widths
|
|
|
|
|
column_widths = [16, 12, 10, 12, 12, 12, 40]
|
|
|
|
|
# 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
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
data_end_row = data_start_row + len(spreadsheet_data) - 1
|
|
|
|
|
|
|
|
|
|
@@ -2400,12 +2525,12 @@ async def generate_report_from_preview(
|
|
|
|
|
chart.title = f"{location_name or 'Sound Level Data'} - Background Noise Study"
|
|
|
|
|
chart.style = 10
|
|
|
|
|
chart.y_axis.title = "Sound Level (dBA)"
|
|
|
|
|
chart.x_axis.title = "Test Increment"
|
|
|
|
|
chart.height = 12
|
|
|
|
|
chart.width = 20
|
|
|
|
|
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)
|
|
|
|
|
categories = Reference(ws, min_col=1, min_row=data_start_row, 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)
|
|
|
|
|
@@ -2415,7 +2540,14 @@ async def generate_report_from_preview(
|
|
|
|
|
chart.series[1].graphicalProperties.line.solidFill = "00B050"
|
|
|
|
|
chart.series[2].graphicalProperties.line.solidFill = "0070C0"
|
|
|
|
|
|
|
|
|
|
ws.add_chart(chart, "I3")
|
|
|
|
|
ws.add_chart(chart, "H3")
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# Save to buffer
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
@@ -2504,8 +2636,9 @@ async def generate_combined_excel_report(
|
|
|
|
|
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(bold=True, size=14)
|
|
|
|
|
header_font = Font(bold=True, size=10)
|
|
|
|
|
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'),
|
|
|
|
|
@@ -2513,6 +2646,9 @@ async def generate_combined_excel_report(
|
|
|
|
|
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')
|
|
|
|
|
|
|
|
|
|
# Create Excel workbook
|
|
|
|
|
wb = openpyxl.Workbook()
|
|
|
|
|
@@ -2533,11 +2669,16 @@ async def generate_combined_excel_report(
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# Row 3: Location name
|
|
|
|
|
ws['A3'] = location_name
|
|
|
|
|
ws['A3'].font = Font(bold=True, size=11)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# Row 7: Headers
|
|
|
|
|
headers = ['Test Increment #', 'Date', 'Time', 'LAmax (dBA)', 'LA01 (dBA)', 'LA10 (dBA)', 'Comments']
|
|
|
|
|
@@ -2546,10 +2687,11 @@ async def generate_combined_excel_report(
|
|
|
|
|
cell.font = header_font
|
|
|
|
|
cell.border = thin_border
|
|
|
|
|
cell.fill = header_fill
|
|
|
|
|
cell.alignment = Alignment(horizontal='center')
|
|
|
|
|
cell.alignment = center_align
|
|
|
|
|
ws.row_dimensions[7].height = 16
|
|
|
|
|
|
|
|
|
|
# Set column widths
|
|
|
|
|
column_widths = [16, 12, 10, 12, 12, 12, 40]
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
@@ -2632,12 +2774,12 @@ async def generate_combined_excel_report(
|
|
|
|
|
chart.title = f"{location_name}"
|
|
|
|
|
chart.style = 10
|
|
|
|
|
chart.y_axis.title = "Sound Level (dBA)"
|
|
|
|
|
chart.x_axis.title = "Test Increment"
|
|
|
|
|
chart.height = 12
|
|
|
|
|
chart.width = 20
|
|
|
|
|
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)
|
|
|
|
|
categories = Reference(ws, min_col=1, min_row=data_start_row, 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)
|
|
|
|
|
@@ -2647,7 +2789,14 @@ async def generate_combined_excel_report(
|
|
|
|
|
chart.series[1].graphicalProperties.line.solidFill = "00B050"
|
|
|
|
|
chart.series[2].graphicalProperties.line.solidFill = "0070C0"
|
|
|
|
|
|
|
|
|
|
ws.add_chart(chart, "I3")
|
|
|
|
|
ws.add_chart(chart, "H3")
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
# 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))]
|
|
|
|
|
|