Skip to content

Graphical User Interface

pages.home

This module defines the layout and callbacks for the home page of the reverberation optimization application. It includes components for inputting room parameters, defining room surfaces, and visualizing the calculated reverberation time.

add_row_to_surface_table(n_clicks, rows)

Add a new row to the surface definition table.

This callback is triggered when the "Add Row" button is clicked. It appends a new, empty row to the area-table DataTable, allowing users to define another room surface.

Parameters:

Name Type Description Default
n_clicks int

The number of times the 'add-row-button' has been clicked.

required
rows list

The existing data in the 'area-table'.

required

Returns:

Type Description
list

The updated data for the 'area-table' with the new row added.

Source code in pages/home.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
@callback(
    Output('area-table', 'data', allow_duplicate=True), # allow_duplicate needed if another callback modifies data
    Input('add-row-button', 'n_clicks'),
    State('area-table', 'data'),
    prevent_initial_call=True
)
def add_row_to_surface_table(n_clicks, rows):
    """Add a new row to the surface definition table.

    This callback is triggered when the "Add Row" button is clicked. It appends a new,
    empty row to the `area-table` DataTable, allowing users to define another room surface.

    Parameters
    ----------
    n_clicks : int
        The number of times the 'add-row-button' has been clicked.
    rows : list
        The existing data in the 'area-table'.

    Returns
    -------
    list
        The updated data for the 'area-table' with the new row added.
    """
    if rows is None:
        rows = []
    new_row_data = {f"col-{i+1}": "💾" if f"col-{i+1}" == "col-3" else "" for i in range(12)}
    new_row_data["col-delete"] = "🗑️" # Add delete icon to new rows
    rows.append(new_row_data)
    return rows

handle_table_interactions(active_cell, close_clicks, modal_is_open, table_data)

Handle interactions with the main table.

This callback manages two user interactions: 1. Opens a material selection modal when the database icon ('💾') is clicked. 2. Deletes a row from the table when the delete icon ('🗑️') is clicked.

Parameters:

Name Type Description Default
active_cell dict

The currently active cell in the table.

required
close_clicks int

Number of clicks on the close button.

required
modal_is_open bool

Current state of the modal.

required
table_data list

Current data in the table.

required

Returns:

Type Description
tuple

A tuple containing the updated modal state, new modal content, updated table data, and the stored active row index.

Source code in pages/home.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
@callback(
    Output("details-modal", "is_open"),
    Output("details-modal-body-content", "children"),
    Output('area-table', 'data', allow_duplicate=True), # Keep allow_duplicate if other callbacks modify data
    Output('active-row-index-store', 'data'),
    Input("area-table", "active_cell"),
    Input("close-details-modal-button", "n_clicks"),
    State("details-modal", "is_open"),
    State("area-table", "data"), 
    prevent_initial_call=True
)
def handle_table_interactions(active_cell, close_clicks, modal_is_open, table_data):
    """Handle interactions with the main table.

    This callback manages two user interactions:
    1. Opens a material selection modal when the database icon ('💾') is clicked.
    2. Deletes a row from the table when the delete icon ('🗑️') is clicked.

    Parameters
    ----------
    active_cell : dict
        The currently active cell in the table.
    close_clicks : int
        Number of clicks on the close button.
    modal_is_open : bool
        Current state of the modal.
    table_data : list
        Current data in the table.

    Returns
    -------
    tuple
        A tuple containing the updated modal state, new modal content,
        updated table data, and the stored active row index.
    """


    ctx = dash.callback_context
    triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]

    new_modal_state = modal_is_open
    new_modal_content = dash.no_update
    new_table_data = dash.no_update 
    stored_row_index = dash.no_update

    if triggered_id == "close-details-modal-button":
        new_modal_state = False
        return new_modal_state, new_modal_content, new_table_data, stored_row_index

    if active_cell and table_data:
        row_index = active_cell["row"]
        col_id = active_cell["column_id"]

        if row_index < len(table_data):
            # Click action on DB icon in the main table
            if col_id == "col-3":
                stored_row_index = row_index
                if not df_materials.empty:
                    modal_table_component = dash_table.DataTable(
                        id='material-selection-table',
                        columns=[{"name": i, "id": i} for i in df_materials.columns],
                        data=df_materials.to_dict('records'), # type: ignore
                        style_table={'overflowY': 'auto', 'height': '400px', 'overflowX': 'auto', 'minWidth': '100%'},
                        style_cell={
                            'textAlign': 'left',
                            'padding': '5px',
                            'color': 'black',
                            'border': '1px solid black',
                            'whiteSpace': 'normal',
                            'height': 'auto'
                        },
                        style_header={
                            'backgroundColor': 'rgb(230, 230, 230)',
                            'color': 'black',
                            'fontWeight': 'bold',
                            'border': '1px solid black'
                        },
                    )
                    new_modal_content = html.Div(
                        modal_table_component, 
                        className='material-table-container'
                    )
                else:
                    new_modal_content = html.Div("Material database (materials.csv) not found.")

                new_modal_state = True

            elif col_id == "col-delete": # Click on Delete icon in the main table
                updated_rows = [row for i, row in enumerate(table_data) if i != row_index]
                new_table_data = updated_rows
                new_modal_state = False 
                return new_modal_state, new_modal_content, new_table_data, stored_row_index

    return new_modal_state, new_modal_content, new_table_data, stored_row_index

update_area_table_with_material(active_cell, material_data, active_row_index, area_table_data)

Update the area table with data from the selected material of the material database.

This callback is triggered when a cell in the material selection modal table is clicked. It takes the absorption coefficient data from the selected material and populates the corresponding row in the main 'area-table'.

Parameters:

Name Type Description Default
active_cell dict

The activated cell in the 'material-selection-table'.

required
material_data list

The data from the 'material-selection-table'.

required
active_row_index int

The index of the row in 'area-table' to be updated.

required
area_table_data list

The current data in the 'area-table'.

required

Returns:

Type Description
tuple

A tuple containing the updated 'area-table' data and a boolean to close the modal.

Source code in pages/home.py
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
@callback(
    Output('area-table', 'data', allow_duplicate=True),
    Output('details-modal', 'is_open', allow_duplicate=True),
    Input('material-selection-table', 'active_cell'),
    State('material-selection-table', 'data'),
    State('active-row-index-store', 'data'),
    State('area-table', 'data'),
    prevent_initial_call=True
)
def update_area_table_with_material(active_cell, material_data, active_row_index, area_table_data):
    """Update the area table with data from the selected material of the material database.

    This callback is triggered when a cell in the material selection modal table is clicked.
    It takes the absorption coefficient data from the selected material and populates the
    corresponding row in the main 'area-table'.

    Parameters
    ----------
    active_cell : dict
        The activated cell in the 'material-selection-table'.
    material_data : list
        The data from the 'material-selection-table'.
    active_row_index : int
        The index of the row in 'area-table' to be updated.
    area_table_data : list
        The current data in the 'area-table'.

    Returns
    -------
    tuple
        A tuple containing the updated 'area-table' data and a boolean to close the modal.
    """
    if not active_cell or active_row_index is None:
        return dash.no_update, dash.no_update

    selected_material_row_index = active_cell['row']
    selected_material = material_data[selected_material_row_index]

    # The row from area-table to be updated
    target_row = area_table_data[active_row_index]

    # Update the values
    target_row['col-4'] = selected_material['name']
    target_row['col-5'] = selected_material['63']
    target_row['col-6'] = selected_material['125']
    target_row['col-7'] = selected_material['250']
    target_row['col-8'] = selected_material['500']
    target_row['col-9'] = selected_material['1000']
    target_row['col-10'] = selected_material['2000']
    target_row['col-11'] = selected_material['4000']
    target_row['col-12'] = selected_material['8000']

    # Close modal
    return area_table_data, False

update_graph_with_calculation(table_data, volume, height, temp, humidity, pressure, room_usage, air_damp_activated)

Update the graph based on all user inputs by calling the calculation module.

This callback gathers all room parameters and surface definitions from the user interface, passes them to the reverberation_calc module for calculation, and plots the resulting reverberation time on the graph. It also plots target reverberation time ranges based on DIN 18041 if a room usage type is selected.

Parameters:

Name Type Description Default
table_data list[dict]

Data from the surface definition table.

required
volume float

Room volume in cubic meters.

required
height float

Room height in meters.

required
temp float

Room temperature in degrees Celsius.

required
humidity float

Relative humidity in percent.

required
pressure float

Air pressure in hPa.

required
room_usage str

Selected room usage type from the dropdown.

required
air_damp_activated bool

State of the air dampening toggle switch.

required

Returns:

Type Description
Figure

An updated Plotly figure with the calculated reverberation time.

Source code in pages/home.py
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
@callback(
    Output('fig-transformed', 'figure'),
    [
        Input('area-table', 'data'),
        Input("input_room_volume", "value"),
        Input("input_room_height", "value"),
        Input("input_room_temperature", "value"),
        Input("input_room_humidity", "value"),
        Input("input_room_pressure", "value"),
        Input("dropdown_room_usage", "value"),
        Input("my-toggle-switch", "value"),
    ]
)
def update_graph_with_calculation(table_data, volume, height, temp, humidity, pressure, room_usage, air_damp_activated):
    """Update the graph based on all user inputs by calling the calculation module.

    This callback gathers all room parameters and surface definitions from the user interface,
    passes them to the `reverberation_calc` module for calculation, and plots the resulting
    reverberation time on the graph. It also plots target reverberation time ranges based on
    DIN 18041 if a room usage type is selected.

    Parameters
    ----------
    table_data : list[dict]
        Data from the surface definition table.
    volume : float
        Room volume in cubic meters.
    height : float, optional
        Room height in meters.
    temp : float
        Room temperature in degrees Celsius.
    humidity : float
        Relative humidity in percent.
    pressure : float
        Air pressure in hPa.
    room_usage : str
        Selected room usage type from the dropdown.
    air_damp_activated : bool
        State of the air dampening toggle switch.

    Returns
    -------
    plotly.graph_objects.Figure
        An updated Plotly figure with the calculated reverberation time.
    """
    # Helper to convert input to float, handling commas.
    def to_float(value, default):
        if value is None or value == '':
            return default
        try:
            return float(str(value).replace(',', '.'))
        except (ValueError, TypeError):
            return default

    # Default values for numeric inputs
    volume = to_float(volume, 30)
    height = to_float(height, None)
    temp = to_float(temp, 20)
    humidity = to_float(humidity, 50)
    pressure = to_float(pressure, 1013.25) # hPa

    # Standard octave bands for the x-axis
    frequency_bands = [63, 125, 250, 500, 1000, 2000, 4000, 8000]

    # Create a default empty figure and pre-format the axes
    fig = go.Figure()
    fig.update_layout(
        title_text="Please provide room volume and surface data for calculation",
        template="plotly_dark",
        paper_bgcolor="rgba(0,0,0,0)",
        plot_bgcolor="rgba(0,0,0,0)",
        font_color="#f6f6f6",
        xaxis=dict(
            type='log',
            tickvals=frequency_bands,
            ticktext=[str(f) for f in frequency_bands],
            title_text="Frequency in Hz",
            range=[np.log10(50), np.log10(10000)]  # Set a fixed range for log axis
        ),
        yaxis_title="Reverberation Time in s",
        yaxis=dict(range=[0, None]),
    )

    if not volume or not table_data:
        return fig

    try:
        # Create Room Object
        calc_room = reverberation_calc.room(volume)
        if height:
            calc_room.set_height(height)
        calc_room.set_temperature(temp)
        calc_room.set_rel_humidity(humidity)
        calc_room.set_pressure(pressure / 10) # Convert hPa to kPa

        # Create Surface Objects
        surfaces = []
        for row in table_data:
            try:
                # Use a helper to safely convert values to float, defaulting to np.nan for empty strings
                def safe_float(val):
                    # The validation callback should have already formatted the numbers,
                    # but this is a safeguard.
                    if isinstance(val, str):
                        val = val.replace(',', '.').strip()
                    try:
                        # Return NaN if the value is an empty string or cannot be converted
                        if val == '' or val is None:
                            return np.nan
                        return float(val)
                    except (ValueError, TypeError):
                        return np.nan

                area = safe_float(row.get('col-2'))
                if area is not None and area > 0 and not np.isnan(area):
                    absorb_coeffs = [
                        safe_float(row.get('col-5')),
                        safe_float(row.get('col-6')),
                        safe_float(row.get('col-7')),
                        safe_float(row.get('col-8')),
                        safe_float(row.get('col-9')),
                        safe_float(row.get('col-10')),
                        safe_float(row.get('col-11')),
                        safe_float(row.get('col-12'))
                    ]
                    mat_name = row.get('col-4') or "Unnamed Material"
                    surface_name = row.get('col-1') or "Unnamed Surface"

                    material = reverberation_calc.material(mat_name, absorb_coeffs)
                    surface = reverberation_calc.surface(surface_name, area, material)
                    surfaces.append(surface)
            except (ValueError, TypeError):
                # This will now primarily catch issues if the row structure is unexpected
                continue

        if not surfaces:
            fig.update_layout(title_text="Valid surface data is required for calculation.")
            return fig

        # Calculate reverberation time
        reverb_obj = reverberation_calc.reverberation_time(calc_room, surfaces, air_damp_calc=air_damp_activated)
        df_reverb = pd.DataFrame({
            'Frequency': reverb_obj.frequency_bands,
            'Reverberation Time': reverb_obj.reverberation_time
        })

        # Get target reverberation time range
        if room_usage != "no requirements":
            limits = reverberation_calc.DIN_18041_limits(calc_room, room_usage)
            df_target = pd.DataFrame({
                'Frequency': reverb_obj.frequency_bands,
                'T_max': limits.T_upper_limit,
                'T_min': limits.T_lower_limit
            })
        else:
            df_target = pd.DataFrame()

        if not df_target.empty and (df_target['T_max'] > 0).any() and room_usage.startswith('B'):
            # Find the first and last indices where T_max is greater than 0
            non_zero_indices = df_target.index[df_target['T_max'] > 0]
            first_index = non_zero_indices[0]
            last_index = non_zero_indices[-1]

            # Get the corresponding frequencies
            first_freq = df_target.loc[first_index, 'Frequency']
            last_freq = df_target.loc[last_index, 'Frequency']

            # Create new rows to form a rectangle
            start_rect_row = pd.DataFrame([{'Frequency': first_freq, 'T_max': 0, 'T_min': 0}])
            end_rect_row = pd.DataFrame([{'Frequency': last_freq, 'T_max': 0, 'T_min': 0}])

            # Insert the new rows into the DataFrame
            df_target = pd.concat([
                df_target.iloc[:first_index],
                start_rect_row,
                df_target.iloc[first_index:last_index + 1],
                end_rect_row,
                df_target.iloc[last_index + 1:]
            ]).reset_index(drop=True)


        # Plotting
        fig.add_trace(go.Scatter(
            x=df_reverb['Frequency'], 
            y=df_reverb['Reverberation Time'],
            mode='lines+markers',
            name='Reverberation Time',
            line=dict(color='#3DED97', width=3)
        ))

        if not df_target.empty:
            fig.add_trace(go.Scatter(
                x=df_target['Frequency'],
                y=df_target['T_max'],
                fill=None,
                mode='lines',
                line_color='rgba(255,107,107,0.5)',
                showlegend=False
            ))
            fig.add_trace(go.Scatter(
                x=df_target['Frequency'],
                y=df_target['T_min'],
                fill='tonexty', # fill area between trace0 and trace1
                mode='lines',
                line_color='rgba(255,107,107,0.5)',
                name='Tolerance Range',
            ))

        fig.update_layout(
            title_text="Reverberation Time Calculation with DIN 18041 requirements",
            xaxis_title="Frequency in Hz",
            yaxis_title="Reverberation Time in s",
            yaxis=dict(range=[0, None]),
            xaxis=dict(
                type='log',
                tickvals=frequency_bands,
                ticktext=[str(f) for f in frequency_bands],
                range=[np.log10(50), np.log10(10000)]  # Also set range here for consistency
            ),
            legend=dict(
                orientation="h", 
                yanchor="bottom", 
                y=1.02, 
                xanchor="right", 
                x=1,
                itemclick=False,
                itemdoubleclick=False
                )
        )

    except Exception as e:
        fig.update_layout(title_text=f"An error occurred: {e}")

    return fig

validate_and_format_table_data(timestamp, data, data_previous)

Validate and format the data in the area-table. Ensures that area and alpha values are numeric and rounded to two decimal places. Reverts invalid inputs to their previous state.

Source code in pages/home.py
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
@callback(
    Output('area-table', 'data', allow_duplicate=True),
    Input('area-table', 'data_timestamp'),
    State('area-table', 'data'),
    State('area-table', 'data_previous'),
    prevent_initial_call=True
)
def validate_and_format_table_data(timestamp, data, data_previous):
    """
    Validate and format the data in the area-table.
    Ensures that area and alpha values are numeric and rounded to two decimal places.
    Reverts invalid inputs to their previous state.
    """
    if data is None or data_previous is None:
        raise dash.exceptions.PreventUpdate

    # If a row was added or deleted, the lengths will be different.
    # In this case, we don't want to validate, so we allow the update to proceed
    # without modification from this callback.
    if len(data) != len(data_previous):
        return data

    # Find the changed cell
    changed_row_idx, changed_col_id = -1, ''
    for i in range(len(data)):
        # Using .get() to avoid KeyErrors if a column was somehow removed
        diff_keys = [key for key in data[i] if data[i].get(key) != data_previous[i].get(key)]
        if diff_keys:
            changed_row_idx = i
            changed_col_id = diff_keys[0]
            break

    if changed_row_idx == -1:
        raise dash.exceptions.PreventUpdate

    # List of columns to validate
    editable_numeric_cols = [f'col-{i}' for i in range(2, 13) if i not in [3, 4]]

    if changed_col_id in editable_numeric_cols:
        value = data[changed_row_idx][changed_col_id]
        prev_value = data_previous[changed_row_idx][changed_col_id]

        if isinstance(value, str):
            cleaned_value = value.replace(',', '.').strip()
            if cleaned_value == '':
                # Allow user to clear the cell
                return data

            # Check for valid float format
            if cleaned_value.count('.') <= 1 and (cleaned_value.replace('.', '', 1).isdigit() or (cleaned_value.startswith('-') and cleaned_value[1:].replace('.', '', 1).isdigit())):
                try:
                    num_value = float(cleaned_value)
                    data[changed_row_idx][changed_col_id] = round(num_value, 2)
                except (ValueError, TypeError):
                    # Revert if conversion fails
                    data[changed_row_idx][changed_col_id] = prev_value
            else:
                # Revert if format is invalid
                data[changed_row_idx][changed_col_id] = prev_value
        elif isinstance(value, (int, float)):
            data[changed_row_idx][changed_col_id] = round(value, 2)
        # If value is None or other type, and it's a change, revert it.
        elif value != prev_value:
             data[changed_row_idx][changed_col_id] = prev_value


    return data