sadickam's picture
Update helpers.py
604c2a8 verified
"""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"