sadickam's picture
Clarify measured and modelled RT60 results
7830440
Raw History Blame Contribute Delete
46 kB
"""
sections.py (SRT757 app - extended)
====================================
Adds, relative to the original:
* New Materials table gains Unit rate, Line cost, Depth (mm), Surface columns,
with a live budget check and depth-limit (buildability) flag.
* A background-noise SOURCE model (facade glazing Rw + HVAC insertion loss),
combined by logarithmic summation, in both Initial and Final checks.
* Final compliance semantics: the New Materials table is the COMPLETE
post-retrofit room (retained existing materials, replacements, and new
treatments). Final RT60 / background noise / speech intelligibility are
computed from that table alone; the Initial Data Entry uploads describe
the existing room and are never altered by the retrofit.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from config import FREQUENCIES, MAJOR_TABS, TOTAL_DOTS, SURFACES, DEPTH_REFERENCE, ai_table2
from helpers import (
slugify,
sanitise_table,
standardise_freq_cols,
validate_numeric,
freq_val,
read_upload,
calc_abs_area,
calc_rt60,
rt60_reconciliation,
nr_by_band,
mid_frequency_absorption,
plot_rt_band,
plot_bn_band,
plot_bn_sources,
background_noise_total,
bn_model_delta,
apply_delta,
line_cost,
depth_exceeds,
articulation_index,
auto_articulation,
plot_ai_dots,
)
# ────────────────────────────────────────────────────────────────────────────
def nav_to(tab: str) -> None:
st.session_state.major_tab = tab
def nav_buttons() -> None:
idx = MAJOR_TABS.index(st.session_state.major_tab)
c1, _, c3 = st.columns([2, 6, 2])
if idx > 0:
c1.button(f"\u2190 {MAJOR_TABS[idx-1]}", on_click=nav_to, args=(MAJOR_TABS[idx-1],))
if idx < len(MAJOR_TABS) - 1:
c3.button(f"{MAJOR_TABS[idx+1]} \u2192", on_click=nav_to, args=(MAJOR_TABS[idx+1],))
# ────────────────────────────────────────────────────────────────────────────
def section_instructions() -> None:
st.title("\U0001F50A Building Acoustics Analysis Tool")
with st.expander("App Information", expanded=False):
st.write("""
- Welcome to the Building Acoustics Analysis Tool.
- Developed to help Deakin Construction Management students complete the SRT757 building acoustics assignment.
- Intended for a retrofitting task; assumes you have measured the current reverberation of the room.
- Analyses and modifies room reverberation, background noise, and speech intelligibility.
- Educational use only; not for professional certification.
""")
df_current_reverberation_times = pd.DataFrame({
'125': [0.8], '250': [0.7], '500': [0.6], '1000': [0.5], '2000': [0.4], '4000': [0.3]})
df_existing_materials = pd.DataFrame({
'Material Name': ['Carpet', 'Curtain'], 'Area_No': [50, 30],
'125': [0.1, 0.2], '250': [0.15, 0.25], '500': [0.2, 0.3],
'1000': [0.3, 0.35], '2000': [0.4, 0.45], '4000': [0.5, 0.55]})
df_new_materials = pd.DataFrame({
'Material Name': ['Carpet (retained)', 'Acoustic Panel (new, replaces Curtain)'],
'Area_No': [50, 40],
'125': [0.1, 0.3], '250': [0.15, 0.35], '500': [0.2, 0.4],
'1000': [0.3, 0.45], '2000': [0.4, 0.5], '4000': [0.5, 0.6]})
df_sound_pressure_levels = pd.DataFrame({
'Location': ['Location 1', 'Location 2'],
'125': [60, 62], '250': [58, 59], '500': [55, 56],
'1000': [52, 53], '2000': [50, 51], '4000': [48, 49]})
st.write("## Instructions")
st.markdown("""
This tool supports analysis of **Reverberation Time (RT60)**, **Background Noise**
(including a facade-glazing and HVAC source model), and **Speech Intelligibility
(Articulation Index)**, across the six standard frequencies (125-4000 Hz).
It also supports two construction-management steps: **costing** your treatments
against a budget, and a **treatment-depth (buildability)** check.
\u26A0\uFE0F Educational use only.
### RT60 - Sabine's Equation
**RT60 = (0.16 \u00D7 V) / A**, where V = room volume (m\u00B3), A = total absorption (m\u00B2 Sabins).
### Background Noise (measured baseline + facade/HVAC model)
Your **measured** background noise per location is the current condition and is
what compliance is judged against. The facade/HVAC model is used two ways:
- As a **diagnostic** at the initial stage, to reveal whether the facade/glazing
or the HVAC dominates your measured noise (so you pick the right lever).
- As a **predictor** at the final stage: the model computes the reduction between
the existing condition and your retrofit, and that reduction is applied to your
measured baseline. So each location shows ONE figure that starts at the measured
value and improves only by what the physics justifies \u2014 there are never two
rival background-noise numbers.
Model paths (combined logarithmically):
- **Facade/glazing:** L_in = L_out \u2212 Rw + 10\u00B7log10(S / A)
- **HVAC:** L_in = L_hvac \u2212 insertion loss (of any silencer you fit)
- **Combined:** 10\u00B7log10(\u03A3 10^(Li/10))
Absorption (A) lowers the facade contribution, but if the glazing or HVAC
dominates, the correct lever is a better Rw or a silencer - not more absorption.
### Speech Intelligibility
Articulation Index via the "dots above a curve" method, computed automatically
(None/Fair/Good/Excellent).
### Formatting Input Data
Use CSV or Excel with the formats below.
#### Current Reverberation Times (one row, frequency headers)
""")
st.write(df_current_reverberation_times)
st.markdown("#### Existing Materials - name, area, absorption coefficients")
st.write(df_existing_materials)
st.markdown("#### New Materials - the COMPLETE post-retrofit room: retained materials, "
"replacements and new treatments in one table (you will add cost, depth, surface in-app)")
st.write(df_new_materials)
st.markdown("#### Sound Pressure Levels - location first column, SPLs by frequency")
st.write(df_sound_pressure_levels)
st.markdown("#### Treatment-depth reference (indicative)")
st.dataframe(pd.DataFrame(DEPTH_REFERENCE,
columns=["Treatment type", "Typical depth (mm)", "Low-frequency (125 Hz) capability"]),
use_container_width=True)
st.info("After reviewing the instructions, proceed to the **Initial Data Entry** tab.")
nav_buttons()
# ---------------------------------------------------------------------------
def section_initial_data() -> None:
st.title("\U0001F50A Building Acoustics Analysis Tool")
st.header("\U0001F4E5 Initial Data Entry")
t_rt, t_bn, t_si = st.tabs(["Reverberation Time", "Background Noise", "Speech Intelligibility"])
with t_rt:
st.write("Provide the initial input data for RT60 analysis. See 'Instructions' for formats.")
st.info(
"These two uploads describe the existing room in different ways. "
"**Current RT60** contains the measured or supplied reverberation times. "
"**Existing Materials** is used with the room volume to calculate a separate "
"Sabine estimate. The two results may differ and will be reconciled in "
"Initial Compliance Checks.")
c1, c2 = st.columns(2)
u_rt = c1.file_uploader("Current RT60 data (CSV/XLSX)", type=["csv", "xlsx"])
if u_rt:
df = sanitise_table(standardise_freq_cols(read_upload(u_rt)))
st.session_state.df_current_rt = df if validate_numeric(df) else None
u_mat = c2.file_uploader("Existing materials (CSV/XLSX)", type=["csv", "xlsx"])
if u_mat:
dfm = sanitise_table(standardise_freq_cols(read_upload(u_mat)))
st.session_state.df_existing_mat = dfm if validate_numeric(dfm.iloc[:, 2:]) else None
st.session_state.room_volume = st.number_input(
"Room volume [m\u00B3]", value=st.session_state.room_volume, min_value=0.0, step=0.1)
# construction-management inputs from the student's brief pack
st.markdown("###### Construction-management limits (from your brief pack)")
cc1, cc2 = st.columns(2)
st.session_state.budget_cap = cc1.number_input(
"Budget cap [A$]", value=st.session_state.budget_cap, min_value=0.0, step=100.0,
help="Your issued maximum retrofit budget.")
st.session_state.depth_limit_mm = cc2.number_input(
"Treatment depth limit [mm]", value=st.session_state.depth_limit_mm, min_value=0.0, step=5.0,
help="Your issued maximum treatment build-down/build-up depth (0 = no limit).")
if st.session_state.df_current_rt is None:
st.warning("\u26A0\uFE0F Upload *Current RT60* data.")
if st.session_state.df_existing_mat is None:
st.warning("\u26A0\uFE0F Upload *Existing Materials* data.")
if st.session_state.room_volume == 0:
st.warning("\u26A0\uFE0F Enter a non-zero room volume.")
if st.session_state.df_current_rt is not None:
st.dataframe(st.session_state.df_current_rt, use_container_width=True)
if st.session_state.df_existing_mat is not None:
st.dataframe(st.session_state.df_existing_mat, use_container_width=True)
with t_bn:
if "df_background_noise" not in st.session_state:
st.session_state.df_background_noise = pd.DataFrame(columns=["Location", "dBA"])
if "bn_input_loc" not in st.session_state:
st.session_state.bn_input_loc = ""
if "bn_input_val" not in st.session_state:
st.session_state.bn_input_val = 0.0
st.write("Enter measured background-noise levels per location (unoccupied).")
c1, c2, c3 = st.columns([3, 2, 1])
st.session_state.bn_input_loc = c1.text_input("Location", st.session_state.bn_input_loc)
st.session_state.bn_input_val = c2.number_input(
"dBA", value=st.session_state.bn_input_val, min_value=0.0, step=0.1)
if c3.button("Add / Update"):
loc = st.session_state.bn_input_loc.strip()
if loc:
df = st.session_state.df_background_noise.copy()
if loc in df["Location"].values:
df.loc[df["Location"] == loc, "dBA"] = st.session_state.bn_input_val
else:
df.loc[len(df)] = [loc, st.session_state.bn_input_val]
st.session_state.df_background_noise = df
if st.session_state.df_background_noise.empty:
st.warning("\u26A0\uFE0F Enter at least one background-noise measurement.")
st.dataframe(st.session_state.df_background_noise, use_container_width=True)
# ── facade + HVAC source inputs (existing/site conditions) ──────────
st.markdown("###### Background-noise source model - existing/site inputs (from your brief pack)")
s1, s2, s3 = st.columns(3)
st.session_state.bn_L_out = s1.number_input(
"Outdoor level L_out [dBA]", value=st.session_state.bn_L_out, min_value=0.0, step=1.0)
st.session_state.bn_Rw_existing = s2.number_input(
"Existing glazing Rw [dB]", value=st.session_state.bn_Rw_existing, min_value=0.0, step=1.0)
st.session_state.bn_facade_area = s3.number_input(
"Facade area S [m\u00B2]", value=st.session_state.bn_facade_area, min_value=0.0, step=0.5)
h1, h2 = st.columns(2)
st.session_state.bn_hvac_existing = h1.number_input(
"Existing HVAC noise [dBA]", value=st.session_state.bn_hvac_existing, min_value=0.0, step=1.0)
st.session_state.bn_hvac_IL = h2.number_input(
"Existing silencer insertion loss [dB]", value=st.session_state.bn_hvac_IL, min_value=0.0, step=1.0)
st.caption("These are your EXISTING conditions. You will choose (and cost) any "
"glazing/silencer upgrades on the Acoustic Treatment tab.")
with t_si:
st.write("Provide SPL data for speech-intelligibility analysis. See 'Instructions' for formats.")
u = st.file_uploader("Current sound pressure level data (CSV/XLSX)", type=["csv", "xlsx"])
if u:
df = standardise_freq_cols(read_upload(u, header=0, index_col=0))
df.columns = [int(c) for c in df.columns]
st.session_state.df_spl = df if set(df.columns) >= set(FREQUENCIES) else None
st.session_state.dots_init_dict, st.session_state.ai_init_dict = {}, {}
st.session_state.dots_final_dict, st.session_state.ai_final_dict = {}, {}
if st.session_state.df_spl is None:
st.warning("\u26A0\uFE0F Upload *SPL* data with columns 125-4000 Hz.")
else:
st.dataframe(st.session_state.df_spl, use_container_width=True)
nav_buttons()
# ---------------------------------------------------------------------------
def _existing_absorption() -> dict:
"""Total existing absorption per frequency (m2 sabins)."""
acc = {f: 0.0 for f in FREQUENCIES}
df = st.session_state.df_existing_mat
if df is None:
return acc
for _, r in df.iterrows():
area = r.iloc[1]
for f, coeff in zip(FREQUENCIES, r.iloc[2:]):
acc[f] += coeff * area
return acc
def section_initial_checks() -> None:
st.title("\U0001F50A Building Acoustics Analysis Tool")
st.header("\u2705 Initial Compliance Checks")
t_rt, t_bn, t_si = st.tabs(["Reverberation Time", "Background Noise", "Speech Intelligibility"])
if (st.session_state.df_current_rt is None or st.session_state.df_existing_mat is None):
st.warning("\u26A0\uFE0F Current RT60 and/or Materials data missing (see *Initial Data Entry*).")
if st.session_state.df_background_noise.empty:
st.warning("\u26A0\uFE0F Background-noise data missing (see *Initial Data Entry*).")
if st.session_state.df_spl is None:
st.warning("\u26A0\uFE0F SPL data missing (see *Initial Data Entry*).")
with t_rt:
st.write("Provide the min and max standard RT60 (two-sided target).")
st.info(
"The graph distinguishes **uploaded/measured current RT60** from "
"**modelled existing RT60**. The modelled curve is calculated from room volume "
"and Existing Materials absorption using RT60 = 0.16V/A; the measured curve is "
"read directly from your Current RT60 file.")
c1, c2 = st.columns(2)
st.session_state.rt_min = c1.number_input("Min standard RT60 [s]", value=st.session_state.rt_min, min_value=0.0, step=0.01)
st.session_state.rt_max = c2.number_input("Max standard RT60 [s]", value=st.session_state.rt_max, min_value=0.0, step=0.01)
if st.session_state.rt_max <= st.session_state.rt_min:
st.warning("\u26A0\uFE0F Max RT60 must be greater than Min RT60.")
if st.session_state.df_existing_mat is not None:
st.session_state.current_absorption = _existing_absorption()
st.markdown("###### Existing-materials absorption (m\u00B2 sabins)")
st.dataframe(pd.DataFrame([st.session_state.current_absorption]), use_container_width=True)
if (st.session_state.df_current_rt is not None and st.session_state.rt_max > st.session_state.rt_min):
y_cur = [freq_val(st.session_state.df_current_rt.iloc[0], f) for f in FREQUENCIES]
fig = plot_rt_band(y_cur, [st.session_state.rt_min]*6, [st.session_state.rt_max]*6,
"Initial RT60 vs Standard RT60 Range")
if st.session_state.room_volume > 0 and st.session_state.current_absorption:
modelled_existing = [calc_rt60(st.session_state.room_volume,
st.session_state.current_absorption[f])
for f in FREQUENCIES]
fig.add_trace(go.Scatter(
x=FREQUENCIES, y=modelled_existing, mode="lines+markers",
name="Modelled existing (materials + Sabine)",
line=dict(color="#9467bd", dash="dot")))
st.plotly_chart(fig, use_container_width=True)
st.session_state.fig_rt_initial = fig
if st.session_state.room_volume > 0 and st.session_state.current_absorption:
st.markdown("###### Measured vs materials-model reconciliation")
st.caption(
"The uploaded RT60 is a measured/input dataset. The materials RT60 is a "
"separate Sabine estimate from the entered volume and ΣS·α. They need not "
"match exactly, but a large difference may indicate missing surfaces, unsuitable "
"coefficients, occupancy/installation differences, or an input error.")
measured = {f: freq_val(st.session_state.df_current_rt.iloc[0], f)
for f in FREQUENCIES}
reconciliation = rt60_reconciliation(
measured, st.session_state.current_absorption,
st.session_state.room_volume)
reconciliation_df = pd.DataFrame(reconciliation)
display_df = reconciliation_df.copy()
numeric_cols = [
"Uploaded/measured RT60 (s)", "Materials absorption A (m² sabins)",
"Sabine-predicted RT60 (s)", "Difference (s)", "Difference (%)"]
display_df[numeric_cols] = display_df[numeric_cols].round(2)
st.dataframe(display_df, use_container_width=True, hide_index=True)
flagged = reconciliation_df[reconciliation_df["Check"] == "Review inputs"]
if not flagged.empty:
bands = ", ".join(f"{int(f)} Hz" for f in flagged["Frequency (Hz)"])
st.warning(
"The measured and materials-model RT60 differ materially at " + bands +
". Check the room volume, areas, coefficients and whether the measured "
"condition matches the materials schedule before interpreting them together.")
with t_bn:
st.write("Provide the min and max standard background noise (comply at/under the max). "
"Your **measured** background noise per location is the current condition; "
"the source model below is a **diagnostic** that explains where that measured "
"noise comes from (facade vs HVAC) so you can choose the right treatment.")
c1, c2 = st.columns(2)
st.session_state.bn_min = c1.number_input("Min standard background noise [dBA]", value=st.session_state.bn_min, min_value=0.0, step=0.1)
st.session_state.bn_max = c2.number_input("Max standard background noise [dBA]", value=st.session_state.bn_max, min_value=0.0, step=0.1)
if st.session_state.bn_max <= st.session_state.bn_min:
st.warning("\u26A0\uFE0F Max background noise must be greater than Min.")
df = st.session_state.df_background_noise
if (not df.empty and st.session_state.bn_max > st.session_state.bn_min):
st.markdown("###### Measured background noise vs standard (current condition)")
fig = plot_bn_band(df["Location"], df["dBA"], st.session_state.bn_min,
st.session_state.bn_max, "Initial Background Noise vs Standard Range")
st.plotly_chart(fig, use_container_width=True)
st.session_state.fig_bn_initial = fig
# source model = DIAGNOSTIC ONLY (which source dominates the measured noise)
st.markdown("###### Source diagnostic \u2014 what makes up your measured noise (facade vs HVAC)")
st.caption("This decomposition is a diagnostic, not a second background-noise figure. "
"Your compliance is judged on the measured value above. The model is used "
"at the Final Checks stage to predict how much your retrofit will reduce it.")
# Single-figure absorption for a broadband dB(A) model: the mid-frequency
# (500 Hz / 1 kHz) mean, NOT the sum of the six per-band absorptions.
A_now = mid_frequency_absorption(_existing_absorption())
if A_now > 0 and st.session_state.bn_facade_area > 0:
tot, lf, lh = background_noise_total(
st.session_state.bn_L_out, st.session_state.bn_Rw_existing,
st.session_state.bn_facade_area, A_now,
st.session_state.bn_hvac_existing, st.session_state.bn_hvac_IL)
m1, m2 = st.columns(2)
m1.metric("Facade / glazing path", f"{lf:.1f} dBA")
m2.metric("HVAC path", f"{lh:.1f} dBA")
fig = plot_bn_sources(lf, lh, tot, st.session_state.bn_max or 40,
"Initial Background-Noise Source Diagnostic")
st.plotly_chart(fig, use_container_width=True)
st.session_state.fig_bn_sources_initial = fig
dominant = "facade/glazing" if lf >= lh else "HVAC"
st.info(f"Dominant source of your background noise: **{dominant}**. "
f"The most effective lever will be "
f"{'a higher glazing Rw' if lf >= lh else 'an HVAC silencer'} "
f"(subject to your constraints and budget). You will test this at the "
f"Acoustic Treatment and Final Checks stages.")
else:
st.caption("Enter facade/HVAC source inputs (from your brief pack) and materials to see the diagnostic.")
with t_si:
st.write("The app plots your sound pressure levels on the standard dot chart, "
"counts the dots above your SPL curve automatically, and computes the "
"Articulation Index (AI) and its Table 2 interpretation. No manual "
"counting is required. Your six octave-band SPLs are interpolated onto "
"the standard third-octave dot chart (a documented teaching simplification).")
if st.session_state.df_spl is None:
st.warning("\u26A0\uFE0F Upload SPL data (Initial Data Entry) to compute AI.")
else:
st.markdown("###### Current sound pressure levels")
st.dataframe(st.session_state.df_spl, use_container_width=True)
ai_rows = []
for loc in st.session_state.df_spl.index:
oct_spl = {f: freq_val(st.session_state.df_spl.loc[loc], f) for f in FREQUENCIES}
ai, dots_above, spl_third, per_band = auto_articulation(oct_spl)
comm, priv = ai_table2(ai)
st.session_state.dots_init_dict[loc] = dots_above
st.session_state.ai_init_dict[loc] = (ai, comm)
ai_rows.append({"Location": loc, "Dots above": dots_above,
"AI": f"{ai:.2f}", "Speech communication": comm,
"Speech privacy": priv})
with st.expander(f"Location {loc}: AI = {ai:.2f} ({comm}) \u2014 view dot chart & working"):
fig = plot_ai_dots(spl_third, per_band, f"AI Dot Chart \u2014 {loc}")
st.plotly_chart(fig, use_container_width=True)
st.caption("Green dots (above your SPL curve) are counted toward AI; "
"grey dots (below) are not.")
st.dataframe(pd.DataFrame(per_band,
columns=["Third-octave (Hz)", "Dots in band", "Your SPL (dB)", "Dots above"]),
use_container_width=True)
st.markdown("###### Articulation Index (AI) results \u2014 all locations")
st.dataframe(pd.DataFrame(ai_rows), use_container_width=True)
nav_buttons()
# ---------------------------------------------------------------------------
def section_acoustic_treatment() -> None:
st.title("\U0001F50A Building Acoustics Analysis Tool")
st.header("\U0001F6E0 Acoustic Treatment")
tab_desired, tab_mat, tab_bn = st.tabs(["Desired RT60", "New Materials", "Background-Noise Upgrades"])
with tab_desired:
st.write("Define your desired RT60 per frequency to see the required absorption. "
"Remember the target is two-sided: stay within the standard band.")
if st.session_state.rt_max == 0:
st.warning("\u26A0\uFE0F Define RT60 standard range in *Initial Compliance Checks* first.")
st.info("Enter the desired RT60 for each frequency.")
desired = {}
for freq in FREQUENCIES:
default = float(st.session_state.desired_rt_df[
st.session_state.desired_rt_df["Frequency"] == freq]["Desired RT60"].values[0])
desired[freq] = st.number_input(f"{freq} Hz", min_value=0.01, step=0.01, value=default, key=f"des_rt_{freq}")
st.session_state.desired_rt_df["Desired RT60"] = st.session_state.desired_rt_df["Frequency"].map(desired)
within = all(st.session_state.rt_min <= v <= st.session_state.rt_max for v in desired.values())
if within:
df_abs = pd.DataFrame([{f: calc_abs_area(st.session_state.room_volume, v) for f, v in desired.items()}],
index=["Required absorption"])
st.markdown("###### Sound absorption based on Desired RT60")
st.dataframe(df_abs, use_container_width=True)
else:
st.error("Each Desired RT60 must lie within the standard range.")
if st.session_state.room_volume == 0:
st.warning("\u26A0\uFE0F Enter a non-zero room volume (see *Initial Data Entry*).")
with tab_mat:
st.write("Add new materials to modify absorption. For each, also enter the unit rate, "
"depth, and the surface it is applied to. The app costs your design and checks "
"it against your budget and depth limit.")
st.info("This table must describe the COMPLETE post-retrofit room: keep a row "
"for every retained existing material, edit a row where a material is "
"replaced, and add rows for new treatments. Final Compliance Checks "
"read this table alone. Give retained rows a unit rate of 0 so only "
"the actual works are costed.")
st.caption(
"Consistency check: if this complete New Materials schedule is identical to "
"Existing Materials, the modelled existing and modelled post-retrofit RT60 curves "
"will overlap. They may still differ from the independently uploaded/measured curve.")
u = st.file_uploader("New materials data (CSV/XLSX)", type=["csv", "xlsx"])
if u:
st.session_state.df_new_mat = sanitise_table(standardise_freq_cols(read_upload(u)))
# ensure the CM columns exist on the working table
if st.session_state.df_new_mat is None:
st.session_state.df_new_mat = pd.DataFrame({
"Material Name": ["New absorber"], "Area_No": [10.0],
"125": [0.3], "250": [0.4], "500": [0.6], "1000": [0.7], "2000": [0.7], "4000": [0.6],
"Unit rate (A$/unit)": [0.0], "Depth (mm)": [0.0], "Surface": [SURFACES[0]],
})
else:
for col, default in [("Unit rate (A$/unit)", 0.0), ("Depth (mm)", 0.0), ("Surface", SURFACES[0])]:
if col not in st.session_state.df_new_mat.columns:
st.session_state.df_new_mat[col] = default
st.info("Edit values below. Absorption, cost, and buildability update instantly.")
df_edit = st.data_editor(
st.session_state.df_new_mat, num_rows="dynamic", use_container_width=True,
key="edit_newmat",
column_config={
"Surface": st.column_config.SelectboxColumn("Surface", options=SURFACES),
"Unit rate (A$/unit)": st.column_config.NumberColumn("Unit rate (A$/unit)", min_value=0.0, step=1.0),
"Depth (mm)": st.column_config.NumberColumn("Depth (mm)", min_value=0.0, step=5.0),
})
st.session_state.df_new_mat = df_edit
# absorption from new materials (freq columns only), via the robust
# freq_val accessor so it works whether headers are int or str.
new_abs = {f: 0.0 for f in FREQUENCIES}
for _, r in df_edit.iterrows():
try:
area = float(r.get("Area_No", 0) or 0)
if area != area: # NaN (blank area cell)
area = 0.0
except (TypeError, ValueError):
area = 0.0
for f in FREQUENCIES:
new_abs[f] += freq_val(r, f) * area
st.session_state.new_absorption = new_abs
st.markdown("###### Post-retrofit room absorption (m\u00B2 sabins) \u2014 \u03a3 S\u00b7\u03b1 over this whole table")
st.dataframe(pd.DataFrame([new_abs], index=["New absorption"]), use_container_width=True)
# ── construction-management: costing + depth (buildability) ────────
st.markdown("###### Costing & buildability check")
rows, total = [], 0.0
depth_flags = []
for _, r in df_edit.iterrows():
lc = line_cost(r.get("Area_No", 0), r.get("Unit rate (A$/unit)", 0))
total += lc
over = depth_exceeds(r.get("Depth (mm)", 0), st.session_state.depth_limit_mm)
if over:
depth_flags.append(r.get("Material Name", "?"))
rows.append({
"Material": r.get("Material Name", ""),
"Surface": r.get("Surface", ""),
"Area/qty": r.get("Area_No", 0),
"Unit rate (A$)": r.get("Unit rate (A$/unit)", 0),
"Line cost (A$)": round(lc, 2),
"Depth (mm)": r.get("Depth (mm)", 0),
"Depth OK?": "\u2716 over" if over else "\u2714",
})
# add glazing/silencer upgrade costs if any (from BN tab)
extra = (st.session_state.bn_glazing_cost or 0) + (st.session_state.bn_silencer_cost or 0)
if extra:
rows.append({"Material": "Glazing/HVAC upgrades", "Surface": "-", "Area/qty": None,
"Unit rate (A$)": None, "Line cost (A$)": round(extra, 2), "Depth (mm)": None, "Depth OK?": "-"})
total += extra
st.dataframe(pd.DataFrame(rows), use_container_width=True)
c1, c2 = st.columns(2)
c1.metric("Project total (A$)", f"{total:,.0f}",
delta=(f"{total - st.session_state.budget_cap:,.0f} vs cap" if st.session_state.budget_cap else None),
delta_color="inverse")
c2.metric("Budget cap (A$)", f"{st.session_state.budget_cap:,.0f}")
if st.session_state.budget_cap and total > st.session_state.budget_cap:
st.error(f"Over budget by A${total - st.session_state.budget_cap:,.0f}. Revise your specification.")
if depth_flags:
st.error("Depth limit exceeded (not buildable within your constraint): " + ", ".join(map(str, depth_flags))
+ f". Your limit is {st.session_state.depth_limit_mm:.0f} mm. "
"Deeper absorbers help low frequencies (125 Hz) - so this limit may force a compromise there.")
with tab_bn:
st.write("Choose (and cost) upgrades to the two background-noise source paths. "
"Diagnose which source dominates first, then pick the matching lever.")
st.markdown("###### Glazing upgrade")
g1, g2 = st.columns(2)
st.session_state.bn_Rw_design = g1.number_input(
"Design glazing Rw [dB]", value=st.session_state.bn_Rw_design or st.session_state.bn_Rw_existing,
min_value=0.0, step=1.0, help="e.g. double glazing ~35 dB, acoustic laminate ~40-45 dB. "
"If a constraint forbids altering the glazing, keep this at the existing value.")
st.session_state.bn_glazing_cost = g2.number_input(
"Glazing upgrade cost [A$]", value=st.session_state.bn_glazing_cost, min_value=0.0, step=100.0)
st.markdown("###### HVAC silencer")
s1, s2 = st.columns(2)
st.session_state.bn_hvac_IL_design = s1.number_input(
"Design silencer insertion loss [dB]", value=st.session_state.bn_hvac_IL_design or st.session_state.bn_hvac_IL,
min_value=0.0, step=1.0)
st.session_state.bn_silencer_cost = s2.number_input(
"Silencer cost [A$]", value=st.session_state.bn_silencer_cost, min_value=0.0, step=100.0)
st.caption("These upgrade costs are added to your project total on the New Materials tab.")
nav_buttons()
# ---------------------------------------------------------------------------
def section_final_checks() -> None:
st.title("\U0001F50A Building Acoustics Analysis Tool")
st.header("\U0001F6A6 Final Compliance Checks")
t_rt, t_bn, t_si = st.tabs(["Reverberation Time", "Background Noise", "Speech Intelligibility"])
# The New Materials table IS the post-retrofit room: retained existing
# materials, replacements, and new treatments in one schedule. All final
# checks read it alone; the existing-room uploads stay untouched.
post_abs = {f: st.session_state.new_absorption.get(f, 0) for f in FREQUENCIES}
with t_rt:
if st.session_state.room_volume == 0 or not st.session_state.new_absorption:
st.warning("\u26A0\uFE0F Provide room volume and at least one new material (Acoustic Treatment).")
else:
new_rt = {f: calc_rt60(st.session_state.room_volume, a)
for f, a in post_abs.items()}
st.markdown("New RT60 after treatment (from the post-retrofit materials table)")
st.info(
"Compare like with like: the **modelled existing** and **modelled post-retrofit** "
"curves both use Sabine's equation and can be compared directly. The "
"**uploaded/measured current** curve is an independent observation and is shown "
"for context; it is not calculated from either materials table.")
st.caption("The Acoustic Treatment table must describe the COMPLETE "
"post-retrofit room \u2014 retained existing materials, replacements "
"and new treatments. If it lists only the new treatments, this "
"RT60 will read far too high.")
st.dataframe(pd.DataFrame([new_rt], index=["New RT60"]), use_container_width=True)
y_cur = ([freq_val(st.session_state.df_current_rt.iloc[0], f) for f in FREQUENCIES]
if st.session_state.df_current_rt is not None else [None]*6)
fig = plot_rt_band(y_cur, [st.session_state.rt_min]*6, [st.session_state.rt_max]*6,
"Final RT60 vs Standard Range")
existing_rt_model = {
f: calc_rt60(st.session_state.room_volume,
st.session_state.current_absorption.get(f, 0))
for f in FREQUENCIES
}
fig.add_trace(go.Scatter(
x=FREQUENCIES, y=[existing_rt_model[f] for f in FREQUENCIES],
mode="lines+markers", name="Modelled existing (materials + Sabine)",
line=dict(color="#9467bd", dash="dot")))
fig.add_trace(go.Scatter(x=FREQUENCIES, y=[new_rt[f] for f in FREQUENCIES],
mode="lines+markers", name="Modelled post-retrofit",
marker_color="#d62728"))
st.plotly_chart(fig, use_container_width=True)
st.caption("Both modelled curves use the same Sabine calculation. If Existing and New "
"Materials schedules are identical, those two modelled curves overlap. The "
"uploaded/measured curve remains separate and may differ from the model.")
st.session_state.fig_rt_final = fig
# two-sided warning
below = [f for f in FREQUENCIES if new_rt[f] < st.session_state.rt_min]
if below:
st.warning(f"Over-treated: RT60 is BELOW the {st.session_state.rt_min} s floor at "
f"{', '.join(str(f) for f in below)} Hz. Reduce absorption at those bands.")
with t_bn:
st.write("Background noise after treatment. The source model predicts how much your "
"retrofit (added absorption + any glazing/silencer upgrade) reduces the noise; "
"that predicted reduction is applied to your **measured** baseline, so each "
"location shows one figure that starts at the measured value and improves only "
"by what the physics justifies.")
df = st.session_state.df_background_noise
# Mid-frequency single-figure absorption (see mid_frequency_absorption).
A_existing = mid_frequency_absorption(st.session_state.current_absorption)
A_after = mid_frequency_absorption(post_abs)
if (not df.empty and A_existing > 0 and A_after > 0
and st.session_state.bn_facade_area > 0):
Rw_design = st.session_state.bn_Rw_design or st.session_state.bn_Rw_existing
IL_design = st.session_state.bn_hvac_IL_design or st.session_state.bn_hvac_IL
delta, model_ex, model_de, lf, lh = bn_model_delta(
st.session_state.bn_L_out,
st.session_state.bn_Rw_existing, Rw_design,
st.session_state.bn_facade_area,
A_existing, A_after,
st.session_state.bn_hvac_existing,
st.session_state.bn_hvac_IL, IL_design)
st.metric("Predicted reduction from your retrofit", f"{delta:.1f} dB",
help="Model existing level minus model design level; applied to each "
"measured location below.")
rows, new_levels = [], []
for _, r in df.iterrows():
loc, meas = r["Location"], float(r["dBA"])
new = apply_delta(meas, delta)
new_levels.append(new)
rows.append({"Location": loc,
"Measured (dBA)": round(meas, 1),
"Predicted after retrofit (dBA)": round(new, 1),
"Meets \u2264 %.0f?" % st.session_state.bn_max:
("\u2714" if st.session_state.bn_max and new <= st.session_state.bn_max else "\u2716")})
st.markdown("###### Background noise: measured vs predicted-after-retrofit")
st.dataframe(pd.DataFrame(rows), use_container_width=True)
# single reconciled plot: predicted-after-retrofit per location vs band
fig = plot_bn_band(df["Location"], pd.Series(new_levels),
st.session_state.bn_min, st.session_state.bn_max,
"Final Background Noise (measured baseline + model reduction) vs Standard")
st.plotly_chart(fig, use_container_width=True)
st.session_state.fig_bn_final = fig
# source decomposition of the DESIGN condition (diagnostic)
with st.expander("Source diagnostic after retrofit (facade vs HVAC)"):
figs = plot_bn_sources(lf, lh, model_de, st.session_state.bn_max or 40,
"Post-retrofit Source Diagnostic")
st.plotly_chart(figs, use_container_width=True)
st.session_state.fig_bn_sources_final = figs
st.caption(f"Model existing {model_ex:.1f} dBA \u2192 model design {model_de:.1f} dBA "
f"(reduction {delta:.1f} dB). This reduction is what was applied to the "
f"measured baseline above.")
worst = max(new_levels) if new_levels else None
if st.session_state.bn_max and worst is not None and worst > st.session_state.bn_max:
dominant = "facade/glazing" if lf >= lh else "HVAC"
st.info(f"At least one location still exceeds {st.session_state.bn_max:.0f} dBA "
f"after your retrofit. Dominant remaining source: **{dominant}**. If a "
f"constraint prevents the needed upgrade, document the compromise and its "
f"justification.")
else:
st.warning("\u26A0\uFE0F Provide measured background noise, source inputs, materials, and treatment first.")
with t_si:
st.write("Speech intelligibility after treatment: added absorption lowers the SPLs, "
"which the app re-plots on the dot chart and re-counts automatically to give "
"the post-treatment AI and Table 2 interpretation.")
if st.session_state.df_spl is None or not post_abs:
st.warning("\u26A0\uFE0F Upload SPL data and run Acoustic Treatment first.")
else:
# Reverberant-level reduction is computed PER OCTAVE BAND:
# NR(f) = 10 * log10( A_after(f) / A_before(f) )
# Absorption is never summed across bands to form one scalar: the AI
# dot count is spectral, and a frequency-selective treatment lowers
# the low bands far more than the high ones.
nr = nr_by_band(st.session_state.current_absorption, post_abs)
df_new_spl = st.session_state.df_spl.copy()
for col in df_new_spl.columns:
try:
f = int(col)
except (TypeError, ValueError):
continue
if f in nr:
df_new_spl[col] = df_new_spl[col] - nr[f]
st.markdown("###### New sound pressure levels after treatment")
st.dataframe(df_new_spl, use_container_width=True)
st.caption("SPLs reduced per octave band by "
+ " · ".join(f"{f} Hz: {nr[f]:.2f} dB" for f in FREQUENCIES)
+ ". Reverberant field only — close to a talker or a diffuser the "
"direct sound dominates and the level falls by less.")
ai_rows = []
for loc in df_new_spl.index:
oct_spl = {f: freq_val(df_new_spl.loc[loc], f) for f in FREQUENCIES}
ai, dots_above, spl_third, per_band = auto_articulation(oct_spl)
comm, priv = ai_table2(ai)
st.session_state.dots_final_dict[loc] = dots_above
st.session_state.ai_final_dict[loc] = (ai, comm)
ai_rows.append({"Location": loc, "Dots above": dots_above,
"AI": f"{ai:.2f}", "Speech communication": comm,
"Speech privacy": priv})
with st.expander(f"Location {loc}: post-treatment AI = {ai:.2f} ({comm}) \u2014 view dot chart"):
fig = plot_ai_dots(spl_third, per_band, f"Post-treatment AI Dot Chart \u2014 {loc}")
st.plotly_chart(fig, use_container_width=True)
st.dataframe(pd.DataFrame(per_band,
columns=["Third-octave (Hz)", "Dots in band", "SPL after treatment (dB)", "Dots above"]),
use_container_width=True)
st.markdown("###### Post-treatment AI results \u2014 all locations")
st.dataframe(pd.DataFrame(ai_rows), use_container_width=True)
nav_buttons()
# ---------------------------------------------------------------------------
def section_faq() -> None:
st.title("\U0001F50A Building Acoustics Analysis Tool")
st.header("\u2753 FAQ / Help")
st.markdown("""
### Frequently Asked Questions
**Q: My frequency columns are not accepted.** Use headings like `125`, `250` ... `4000`
(or `125Hz`, `1KHz`, etc.). Remove extra rows/columns.
**Q: Why is my RT60 not in range?** The target is two-sided. Too much absorption pushes
RT60 below the floor; too little leaves it above the ceiling. Tune per frequency.
**Q: Why does uploaded/measured RT60 differ from the materials-model RT60?** They are
independent inputs. The first is the RT60 supplied in your Current RT60 file; the second
is Sabine's estimate from room volume and the material schedule. Use the reconciliation
table to check large differences before interpreting the datasets together.
**Q: Why don't identical Existing and New Materials match the measured curve?** Identical
schedules make the two **modelled** curves overlap. They do not force the independently
uploaded/measured curve to match. Compare like with like using the graph legend.
**Q: Background noise won't drop below the limit even with lots of absorption.**
Absorption mainly lowers the facade-borne component. If the glazing or HVAC dominates,
you need a higher glazing Rw or an HVAC silencer. If a constraint forbids the needed
upgrade, document the compromise.
**Q: Where do costs and depth come in?** On the New Materials tab: enter unit rate and
depth for each item. The app totals cost against your budget and flags any treatment
deeper than your limit (a buildability check).
**Q: How do I save results?** Use the report download (where available) and take
screenshots of each compliance chart for your appendix.
""")
nav_buttons()