Spaces:
Runtime error
Runtime error
Download sections.py from Building-science/Building-Acoustic-Analysis-v3: direct link, hf CLI and curl.
- Browser
- Download file 46 kB
-
https://huggingface.co/spaces/Building-science/Building-Acoustic-Analysis-v3/resolve/main/sections.py
- Command line
-
hf download hf://spaces/Building-science/Building-Acoustic-Analysis-v3/sections.py
-
curl -L -o sections.py https://huggingface.co/spaces/Building-science/Building-Acoustic-Analysis-v3/resolve/main/sections.py
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() | |