|
|
"""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 |
|
|
|
|
|
|
|
|
|
|
|
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() |
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
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" |
|
|
|