"""Tab 1: Input - Combined Room, Images, and Observations. Consolidated input tab using Accordions for collapsible sections. Room and Images are open by default, Observations is collapsed. """ import uuid import io import gradio as gr from typing import Any from PIL import Image from ui.state import SessionState, ImageFormData, ObservationsFormData from ui.constants import CEILING_HEIGHT_PRESETS from ui.components import image_store from config.settings import settings # Facility classification options FACILITY_OPTIONS = [ ("Operational", "operational"), ("Non-Operational", "non-operational"), ("Public/Childcare", "public-childcare"), ] # Construction era options CONSTRUCTION_ERA_OPTIONS = [ ("Pre-1980 (potential LBP/ACM)", "pre-1980"), ("1980-2000", "1980-2000"), ("Post-2000", "post-2000"), ] # Odor mapping ODOR_MAP = { "None": "none", "Faint": "faint", "Moderate": "moderate", "Strong": "strong", } ODOR_MAP_REVERSE = {v: k for k, v in ODOR_MAP.items()} # Char density mapping CHAR_DENSITY_MAP = { "None": None, "Sparse": "sparse", "Moderate": "moderate", "Dense": "dense", } CHAR_DENSITY_MAP_REVERSE = {v: k for k, v in CHAR_DENSITY_MAP.items()} def create_tab() -> dict[str, Any]: """Create combined Input tab with accordions. Returns: Dictionary of component references for event wiring. """ # --- Room Details Accordion (OPEN by default) --- with gr.Accordion("Room Details", open=True): room_name = gr.Textbox( label="Room/Area Name *", placeholder="e.g., Warehouse Bay A, Office 101", elem_id="room_name", ) with gr.Row(): room_length = gr.Number( label="Length (ft) *", minimum=1, value=None, elem_id="room_length", ) room_width = gr.Number( label="Width (ft) *", minimum=1, value=None, elem_id="room_width", ) with gr.Row(): room_height_preset = gr.Dropdown( label="Ceiling Height *", choices=CEILING_HEIGHT_PRESETS, elem_id="room_height_preset", info="Select preset or choose Custom", ) room_height_custom = gr.Number( label="Custom Height (ft)", minimum=1, value=None, visible=False, elem_id="room_height_custom", ) with gr.Row(): floor_area = gr.Textbox( label="Floor Area (SF)", value="0", interactive=False, ) room_volume = gr.Textbox( label="Volume (CF)", value="0", interactive=False, ) facility_classification = gr.Radio( label="Facility Classification *", choices=FACILITY_OPTIONS, value="non-operational", elem_id="facility_classification", info="Affects clearance thresholds", ) construction_era = gr.Radio( label="Construction Era *", choices=CONSTRUCTION_ERA_OPTIONS, value="post-2000", elem_id="construction_era", info="Pre-1980 triggers LBP/ACM flags", ) # --- Images Accordion (OPEN by default) --- with gr.Accordion("Images", open=True): gr.Markdown( f"*Upload up to {settings.max_images_per_assessment} images for AI analysis.*" ) with gr.Row(): with gr.Column(scale=2): image_upload = gr.Files( label="Upload Images (select multiple)", file_count="multiple", file_types=["image"], elem_id="image_upload", ) image_description = gr.Textbox( label="Description (optional)", placeholder="e.g., View of ceiling deck from center aisle", elem_id="image_description", info="Applied to all images in batch", ) with gr.Row(): add_image_btn = gr.Button("Add Images", variant="primary") clear_upload_btn = gr.Button("Clear", variant="secondary") with gr.Column(scale=3): images_gallery = gr.Gallery( label="Images Added", columns=3, height="auto", elem_id="images_gallery", ) with gr.Row(): remove_last_btn = gr.Button("Remove Last", variant="secondary") clear_all_btn = gr.Button("Clear All", variant="stop") with gr.Row(): image_count = gr.Textbox( label="Images Added", value="0 / 20", interactive=False, ) # Resume warning (shown when images need re-upload) resume_warning = gr.HTML( value="", elem_id="resume_warning", visible=False, ) # --- Observations Accordion (COLLAPSED by default) --- with gr.Accordion("Field Observations (Optional)", open=False): gr.Markdown("*Document observations per FDAM §2.3. All fields optional.*") with gr.Row(): with gr.Column(): smoke_odor = gr.Checkbox( label="Smoke/fire odor present?", elem_id="smoke_odor", ) odor_intensity = gr.Radio( choices=["None", "Faint", "Moderate", "Strong"], label="Odor Intensity", value="None", elem_id="odor_intensity", ) visible_soot = gr.Checkbox( label="Visible soot deposits?", elem_id="visible_soot", ) soot_description = gr.Textbox( label="Soot Pattern Description", placeholder="e.g., Heavy deposits on ceiling", elem_id="soot_description", ) large_char = gr.Checkbox( label="Large char particles observed?", elem_id="large_char", ) char_density = gr.Radio( choices=["None", "Sparse", "Moderate", "Dense"], label="Char Density", value="None", elem_id="char_density", ) ash_residue = gr.Checkbox( label="Ash-like residue present?", elem_id="ash_residue", ) ash_description = gr.Textbox( label="Ash Color/Texture", placeholder="e.g., Gray powdery residue", elem_id="ash_description", ) with gr.Column(): surface_discoloration = gr.Checkbox( label="Surface discoloration?", elem_id="surface_discoloration", ) discoloration_description = gr.Textbox( label="Discoloration Description", placeholder="e.g., Yellowing on painted surfaces", elem_id="discoloration_description", ) dust_interference = gr.Checkbox( label="Dust loading or interference?", info="Pre-existing dust may affect samples", elem_id="dust_interference", ) dust_notes = gr.Textbox( label="Dust Notes", placeholder="e.g., Heavy ambient dust", elem_id="dust_notes", ) wildfire_indicators = gr.Checkbox( label="Wildfire indicators (vegetation/pollen)?", info="May indicate wildfire vs structural fire", elem_id="wildfire_indicators", ) wildfire_notes = gr.Textbox( label="Wildfire Notes", placeholder="e.g., Burned pine pollen visible", elem_id="wildfire_notes", ) additional_notes = gr.Textbox( label="Additional Observations", lines=3, placeholder="Any other relevant observations...", elem_id="additional_notes", ) # --- Generate Button and Validation --- validation_status = gr.HTML( value="", elem_id="input_validation", ) generate_btn = gr.Button( "Generate Assessment →", variant="primary", size="lg", ) return { # Room components "room_name": room_name, "room_length": room_length, "room_width": room_width, "room_height_preset": room_height_preset, "room_height_custom": room_height_custom, "floor_area": floor_area, "room_volume": room_volume, "facility_classification": facility_classification, "construction_era": construction_era, # Image components "image_upload": image_upload, "image_description": image_description, "add_image_btn": add_image_btn, "clear_upload_btn": clear_upload_btn, "images_gallery": images_gallery, "remove_last_btn": remove_last_btn, "clear_all_btn": clear_all_btn, "image_count": image_count, "resume_warning": resume_warning, # Observation components "smoke_odor": smoke_odor, "odor_intensity": odor_intensity, "visible_soot": visible_soot, "soot_description": soot_description, "large_char": large_char, "char_density": char_density, "ash_residue": ash_residue, "ash_description": ash_description, "surface_discoloration": surface_discoloration, "discoloration_description": discoloration_description, "dust_interference": dust_interference, "dust_notes": dust_notes, "wildfire_indicators": wildfire_indicators, "wildfire_notes": wildfire_notes, "additional_notes": additional_notes, # Validation and generation "validation_status": validation_status, "generate_btn": generate_btn, } # --- Room Functions --- def on_height_preset_change(preset_value: int | None) -> dict: """Show/hide custom height input based on preset selection.""" return gr.update(visible=(preset_value is None)) def update_calculated_values( length: float | None, width: float | None, height_preset: int | None, height_custom: float | None, ) -> tuple[str, str]: """Calculate and return floor area and volume.""" length_val = float(length) if length and length > 0 else 0 width_val = float(width) if width and width > 0 else 0 if height_preset is not None: height_val = float(height_preset) elif height_custom is not None and height_custom > 0: height_val = float(height_custom) else: height_val = 0 area = length_val * width_val volume = area * height_val return f"{area:,.0f}", f"{volume:,.0f}" def save_room_to_session( session: SessionState, name: str, length: float | None, width: float | None, height_preset: int | None, height_custom: float | None, facility_classification: str, construction_era: str, ) -> SessionState: """Save room data to session.""" if height_preset is not None: height = float(height_preset) elif height_custom is not None and height_custom > 0: height = float(height_custom) else: height = 0 session.room.name = name.strip() if name else "" session.room.length_ft = float(length) if length and length > 0 else 0 session.room.width_ft = float(width) if width and width > 0 else 0 session.room.ceiling_height_ft = height session.room.facility_classification = facility_classification session.room.construction_era = construction_era session.update_timestamp() return session def load_room_from_session( session: SessionState, ) -> tuple[str, float | None, float | None, int | None, float | None, str, str, str, str]: """Load room data from session.""" r = session.room height_preset = None height_custom = None preset_values = [p[1] for p in CEILING_HEIGHT_PRESETS if p[1] is not None] if r.ceiling_height_ft in preset_values: height_preset = int(r.ceiling_height_ft) elif r.ceiling_height_ft > 0: height_custom = r.ceiling_height_ft area = r.length_ft * r.width_ft volume = area * r.ceiling_height_ft return ( r.name, r.length_ft if r.length_ft > 0 else None, r.width_ft if r.width_ft > 0 else None, height_preset, height_custom, f"{area:,.0f}", f"{volume:,.0f}", r.facility_classification, r.construction_era, ) # --- Image Functions --- def add_image( session: SessionState, files: list | None, description: str, ) -> tuple[SessionState, list[tuple], str, str, None, str]: """Add images to the session.""" validation_html = "" errors = [] if not files or len(files) == 0: errors.append("Please upload at least one image") current_count = len(session.images) max_allowed = settings.max_images_per_assessment if files and current_count + len(files) > max_allowed: remaining = max_allowed - current_count if remaining <= 0: errors.append(f"Maximum of {max_allowed} images allowed") else: errors.append(f"Can only add {remaining} more image(s)") if errors: error_items = "".join(f"