"""Utility functions shared by all modules.""" import io import re from typing import Tuple import numpy as np import pandas as pd import plotly.graph_objects as go import streamlit as st from config import FREQUENCIES, TOTAL_DOTS, AI_BANDS # ── misc helpers ────────────────────────────────────────────────────────── def slugify(txt: str) -> str: """Return a filesystem‑ / url‑safe identifier.""" return re.sub(r"[^0-9a-zA-Z_]+", "_", txt) def standardise_freq_cols(df: pd.DataFrame) -> pd.DataFrame: """ Renames common textual frequency headings to plain numbers and returns a **new** DataFrame so the caller’s original is left untouched. """ df = df.copy() # defensive copy mapping = { "125Hz": "125", "250Hz": "250", "500Hz": "500", "1000Hz": "1000", "1KHz": "1000", "2KHz": "2000", "4KHz": "4000", } df.columns = [ mapping.get( str(c).replace(" Hz", "").replace("KHz", "000").strip(), str(c).strip() ) for c in df.columns ] df.columns = pd.to_numeric(df.columns, errors="ignore") return df def validate_numeric(df: pd.DataFrame) -> bool: """True iff every element of *df* is numeric.""" return not df.empty and df.applymap(np.isreal).all().all() def read_upload( upload, *, header: int | None = 0, index_col: int | None = None ) -> pd.DataFrame: """ Read an uploaded CSV or Excel file into a fresh DataFrame. No caching is used so that every Streamlit session receives its own independent object which can be mutated freely without leaking state. """ raw: bytes = upload.getvalue() if upload.name.lower().endswith(".csv"): return pd.read_csv(io.BytesIO(raw), header=header, index_col=index_col) return pd.read_excel(io.BytesIO(raw), header=header, index_col=index_col) def calc_abs_area(volume_m3: float, rt_s: float) -> float: """Sabine: absorption area required to achieve *rt_s* in a room of *volume_m3*.""" return float("inf") if rt_s == 0 else 0.16 * volume_m3 / rt_s # ── plotting helpers ────────────────────────────────────────────────────── def _base_layout(title: str, x_title: str, y_title: str) -> dict: """Common Plotly layout options.""" return dict( template="plotly_white", title=title, xaxis_title=x_title, yaxis_title=y_title, legend=dict(orientation="h", y=-0.2), ) def plot_rt_band( y_cur: list[float], y_min: list[float], y_max: list[float], title: str ) -> go.Figure: """RT60 band plot.""" fig = go.Figure() fig.add_trace( go.Scatter( x=FREQUENCIES, y=y_cur, mode="lines+markers", name="Current", marker_color="#1f77b4", ) ) fig.add_trace( go.Scatter( x=FREQUENCIES, y=y_max, mode="lines", name="Max Std", line=dict(dash="dash", color="#ff7f0e"), ) ) fig.add_trace( go.Scatter( x=FREQUENCIES, y=y_min, mode="lines", name="Min Std", line=dict(dash="dash", color="#2ca02c"), fill="tonexty", fillcolor="rgba(44,160,44,0.15)", ) ) fig.update_layout(**_base_layout(title, "Frequency (Hz)", "Reverberation Time (s)")) return fig def plot_bn_band( x: pd.Series, y_meas: pd.Series, y_min: float, y_max: float, title: str, ) -> go.Figure: """Background‑noise bar plot with standard band overlay.""" fig = go.Figure() fig.add_trace( go.Bar(x=x, y=y_meas, name="Measured", marker_color="#1f77b4", opacity=0.6) ) # standard band fig.add_shape( type="rect", x0=-0.5, x1=len(x) - 0.5, y0=y_min, y1=y_max, fillcolor="rgba(255,0,0,0.15)", line=dict(width=0), layer="below", ) for y, label in [(y_max, "Max Std"), (y_min, "Min Std")]: fig.add_shape( type="line", x0=-0.5, x1=len(x) - 0.5, y0=y, y1=y, line=dict(color="#ff0000", dash="dash"), ) fig.add_trace( go.Scatter( x=[None], y=[None], mode="lines", line=dict(color="#ff0000", dash="dash"), showlegend=True, name=label, ) ) fig.update_layout(**_base_layout(title, "Location", "Sound Level (dBA)")) return fig # ── speech‑intelligibility helpers ──────────────────────────────────────── def articulation_index(dots: int) -> Tuple[float, str]: """Return (AI value, interpretation label) given dots‑above‑curve count.""" ai = dots / TOTAL_DOTS for (lo, hi), lbl in AI_BANDS.items(): if lo <= ai <= hi: return ai, lbl return ai, "Out of range"