diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..eac20de1e16b06290c9cef9a2a122e52bf3d04c4 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# FDAM AI Pipeline Environment Configuration + +# Set to true for local development with mock models (RTX 4090) +# Set to false for production with real models (HuggingFace 4xL4) +MOCK_MODELS=true + +# Server configuration (0.0.0.0 required for WSL) +SERVER_HOST=0.0.0.0 +SERVER_PORT=7860 + +# Optional: Override model paths +# VISION_MODEL=Qwen/Qwen3-VL-30B-A3B-Instruct +# EMBEDDING_MODEL=Qwen/Qwen3-VL-Embedding-8B +# RERANKER_MODEL=Qwen/Qwen3-VL-Reranker-8B diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5aaa02c801e4641960dc75fe988f878c06160d39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ + +# Environment +.env + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ + +# Generated +chroma_db/ +outputs/ +*.pdf +*.log + +# OS +.DS_Store +Thumbs.db + +# HuggingFace +*.safetensors +*.bin +*.pt +*.ckpt + +# Claude Code +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..3ef52885e7845ecd7e41b6bb910f8b2061203a20 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**FDAM AI Pipeline** - Fire Damage Assessment Methodology v4.0.1 implementation. An AI-powered system that generates professional Cleaning Specifications / Scope of Work documents for fire damage restoration. + +- **Deployment**: HuggingFace Spaces with Nvidia 4xL4 (96GB VRAM total, 24GB per GPU) +- **Local Dev**: RTX 4090 (24GB) - insufficient for full model stack; use mock models locally +- **Spec Document**: `FDAM_AI_Pipeline_Technical_Spec.md` is the authoritative technical reference + +## Critical Constraints + +1. **No External API Calls** - 100% locally-owned models only (no Claude/OpenAI APIs) +2. **Memory Budget** - 4xL4 96GB total: ~58GB vision (30B BF16) + ~16GB embedding + ~16GB reranker (~90GB used, ~6GB headroom) +3. **Processing Time** - 60-90 seconds per assessment is acceptable +4. **MVP Scope** - Phase 1 (PRE) and Phase 2 (PRA) only; no lab results processing yet +5. **Static RAG** - Knowledge base is pre-indexed; no user document uploads + +## Tech Stack + +| Component | Technology | +|-----------|------------| +| UI Framework | Gradio 4.x | +| Vision/Generation | Qwen3-VL-30B-A3B-Instruct | +| Embeddings | Qwen3-VL-Embedding-8B | +| Reranker | Qwen3-VL-Reranker-8B | +| Vector Store | ChromaDB 0.4.x | +| Validation | Pydantic 2.x | +| PDF Generation | Pandoc 3.x | +| Package Manager | pip + requirements.txt | + +## Development Commands + +```sh +# Install dependencies +pip install -r requirements.txt + +# Run locally with mock models +MOCK_MODELS=true python app.py + +# Run with real models (HuggingFace only - requires A100) +python app.py + +# Recommended tooling (install as dev dependencies) +ruff check . # Linting +ruff format . # Formatting +pytest tests/ -v # Testing +mypy . # Type checking +``` + +## Architecture + +### 6-Stage Processing Pipeline +1. **Input Validation** - Pydantic schema validation (schemas/input.py) +2. **Vision Analysis** - Per-image zone/material/condition detection (pipeline/vision.py) +3. **RAG Retrieval** - Disposition lookup, thresholds, methods (rag/retriever.py) +4. **FDAM Logic** - Disposition matrix application (pipeline/main.py) +5. **Calculations** - Surface areas, ACH, labor estimates (pipeline/calculations.py) +6. **Document Generation** - SOW, sampling plan, confidence report (pipeline/generator.py) + +### Target Project Structure +``` +├── app.py # Gradio entry point +├── config/ # Inference and app settings +├── models/ # Model loading (mock vs real) +├── rag/ # Chunking, vectorstore, retrieval +├── schemas/ # Pydantic input/output models +├── pipeline/ # Main processing logic +├── ui/ # Gradio UI components +├── RAG-KB/ # Knowledge base source files +├── chroma_db/ # ChromaDB persistence (generated) +└── tests/ +``` + +## Domain Knowledge + +### Zone Classifications +- **Burn Zone**: Direct fire involvement, structural char, exposed/damaged elements +- **Near-Field**: Adjacent to burn zone, heavy smoke/heat exposure, visible contamination +- **Far-Field**: Smoke migration only, light deposits, no structural damage + +### Condition Levels +- **Background**: No visible contamination +- **Light**: Faint discoloration, minimal deposits +- **Moderate**: Visible film/deposits, surface color altered +- **Heavy**: Thick deposits, surface texture obscured +- **Structural Damage**: Physical damage requiring repair before cleaning + +### Dispositions (FDAM §4.3) +- **No Action**: Document only +- **Clean**: Standard cleaning protocol +- **Evaluate**: Requires professional judgment +- **Remove**: Material must be removed +- **Remove/Repair**: Remove and repair/replace + +### Facility Classifications (affects thresholds) +- **Operational**: Active workplace (higher thresholds: 500 µg/100cm² lead) +- **Non-Operational**: Unoccupied (lower thresholds: 22 µg/100cm² lead) +- **Public/Childcare**: Most stringent (EPA/HUD Oct 2024: 0.54 µg/100cm² floors) + +### Key Calculations +- **ACH Formula**: `Units = (Volume × 4) / (CFM × 60)` per NADCA ACR 2021 +- **Sample Density**: Varies by area size per FDAM §2.3 +- **Ceiling Deck**: Enhanced sampling (1 per 2,500 SF per FDAM §4.5) + +## RAG Knowledge Base + +Source documents in `/RAG-KB/`: +- FDAM v4.0.1 methodology (primary reference) +- BNL SOP IH75190 (metals clearance thresholds) +- IICRC/RIA/CIRI Technical Guide (wildfire restoration) +- Lab method guides (PLM, ICP-MS) + +**Chunking rules:** +- Keep tables intact (never split markdown tables) +- Preserve headers with content +- Include metadata (source, category, section) + +## Confidence Framework + +| Score | Level | Action | +|-------|-------|--------| +| ≥90% | Very High | Accept without review | +| 70-89% | High | Accept, note in report | +| 50-69% | Moderate | Flag for human review | +| <50% | Low | Require human verification | + +## Multi-GPU Model Loading + +The 4xL4 setup requires models to be distributed across GPUs. Use `device_map="auto"` in transformers: + +```python +model = AutoModel.from_pretrained( + "Qwen/Qwen3-VL-30B-A3B-Instruct", + torch_dtype=torch.bfloat16, + device_map="auto", # Automatically distributes across available GPUs + trust_remote_code=True +) +``` + +Expected distribution (BF16, ~90GB total): +- Vision model (30B): ~58GB spread across GPUs via device_map="auto" +- Embedding model (8B): ~16GB +- Reranker model (8B): ~16GB +- Headroom: ~6GB for KV cache + +**Fallback**: If VRAM issues arise, use `Qwen/Qwen3-VL-8B-Instruct` (~16GB) instead of 30B + +## Local Development Strategy + +The RTX 4090 (24GB VRAM) cannot run the full model stack (~90GB required). Use this workflow: + +1. Set `MOCK_MODELS=true` environment variable +2. Mock responses return realistic JSON matching vision output schema +3. Test pipeline logic, UI, calculations without real inference +4. Deploy to HuggingFace Spaces for real model testing +5. Request build logs after deployment to confirm success + +## Code Style + +- Use `Literal["a", "b", "c"]` unions instead of Enum for simple string choices +- Pydantic models for all input/output validation +- Explicit return types on public functions +- Result types or explicit error returns over thrown exceptions +- Group imports: stdlib → third-party → local + +## WSL Note + +Dev servers must be exposed for WSL access. Use `--host 0.0.0.0` with Gradio: +```python +app.launch(server_name="0.0.0.0", server_port=7860) +``` diff --git a/FDAM_AI_Pipeline_Technical_Spec.md b/FDAM_AI_Pipeline_Technical_Spec.md new file mode 100644 index 0000000000000000000000000000000000000000..2b345e2984544de625b3f2a1a62c6aa1680b7660 --- /dev/null +++ b/FDAM_AI_Pipeline_Technical_Spec.md @@ -0,0 +1,3206 @@ +# FDAM AI Pipeline - Technical Specification +## Fire Damage Assessment Methodology Implementation Guide +### Version 1.0 | January 2026 + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [System Overview](#2-system-overview) +3. [Model Stack Configuration](#3-model-stack-configuration) +4. [RAG Knowledge Base](#4-rag-knowledge-base) +5. [Input Schema](#5-input-schema) +6. [Processing Pipeline](#6-processing-pipeline) +7. [Vision Analysis Module](#7-vision-analysis-module) +8. [Calculation Engine](#8-calculation-engine) +9. [Output Generation](#9-output-generation) +10. [Gradio UI Specification](#10-gradio-ui-specification) +11. [Confidence Framework](#11-confidence-framework) +12. [Project Structure](#12-project-structure) +13. [Implementation Notes](#13-implementation-notes) + +--- + +## 1. Executive Summary + +### Purpose +Build an AI-powered fire damage assessment system that generates professional Cleaning Specifications / Scope of Work documents aligned with FDAM v4.0.1 methodology. + +### Scope +- **MVP Focus**: Phase 1 (PRE) and Phase 2 (PRA) — pre-lab assessment +- **Primary Output**: Cleaning Specification / Scope of Work document +- **Secondary Output**: Sampling plan recommendations for lab testing + +### Key Constraints +- 100% locally-owned models (no Claude/OpenAI API calls) +- HuggingFace Spaces deployment with Nvidia A100 80GB +- 60-90 second processing time acceptable +- Static RAG knowledge base (no user-uploaded documents) + +### What This System Does NOT Do +- Process lab results (future phase) +- Make pass/fail determinations (requires lab data) +- Replace professional industrial hygienist judgment +- Perform microscopy-level particle analysis + +--- + +## 2. System Overview + +### Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ FDAM AI Pipeline Architecture │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ USER INTERFACE │ +│ (Multi-Tab Gradio) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Tab 1: Project Tab 2: Building Tab 3: Images Tab 4: Observations │ +│ - Facility name - Rooms/areas - Upload 1-20 - Qualitative │ +│ - Address - Dimensions - Per-image - Odor/soot/char │ +│ - Classification - Surface types metadata - Checklist │ +│ - Construction - Manual inventory │ +└───────────────────────────────┬─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ INPUT VALIDATION │ +│ - Schema validation - Image format check - Dimension ranges │ +└───────────────────────────────┬─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ VISION ANALYSIS MODULE │ +│ (Qwen3-VL-30B-A3B-Instruct) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Per Image: │ +│ ├── Zone Classification (Burn/Near-Field/Far-Field) + confidence │ +│ ├── Material Identification (steel, concrete, drywall, carpet...) │ +│ ├── Condition Assessment (Background/Light/Moderate/Heavy/Structural) │ +│ ├── Combustion Particle Patterns (visual soot/char/ash deposits) │ +│ └── Bounding Box Annotations for detected elements │ +└───────────────────────────────┬─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ RAG RETRIEVAL MODULE │ +│ (Qwen3-VL-Embedding-8B + Qwen3-VL-Reranker-8B) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Query Types: │ +│ ├── Disposition lookup: "steel near-field moderate" → clean protocol │ +│ ├── Threshold retrieval: "lead non-operational" → 22 µg/100cm² │ +│ ├── Method reference: "ceiling deck cleaning" → HEPA + wet wipe │ +│ └── Image similarity: (future) match to reference damage patterns │ +└───────────────────────────────┬─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ FDAM LOGIC ENGINE │ +│ (Deterministic Rules + Calculations) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ ├── Surface Area Aggregation (by type, by disposition) │ +│ ├── Disposition Matrix Application (FDAM §4.3) │ +│ ├── ACH Calculation (Volume × 4 / (CFM × 60)) │ +│ ├── Sample Density Recommendation (FDAM §2.3) │ +│ ├── Labor Estimation (hours by task) │ +│ └── Regulatory Flag Generation (LBP/ACM by construction date) │ +└───────────────────────────────┬─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ DOCUMENT GENERATION MODULE │ +│ (Qwen3-VL-30B-A3B-Instruct) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Outputs: │ +│ ├── Cleaning Specification / SOW (primary) │ +│ │ ├── Project identification │ +│ │ ├── Scope summary with zone classifications │ +│ │ ├── Surface inventory with dispositions │ +│ │ ├── Air filtration calculations (4 ACH) │ +│ │ ├── Surface-specific procedures │ +│ │ ├── Labor estimates │ +│ │ ├── Equipment requirements │ +│ │ └── Sampling plan recommendations │ +│ ├── Annotated Images (bounding boxes overlay) │ +│ └── Confidence Report (flagged items for review) │ +└───────────────────────────────┬─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ OUTPUT DELIVERY │ +│ ├── In-UI Preview (Markdown rendered) │ +│ ├── Downloadable Markdown (.md) │ +│ ├── Downloadable PDF (.pdf via pandoc) │ +│ └── Annotated Images Gallery │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Technology Stack + +| Component | Technology | Version | +|-----------|------------|---------| +| Platform | HuggingFace Spaces | - | +| GPU | Nvidia A100 | 80GB | +| Vision/Generation Model | Qwen3-VL-30B-A3B-Instruct | Latest | +| Embedding Model | Qwen3-VL-Embedding-8B | Latest | +| Reranker Model | Qwen3-VL-Reranker-8B | Latest | +| Vector Store | ChromaDB | 0.4.x | +| UI Framework | Gradio | 4.x | +| PDF Generation | Pandoc | 3.x | +| Image Processing | Pillow, OpenCV | Latest | + +--- + +## 3. Model Stack Configuration + +### Memory Budget (A100 80GB) + +| Component | VRAM | Status | +|-----------|------|--------| +| Qwen3-VL-30B-A3B-Instruct | ~24GB | Always loaded | +| Qwen3-VL-Embedding-8B | ~16GB | Always loaded | +| Qwen3-VL-Reranker-8B | ~16GB | Always loaded | +| ChromaDB + KV Cache | ~5GB | Always loaded | +| **Available Headroom** | ~19GB | Context expansion | +| **Total** | ~61GB | ✅ Fits | + +### Model Loading Configuration + +```python +# models/loader.py + +import torch +from transformers import ( + Qwen3VLMoeForConditionalGeneration, # Note: Qwen3-VL uses MoE architecture + AutoProcessor, + AutoModel, + AutoTokenizer +) + +class ModelStack: + """Manages all models with concurrent loading on A100 80GB.""" + + def __init__(self, device="cuda"): + self.device = device + self.models = {} + self.processors = {} + + def load_all(self): + """Load all models into VRAM.""" + print("Loading Qwen3-VL-30B-A3B-Instruct (Vision + Generation)...") + self.models["vision"] = Qwen3VLMoeForConditionalGeneration.from_pretrained( + "Qwen/Qwen3-VL-30B-A3B-Instruct", + torch_dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True + ) + self.processors["vision"] = AutoProcessor.from_pretrained( + "Qwen/Qwen3-VL-30B-A3B-Instruct", + trust_remote_code=True + ) + + print("Loading Qwen3-VL-Embedding-8B (Multimodal RAG)...") + self.models["embedding"] = AutoModel.from_pretrained( + "Qwen/Qwen3-VL-Embedding-8B", + torch_dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True + ) + self.processors["embedding"] = AutoProcessor.from_pretrained( + "Qwen/Qwen3-VL-Embedding-8B", + trust_remote_code=True + ) + + print("Loading Qwen3-VL-Reranker-8B (Retrieval Precision)...") + self.models["reranker"] = AutoModel.from_pretrained( + "Qwen/Qwen3-VL-Reranker-8B", + torch_dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True + ) + self.processors["reranker"] = AutoProcessor.from_pretrained( + "Qwen/Qwen3-VL-Reranker-8B", + trust_remote_code=True + ) + + print("All models loaded successfully.") + return self + +# Global singleton +model_stack = ModelStack() +``` + +### Inference Configuration + +```python +# config/inference.py + +VISION_CONFIG = { + "max_new_tokens": 4096, + "temperature": 0.1, # Low for consistency + "top_p": 0.9, + "do_sample": True, + "repetition_penalty": 1.1 +} + +GENERATION_CONFIG = { + "max_new_tokens": 8192, # Long documents + "temperature": 0.2, + "top_p": 0.95, + "do_sample": True, + "repetition_penalty": 1.05 +} + +RAG_CONFIG = { + "top_k_retrieval": 10, + "top_k_rerank": 5, + "similarity_threshold": 0.7, + "chunk_size": 500, # tokens + "chunk_overlap": 50 +} +``` + +--- + +## 4. RAG Knowledge Base + +### Directory Structure + +``` +rag_knowledge/ +├── README.md # Index and navigation guide +│ +├── methodology/ +│ ├── FDAM_v4.0.1/ +│ │ ├── 01_executive_summary.md +│ │ ├── 02_standards_basis.md +│ │ ├── 03_threshold_classification.md +│ │ ├── 04_metals_thresholds.md +│ │ ├── 05_combustion_definitions.md +│ │ ├── 06_particulate_thresholds.md +│ │ ├── 07_assessment_workflow.md +│ │ ├── 08_facility_classification.md +│ │ ├── 09_zone_classification.md +│ │ ├── 10_condition_scale.md +│ │ ├── 11_disposition_matrix_nonporous.md +│ │ ├── 12_disposition_matrix_porous.md +│ │ ├── 13_material_disposition_tiers.md +│ │ ├── 14_ceiling_deck_protocol.md +│ │ ├── 15_cleaning_sequence.md +│ │ ├── 16_surface_methods.md +│ │ ├── 17_air_filtration_ach.md +│ │ ├── 18_reclean_retest.md +│ │ ├── 19_sow_template.md +│ │ ├── 20_results_template.md +│ │ ├── 21_executive_summary_template.md +│ │ ├── 22_lab_format_quantitative.md +│ │ ├── 23_lab_format_semiquantitative.md +│ │ ├── 24_unit_conversions.md +│ │ └── 25_regulatory_justification_blocks.md +│ │ +│ └── sampling/ +│ ├── sample_density_guidelines.md +│ ├── tape_lift_protocol.md +│ └── surface_wipe_protocol.md +│ +├── lab_methods/ +│ ├── EAA_Method_Guide/ +│ │ ├── 01_particle_classification.md +│ │ ├── 02_biogenic_particles.md +│ │ ├── 03_fibrous_particles.md +│ │ ├── 04_inorganic_particles.md +│ │ ├── 05_combustion_categories.md +│ │ ├── 06_soot_morphology.md +│ │ ├── 07_char_morphology.md +│ │ ├── 08_ash_morphology.md +│ │ ├── 09_wildfire_thresholds.md +│ │ ├── 10_mold_background_levels.md +│ │ ├── 11_sem_spectral_patterns.md +│ │ └── 12_concentration_ranges.md +│ │ +│ └── Hayes_Reference/ +│ └── normal_ranges_astm_d6602.md +│ +├── standards/ +│ ├── BNL_SOP_IH75190/ +│ │ ├── operational_thresholds.md +│ │ ├── nonoperational_thresholds.md +│ │ └── eating_surfaces.md +│ │ +│ ├── EPA_HUD_Lead/ +│ │ ├── public_childcare_thresholds.md +│ │ └── october_2024_update.md +│ │ +│ ├── NADCA_ACR_2021/ +│ │ ├── ach_requirements.md +│ │ └── duct_cleaning_standards.md +│ │ +│ └── IICRC_RIA_CIRI/ +│ ├── zone_definitions.md +│ └── wildfire_restoration.md +│ +├── regulatory/ +│ ├── OSHA_1910.1025_lead.md +│ ├── OSHA_1910.1018_arsenic.md +│ ├── OSHA_1910.1027_cadmium.md +│ ├── OSHA_technical_manual.md +│ └── construction_date_flags.md +│ +└── reference_images/ # For multimodal RAG (future) + ├── soot_patterns/ + ├── char_deposits/ + ├── ash_residue/ + └── material_types/ +``` + +### Chunking Implementation + +```python +# rag/chunker.py + +import os +from pathlib import Path +from typing import List, Dict +import hashlib + +class KnowledgeChunker: + """Chunks FDAM knowledge base for RAG indexing.""" + + def __init__(self, knowledge_dir: str = "rag_knowledge"): + self.knowledge_dir = Path(knowledge_dir) + self.chunks: List[Dict] = [] + + def chunk_document(self, filepath: Path, chunk_size: int = 500, overlap: int = 50) -> List[Dict]: + """ + Chunk a markdown document while preserving structure. + + Rules: + - Preserve table integrity (don't split tables) + - Keep headers with their content + - Include metadata (source, section, category) + """ + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + # Extract metadata from path + parts = filepath.relative_to(self.knowledge_dir).parts + category = parts[0] if len(parts) > 0 else "general" + subcategory = parts[1] if len(parts) > 1 else "" + filename = filepath.stem + + # Split by headers first + sections = self._split_by_headers(content) + + chunks = [] + for section_title, section_content in sections: + # Check for tables - keep them intact + if self._contains_table(section_content): + chunks.append({ + "id": self._generate_id(filepath, section_title), + "content": section_content, + "metadata": { + "source": str(filepath), + "category": category, + "subcategory": subcategory, + "section": section_title, + "has_table": True, + "chunk_type": "table" + } + }) + else: + # Split long sections by approximate token count + sub_chunks = self._split_by_tokens(section_content, chunk_size, overlap) + for i, sub_chunk in enumerate(sub_chunks): + chunks.append({ + "id": self._generate_id(filepath, f"{section_title}_{i}"), + "content": f"## {section_title}\n\n{sub_chunk}", + "metadata": { + "source": str(filepath), + "category": category, + "subcategory": subcategory, + "section": section_title, + "has_table": False, + "chunk_type": "text", + "chunk_index": i + } + }) + + return chunks + + def _split_by_headers(self, content: str) -> List[tuple]: + """Split content by markdown headers.""" + import re + pattern = r'^(#{1,3})\s+(.+)$' + sections = [] + current_title = "Introduction" + current_content = [] + + for line in content.split('\n'): + match = re.match(pattern, line) + if match: + if current_content: + sections.append((current_title, '\n'.join(current_content))) + current_title = match.group(2) + current_content = [] + else: + current_content.append(line) + + if current_content: + sections.append((current_title, '\n'.join(current_content))) + + return sections + + def _contains_table(self, content: str) -> bool: + """Check if content contains a markdown table.""" + lines = content.split('\n') + for line in lines: + if '|' in line and line.count('|') >= 2: + return True + return False + + def _split_by_tokens(self, content: str, chunk_size: int, overlap: int) -> List[str]: + """Split content by approximate token count (4 chars ≈ 1 token).""" + char_size = chunk_size * 4 + char_overlap = overlap * 4 + + if len(content) <= char_size: + return [content] + + chunks = [] + start = 0 + while start < len(content): + end = start + char_size + + # Try to break at paragraph boundary + if end < len(content): + newline_pos = content.rfind('\n\n', start, end) + if newline_pos > start + char_size // 2: + end = newline_pos + + chunks.append(content[start:end].strip()) + start = end - char_overlap + + return chunks + + def _generate_id(self, filepath: Path, section: str) -> str: + """Generate unique chunk ID.""" + raw = f"{filepath}_{section}" + return hashlib.md5(raw.encode()).hexdigest()[:12] + + def process_all(self) -> List[Dict]: + """Process entire knowledge base.""" + self.chunks = [] + + for md_file in self.knowledge_dir.rglob("*.md"): + if md_file.name == "README.md": + continue + file_chunks = self.chunk_document(md_file) + self.chunks.extend(file_chunks) + print(f"Chunked {md_file}: {len(file_chunks)} chunks") + + print(f"Total chunks: {len(self.chunks)}") + return self.chunks +``` + +### ChromaDB Setup + +```python +# rag/vectorstore.py + +import chromadb +from chromadb.config import Settings +from typing import List, Dict, Optional +import numpy as np + +class FDAMVectorStore: + """ChromaDB vector store for FDAM knowledge base.""" + + def __init__(self, persist_dir: str = "./chroma_db"): + self.client = chromadb.PersistentClient( + path=persist_dir, + settings=Settings(anonymized_telemetry=False) + ) + + # Collections for different retrieval modes + self.text_collection = self.client.get_or_create_collection( + name="fdam_text", + metadata={"description": "FDAM methodology text chunks"} + ) + + self.image_collection = self.client.get_or_create_collection( + name="fdam_images", + metadata={"description": "Reference damage pattern images"} + ) + + def index_chunks(self, chunks: List[Dict], embeddings: List[np.ndarray]): + """Index text chunks with embeddings.""" + self.text_collection.add( + ids=[c["id"] for c in chunks], + embeddings=[e.tolist() for e in embeddings], + documents=[c["content"] for c in chunks], + metadatas=[c["metadata"] for c in chunks] + ) + print(f"Indexed {len(chunks)} chunks to text collection.") + + def query_text( + self, + query_embedding: np.ndarray, + n_results: int = 10, + filter_category: Optional[str] = None + ) -> List[Dict]: + """Query text collection.""" + where_filter = {"category": filter_category} if filter_category else None + + results = self.text_collection.query( + query_embeddings=[query_embedding.tolist()], + n_results=n_results, + where=where_filter, + include=["documents", "metadatas", "distances"] + ) + + return self._format_results(results) + + def query_by_metadata( + self, + metadata_filter: Dict, + n_results: int = 10 + ) -> List[Dict]: + """Query by metadata only (e.g., get all threshold chunks).""" + results = self.text_collection.query( + query_embeddings=None, + n_results=n_results, + where=metadata_filter, + include=["documents", "metadatas"] + ) + return self._format_results(results) + + def _format_results(self, results: Dict) -> List[Dict]: + """Format ChromaDB results for pipeline consumption.""" + formatted = [] + for i in range(len(results["ids"][0])): + formatted.append({ + "id": results["ids"][0][i], + "content": results["documents"][0][i], + "metadata": results["metadatas"][0][i], + "distance": results.get("distances", [[]])[0][i] if results.get("distances") else None + }) + return formatted +``` + +### RAG Query Types + +```python +# rag/retriever.py + +from typing import List, Dict, Tuple +from .vectorstore import FDAMVectorStore + +class FDAMRetriever: + """Retrieval strategies for different query types.""" + + def __init__(self, vectorstore: FDAMVectorStore, model_stack): + self.vectorstore = vectorstore + self.embedding_model = model_stack.models["embedding"] + self.embedding_processor = model_stack.processors["embedding"] + self.reranker = model_stack.models["reranker"] + self.reranker_processor = model_stack.processors["reranker"] + + def retrieve_disposition( + self, + material: str, + zone: str, + condition: str + ) -> Dict: + """ + Retrieve disposition for a specific material/zone/condition combination. + + Example: retrieve_disposition("steel", "near-field", "moderate") + Returns: {"disposition": "Clean", "protocol": "Aggressive protocol, multiple passes"} + """ + query = f"disposition {material} {zone} {condition}" + + # Embed query + embedding = self._embed_text(query) + + # Retrieve candidates + candidates = self.vectorstore.query_text( + query_embedding=embedding, + n_results=10, + filter_category="methodology" + ) + + # Rerank for precision + reranked = self._rerank(query, candidates, top_k=3) + + return reranked[0] if reranked else None + + def retrieve_threshold( + self, + analyte: str, + facility_class: str, + surface_type: str = None + ) -> Dict: + """ + Retrieve threshold for specific analyte and facility classification. + + Example: retrieve_threshold("lead", "non-operational") + Returns: {"threshold": 22, "unit": "µg/100cm²", "source": "BNL SOP IH75190"} + """ + query = f"threshold {analyte} {facility_class}" + if surface_type: + query += f" {surface_type}" + + embedding = self._embed_text(query) + + candidates = self.vectorstore.query_text( + query_embedding=embedding, + n_results=10, + filter_category="standards" + ) + + reranked = self._rerank(query, candidates, top_k=3) + return reranked[0] if reranked else None + + def retrieve_cleaning_method( + self, + surface_type: str + ) -> Dict: + """ + Retrieve cleaning method for surface type. + + Example: retrieve_cleaning_method("steel roof deck") + Returns: {"method": "HEPA vac → Wet wipe → Rinse", "sequence": [...]} + """ + query = f"cleaning method {surface_type}" + + embedding = self._embed_text(query) + + candidates = self.vectorstore.query_text( + query_embedding=embedding, + n_results=10, + filter_category="methodology" + ) + + reranked = self._rerank(query, candidates, top_k=3) + return reranked[0] if reranked else None + + def retrieve_regulatory_justification( + self, + facility_class: str + ) -> str: + """ + Retrieve regulatory justification block for facility classification. + + Example: retrieve_regulatory_justification("non-operational") + Returns: Full justification text block per FDAM §3.3 + """ + query = f"regulatory justification {facility_class}" + + embedding = self._embed_text(query) + + candidates = self.vectorstore.query_text( + query_embedding=embedding, + n_results=5, + filter_category="methodology" + ) + + # Return first match content directly + if candidates: + return candidates[0]["content"] + return "" + + def retrieve_sample_density( + self, + area_sf: float + ) -> Dict: + """ + Retrieve sample density guidelines for area size. + + Example: retrieve_sample_density(15000) + Returns: {"tape_lifts": "5-10 per surface type", "surface_wipes": "5-10 per surface type"} + """ + # Determine size category + if area_sf < 5000: + size_cat = "small under 5000" + elif area_sf < 25000: + size_cat = "medium 5000 to 25000" + elif area_sf < 100000: + size_cat = "large 25000 to 100000" + else: + size_cat = "very large over 100000" + + query = f"sample density {size_cat}" + + embedding = self._embed_text(query) + + candidates = self.vectorstore.query_text( + query_embedding=embedding, + n_results=5, + filter_category="methodology" + ) + + return candidates[0] if candidates else None + + def _embed_text(self, text: str) -> np.ndarray: + """Generate embedding for text query.""" + inputs = self.embedding_processor( + text=text, + return_tensors="pt" + ).to(self.embedding_model.device) + + with torch.no_grad(): + outputs = self.embedding_model(**inputs) + embedding = outputs.last_hidden_state.mean(dim=1).cpu().numpy()[0] + + return embedding + + def _rerank( + self, + query: str, + candidates: List[Dict], + top_k: int = 5 + ) -> List[Dict]: + """Rerank candidates for precision.""" + if not candidates: + return [] + + # Score each candidate + scores = [] + for candidate in candidates: + inputs = self.reranker_processor( + text=query, + text_pair=candidate["content"], + return_tensors="pt" + ).to(self.reranker.device) + + with torch.no_grad(): + outputs = self.reranker(**inputs) + score = outputs.logits[0].item() + + scores.append(score) + + # Sort by score descending + ranked_indices = np.argsort(scores)[::-1][:top_k] + + return [candidates[i] for i in ranked_indices] +``` + +--- + +## 5. Input Schema + +### Project Input + +```python +# schemas/input.py + +from pydantic import BaseModel, Field, validator +from typing import List, Optional, Literal +from datetime import date +from enum import Enum + +class FacilityClassification(str, Enum): + OPERATIONAL = "operational" + NON_OPERATIONAL = "non-operational" + PUBLIC_CHILDCARE = "public-childcare" + +class ConstructionEra(str, Enum): + PRE_1980 = "pre-1980" + ERA_1980_2000 = "1980-2000" + POST_2000 = "post-2000" + +class ZoneType(str, Enum): + BURN = "burn" + NEAR_FIELD = "near-field" + FAR_FIELD = "far-field" + +class ConditionLevel(str, Enum): + BACKGROUND = "background" + LIGHT = "light" + MODERATE = "moderate" + HEAVY = "heavy" + STRUCTURAL_DAMAGE = "structural-damage" + +class MaterialCategory(str, Enum): + # Non-porous + STEEL = "steel" + CONCRETE = "concrete" + GLASS = "glass" + METAL = "metal" + CMU = "cmu" + # Semi-porous + DRYWALL_PAINTED = "drywall-painted" + DRYWALL_UNPAINTED = "drywall-unpainted" + WOOD_SEALED = "wood-sealed" + WOOD_UNSEALED = "wood-unsealed" + # Porous + CARPET = "carpet" + CARPET_PAD = "carpet-pad" + INSULATION_FIBERGLASS = "insulation-fiberglass" + INSULATION_OTHER = "insulation-other" + ACOUSTIC_TILE = "acoustic-tile" + UPHOLSTERY = "upholstery" + # HVAC + DUCTWORK_RIGID = "ductwork-rigid" + DUCTWORK_FLEXIBLE = "ductwork-flexible" + HVAC_INTERIOR_INSULATION = "hvac-interior-insulation" + +class Disposition(str, Enum): + NO_ACTION = "no-action" + CLEAN = "clean" + EVALUATE = "evaluate" + REMOVE = "remove" + REMOVE_REPAIR = "remove-repair" + +# --- Project Level --- + +class ProjectInfo(BaseModel): + """Project-level information.""" + project_name: str = Field(..., description="Project or facility name") + address: str = Field(..., description="Full street address") + city: str + state: str + zip_code: str + + client_name: str + client_contact: Optional[str] = None + client_email: Optional[str] = None + client_phone: Optional[str] = None + + fire_date: date = Field(..., description="Date of fire incident") + assessment_date: date = Field(..., description="Date of assessment") + + facility_classification: FacilityClassification + construction_era: ConstructionEra + + assessor_name: str = Field(..., description="Industrial hygienist name") + assessor_credentials: Optional[str] = Field(None, description="CIH, CSP, etc.") + +# --- Room/Area Level --- + +class Dimensions(BaseModel): + """Room dimensions for calculations.""" + length_ft: float = Field(..., gt=0, le=10000) + width_ft: float = Field(..., gt=0, le=10000) + ceiling_height_ft: float = Field(..., gt=0, le=500) + + @property + def area_sf(self) -> float: + return self.length_ft * self.width_ft + + @property + def volume_cf(self) -> float: + return self.area_sf * self.ceiling_height_ft + +class Surface(BaseModel): + """Individual surface within a room.""" + id: str = Field(..., description="Unique surface identifier") + material: MaterialCategory + description: str = Field(..., description="e.g., 'North wall drywall'") + area_sf: float = Field(..., gt=0) + + zone: Optional[ZoneType] = None # Can be set by AI or user + condition: Optional[ConditionLevel] = None # Can be set by AI or user + disposition: Optional[Disposition] = None # Calculated by system + + ai_detected: bool = Field(False, description="Was this detected by AI from images?") + confidence: Optional[float] = Field(None, ge=0, le=1) + +class Room(BaseModel): + """Room or area within the building.""" + id: str = Field(..., description="Unique room identifier") + name: str = Field(..., description="e.g., 'Warehouse Bay A'") + floor: Optional[str] = Field(None, description="e.g., 'Ground Floor'") + + dimensions: Dimensions + + zone_classification: Optional[ZoneType] = None # AI-determined or user override + zone_confidence: Optional[float] = Field(None, ge=0, le=1) + zone_user_override: bool = Field(False) + + surfaces: List[Surface] = Field(default_factory=list) + image_ids: List[str] = Field(default_factory=list, description="Associated image IDs") + +# --- Image Level --- + +class ImageMetadata(BaseModel): + """Metadata for uploaded image.""" + id: str + filename: str + room_id: str = Field(..., description="Associated room ID") + description: Optional[str] = Field(None, description="User description of image") + + # AI-populated fields + detected_materials: List[MaterialCategory] = Field(default_factory=list) + detected_zone: Optional[ZoneType] = None + zone_confidence: Optional[float] = None + detected_condition: Optional[ConditionLevel] = None + condition_confidence: Optional[float] = None + + # Bounding box annotations (for UI overlay) + annotations: List[dict] = Field(default_factory=list) + + analysis_complete: bool = Field(False) + +# --- Qualitative Observations --- + +class QualitativeObservations(BaseModel): + """Qualitative observation checklist per FDAM §2.3.""" + smoke_fire_odor: bool = Field(..., description="Smoke/fire odor present?") + odor_intensity: Optional[Literal["none", "faint", "moderate", "strong"]] = None + + visible_soot_deposits: bool = Field(..., description="Visible soot deposits?") + soot_pattern_description: Optional[str] = None + + large_char_particles: bool = Field(..., description="Large char particles observed?") + char_density_estimate: Optional[Literal["sparse", "moderate", "dense"]] = None + + ash_like_residue: bool = Field(..., description="Ash-like residue present?") + ash_color_texture: Optional[str] = None + + surface_discoloration: bool = Field(..., description="Surface discoloration?") + discoloration_description: Optional[str] = None + + dust_loading_interference: bool = Field(..., description="Dust loading or interference?") + dust_notes: Optional[str] = None + + wildfire_indicators: bool = Field(..., description="Burned soil/pollen/vegetation indicators?") + wildfire_notes: Optional[str] = None + + additional_notes: Optional[str] = None + +# --- Complete Assessment Input --- + +class AssessmentInput(BaseModel): + """Complete input for FDAM AI assessment.""" + project: ProjectInfo + rooms: List[Room] = Field(..., min_items=1) + images: List[ImageMetadata] = Field(default_factory=list, max_items=20) + observations: QualitativeObservations + + @validator('rooms') + def validate_room_ids(cls, rooms): + ids = [r.id for r in rooms] + if len(ids) != len(set(ids)): + raise ValueError("Room IDs must be unique") + return rooms + + @validator('images') + def validate_image_rooms(cls, images, values): + if 'rooms' not in values: + return images + room_ids = {r.id for r in values['rooms']} + for img in images: + if img.room_id not in room_ids: + raise ValueError(f"Image {img.id} references unknown room {img.room_id}") + return images +``` + +--- + +## 6. Processing Pipeline + +### Main Pipeline + +```python +# pipeline/main.py + +from typing import Dict, List, Tuple +from schemas.input import AssessmentInput, Room, Surface +from schemas.output import AssessmentOutput, CleaningSpecification +from models.loader import model_stack +from rag.retriever import FDAMRetriever +from .vision import VisionAnalyzer +from .calculations import FDAMCalculator +from .generator import DocumentGenerator + +class FDAMPipeline: + """Main processing pipeline for FDAM assessments.""" + + def __init__(self): + self.vision = VisionAnalyzer(model_stack) + self.retriever = FDAMRetriever(vectorstore, model_stack) + self.calculator = FDAMCalculator() + self.generator = DocumentGenerator(model_stack, self.retriever) + + async def process( + self, + input_data: AssessmentInput, + images: Dict[str, bytes] # image_id -> image bytes + ) -> AssessmentOutput: + """ + Process complete FDAM assessment. + + Pipeline stages: + 1. Input validation (already done by Pydantic) + 2. Vision analysis (per image) + 3. RAG context retrieval + 4. FDAM logic application + 5. Calculations + 6. Document generation + """ + + # Stage 2: Vision Analysis + print("Stage 2: Analyzing images...") + vision_results = await self._analyze_images(input_data, images) + + # Update input with vision detections + input_data = self._merge_vision_results(input_data, vision_results) + + # Stage 3: RAG Context Retrieval + print("Stage 3: Retrieving methodology context...") + rag_context = self._retrieve_context(input_data) + + # Stage 4: Apply FDAM Logic + print("Stage 4: Applying FDAM disposition logic...") + input_data = self._apply_fdam_logic(input_data, rag_context) + + # Stage 5: Calculations + print("Stage 5: Running calculations...") + calculations = self.calculator.compute_all(input_data) + + # Stage 6: Document Generation + print("Stage 6: Generating documents...") + documents = await self.generator.generate(input_data, rag_context, calculations) + + # Build output + output = AssessmentOutput( + input=input_data, + vision_results=vision_results, + calculations=calculations, + documents=documents, + confidence_report=self._build_confidence_report(input_data, vision_results) + ) + + return output + + async def _analyze_images( + self, + input_data: AssessmentInput, + images: Dict[str, bytes] + ) -> Dict[str, dict]: + """Run vision analysis on all images.""" + results = {} + + for img_meta in input_data.images: + if img_meta.id not in images: + continue + + image_bytes = images[img_meta.id] + room = next((r for r in input_data.rooms if r.id == img_meta.room_id), None) + + result = await self.vision.analyze_image( + image_bytes=image_bytes, + room_context=room, + observations=input_data.observations + ) + + results[img_meta.id] = result + + return results + + def _merge_vision_results( + self, + input_data: AssessmentInput, + vision_results: Dict[str, dict] + ) -> AssessmentInput: + """Merge vision detections into input data.""" + + for img_id, result in vision_results.items(): + # Update image metadata + for img in input_data.images: + if img.id == img_id: + img.detected_materials = result.get("materials", []) + img.detected_zone = result.get("zone") + img.zone_confidence = result.get("zone_confidence") + img.detected_condition = result.get("condition") + img.condition_confidence = result.get("condition_confidence") + img.annotations = result.get("annotations", []) + img.analysis_complete = True + break + + # Add detected surfaces to room if not already present + room_id = next((img.room_id for img in input_data.images if img.id == img_id), None) + if room_id: + room = next((r for r in input_data.rooms if r.id == room_id), None) + if room: + # Update room zone if higher confidence + if result.get("zone_confidence", 0) > (room.zone_confidence or 0): + if not room.zone_user_override: + room.zone_classification = result.get("zone") + room.zone_confidence = result.get("zone_confidence") + + # Add AI-detected surfaces + for detected_surface in result.get("detected_surfaces", []): + # Check if similar surface already exists + existing = self._find_similar_surface(room.surfaces, detected_surface) + if not existing: + room.surfaces.append(Surface( + id=f"ai_{img_id}_{detected_surface['material']}", + material=detected_surface["material"], + description=detected_surface.get("description", "AI-detected"), + area_sf=detected_surface.get("area_sf", 0), # Needs user input + zone=result.get("zone"), + condition=result.get("condition"), + ai_detected=True, + confidence=detected_surface.get("confidence") + )) + + return input_data + + def _retrieve_context(self, input_data: AssessmentInput) -> Dict: + """Retrieve all necessary RAG context.""" + + context = { + "regulatory_justification": self.retriever.retrieve_regulatory_justification( + input_data.project.facility_classification.value + ), + "thresholds": {}, + "methods": {}, + "sample_density": None + } + + # Get thresholds for facility class + for analyte in ["lead", "cadmium", "arsenic", "ash_char", "aciniform_soot"]: + context["thresholds"][analyte] = self.retriever.retrieve_threshold( + analyte, + input_data.project.facility_classification.value + ) + + # Get cleaning methods for each material type + materials = set() + for room in input_data.rooms: + for surface in room.surfaces: + materials.add(surface.material.value) + + for material in materials: + context["methods"][material] = self.retriever.retrieve_cleaning_method(material) + + # Get sample density for total area + total_sf = sum(room.dimensions.area_sf for room in input_data.rooms) + context["sample_density"] = self.retriever.retrieve_sample_density(total_sf) + + return context + + def _apply_fdam_logic( + self, + input_data: AssessmentInput, + rag_context: Dict + ) -> AssessmentInput: + """Apply FDAM disposition matrix logic.""" + + for room in input_data.rooms: + for surface in room.surfaces: + if surface.disposition is not None: + continue # Already set by user + + # Determine zone (room or surface level) + zone = surface.zone or room.zone_classification + + # Determine condition + condition = surface.condition or ConditionLevel.LIGHT # Default + + # Look up disposition + disposition_info = self.retriever.retrieve_disposition( + material=surface.material.value, + zone=zone.value if zone else "far-field", + condition=condition.value + ) + + if disposition_info: + surface.disposition = self._parse_disposition(disposition_info) + else: + # Default conservative disposition + surface.disposition = Disposition.CLEAN + + return input_data + + def _parse_disposition(self, info: Dict) -> Disposition: + """Parse disposition from RAG result.""" + content = info.get("content", "").lower() + + if "remove" in content and "repair" in content: + return Disposition.REMOVE_REPAIR + elif "remove" in content: + return Disposition.REMOVE + elif "evaluate" in content: + return Disposition.EVALUATE + elif "clean" in content: + return Disposition.CLEAN + elif "no action" in content or "document only" in content: + return Disposition.NO_ACTION + else: + return Disposition.CLEAN # Conservative default + + def _find_similar_surface(self, surfaces: List[Surface], detected: Dict) -> Surface: + """Find existing surface similar to detected one.""" + for s in surfaces: + if s.material.value == detected.get("material"): + return s + return None + + def _build_confidence_report( + self, + input_data: AssessmentInput, + vision_results: Dict[str, dict] + ) -> Dict: + """Build confidence report for flagged items.""" + + flagged_items = [] + + for room in input_data.rooms: + # Flag low confidence zone classifications + if room.zone_confidence and room.zone_confidence < 0.7: + flagged_items.append({ + "type": "zone_classification", + "room": room.name, + "confidence": room.zone_confidence, + "recommendation": "Professional review recommended for zone classification" + }) + + for surface in room.surfaces: + # Flag AI-detected surfaces with low confidence + if surface.ai_detected and surface.confidence and surface.confidence < 0.7: + flagged_items.append({ + "type": "material_detection", + "room": room.name, + "surface": surface.description, + "confidence": surface.confidence, + "recommendation": "Verify material identification on-site" + }) + + return { + "flagged_items": flagged_items, + "overall_confidence": self._calculate_overall_confidence(input_data, vision_results), + "review_required": len(flagged_items) > 0 + } + + def _calculate_overall_confidence( + self, + input_data: AssessmentInput, + vision_results: Dict[str, dict] + ) -> float: + """Calculate overall assessment confidence.""" + confidences = [] + + for room in input_data.rooms: + if room.zone_confidence: + confidences.append(room.zone_confidence) + for surface in room.surfaces: + if surface.confidence: + confidences.append(surface.confidence) + + if not confidences: + return 0.5 # No confidence data + + return sum(confidences) / len(confidences) +``` + +--- + +## 7. Vision Analysis Module + +### System Prompt + +```python +# pipeline/vision.py + +VISION_SYSTEM_PROMPT = """You are an expert industrial hygienist analyzing fire damage images for the FDAM (Fire Damage Assessment Methodology) framework. + +## Your Task +Analyze the provided image and extract structured information about fire damage, materials, and conditions. + +## Zone Classification Criteria +- **Burn Zone**: Direct fire involvement. Look for structural char, complete combustion, exposed/damaged structural elements. +- **Near-Field**: Adjacent to burn zone with heavy smoke/heat exposure. Look for heavy soot deposits, heat damage (warping, discoloration), strong visible contamination. +- **Far-Field**: Smoke migration without direct heat exposure. Look for light to moderate deposits, discoloration, no structural damage. + +## Condition Assessment Criteria +- **Background**: No visible contamination; surfaces appear normal/clean. +- **Light**: Faint discoloration; minimal visible deposits; would show faint marks on white wipe test. +- **Moderate**: Visible film or deposits; clear contamination; surface color noticeably altered. +- **Heavy**: Thick deposits; surface texture obscured; heavy coating visible. +- **Structural Damage**: Physical damage requiring repair before cleaning (charring, warping, holes, collapse). + +## Material Identification +Identify visible materials and categorize as: +- **Non-porous**: steel, concrete, glass, metal, CMU (concrete masonry unit) +- **Semi-porous**: painted drywall, sealed wood +- **Porous**: unpainted drywall, carpet, insulation, acoustic tile, upholstery +- **HVAC**: rigid ductwork, flexible ductwork + +## Combustion Particle Visual Indicators +- **Soot**: Black/dark gray coating with oily/sticky appearance; fine uniform texture; often creates "shadow" patterns +- **Char**: Black angular fragments; visible wood grain or fibrous structure; larger particles +- **Ash**: Gray/white powdery residue; crystalline appearance; often found with char + +## Output Format +Respond with a JSON object containing your analysis. Include confidence scores (0.0-1.0) for each determination. + +## Important Notes +- This is VISUAL assessment only - definitive particle identification requires laboratory analysis +- When uncertain between two classifications, note both with relative confidence +- Flag any areas that require professional on-site verification +- Note any potential access issues visible in the image +""" + +VISION_OUTPUT_SCHEMA = { + "type": "object", + "properties": { + "zone": { + "type": "object", + "properties": { + "classification": {"type": "string", "enum": ["burn", "near-field", "far-field"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "reasoning": {"type": "string"} + }, + "required": ["classification", "confidence", "reasoning"] + }, + "condition": { + "type": "object", + "properties": { + "level": {"type": "string", "enum": ["background", "light", "moderate", "heavy", "structural-damage"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "reasoning": {"type": "string"} + }, + "required": ["level", "confidence", "reasoning"] + }, + "materials": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "category": {"type": "string", "enum": ["non-porous", "semi-porous", "porous", "hvac"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "location_description": {"type": "string"}, + "bounding_box": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "width": {"type": "number"}, + "height": {"type": "number"} + } + } + }, + "required": ["type", "category", "confidence"] + } + }, + "combustion_indicators": { + "type": "object", + "properties": { + "soot_visible": {"type": "boolean"}, + "soot_pattern": {"type": "string"}, + "char_visible": {"type": "boolean"}, + "char_description": {"type": "string"}, + "ash_visible": {"type": "boolean"}, + "ash_description": {"type": "string"} + } + }, + "structural_concerns": { + "type": "array", + "items": {"type": "string"} + }, + "access_issues": { + "type": "array", + "items": {"type": "string"} + }, + "recommended_sampling_locations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "description": {"type": "string"}, + "sample_type": {"type": "string", "enum": ["tape_lift", "surface_wipe", "both"]}, + "priority": {"type": "string", "enum": ["high", "medium", "low"]} + } + } + }, + "flags_for_review": { + "type": "array", + "items": {"type": "string"} + } + }, + "required": ["zone", "condition", "materials", "combustion_indicators"] +} +``` + +### Vision Analyzer Implementation + +```python +# pipeline/vision.py (continued) + +import json +import base64 +from PIL import Image +import io +import torch + +class VisionAnalyzer: + """Analyzes fire damage images using Qwen3-VL.""" + + def __init__(self, model_stack): + self.model = model_stack.models["vision"] + self.processor = model_stack.processors["vision"] + + async def analyze_image( + self, + image_bytes: bytes, + room_context: Room = None, + observations: QualitativeObservations = None + ) -> dict: + """ + Analyze a single fire damage image. + + Returns structured analysis per FDAM methodology. + """ + + # Build context prompt + context_parts = [] + + if room_context: + context_parts.append(f"Room: {room_context.name}") + context_parts.append(f"Dimensions: {room_context.dimensions.length_ft}' x {room_context.dimensions.width_ft}' x {room_context.dimensions.ceiling_height_ft}' ceiling") + if room_context.zone_classification: + context_parts.append(f"Pre-assigned zone: {room_context.zone_classification.value} (user-provided)") + + if observations: + obs_parts = [] + if observations.smoke_fire_odor: + obs_parts.append(f"Smoke odor: {observations.odor_intensity or 'present'}") + if observations.visible_soot_deposits: + obs_parts.append("Visible soot deposits reported") + if observations.large_char_particles: + obs_parts.append("Large char particles observed") + if observations.ash_like_residue: + obs_parts.append("Ash-like residue present") + if observations.wildfire_indicators: + obs_parts.append("Wildfire indicators noted") + if obs_parts: + context_parts.append("Field observations: " + "; ".join(obs_parts)) + + context_text = "\n".join(context_parts) if context_parts else "No additional context provided." + + # Prepare image + image = Image.open(io.BytesIO(image_bytes)) + + # Build prompt + user_prompt = f"""Analyze this fire damage image and provide a structured assessment. + +## Context +{context_text} + +## Instructions +1. Classify the zone (burn/near-field/far-field) based on visible damage +2. Assess the condition level (background/light/moderate/heavy/structural-damage) +3. Identify all visible materials and their categories +4. Note any combustion indicators (soot patterns, char, ash) +5. Flag any structural concerns or access issues +6. Recommend sampling locations for laboratory analysis + +Respond with a JSON object following the specified schema. Include confidence scores for each determination.""" + + # Process with vision model + messages = [ + {"role": "system", "content": VISION_SYSTEM_PROMPT}, + {"role": "user", "content": [ + {"type": "image", "image": image}, + {"type": "text", "text": user_prompt} + ]} + ] + + inputs = self.processor( + text=self.processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True), + images=[image], + return_tensors="pt" + ).to(self.model.device) + + with torch.no_grad(): + outputs = self.model.generate( + **inputs, + max_new_tokens=4096, + temperature=0.1, + top_p=0.9, + do_sample=True + ) + + response_text = self.processor.decode(outputs[0], skip_special_tokens=True) + + # Extract JSON from response + result = self._parse_json_response(response_text) + + # Add bounding box annotations for UI + result["annotations"] = self._build_annotations(result, image.size) + + return result + + def _parse_json_response(self, response: str) -> dict: + """Extract and parse JSON from model response.""" + # Find JSON block + try: + # Try to find JSON in code block + if "```json" in response: + start = response.find("```json") + 7 + end = response.find("```", start) + json_str = response[start:end].strip() + elif "```" in response: + start = response.find("```") + 3 + end = response.find("```", start) + json_str = response[start:end].strip() + else: + # Try to find raw JSON object + start = response.find("{") + end = response.rfind("}") + 1 + json_str = response[start:end] + + return json.loads(json_str) + except (json.JSONDecodeError, ValueError) as e: + # Return default structure on parse failure + return { + "zone": {"classification": "far-field", "confidence": 0.5, "reasoning": "Parse error - defaulting to far-field"}, + "condition": {"level": "moderate", "confidence": 0.5, "reasoning": "Parse error - defaulting to moderate"}, + "materials": [], + "combustion_indicators": {"soot_visible": False, "char_visible": False, "ash_visible": False}, + "flags_for_review": ["Model response parsing failed - manual review required"] + } + + def _build_annotations(self, result: dict, image_size: tuple) -> list: + """Build bounding box annotations for UI overlay.""" + annotations = [] + width, height = image_size + + for material in result.get("materials", []): + if "bounding_box" in material: + bbox = material["bounding_box"] + annotations.append({ + "type": "material", + "label": f"{material['type']} ({material['category']})", + "confidence": material.get("confidence", 0), + "bbox": { + "x": int(bbox["x"] * width), + "y": int(bbox["y"] * height), + "width": int(bbox["width"] * width), + "height": int(bbox["height"] * height) + }, + "color": self._get_category_color(material["category"]) + }) + + return annotations + + def _get_category_color(self, category: str) -> str: + """Get annotation color by material category.""" + colors = { + "non-porous": "#00FF00", # Green - cleanable + "semi-porous": "#FFFF00", # Yellow - evaluate + "porous": "#FF0000", # Red - likely remove + "hvac": "#00FFFF" # Cyan - special handling + } + return colors.get(category, "#FFFFFF") +``` + +--- + +## 8. Calculation Engine + +```python +# pipeline/calculations.py + +from typing import Dict, List +from schemas.input import AssessmentInput, Room, Surface, Disposition +import math + +class FDAMCalculator: + """FDAM-aligned calculation engine.""" + + # Production rates (SF per labor hour) + PRODUCTION_RATES = { + "hepa_vacuum": 500, # SF/hr + "wet_wipe": 200, # SF/hr + "dry_sponge": 150, # SF/hr + "power_wash": 300, # SF/hr + "scrubber": 1000, # SF/hr + "removal_drywall": 100, # SF/hr + "removal_insulation": 150, # SF/hr + "removal_carpet": 200, # SF/hr + } + + # Air scrubber specs + AIR_SCRUBBER_CFM = 2000 # Standard unit + REQUIRED_ACH = 4 # Per NADCA ACR 2021 + + def compute_all(self, input_data: AssessmentInput) -> Dict: + """Compute all FDAM calculations.""" + + return { + "surface_areas": self.compute_surface_areas(input_data), + "air_filtration": self.compute_air_filtration(input_data), + "sample_density": self.compute_sample_density(input_data), + "labor_estimate": self.compute_labor_estimate(input_data), + "equipment": self.compute_equipment_requirements(input_data), + "regulatory_flags": self.compute_regulatory_flags(input_data) + } + + def compute_surface_areas(self, input_data: AssessmentInput) -> Dict: + """Aggregate surface areas by type and disposition.""" + + by_type = {} + by_disposition = {} + by_zone = {} + by_room = {} + + for room in input_data.rooms: + room_total = 0 + + for surface in room.surfaces: + # By material type + mat_key = surface.material.value + if mat_key not in by_type: + by_type[mat_key] = 0 + by_type[mat_key] += surface.area_sf + + # By disposition + disp_key = surface.disposition.value if surface.disposition else "undetermined" + if disp_key not in by_disposition: + by_disposition[disp_key] = 0 + by_disposition[disp_key] += surface.area_sf + + # By zone + zone_key = (surface.zone or room.zone_classification or "undetermined") + if hasattr(zone_key, 'value'): + zone_key = zone_key.value + if zone_key not in by_zone: + by_zone[zone_key] = 0 + by_zone[zone_key] += surface.area_sf + + room_total += surface.area_sf + + by_room[room.name] = { + "floor_area": room.dimensions.area_sf, + "surface_area": room_total, + "volume": room.dimensions.volume_cf + } + + return { + "by_type": by_type, + "by_disposition": by_disposition, + "by_zone": by_zone, + "by_room": by_room, + "total_floor_sf": sum(r.dimensions.area_sf for r in input_data.rooms), + "total_surface_sf": sum(by_type.values()), + "total_volume_cf": sum(r.dimensions.volume_cf for r in input_data.rooms) + } + + def compute_air_filtration(self, input_data: AssessmentInput) -> Dict: + """Calculate air scrubber requirements per NADCA ACR 2021.""" + + total_volume = sum(r.dimensions.volume_cf for r in input_data.rooms) + + # Formula: Units = (Volume × ACH) / (CFM × 60) + units_required = (total_volume * self.REQUIRED_ACH) / (self.AIR_SCRUBBER_CFM * 60) + units_required = math.ceil(units_required) # Round up + + return { + "total_volume_cf": total_volume, + "required_ach": self.REQUIRED_ACH, + "unit_cfm": self.AIR_SCRUBBER_CFM, + "units_required": units_required, + "calculation": f"({total_volume:,.0f} CF × {self.REQUIRED_ACH} ACH) / ({self.AIR_SCRUBBER_CFM} CFM × 60) = {units_required} units", + "standard_reference": "NADCA ACR 2021, Section 3.6" + } + + def compute_sample_density(self, input_data: AssessmentInput) -> Dict: + """Compute sampling recommendations per FDAM §2.3.""" + + total_sf = sum(r.dimensions.area_sf for r in input_data.rooms) + + # Determine size category and recommendations + if total_sf < 5000: + tape_range = "3-5" + wipe_range = "3-5" + size_cat = "< 5,000 SF" + elif total_sf < 25000: + tape_range = "5-10" + wipe_range = "5-10" + size_cat = "5,000 - 25,000 SF" + elif total_sf < 100000: + tape_range = "10-20" + wipe_range = "10-15" + size_cat = "25,000 - 100,000 SF" + else: + tape_range = "20+" + wipe_range = "15-25" + size_cat = "> 100,000 SF" + + # Count unique surface types + surface_types = set() + has_ceiling_deck = False + + for room in input_data.rooms: + for surface in room.surfaces: + surface_types.add(surface.material.value) + if "ceiling" in surface.material.value.lower() or "deck" in surface.description.lower(): + has_ceiling_deck = True + + # Calculate recommended counts + tape_min, tape_max = map(int, tape_range.replace("+", "").split("-") if "-" in tape_range else [int(tape_range.replace("+", "")), int(tape_range.replace("+", "")) + 5]) + wipe_min, wipe_max = map(int, wipe_range.replace("+", "").split("-") if "-" in wipe_range else [int(wipe_range.replace("+", "")), int(wipe_range.replace("+", "")) + 5]) + + recommended_tape_lifts = tape_max * len(surface_types) + recommended_surface_wipes = wipe_max * len(surface_types) + + # Ceiling deck enhancement per FDAM §4.5 + ceiling_deck_note = None + if has_ceiling_deck: + ceiling_deck_samples = math.ceil(total_sf / 2500) # 1 per 2,500 SF + ceiling_deck_note = f"Ceiling deck surfaces require enhanced sampling: minimum {ceiling_deck_samples} samples (1 per 2,500 SF per FDAM §4.5)" + + return { + "total_sf": total_sf, + "size_category": size_cat, + "surface_types_count": len(surface_types), + "surface_types": list(surface_types), + "tape_lifts_per_type": tape_range, + "surface_wipes_per_type": wipe_range, + "recommended_tape_lifts": recommended_tape_lifts, + "recommended_surface_wipes": recommended_surface_wipes, + "ceiling_deck_note": ceiling_deck_note, + "control_samples_recommended": True, + "control_sample_note": "Control samples from unaffected areas recommended for baseline comparison" + } + + def compute_labor_estimate(self, input_data: AssessmentInput) -> Dict: + """Estimate labor hours by task.""" + + labor = { + "hepa_vacuum": 0, + "wet_wipe": 0, + "dry_sponge": 0, + "power_wash": 0, + "scrubber": 0, + "removal": 0, + "hvac_cleaning": 0 + } + + for room in input_data.rooms: + for surface in room.surfaces: + if surface.disposition == Disposition.NO_ACTION: + continue + + area = surface.area_sf + mat = surface.material.value + + if surface.disposition == Disposition.REMOVE or surface.disposition == Disposition.REMOVE_REPAIR: + if "insulation" in mat: + labor["removal"] += area / self.PRODUCTION_RATES["removal_insulation"] + elif "carpet" in mat: + labor["removal"] += area / self.PRODUCTION_RATES["removal_carpet"] + elif "drywall" in mat: + labor["removal"] += area / self.PRODUCTION_RATES["removal_drywall"] + else: + labor["removal"] += area / self.PRODUCTION_RATES["removal_drywall"] # Default + + elif surface.disposition == Disposition.CLEAN: + # Determine cleaning method by material + if mat in ["steel", "metal", "glass"]: + labor["hepa_vacuum"] += area / self.PRODUCTION_RATES["hepa_vacuum"] + labor["wet_wipe"] += area / self.PRODUCTION_RATES["wet_wipe"] + elif mat in ["concrete"]: + labor["scrubber"] += area / self.PRODUCTION_RATES["scrubber"] + elif mat in ["cmu"]: + labor["hepa_vacuum"] += area / self.PRODUCTION_RATES["hepa_vacuum"] + labor["power_wash"] += area / self.PRODUCTION_RATES["power_wash"] + elif "ductwork" in mat: + labor["hvac_cleaning"] += area / 100 # Estimate + else: + labor["hepa_vacuum"] += area / self.PRODUCTION_RATES["hepa_vacuum"] + labor["wet_wipe"] += area / self.PRODUCTION_RATES["wet_wipe"] + + # Round up all values + labor = {k: math.ceil(v) for k, v in labor.items()} + + total_hours = sum(labor.values()) + + return { + "by_task": labor, + "total_hours": total_hours, + "crew_days_2_person": math.ceil(total_hours / 16), + "crew_days_4_person": math.ceil(total_hours / 32), + "note": "Estimates based on standard production rates. Adjust for site conditions, access constraints, and contamination severity." + } + + def compute_equipment_requirements(self, input_data: AssessmentInput) -> Dict: + """Compute equipment requirements.""" + + air_filt = self.compute_air_filtration(input_data) + surface_areas = self.compute_surface_areas(input_data) + + # Determine if lifts needed based on ceiling heights + max_ceiling = max(r.dimensions.ceiling_height_ft for r in input_data.rooms) + lift_type = None + if max_ceiling > 12: + lift_type = "scissor_lift" if max_ceiling <= 30 else "boom_lift" + + return { + "air_scrubbers": { + "quantity": air_filt["units_required"], + "cfm_each": self.AIR_SCRUBBER_CFM, + "filter_type": "HEPA" + }, + "hepa_vacuums": { + "quantity": max(2, math.ceil(surface_areas["total_surface_sf"] / 50000)), + "note": "Minimum 2 units for efficiency" + }, + "lift_equipment": { + "required": lift_type is not None, + "type": lift_type, + "max_ceiling_height": max_ceiling + }, + "ppe_sets": { + "quantity": 8, # Standard crew assumption + "includes": ["Tyvek suit", "N95/P100 respirator", "safety glasses", "gloves"] + }, + "cleaning_supplies": { + "alkaline_detergent_gallons": math.ceil(surface_areas["total_surface_sf"] / 500), + "degreaser_gallons": math.ceil(surface_areas["total_surface_sf"] / 2000), + "dry_sponges": math.ceil(surface_areas["total_surface_sf"] / 1000), + "microfiber_cloths": math.ceil(surface_areas["total_surface_sf"] / 200) + } + } + + def compute_regulatory_flags(self, input_data: AssessmentInput) -> Dict: + """Generate regulatory flags based on project characteristics.""" + + flags = [] + + # Construction era flags + era = input_data.project.construction_era.value + if era == "pre-1980": + flags.append({ + "type": "lbp", + "severity": "high", + "message": "Pre-1980 construction: Lead-based paint (LBP) presumed present. EPA RRP Rule compliance required.", + "reference": "40 CFR 745" + }) + flags.append({ + "type": "acm", + "severity": "high", + "message": "Pre-1980 construction: Asbestos-containing materials (ACM) presumed present. Survey recommended before disturbance.", + "reference": "40 CFR 61 Subpart M" + }) + elif era == "1980-2000": + flags.append({ + "type": "lbp", + "severity": "medium", + "message": "1980-2000 construction: LBP possible in some applications. Testing recommended for painted surfaces.", + "reference": "40 CFR 745" + }) + flags.append({ + "type": "acm", + "severity": "medium", + "message": "1980-2000 construction: ACM possible in specific applications (floor tile, roofing, insulation). Survey recommended.", + "reference": "40 CFR 61 Subpart M" + }) + + # Facility classification flags + if input_data.project.facility_classification.value == "public-childcare": + flags.append({ + "type": "childcare", + "severity": "high", + "message": "Public/Childcare facility: Enhanced lead clearance thresholds apply (0.54 µg/100cm² floors, 4.3 µg/100cm² window sills).", + "reference": "EPA/HUD October 2024" + }) + + # Observation-based flags + if input_data.observations.wildfire_indicators: + flags.append({ + "type": "wildfire", + "severity": "medium", + "message": "Wildfire indicators noted: Apply IICRC/RIA/CIRI Technical Guide zone framework. Consider outdoor air quality impacts.", + "reference": "IICRC/RIA/CIRI Technical Guide December 2025" + }) + + return { + "flags": flags, + "high_severity_count": len([f for f in flags if f["severity"] == "high"]), + "medium_severity_count": len([f for f in flags if f["severity"] == "medium"]) + } +``` + +--- + +## 9. Output Generation + +### Document Templates + +```python +# pipeline/generator.py + +from typing import Dict +from datetime import datetime + +class DocumentGenerator: + """Generates FDAM-aligned documents.""" + + def __init__(self, model_stack, retriever): + self.model = model_stack.models["vision"] # Same model for generation + self.processor = model_stack.processors["vision"] + self.retriever = retriever + + async def generate( + self, + input_data: AssessmentInput, + rag_context: Dict, + calculations: Dict + ) -> Dict: + """Generate all output documents.""" + + return { + "cleaning_specification": await self._generate_sow(input_data, rag_context, calculations), + "sampling_plan": self._generate_sampling_plan(input_data, calculations), + "confidence_summary": self._generate_confidence_summary(input_data) + } + + async def _generate_sow( + self, + input_data: AssessmentInput, + rag_context: Dict, + calculations: Dict + ) -> str: + """Generate Cleaning Specification / Scope of Work.""" + + # Build document sections + sections = [] + + # Header + sections.append(self._build_header(input_data)) + + # Scope Summary + sections.append(self._build_scope_summary(input_data, calculations)) + + # Zone Summary Table + sections.append(self._build_zone_summary_table(input_data, calculations)) + + # Surface Inventory + sections.append(self._build_surface_inventory(input_data)) + + # Regulatory Framework + sections.append(self._build_regulatory_framework(input_data, rag_context)) + + # Work Area Preparation + sections.append(self._build_work_preparation(calculations)) + + # Surface-Specific Procedures + sections.append(self._build_cleaning_procedures(input_data, rag_context)) + + # Removal Scope + sections.append(self._build_removal_scope(input_data, calculations)) + + # Labor Estimate + sections.append(self._build_labor_estimate(calculations)) + + # Equipment Requirements + sections.append(self._build_equipment_requirements(calculations)) + + # Sampling Plan Summary + sections.append(self._build_sampling_section(input_data, calculations)) + + # Acceptance Criteria + sections.append(self._build_acceptance_criteria(input_data, rag_context)) + + # Regulatory Flags + if calculations["regulatory_flags"]["flags"]: + sections.append(self._build_regulatory_flags(calculations)) + + # Confidence Notes + sections.append(self._build_confidence_notes(input_data)) + + return "\n\n---\n\n".join(sections) + + def _build_header(self, input_data: AssessmentInput) -> str: + """Build document header.""" + return f"""# Cleaning Specification / Scope of Work + +## Fire Damage Restoration + +**Project:** {input_data.project.project_name} +**Address:** {input_data.project.address}, {input_data.project.city}, {input_data.project.state} {input_data.project.zip_code} + +**Client:** {input_data.project.client_name} +**Fire Date:** {input_data.project.fire_date.strftime('%B %d, %Y')} +**Assessment Date:** {input_data.project.assessment_date.strftime('%B %d, %Y')} + +**Prepared By:** {input_data.project.assessor_name}{f', {input_data.project.assessor_credentials}' if input_data.project.assessor_credentials else ''} +**Generated:** {datetime.now().strftime('%B %d, %Y at %I:%M %p')} + +**Methodology:** FDAM v4.0.1 (Fire Damage Assessment Methodology) +**Facility Classification:** {input_data.project.facility_classification.value.replace('-', '/').title()} +""" + + def _build_scope_summary(self, input_data: AssessmentInput, calculations: Dict) -> str: + """Build scope summary section.""" + areas = calculations["surface_areas"] + + summary = f"""## Scope Summary + +{input_data.project.project_name} sustained fire damage on {input_data.project.fire_date.strftime('%B %d, %Y')}. Based on visual assessment and field observations, the following scope has been developed for fire residue restoration. + +### Project Metrics + +| Metric | Value | +|--------|-------| +| Total Floor Area | {areas['total_floor_sf']:,.0f} SF | +| Total Surface Area | {areas['total_surface_sf']:,.0f} SF | +| Total Volume | {areas['total_volume_cf']:,.0f} CF | +| Rooms/Areas Assessed | {len(input_data.rooms)} | +| Images Analyzed | {len(input_data.images)} | + +### Disposition Summary + +| Disposition | Area (SF) | +|-------------|-----------| +""" + for disp, area in areas["by_disposition"].items(): + summary += f"| {disp.replace('-', ' ').title()} | {area:,.0f} |\n" + + return summary + + def _build_zone_summary_table(self, input_data: AssessmentInput, calculations: Dict) -> str: + """Build zone summary table.""" + + table = """## Zone Classification Summary + +| Room/Area | Zone | Floor SF | Condition | Disposition Summary | +|-----------|------|----------|-----------|---------------------| +""" + for room in input_data.rooms: + zone = room.zone_classification.value if room.zone_classification else "TBD" + + # Summarize dispositions for room + disp_counts = {} + for s in room.surfaces: + d = s.disposition.value if s.disposition else "undetermined" + disp_counts[d] = disp_counts.get(d, 0) + 1 + disp_summary = ", ".join([f"{v} {k}" for k, v in disp_counts.items()]) + + # Get predominant condition + conditions = [s.condition.value for s in room.surfaces if s.condition] + condition = max(set(conditions), key=conditions.count) if conditions else "TBD" + + table += f"| {room.name} | {zone.title()} | {room.dimensions.area_sf:,.0f} | {condition.title()} | {disp_summary} |\n" + + return table + + def _build_surface_inventory(self, input_data: AssessmentInput) -> str: + """Build surface inventory section.""" + + inventory = """## Surface Inventory + +### By Material Type + +| Material | Category | Total SF | Disposition | +|----------|----------|----------|-------------| +""" + # Aggregate by material + by_material = {} + for room in input_data.rooms: + for surface in room.surfaces: + mat = surface.material.value + if mat not in by_material: + by_material[mat] = {"area": 0, "dispositions": set()} + by_material[mat]["area"] += surface.area_sf + if surface.disposition: + by_material[mat]["dispositions"].add(surface.disposition.value) + + for mat, data in sorted(by_material.items()): + category = self._get_material_category(mat) + disps = ", ".join(data["dispositions"]) if data["dispositions"] else "TBD" + inventory += f"| {mat.replace('-', ' ').title()} | {category} | {data['area']:,.0f} | {disps} |\n" + + # Detailed room breakdown + inventory += "\n### Detailed Inventory by Room\n" + + for room in input_data.rooms: + inventory += f"\n#### {room.name}\n\n" + inventory += "| Surface | Material | Area (SF) | Zone | Condition | Disposition |\n" + inventory += "|---------|----------|-----------|------|-----------|-------------|\n" + + for surface in room.surfaces: + zone = surface.zone.value if surface.zone else (room.zone_classification.value if room.zone_classification else "TBD") + condition = surface.condition.value if surface.condition else "TBD" + disposition = surface.disposition.value if surface.disposition else "TBD" + + inventory += f"| {surface.description} | {surface.material.value.replace('-', ' ').title()} | {surface.area_sf:,.0f} | {zone.title()} | {condition.title()} | {disposition.title()} |\n" + + return inventory + + def _get_material_category(self, material: str) -> str: + """Get material category.""" + non_porous = ["steel", "concrete", "glass", "metal", "cmu"] + semi_porous = ["drywall-painted", "wood-sealed"] + porous = ["drywall-unpainted", "carpet", "insulation", "acoustic-tile", "upholstery"] + hvac = ["ductwork-rigid", "ductwork-flexible", "hvac-interior-insulation"] + + if any(np in material for np in non_porous): + return "Non-Porous" + elif any(sp in material for sp in semi_porous): + return "Semi-Porous" + elif any(p in material for p in porous): + return "Porous" + elif any(h in material for h in hvac): + return "HVAC" + return "Other" + + def _build_regulatory_framework(self, input_data: AssessmentInput, rag_context: Dict) -> str: + """Build regulatory framework section.""" + + classification = input_data.project.facility_classification.value + + framework = f"""## Regulatory Framework + +### Facility Classification + +**Classification:** {classification.replace('-', '/').title()} + +{rag_context.get('regulatory_justification', '')} + +### Applicable Standards + +| Standard | Application | +|----------|-------------| +| BNL SOP IH75190 (Rev23) | Surface wipe clearance for metals | +| NADCA ACR 2021 | Air filtration requirements (4 ACH minimum) | +| IICRC/RIA/CIRI Technical Guide (Dec 2025) | Zone-based assessment framework | +""" + + if classification == "public-childcare": + framework += "| EPA/HUD Lead Standards (Oct 2024) | Public/Childcare lead thresholds |\n" + + return framework + + def _build_work_preparation(self, calculations: Dict) -> str: + """Build work preparation section.""" + + air_filt = calculations["air_filtration"] + + return f"""## Work Area Preparation + +### Air Filtration Requirements + +Per NADCA ACR 2021 Section 3.6, minimum 4 air changes per hour (ACH) required during restoration activities. + +**Calculation:** +``` +Work area volume: {air_filt['total_volume_cf']:,.0f} CF +Required ACH: {air_filt['required_ach']} +Air scrubber capacity: {air_filt['unit_cfm']:,} CFM per unit + +Units required: {air_filt['calculation']} +``` + +**Requirement:** {air_filt['units_required']} HEPA air scrubbers @ {air_filt['unit_cfm']:,} CFM each + +### Containment + +- Establish work area boundaries with warning signage +- Seal HVAC supply/return registers in work areas +- Maintain negative pressure during active cleaning +- Run air scrubbers continuously during work and minimum 4 hours after completion +""" + + def _build_cleaning_procedures(self, input_data: AssessmentInput, rag_context: Dict) -> str: + """Build cleaning procedures section.""" + + procedures = """## Surface-Specific Cleaning Procedures + +### Standard Cleaning Sequence (per FDAM §5.1) + +1. **HEPA Vacuum** — Remove loose particulate from all surfaces +2. **Dry Sponge** (if needed) — Chemical sponge for char/soot on non-porous surfaces +3. **Wet Wipe - Alkaline Detergent** — pH 10-12 solution for chemical residue removal +4. **Rinse Wipe** — Clean water to remove detergent residue +5. **Degreaser** (if needed) — For stubborn residues not removed by standard protocol + +**Sequencing Rule:** Clean top-down (roof deck → structure → walls → floor) to prevent recontamination. + +### Procedures by Surface Type + +| Surface Type | Standard Method | +|--------------|-----------------| +| Steel roof deck | HEPA vac → Wet wipe → Rinse | +| Steel joists/beams | HEPA vac → Wet wipe → Rinse | +| Steel columns | HEPA vac → Wet wipe → Rinse | +| Concrete floor | Scrubber machine + alkaline | +| CMU walls | HEPA vac → Wet wipe OR power wash | +| Metal doors | Wet wipe → Rinse | +| Rigid ductwork | Per NADCA ACR | +""" + return procedures + + def _build_removal_scope(self, input_data: AssessmentInput, calculations: Dict) -> str: + """Build removal scope section.""" + + removal_surfaces = [] + for room in input_data.rooms: + for surface in room.surfaces: + if surface.disposition in [Disposition.REMOVE, Disposition.REMOVE_REPAIR]: + removal_surfaces.append({ + "room": room.name, + "surface": surface.description, + "material": surface.material.value, + "area": surface.area_sf, + "disposition": surface.disposition.value + }) + + if not removal_surfaces: + return """## Removal Scope + +No materials identified for removal at this time. All surfaces designated for cleaning.""" + + removal = """## Removal Scope + +The following materials require removal and are beyond the scope of cleaning: + +| Room | Surface | Material | Area (SF) | Rationale | +|------|---------|----------|-----------|-----------| +""" + for item in removal_surfaces: + rationale = "Porous material contamination" if "porous" in self._get_material_category(item["material"]).lower() else "Structural damage" + removal += f"| {item['room']} | {item['surface']} | {item['material'].replace('-', ' ').title()} | {item['area']:,.0f} | {rationale} |\n" + + total_removal = sum(s["area"] for s in removal_surfaces) + removal += f"\n**Total Removal Area:** {total_removal:,.0f} SF\n" + + return removal + + def _build_labor_estimate(self, calculations: Dict) -> str: + """Build labor estimate section.""" + + labor = calculations["labor_estimate"] + + estimate = """## Labor Estimate + +### Hours by Task + +| Task | Estimated Hours | +|------|-----------------| +""" + for task, hours in labor["by_task"].items(): + if hours > 0: + estimate += f"| {task.replace('_', ' ').title()} | {hours} |\n" + + estimate += f""" +**Total Labor Hours:** {labor['total_hours']} +**Crew Days (2-person):** {labor['crew_days_2_person']} +**Crew Days (4-person):** {labor['crew_days_4_person']} + +*Note: {labor['note']}* +""" + return estimate + + def _build_equipment_requirements(self, calculations: Dict) -> str: + """Build equipment requirements section.""" + + equip = calculations["equipment"] + + requirements = f"""## Equipment Requirements + +### Air Filtration +- **HEPA Air Scrubbers:** {equip['air_scrubbers']['quantity']} units @ {equip['air_scrubbers']['cfm_each']:,} CFM each + +### Cleaning Equipment +- **HEPA Vacuums:** {equip['hepa_vacuums']['quantity']} units +""" + + if equip['lift_equipment']['required']: + requirements += f"- **Lift Equipment:** {equip['lift_equipment']['type'].replace('_', ' ').title()} (max ceiling height: {equip['lift_equipment']['max_ceiling_height']} ft)\n" + + requirements += f""" +### Supplies +- Alkaline Detergent: {equip['cleaning_supplies']['alkaline_detergent_gallons']} gallons +- Degreaser: {equip['cleaning_supplies']['degreaser_gallons']} gallons +- Dry Sponges: {equip['cleaning_supplies']['dry_sponges']} units +- Microfiber Cloths: {equip['cleaning_supplies']['microfiber_cloths']} units + +### Personal Protective Equipment +- PPE Sets: {equip['ppe_sets']['quantity']} +- Includes: {', '.join(equip['ppe_sets']['includes'])} +""" + return requirements + + def _build_sampling_section(self, input_data: AssessmentInput, calculations: Dict) -> str: + """Build sampling plan section.""" + + sampling = calculations["sample_density"] + + section = f"""## Sampling Plan Recommendations + +### Pre-Restoration Assessment (PRA) Sampling + +Based on total area of {sampling['total_sf']:,.0f} SF ({sampling['size_category']}): + +| Sample Type | Per Surface Type | Surface Types | Total Recommended | +|-------------|------------------|---------------|-------------------| +| Tape Lift (PLM) | {sampling['tape_lifts_per_type']} | {sampling['surface_types_count']} | {sampling['recommended_tape_lifts']} | +| Surface Wipe (ICP-MS) | {sampling['surface_wipes_per_type']} | {sampling['surface_types_count']} | {sampling['recommended_surface_wipes']} | + +### Surface Types Identified + +""" + for st in sampling["surface_types"]: + section += f"- {st.replace('-', ' ').title()}\n" + + if sampling["ceiling_deck_note"]: + section += f"\n**Ceiling Deck Protocol:** {sampling['ceiling_deck_note']}\n" + + section += """ +### Control Samples + +Control samples from unaffected areas are recommended for baseline comparison. Minimum 2 control samples per sample type. + +### Laboratory Requirements + +- Tape Lift Analysis: Polarized light microscopy (PLM) at AIHA-accredited laboratory +- Surface Wipe Analysis: ICP-MS or ICP-OES for metals at AIHA-accredited laboratory +- Sample Media: Ghost Wipes or equivalent pre-moistened media +- Sample Area: 100 cm² (10cm × 10cm template) per NIOSH Method 9100 +""" + return section + + def _build_acceptance_criteria(self, input_data: AssessmentInput, rag_context: Dict) -> str: + """Build acceptance criteria section.""" + + classification = input_data.project.facility_classification.value + + # Get appropriate thresholds + if classification == "operational": + lead_threshold = "500 µg/100cm²" + lead_source = "BNL SOP IH75190 Operational" + elif classification == "public-childcare": + lead_threshold = "0.54 µg/100cm² (floors), 4.3 µg/100cm² (sills/troughs)" + lead_source = "EPA/HUD October 2024" + else: + lead_threshold = "22 µg/100cm²" + lead_source = "BNL SOP IH75190 Non-Operational" + + return f"""## Acceptance Criteria + +### Post-Restoration Verification (PRV) Thresholds + +Post-restoration verification sampling will be conducted per FDAM methodology. The following clearance thresholds apply: + +#### Metals Thresholds + +| Analyte | Threshold | Unit | Source | +|---------|-----------|------|--------| +| Lead (Pb) | {lead_threshold} | µg/100cm² | {lead_source} | +| Cadmium (Cd) | 3.3 (Non-Op) / 50 (Op) | µg/100cm² | BNL SOP IH75190 | +| Arsenic (As) | 6.7 (Non-Op) / 100 (Op) | µg/100cm² | BNL SOP IH75190 | + +#### Particulate Thresholds + +| Analyte | Threshold | Unit | Classification | +|---------|-----------|------|----------------| +| Ash and Char | < 150 | particles/cm² | Professional Judgment* | +| Aciniform Soot | < 500 | particles/cm² | Professional Judgment* | + +*Professional Judgment thresholds validated at 93.3% first-pass clearance rate (n=45, QVC dataset). See FDAM Appendix B. + +### Pass/Fail Criteria + +- All samples must pass applicable thresholds +- Visual inspection confirms dust-free surfaces +- No detectable fire/smoke odor + +### Reclean/Retest Protocol + +Surfaces exceeding thresholds require reclean and retest until passing per FDAM §5.4. +""" + + def _build_regulatory_flags(self, calculations: Dict) -> str: + """Build regulatory flags section.""" + + flags = calculations["regulatory_flags"]["flags"] + + section = """## Regulatory Considerations + +The following regulatory flags have been identified for this project: + +""" + for flag in flags: + severity_icon = "🔴" if flag["severity"] == "high" else "🟡" + section += f"""### {severity_icon} {flag['type'].upper()} + +{flag['message']} + +*Reference: {flag['reference']}* + +""" + return section + + def _build_confidence_notes(self, input_data: AssessmentInput) -> str: + """Build confidence notes section.""" + + notes = """## Assessment Confidence Notes + +### AI-Assisted Analysis + +This assessment utilized AI-powered image analysis to assist with: +- Zone classification +- Material identification +- Condition assessment +- Combustion particle pattern recognition + +### Items Flagged for Review + +""" + flagged = [] + for room in input_data.rooms: + if room.zone_confidence and room.zone_confidence < 0.7: + flagged.append(f"- **{room.name}**: Zone classification confidence {room.zone_confidence:.0%} - recommend verification") + + for surface in room.surfaces: + if surface.ai_detected and surface.confidence and surface.confidence < 0.7: + flagged.append(f"- **{room.name} - {surface.description}**: Material detection confidence {surface.confidence:.0%} - recommend verification") + + if flagged: + notes += "\n".join(flagged) + else: + notes += "No items flagged for additional review. All confidence scores above 70% threshold." + + notes += """ + +### Limitations + +1. **Visual Analysis Only** — Definitive particle identification requires laboratory microscopy (PLM/SEM) +2. **Surface Area Estimates** — Areas from images are approximations; user-provided dimensions used for calculations +3. **Odor Assessment** — Odor presence/intensity based on user-reported observations, not instrument measurement +4. **Professional Review Required** — This specification should be reviewed by a qualified industrial hygienist before execution + +### Standards Basis Statement + +Metals thresholds are standards-based per BNL SOP IH75190 (Rev23, 06/23/17). Particulate thresholds represent professional judgment with empirical validation (93.3% pass rate, n=45). See FDAM v4.0.1 for complete methodology documentation. +""" + return notes + + def _generate_sampling_plan(self, input_data: AssessmentInput, calculations: Dict) -> str: + """Generate standalone sampling plan document.""" + + sampling = calculations["sample_density"] + + plan = f"""# Sampling Plan + +## Project: {input_data.project.project_name} +## Date: {datetime.now().strftime('%B %d, %Y')} + +--- + +## Summary + +| Parameter | Value | +|-----------|-------| +| Total Area | {sampling['total_sf']:,.0f} SF | +| Size Category | {sampling['size_category']} | +| Surface Types | {sampling['surface_types_count']} | +| Recommended Tape Lifts | {sampling['recommended_tape_lifts']} | +| Recommended Surface Wipes | {sampling['recommended_surface_wipes']} | + +--- + +## Sample Locations by Room + +""" + for room in input_data.rooms: + zone = room.zone_classification.value if room.zone_classification else "TBD" + + plan += f"""### {room.name} ({zone.title()} Zone) + +**Dimensions:** {room.dimensions.length_ft}' × {room.dimensions.width_ft}' × {room.dimensions.ceiling_height_ft}' = {room.dimensions.area_sf:,.0f} SF + +**Recommended Sample Locations:** + +| Location | Surface Type | Sample Type | Priority | +|----------|--------------|-------------|----------| +""" + # Generate sample locations based on surfaces + for i, surface in enumerate(room.surfaces[:5]): # Top 5 surfaces per room + sample_type = "Both" if surface.material.value in ["steel", "concrete"] else "Tape Lift" + priority = "High" if zone == "near-field" else "Medium" + plan += f"| {surface.description} | {surface.material.value.replace('-', ' ').title()} | {sample_type} | {priority} |\n" + + plan += "\n" + + if sampling["ceiling_deck_note"]: + plan += f"""--- + +## Ceiling Deck Enhanced Sampling + +{sampling['ceiling_deck_note']} + +Per FDAM §4.5, ceiling deck surfaces exhibit higher post-cleaning contamination rates (82.4% vs 95%+ for other surfaces). Increase sample density by 50% for ceiling decks. +""" + + plan += """--- + +## Control Sample Locations + +Collect control samples from unaffected areas for baseline comparison: + +1. **Control Location 1:** [To be determined on-site - area with no visible contamination] +2. **Control Location 2:** [To be determined on-site - separate building or wing if available] + +Minimum 2 control samples per sample type (tape lift and surface wipe). + +--- + +## Laboratory Instructions + +- **Laboratory:** AIHA-accredited laboratory +- **Tape Lift Analysis:** Polarized light microscopy (PLM) +- **Surface Wipe Analysis:** ICP-MS for metals (Pb, Cd, As) +- **Reporting Format:** Request particles/cm² format when available +- **Turnaround:** Standard (5-7 business days) unless expedited required +""" + + return plan + + def _generate_confidence_summary(self, input_data: AssessmentInput) -> str: + """Generate confidence summary for flagged items.""" + + summary = f"""# Confidence Summary Report + +## Project: {input_data.project.project_name} +## Generated: {datetime.now().strftime('%B %d, %Y at %I:%M %p')} + +--- + +## Overall Assessment Confidence + +""" + # Calculate overall confidence + confidences = [] + for room in input_data.rooms: + if room.zone_confidence: + confidences.append(("Zone", room.name, room.zone_confidence)) + for surface in room.surfaces: + if surface.confidence: + confidences.append(("Material", f"{room.name} - {surface.description}", surface.confidence)) + + if confidences: + avg_confidence = sum(c[2] for c in confidences) / len(confidences) + summary += f"**Average Confidence Score:** {avg_confidence:.0%}\n\n" + + # Confidence breakdown + high_conf = [c for c in confidences if c[2] >= 0.9] + med_conf = [c for c in confidences if 0.7 <= c[2] < 0.9] + low_conf = [c for c in confidences if c[2] < 0.7] + + summary += f"""### Confidence Distribution + +| Level | Count | Percentage | +|-------|-------|------------| +| High (≥90%) | {len(high_conf)} | {len(high_conf)/len(confidences)*100:.0f}% | +| Medium (70-89%) | {len(med_conf)} | {len(med_conf)/len(confidences)*100:.0f}% | +| Low (<70%) | {len(low_conf)} | {len(low_conf)/len(confidences)*100:.0f}% | + +""" + if low_conf: + summary += """--- + +## Items Requiring Review + +The following items have confidence scores below 70% and require professional verification: + +| Type | Location | Confidence | Recommendation | +|------|----------|------------|----------------| +""" + for item_type, location, conf in low_conf: + rec = "Verify on-site" if item_type == "Material" else "Confirm zone classification" + summary += f"| {item_type} | {location} | {conf:.0%} | {rec} |\n" + else: + summary += "No AI-generated confidence scores available. All determinations were user-provided.\n" + + return summary +``` + +--- + +## 10. Gradio UI Specification + +### Multi-Tab Interface + +```python +# ui/app.py + +import gradio as gr +from typing import Dict, List, Tuple +from PIL import Image +import io + +class FDAMUI: + """Multi-tab Gradio interface for FDAM AI Pipeline.""" + + def __init__(self, pipeline): + self.pipeline = pipeline + self.state = {} + + def build(self) -> gr.Blocks: + """Build the Gradio interface.""" + + with gr.Blocks( + title="FDAM AI Pipeline - Fire Damage Assessment", + theme=gr.themes.Soft(), + css=self._custom_css() + ) as app: + + gr.Markdown(""" + # 🔥 FDAM AI Pipeline + ## Fire Damage Assessment Methodology v4.0.1 + + Upload images and project information to generate a professional Cleaning Specification / Scope of Work. + """) + + with gr.Tabs() as tabs: + + # Tab 1: Project Information + with gr.Tab("1. Project Info", id="project"): + with gr.Row(): + with gr.Column(): + project_name = gr.Textbox(label="Project/Facility Name", placeholder="e.g., ABC Warehouse") + address = gr.Textbox(label="Street Address") + + with gr.Row(): + city = gr.Textbox(label="City") + state = gr.Textbox(label="State", max_lines=1) + zip_code = gr.Textbox(label="ZIP Code", max_lines=1) + + with gr.Column(): + client_name = gr.Textbox(label="Client Name") + client_contact = gr.Textbox(label="Client Contact (optional)") + client_email = gr.Textbox(label="Client Email (optional)") + client_phone = gr.Textbox(label="Client Phone (optional)") + + with gr.Row(): + fire_date = gr.Textbox(label="Fire Date", placeholder="YYYY-MM-DD") + assessment_date = gr.Textbox(label="Assessment Date", placeholder="YYYY-MM-DD") + + with gr.Row(): + facility_classification = gr.Radio( + choices=["Non-Operational", "Operational", "Public/Childcare"], + label="Facility Classification", + value="Non-Operational", + info="See FDAM §3.1 for classification criteria" + ) + + construction_era = gr.Radio( + choices=["Pre-1980", "1980-2000", "Post-2000"], + label="Construction Era", + value="Post-2000", + info="Affects LBP/ACM regulatory flags" + ) + + with gr.Row(): + assessor_name = gr.Textbox(label="Assessor Name") + assessor_credentials = gr.Textbox(label="Credentials (optional)", placeholder="CIH, CSP, etc.") + + # Tab 2: Building/Rooms + with gr.Tab("2. Building/Rooms", id="rooms"): + gr.Markdown("### Add rooms/areas to assess") + + rooms_data = gr.State([]) + + with gr.Row(): + with gr.Column(scale=2): + room_name = gr.Textbox(label="Room/Area Name", placeholder="e.g., Warehouse Bay A") + room_floor = gr.Textbox(label="Floor (optional)", placeholder="e.g., Ground Floor") + + with gr.Row(): + room_length = gr.Number(label="Length (ft)", minimum=1, maximum=10000) + room_width = gr.Number(label="Width (ft)", minimum=1, maximum=10000) + room_height = gr.Number(label="Ceiling Height (ft)", minimum=1, maximum=500) + + room_zone = gr.Radio( + choices=["Undetermined (AI will analyze)", "Burn Zone", "Near-Field", "Far-Field"], + label="Zone Classification (optional - can be AI-determined)", + value="Undetermined (AI will analyze)" + ) + + add_room_btn = gr.Button("➕ Add Room", variant="primary") + + with gr.Column(scale=3): + rooms_table = gr.Dataframe( + headers=["ID", "Name", "Floor", "L × W × H", "Area (SF)", "Zone"], + datatype=["str", "str", "str", "str", "number", "str"], + label="Rooms Added", + interactive=False + ) + + clear_rooms_btn = gr.Button("🗑️ Clear All Rooms", variant="secondary") + + gr.Markdown("### Manual Surface Entry (optional)") + gr.Markdown("*Surfaces can also be detected automatically from images*") + + with gr.Row(): + with gr.Column(): + surface_room_id = gr.Dropdown(label="Room", choices=[], interactive=True) + surface_material = gr.Dropdown( + label="Material", + choices=[ + "Steel", "Concrete", "Glass", "Metal", "CMU", + "Drywall (Painted)", "Drywall (Unpainted)", + "Wood (Sealed)", "Wood (Unsealed)", + "Carpet", "Carpet Pad", "Insulation (Fiberglass)", + "Acoustic Tile", "Upholstery", + "Ductwork (Rigid)", "Ductwork (Flexible)" + ] + ) + surface_description = gr.Textbox(label="Description", placeholder="e.g., North wall") + surface_area = gr.Number(label="Area (SF)", minimum=0) + add_surface_btn = gr.Button("➕ Add Surface") + + with gr.Column(): + surfaces_table = gr.Dataframe( + headers=["Room", "Material", "Description", "Area (SF)"], + datatype=["str", "str", "str", "number"], + label="Surfaces Added", + interactive=False + ) + + # Tab 3: Images + with gr.Tab("3. Images", id="images"): + gr.Markdown(""" + ### Upload Fire Damage Images + + Upload 1-20 images for AI analysis. The system will identify: + - Zone classification (Burn/Near-Field/Far-Field) + - Materials present + - Condition assessment + - Combustion particle indicators + """) + + images_data = gr.State([]) + + with gr.Row(): + with gr.Column(scale=2): + image_upload = gr.Image( + label="Upload Image", + type="pil", + sources=["upload"] + ) + + image_room = gr.Dropdown( + label="Associated Room", + choices=[], + interactive=True + ) + + image_description = gr.Textbox( + label="Image Description (optional)", + placeholder="e.g., View of ceiling deck from center aisle" + ) + + add_image_btn = gr.Button("➕ Add Image", variant="primary") + + with gr.Column(scale=3): + images_gallery = gr.Gallery( + label="Images Added", + columns=3, + height="auto", + object_fit="contain" + ) + + images_info = gr.Dataframe( + headers=["#", "Room", "Description", "Status"], + datatype=["number", "str", "str", "str"], + label="Image Details" + ) + + clear_images_btn = gr.Button("🗑️ Clear All Images", variant="secondary") + + # Tab 4: Observations + with gr.Tab("4. Observations", id="observations"): + gr.Markdown(""" + ### Qualitative Observation Checklist + + Per FDAM §2.3, document the following field observations: + """) + + with gr.Row(): + with gr.Column(): + gr.Markdown("#### Odor Assessment") + smoke_odor = gr.Checkbox(label="Smoke/fire odor present?") + odor_intensity = gr.Radio( + choices=["None", "Faint", "Moderate", "Strong"], + label="Odor Intensity", + visible=True + ) + + gr.Markdown("#### Visible Contamination") + visible_soot = gr.Checkbox(label="Visible soot deposits?") + soot_pattern = gr.Textbox(label="Soot pattern description (if present)", visible=True) + + large_char = gr.Checkbox(label="Large char particles observed?") + char_density = gr.Radio( + choices=["Sparse", "Moderate", "Dense"], + label="Char density estimate", + visible=True + ) + + with gr.Column(): + ash_residue = gr.Checkbox(label="Ash-like residue present?") + ash_description = gr.Textbox(label="Ash color/texture (if present)") + + surface_discoloration = gr.Checkbox(label="Surface discoloration?") + discoloration_description = gr.Textbox(label="Discoloration description") + + dust_interference = gr.Checkbox(label="Dust loading or interference?") + dust_notes = gr.Textbox(label="Dust notes") + + wildfire_indicators = gr.Checkbox(label="Wildfire indicators (burned soil/pollen/vegetation)?") + wildfire_notes = gr.Textbox(label="Wildfire notes") + + additional_notes = gr.Textbox( + label="Additional Observations", + lines=3, + placeholder="Any other relevant observations..." + ) + + # Tab 5: Results + with gr.Tab("5. Generate Results", id="results"): + gr.Markdown(""" + ### Generate Assessment Documents + + Click below to process all inputs and generate: + 1. **Cleaning Specification / Scope of Work** (primary output) + 2. **Sampling Plan Recommendations** + 3. **Confidence Report** + """) + + with gr.Row(): + generate_btn = gr.Button( + "🚀 Generate Assessment", + variant="primary", + scale=2 + ) + + processing_status = gr.Textbox( + label="Status", + value="Ready", + interactive=False + ) + + with gr.Row(): + with gr.Column(): + gr.Markdown("### Annotated Images") + annotated_gallery = gr.Gallery( + label="AI-Analyzed Images", + columns=2, + height="auto" + ) + + with gr.Column(): + gr.Markdown("### Quick Stats") + stats_output = gr.JSON(label="Assessment Statistics") + + gr.Markdown("### Cleaning Specification / Scope of Work") + sow_output = gr.Markdown(label="SOW Preview") + + with gr.Row(): + download_md = gr.File(label="Download Markdown (.md)") + download_pdf = gr.File(label="Download PDF (.pdf)") + + gr.Markdown("### Sampling Plan") + with gr.Accordion("View Sampling Plan", open=False): + sampling_output = gr.Markdown() + + gr.Markdown("### Confidence Report") + with gr.Accordion("View Confidence Report", open=False): + confidence_output = gr.Markdown() + + # Event handlers + add_room_btn.click( + fn=self._add_room, + inputs=[rooms_data, room_name, room_floor, room_length, room_width, room_height, room_zone], + outputs=[rooms_data, rooms_table, surface_room_id, image_room, room_name, room_floor, room_length, room_width, room_height] + ) + + clear_rooms_btn.click( + fn=self._clear_rooms, + inputs=[], + outputs=[rooms_data, rooms_table, surface_room_id, image_room] + ) + + add_image_btn.click( + fn=self._add_image, + inputs=[images_data, image_upload, image_room, image_description], + outputs=[images_data, images_gallery, images_info, image_upload, image_description] + ) + + clear_images_btn.click( + fn=self._clear_images, + inputs=[], + outputs=[images_data, images_gallery, images_info] + ) + + generate_btn.click( + fn=self._generate_assessment, + inputs=[ + # Project info + project_name, address, city, state, zip_code, + client_name, client_contact, client_email, client_phone, + fire_date, assessment_date, facility_classification, construction_era, + assessor_name, assessor_credentials, + # Rooms and images + rooms_data, images_data, + # Observations + smoke_odor, odor_intensity, visible_soot, soot_pattern, + large_char, char_density, ash_residue, ash_description, + surface_discoloration, discoloration_description, + dust_interference, dust_notes, wildfire_indicators, wildfire_notes, + additional_notes + ], + outputs=[ + processing_status, annotated_gallery, stats_output, + sow_output, download_md, download_pdf, + sampling_output, confidence_output + ] + ) + + return app + + def _custom_css(self) -> str: + """Custom CSS for styling.""" + return """ + .primary-btn { background-color: #ff6b35 !important; } + .tab-selected { border-bottom: 3px solid #ff6b35 !important; } + """ + + # Event handler implementations... + def _add_room(self, rooms_data, name, floor, length, width, height, zone): + # Implementation + pass + + def _clear_rooms(self): + # Implementation + pass + + def _add_image(self, images_data, image, room, description): + # Implementation + pass + + def _clear_images(self): + # Implementation + pass + + async def _generate_assessment(self, *args): + # Implementation - calls pipeline + pass +``` + +--- + +## 11. Confidence Framework + +### Confidence Thresholds + +| Level | Score Range | Action | +|-------|-------------|--------| +| Very High | 90-100% | Accept without review | +| High | 70-89% | Accept, note in report | +| Moderate | 50-69% | Flag for human review | +| Low | <50% | Require human verification | + +### Confidence Application Rules + +```python +# pipeline/confidence.py + +class ConfidenceFramework: + """FDAM confidence framework for AI determinations.""" + + THRESHOLDS = { + "very_high": 0.90, + "high": 0.70, + "moderate": 0.50, + "low": 0.0 + } + + @staticmethod + def get_level(confidence: float) -> str: + """Get confidence level from score.""" + if confidence >= 0.90: + return "very_high" + elif confidence >= 0.70: + return "high" + elif confidence >= 0.50: + return "moderate" + else: + return "low" + + @staticmethod + def requires_review(confidence: float) -> bool: + """Check if confidence requires human review.""" + return confidence < 0.70 + + @staticmethod + def format_confidence(confidence: float) -> str: + """Format confidence for display.""" + level = ConfidenceFramework.get_level(confidence) + emoji = { + "very_high": "🟢", + "high": "🟢", + "moderate": "🟡", + "low": "🔴" + } + return f"{emoji[level]} {confidence:.0%} ({level.replace('_', ' ').title()})" +``` + +--- + +## 12. Project Structure + +``` +fdam-ai-pipeline/ +├── README.md +├── requirements.txt +├── app.py # Main Gradio application entry point +│ +├── config/ +│ ├── __init__.py +│ ├── inference.py # Model inference configuration +│ └── settings.py # Application settings +│ +├── models/ +│ ├── __init__.py +│ └── loader.py # Model loading and management +│ +├── rag/ +│ ├── __init__.py +│ ├── chunker.py # Knowledge base chunking +│ ├── vectorstore.py # ChromaDB setup +│ └── retriever.py # RAG retrieval strategies +│ +├── schemas/ +│ ├── __init__.py +│ ├── input.py # Pydantic input models +│ └── output.py # Pydantic output models +│ +├── pipeline/ +│ ├── __init__.py +│ ├── main.py # Main processing pipeline +│ ├── vision.py # Vision analysis module +│ ├── calculations.py # FDAM calculation engine +│ ├── generator.py # Document generation +│ └── confidence.py # Confidence framework +│ +├── ui/ +│ ├── __init__.py +│ └── app.py # Gradio UI components +│ +├── rag_knowledge/ # RAG knowledge base +│ ├── README.md +│ ├── methodology/ +│ │ ├── FDAM_v4.0.1/ +│ │ └── sampling/ +│ ├── lab_methods/ +│ │ ├── EAA_Method_Guide/ +│ │ └── Hayes_Reference/ +│ ├── standards/ +│ │ ├── BNL_SOP_IH75190/ +│ │ ├── EPA_HUD_Lead/ +│ │ ├── NADCA_ACR_2021/ +│ │ └── IICRC_RIA_CIRI/ +│ ├── regulatory/ +│ └── reference_images/ +│ +├── chroma_db/ # ChromaDB persistence +│ +├── outputs/ # Generated documents +│ +└── tests/ + ├── __init__.py + ├── test_pipeline.py + ├── test_calculations.py + └── test_rag.py +``` + +--- + +## 13. Implementation Notes + +### Critical Implementation Details + +1. **Model Loading Order** + - Load all three models at startup (no swapping) + - Use `torch.bfloat16` for memory efficiency + - Use `device_map="auto"` for automatic GPU allocation + +2. **RAG Knowledge Base Setup** + - Pre-chunk and index at deployment time + - Store ChromaDB in persistent storage + - Rebuild index only when knowledge base changes + +3. **Image Processing** + - Accept JPEG, PNG, WebP formats + - Resize images > 2048px for processing efficiency + - Maintain original for annotation overlay + +4. **Output Generation** + - Generate Markdown as primary format + - Convert to PDF via pandoc for download + - Store temporary files in `/tmp` or designated output directory + +5. **Error Handling** + - Graceful degradation if vision analysis fails + - Default to conservative dispositions on uncertainty + - Log all errors for debugging + +### HuggingFace Spaces Requirements + +```txt +# requirements.txt + +torch>=2.0.0 +transformers>=4.40.0 +accelerate>=0.27.0 +gradio>=4.0.0 +chromadb>=0.4.0 +pydantic>=2.0.0 +pillow>=10.0.0 +opencv-python>=4.8.0 +pandas>=2.0.0 +numpy>=1.24.0 +``` + +### Deployment Configuration + +```yaml +# README.md (HuggingFace Spaces metadata) + +--- +title: FDAM AI Pipeline +emoji: 🔥 +colorFrom: orange +colorTo: red +sdk: gradio +sdk_version: 4.44.0 +app_file: app.py +pinned: true +license: apache-2.0 +suggested_hardware: a100-large +--- +``` + +--- + +## Document End + +**Version:** 1.0 +**Last Updated:** January 2026 +**Methodology Reference:** FDAM v4.0.1 + +This specification is intended for implementation by a Claude coding agent. All code examples are illustrative and should be adapted based on actual model API requirements and HuggingFace Spaces constraints. diff --git a/RAG-KB/FDAM_v4_METHODOLOGY.md b/RAG-KB/FDAM_v4_METHODOLOGY.md new file mode 100644 index 0000000000000000000000000000000000000000..af82f85c1b614389b340cbadbdfcea3870fb8adf --- /dev/null +++ b/RAG-KB/FDAM_v4_METHODOLOGY.md @@ -0,0 +1,994 @@ +# FDAM: Fire Damage Assessment Methodology + +## A Systematic Framework for Fire Restoration Industrial Hygiene Documentation + +**Version 4.0.1 | January 2026** + +**Developed in partnership:** IHC and GVO +**Empirical Validation Analysis:** January 2026 (QVC Distribution Center March 2023, Our Lady of Victory February 2025) + +--- + +## Document Control + +| Version | Date | Changes | +|---------|------|---------| +| 3.0 | January 2026 | Standards verification; ACH revised to 4 minimum per NADCA ACR 2021; metals aligned with BNL SOP IH75190; Public/Childcare lead updated to EPA/HUD October 2024 | +| 4.0 | January 2026 | Empirical validation integration; dual lab format support; regulatory justification blocks; ceiling deck protocols; reclean/retest procedures; deliverable consolidation; appendix restructure | +| 4.0.1 | January 2026 | EAA Method Guide integration: combustion particle definitions (soot/char/ash); qualitative observation checklist; unit conversion reference (cts/mm² to cts/cm²); EAA classification cross-reference | + +--- + +## Executive Summary + +FDAM is a systematic framework for assessing fire-damaged properties and generating scientifically defensible restoration documentation. The methodology synthesizes regulatory standards, industry guidance, and empirical field data from IHC fire restoration projects. + +**FDAM produces three deliverables:** + +1. **Cleaning Specification / Scope of Work** — Scope, methods, labor, equipment, and acceptance criteria +2. **Results Interpretation** — Threshold justification, regulatory basis, and pass/fail determination +3. **Executive Summary Report** — Completion verification and compliance documentation + +**Standards Basis:** +- Metals clearance: BNL SOP IH75190 (Rev23, 06/23/17) +- Non-Operational alternative: Army/Air Force National Guard Indoor Firing Range Guidelines (200 µg/ft²) +- Air filtration: NADCA ACR 2021 (4 ACH minimum) +- Zone framework: IICRC/RIA/CIRI Technical Guide (December 2025) +- Particulate clearance: IHC professional judgment with empirical validation + +--- + +## Part 1: Methodology Foundation + +### 1.1 Scientific Basis + +FDAM synthesizes: + +- **Regulatory frameworks:** OSHA Technical Manual, NIOSH sampling methods, EPA clearance standards +- **Industry standards:** IICRC S700/S760, IICRC/RIA/CIRI Technical Guide, NADCA ACR, RIA Fire & Smoke Damage Repair +- **Published guidance:** BNL SOP IH75190, AIHA Technical Guide for Wildfire Impact Assessments +- **Empirical validation:** IHC field data from commercial fire restoration projects (see Appendix B) + +### 1.2 Regulatory Framework + +| Source | Application | Status | +|--------|-------------|--------| +| BNL SOP IH75190 (Rev23) | Surface wipe clearance for metals | **Primary - verified** | +| Army/Air Force National Guard Guidelines | Non-Operational lead alternative (200 µg/ft²) | **Primary - verified** | +| EPA/HUD Lead Dust Hazard Standards (October 2024) | Public/Childcare lead clearance | **Primary - verified** | +| OSHA Technical Manual, Section II Ch. 2 | Surface contaminant methodology, facility classification | Referenced | +| NIOSH Method 9100 | Surface wipe sampling procedures | Referenced | +| 29 CFR 1910.1025 | Lead housekeeping requirements | Referenced | +| 29 CFR 1910.1018 | Arsenic housekeeping requirements | Referenced | +| 29 CFR 1910.1027 | Cadmium housekeeping requirements | Referenced | +| NADCA ACR 2021 | Air filtration requirements | **Primary - verified** | +| IICRC/RIA/CIRI Technical Guide (Dec 2025) | Zone-based assessment | **Primary - verified** | +| IICRC S520 | Mold remediation (cross-reference for fungal co-occurrence) | Referenced | + +### 1.3 Threshold Classification + +**Standards-Based Thresholds:** Values from published, peer-reviewed, or regulatory sources with explicit citations. + +**Professional Judgment Thresholds:** Values developed through field experience where no published standards exist. Explicitly labeled with empirical validation data where available. + +### 1.4 Metals Clearance Thresholds + +**Source:** BNL SOP IH75190, Attachment 9.3 (Rev23, 06/23/17) + +| Metal | Non-Operational | Operational | Unit | Regulatory Basis | +|-------|-----------------|-------------|------|------------------| +| Lead (Pb) | 22 | 500 | µg/100cm² | 29 CFR 1910.1025 | +| Cadmium (Cd) | 3.3 | 50 | µg/100cm² | 29 CFR 1910.1027 | +| Arsenic (As) | 6.7 | 100 | µg/100cm² | 29 CFR 1910.1018 | + +**Unit Conversions:** +- µg/100cm² × 9.29 = µg/ft² +- Lead Non-Op: 22 µg/100cm² ≈ 204 µg/ft² + +**Alternative Non-Operational Reference:** +Army and Air Force National Guard "Guidelines and Procedures for Rehabilitation and Conversion of Indoor Firing Ranges" establishes 200 µg/ft² as acceptable surface contamination for spaces converted to general use. This is consistent with BNL Non-Operational threshold (22 µg/100cm² ≈ 204 µg/ft²). + +**Public/Childcare Thresholds (EPA/HUD October 2024):** + +| Surface | Threshold | Unit | +|---------|-----------|------| +| Floors | 0.54 | µg/100cm² | +| Window Sills | 4.3 | µg/100cm² | +| Window Troughs | 4.3 | µg/100cm² | + +### 1.5 Combustion Particle Definitions + +Fire/combustion residue particles are classified into three categories based on combustion process: + +| Category | Definition | Morphology | +|----------|------------|------------| +| **Soot** | Residues from combustion of organic resins and compounds | Aciniform (grape-like clusters); fine spherical particles; optically opaque | +| **Char** | Incomplete combustion of cellulose/vegetation material | Irregular angular fragments; carbonized plant structure visible; variable size | +| **Ash** | Residual mineral elements remaining after complete combustion (Ca, Na, Mg, K salts) | Irregular crystalline; often white/gray; variable opacity | + +Source: Environmental Analysis Associates, Air-O-Cell Method Guide & Particle Atlas (2018) + +**Laboratory Reporting Note:** Some laboratories report "Ash and Char" as a combined category. When combined reporting is used, interpret results against the Ash/Char threshold. When separated, sum the values for threshold comparison unless laboratory provides specific guidance. + +### 1.6 Particulate Clearance Thresholds + +**Classification:** Professional Judgment with Empirical Validation + +| Analyte | Clearance Threshold | Unit | Validation Status | +|---------|---------------------|------|-------------------| +| Ash and Char (combined) | < 150 | particles/cm² | Validated (97.8% pass rate, n=45) | +| Aciniform Soot | < 500 | particles/cm² | Validated (91.1% pass rate, n=45) | +| Cellulose/Synthetic Fibers | < 500 | particles/cm² | Professional judgment | +| Silicates | < 1,500 | particles/cm² | Professional judgment | + +**Laboratory Reference Comparison:** + +| Particle Type | Lab "Normal" Range | FDAM Clearance | Position | +|---------------|-------------------|----------------|----------| +| Ash/Char | 0-300/cm² | < 150/cm² | 50% of upper normal | +| Aciniform Soot | 0-800/cm² | < 500/cm² | 62.5% of upper normal | + +Source: Hayes Microbial Consulting, Estimated Normal Ranges based on ASTM D6602 + +FDAM clearance thresholds are set below laboratory "normal" ranges to ensure post-restoration surfaces are demonstrably cleaner than typical unaffected environments. + +**Empirical Validation Summary:** +- Dataset: 45 post-restoration samples (QVC Distribution Center, March 2023) +- Pass rate at current thresholds: 93.3% +- Typical achievable post-cleaning levels: 5-15/cm² (both particle types) +- See Appendix B for complete analysis + +**Application:** +- Evaluate in conjunction with visual inspection and odor assessment +- Compare to control/background samples from unaffected areas +- Results interpreted by qualified industrial hygienist + +--- + +## Part 2: Assessment Workflow + +### 2.1 Project Phases + +``` +PHASE 1: PRE (Pre-Restoration Evaluation) +├── Site inspection and documentation +├── Contamination mapping +├── Material inventory +├── Zone classification (Burn/Near-Field/Far-Field) +└── Output: Preliminary findings, PRA recommendation + +PHASE 2: PRA (Pre-Restoration Assessment) +├── Sampling plan development +├── Tape lift and surface wipe collection +├── Laboratory analysis +├── Results interpretation +└── Output: CLEANING SPECIFICATION / SCOPE OF WORK + +PHASE 3: RESTORATION (Contractor Execution) +├── Work performed per specification +└── Output: Completion notification + +PHASE 4: PRV (Post-Restoration Verification) +├── Verification sampling +├── Laboratory analysis +├── Pass/fail determination +├── Reclean/retest if required +└── Output: EXECUTIVE SUMMARY REPORT +``` + +### 2.2 Phase 1: Pre-Restoration Evaluation (PRE) + +**Field Activities:** + +| Activity | Method | Data Captured | +|----------|--------|---------------| +| Site walk-through | Visual inspection | Affected areas, impact severity by zone | +| Odor assessment | Sensory | Presence/intensity/location of smoke odor | +| White wipe test | Clean cloth on surfaces | Preliminary contamination indicator | +| Photo documentation | Camera/device | Conditions, damage, access constraints | +| Material inventory | Visual identification | Surface types, quantities, restorability | +| Dimensional survey | Manual measurement | Room dimensions, surface areas | +| Zone classification | Distance from fire origin | Burn Zone / Near-Field / Far-Field | + +**PRE Decision Logic:** + +``` +IF visible contamination is widespread + OR odor is significant + OR white wipe test shows deposits + OR materials of concern present + OR property is in Burn Zone or Near-Field Zone +THEN → Recommend PRA (laboratory assessment) + +IF contamination is superficial + AND limited to small area + AND no materials of concern + AND Far-Field Zone only +THEN → May proceed directly to cleaning specification +``` + +### 2.3 Phase 2: Pre-Restoration Assessment (PRA) + +**Sampling Protocol:** + +*Tape Lift Samples (Particulate Identification):* +- Minimum 1 per distinct surface type per zone +- Additional samples at contamination gradients +- Control samples from unaffected areas (recommended) +- Analysis: Polarized light microscopy (PLM) + +*Surface Wipe Samples (Metals Quantification):* +- Per NIOSH Method 9100 / BNL SOP IH75190 +- 100 cm² sample area (10cm × 10cm template) +- Ghost Wipes or equivalent pre-moistened media +- Analysis: ICP-MS or ICP-OES at AIHA-accredited laboratory + +**Sample Density Guidelines:** + +| Area Size | Tape Lifts | Surface Wipes | +|-----------|------------|---------------| +| < 5,000 SF | 3-5 per surface type | 3-5 per surface type | +| 5,000 - 25,000 SF | 5-10 per surface type | 5-10 per surface type | +| 25,000 - 100,000 SF | 10-20 per surface type | 10-15 per surface type | +| > 100,000 SF | 20+ per surface type | 15-25 per surface type | + +**Ceiling Deck Sample Density (Enhanced):** +Empirical data indicates ceiling deck surfaces exhibit higher post-cleaning contamination rates (82.4% pass rate vs 95%+ for other structural surfaces). For ceiling decks: +- Increase sample density by 50% above standard guidelines +- Minimum 1 sample per 2,500 SF (vs standard 1 per 5,000 SF) + +**Qualitative Observation Checklist:** + +Document the following at each sample location: + +| Observation | Response | Notes | +|-------------|----------|-------| +| Smoke/fire odor present? | Yes / No | Intensity if present | +| Visible soot deposits? | Yes / No | Describe pattern | +| Large char particles observed? | Yes / No | Estimated density | +| Ash-like residue present? | Yes / No | Color, texture | +| Surface discoloration? | Yes / No | Describe | +| Dust loading or interference? | Yes / No | May affect lab accuracy | +| Burned soil/pollen/vegetation indicators? | Yes / No | Wildfire indicator | + +This checklist supports visual-to-lab correlation and identifies potential analytical interferences. + +### 2.4 Phase 4: Post-Restoration Verification (PRV) + +**Verification Protocol:** +1. Visual inspection for dust-free surfaces +2. Odor assessment (no detectable fire/smoke odor) +3. Verification sampling (same methods as PRA) +4. Laboratory analysis +5. Results comparison to clearance criteria +6. Pass/fail determination by area + +**PRV Decision Logic:** + +``` +IF all samples pass clearance thresholds + AND visual inspection confirms dust-free + AND no detectable odor +THEN → Issue clearance, generate Executive Summary + +IF any samples exceed thresholds +THEN → Execute Reclean/Retest Protocol (Section 5.4) +``` + +--- + +## Part 3: Facility Classification + +### 3.1 Classification Categories + +| Classification | Definition | Lead Threshold | Applicable Standards | +|----------------|------------|----------------|---------------------| +| Operational | OSHA regulated substance used; workers trained; hygiene controls in place | 500 µg/100cm² | BNL SOP IH75190 Operational | +| Non-Operational | No regulated substance use; workers not trained; eating/drinking permitted | 22 µg/100cm² | BNL SOP IH75190 Non-Operational | +| Public-Childcare | Schools, daycare, child-occupied facilities | 0.54 µg/100cm² (floors) | EPA/HUD October 2024 | + +### 3.2 Classification Determination + +Facility classification is a professional judgment decision documented in the Results Interpretation deliverable. The determination considers: + +- Facility use and occupancy type +- Presence of OSHA regulated substances +- Worker training status +- Personal hygiene controls (eating/drinking restrictions, handwashing requirements) +- Occupant populations (children, general public, trained workers) + +### 3.3 Regulatory Justification Blocks + +**Non-Operational Commercial/Industrial:** + +> The indoor environment within [FACILITY] is comparable to the definition of a "Non-Operational Area" per OSHA Technical Manual Section II Chapter 2: an area where an OSHA Regulated Substance is not used and where workers are not trained in hazards and controls. Personal hygiene control practices are not in place (hand washing is not expected on exiting the area) and eating & drinking are permitted. +> +> The applicable standard for measuring cleaning performance is derived from BNL SOP IH75190 "Surface Wipe Sampling for Metals" (Rev23, 06/23/17), which establishes 22 µg/100cm² (≈204 µg/ft²) for Non-Operational areas. This threshold is consistent with the Army and Air Force National Guard "Guidelines and Procedures for Rehabilitation and Conversion of Indoor Firing Ranges" which establishes 200 µg/ft² as acceptable for spaces converted to general use. +> +> OSHA housekeeping provisions (29 CFR 1910.1025, 1910.1018, 1910.1027) require surfaces be maintained "as free as practicable" of accumulations of regulated metals. + +**Operational Industrial:** + +> [FACILITY] meets the definition of an "Operational Area" per OSHA Technical Manual Section II Chapter 2: an area where workers are routinely in the presence of an OSHA Regulated Substance as part of their work activity. Workers who handle the substance have been trained in hazards and controls. Substances are routinely used, handled or stored and personal hygiene control practices are in place. +> +> The applicable standard is BNL SOP IH75190 Operational threshold of 500 µg/100cm² for lead. + +**Public-Childcare:** + +> [FACILITY] is classified as a child-occupied facility subject to EPA/HUD Lead Dust Hazard Standards (October 2024). These standards establish protective thresholds for environments where children may be present. +> +> Applicable thresholds: 0.54 µg/100cm² (floors), 4.3 µg/100cm² (window sills and troughs). + +--- + +## Part 4: Surface Assessment + +### 4.1 Zone Classification + +**Source:** IICRC/RIA/CIRI Technical Guide for Wildfire Restoration (December 2025) + +| Zone | Definition | Typical Characteristics | +|------|------------|------------------------| +| Burn Zone | Direct fire involvement | Structural damage, char, complete combustion | +| Near-Field | Adjacent to burn zone, heavy smoke/heat exposure | Heavy soot deposits, heat damage, strong odor | +| Far-Field | Smoke migration without direct heat exposure | Light to moderate deposits, odor, no structural damage | + +### 4.2 Condition Scale + +| Condition | Visual Indicators | +|-----------|-------------------| +| Background | No visible contamination; equivalent to unaffected areas | +| Light | Faint discoloration; minimal deposits visible on white wipe | +| Moderate | Visible film or deposits; clear contamination on white wipe | +| Heavy | Thick deposits; surface texture obscured; strong odor | +| Structural Damage | Physical damage requiring repair before cleaning | + +### 4.3 Disposition Matrix + +**Non-Porous Surfaces (Steel, Concrete, Glass, Metal):** + +| Zone | Condition | Disposition | Protocol | +|------|-----------|-------------|----------| +| Any | Background | No action | Document only | +| Far-Field | Light | Clean | Standard protocol | +| Far-Field | Moderate | Clean | Full protocol | +| Near-Field | Light | Clean | Full protocol | +| Near-Field | Moderate | Clean | Aggressive protocol, multiple passes | +| Near-Field | Heavy | Clean | Aggressive protocol with verification sampling | +| Burn Zone | Any restorable | Clean | Post-structural repair; aggressive protocol | +| Any | Structural Damage | Remove/Repair | Beyond cleaning scope | + +**Porous/Semi-Porous Surfaces (Drywall, Carpet, Insulation, Acoustic Tile):** + +| Zone | Condition | Disposition | Rationale | +|------|-----------|-------------|-----------| +| Far-Field | Background | Evaluate | May clean if truly superficial | +| Far-Field | Light | Evaluate/Clean | Assessment determines restorability | +| Far-Field | Moderate+ | Remove | Porous materials absorb contaminants | +| Near-Field | Light+ | Remove | Porous materials absorb contaminants and VOCs | +| Burn Zone | Any | Remove | Cannot effectively decontaminate | + +### 4.4 Material Disposition Categories + +**Tier 1: Generally Replace When Fire/Smoke Affected** + +| Material | Rationale | +|----------|-----------| +| Fiberglass insulation | Absorbs particulates and VOCs into fiber matrix | +| Flexible ductwork | Interior lining absorbs contaminants; cannot effectively clean | +| HVAC duct interior insulation | Porous material in air pathway; recontamination risk | +| Mattresses and bedding | Multi-layer foam construction; deep penetration | + +**Tier 2: Assess Based on Condition** + +| Material | Clean When | Remove When | +|----------|------------|-------------| +| Carpet and pad | Far-Field, Light | Near-Field, Moderate+ | +| Drop ceiling tile | Far-Field, Light, smooth | Near-Field, or textured/acoustic | +| Drywall (painted) | Far-Field, Light | Near-Field Moderate+, or unpainted | +| Upholstered furniture | Far-Field, Light, high value | Near-Field, or low value | + +**Tier 3: Generally Cleanable** + +| Material | Standard Protocol | +|----------|-------------------| +| Structural steel | HEPA vac → wet wipe → rinse | +| Concrete (sealed) | Scrubber or power wash | +| Metal doors/frames | Wet wipe → rinse | +| Glass/windows | Wet wipe → squeegee | +| Smooth rigid ductwork | Per NADCA ACR | + +### 4.5 Ceiling Deck Protocol + +Empirical data indicates ceiling deck surfaces require enhanced attention: + +**Finding:** 82.4% pass rate for ceiling decks vs 95%+ for other structural surfaces (n=45, QVC dataset) + +**Requirements:** +- Increase PRV sample density by 50% +- Consider additional cleaning pass before PRV +- Document access method and cleaning thoroughness +- Priority surface for reclean if failures occur + +### 4.6 Secondary Contamination + +If fungal/mold growth is identified during fire damage assessment: +- Document presence, type, and extent +- Cross-reference IICRC S520 for remediation protocols +- Address fire damage and biological contamination as separate scopes +- Sequential remediation may be required (mold first if active growth) + +--- + +## Part 5: Cleaning Protocol Framework + +### 5.1 Standard Cleaning Sequence + +``` +Step 1: HEPA Vacuum + └── Remove loose particulate from all surfaces + +Step 2: Dry Sponge (if needed) + └── Chemical sponge for char/soot on non-porous surfaces + +Step 3: Wet Wipe - Alkaline Detergent + └── pH 10-12 solution for chemical residue removal + +Step 4: Rinse Wipe + └── Clean water to remove detergent residue + +Step 5: Degreaser (if needed) + └── For stubborn residues not removed by standard protocol +``` + +**Sequencing Rule:** Clean top-down (roof deck → structure → walls → floor) to prevent recontamination. + +### 5.2 Surface-Specific Methods + +| Surface Type | Standard Method | +|--------------|-----------------| +| Steel roof deck | HEPA vac → Wet wipe → Rinse | +| Steel joists/beams | HEPA vac → Wet wipe → Rinse | +| Steel columns | HEPA vac → Wet wipe → Rinse | +| Concrete floor | Scrubber machine + alkaline | +| CMU walls | HEPA vac → Wet wipe OR power wash | +| Metal doors | Wet wipe → Rinse | +| Rigid ductwork | Per NADCA ACR | + +### 5.3 Air Filtration Requirements + +**Source:** NADCA ACR 2021 Edition, Section 3.6 + +**Minimum Requirement:** 4 air changes per hour (ACH) + +**Calculation:** +``` +Units Required = (Volume CF × 4 ACH) / (Unit CFM × 60) + +Where: + Volume CF = Area SF × Ceiling Height FT + Unit CFM = Rated capacity of air scrubber +``` + +**Example:** +``` +Work Area: 50,000 SF × 30 FT = 1,500,000 CF +Units = (1,500,000 × 4) / (2,000 CFM × 60) = 50 units +``` + +### 5.4 Reclean/Retest Protocol + +When PRV samples exceed clearance thresholds: + +**Step 1: Identify Deficient Areas** +- Map failed sample locations +- Determine surface types affected +- Assess pattern (localized vs widespread) + +**Step 2: Reclean Specification** +``` +Failed surfaces at [SAMPLE LOCATIONS] require additional cleaning: +- [SURFACE TYPE]: Execute [PROTOCOL] with additional pass +- Extend cleaning 10 feet beyond failed sample locations +- Document cleaning date, method, and personnel +``` + +**Step 3: Retest Protocol** +- Resample at original failed locations +- Add samples at adjacent locations if pattern suggests broader issue +- Same laboratory and analytical methods as original PRV + +**Step 4: Documentation** +- Reference original sample numbers and results +- Document reclean activities +- Report retest results with comparison to original + +**Iteration:** Repeat until all samples pass clearance thresholds. + +--- + +## Part 6: Documentation Outputs + +### 6.1 Deliverable 1: Cleaning Specification / Scope of Work + +**Purpose:** Define scope, methods, labor, equipment, and acceptance criteria for contractor execution. + +**Required Sections:** + +| Section | Content | +|---------|---------| +| Project Identification | Facility, address, contact, dates | +| Scope Summary | Affected areas, zone classifications, total SF by disposition | +| Surface Inventory | Itemized surfaces by type, area, condition, disposition | +| Work Area Preparation | Containment, air filtration calculations (4 ACH minimum) | +| Surface-Specific Procedures | Cleaning methods by surface type | +| Removal Scope | Materials requiring removal with quantities | +| Labor Estimate | Hours by task, production rates applied | +| Equipment Requirements | Air scrubbers, lifts, supplies with quantities | +| Quality Assurance Criteria | Pass/fail thresholds for PRV | +| Worker Protection | PPE, safety protocols | + +**Ceiling Deck Emphasis:** When ceiling decks are in scope, include: +- Note regarding enhanced sample density at PRV +- Recommendation for additional cleaning pass +- Access method requirements + +### 6.2 Deliverable 2: Results Interpretation + +**Purpose:** Establish applicable thresholds with regulatory justification and determine pass/fail status. + +**Required Sections:** + +| Section | Content | +|---------|---------| +| Purpose Statement | Why interpretation needed, specific questions addressed | +| Facility Classification | Operational / Non-Operational / Public-Childcare determination | +| Regulatory Framework | Applicable standards with citations | +| Regulatory Justification | Justification block per Section 3.3 | +| Recommended Thresholds | Specific values with source citations | +| Results Comparison | Actual data vs thresholds | +| Pass/Fail Determination | By sample, by area, overall | +| Reclean Requirements | If applicable, per Section 5.4 | +| Response to Inquiries | Address specific stakeholder questions if applicable | + +**Standards Basis Statement (Required):** +> Metals thresholds are standards-based per BNL SOP IH75190. Particulate thresholds represent professional judgment with empirical validation (see FDAM Appendix B). + +### 6.3 Deliverable 3: Executive Summary Report + +**Purpose:** Document completion and compliance for closeout. + +**Required Sections:** + +| Section | Content | +|---------|---------| +| Project Summary | Identification, scope performed, conclusions | +| Clearance Confirmation | Statement that all areas passed clearance criteria | +| Discussion of Results | Testing summary, any reclean/retest activities | +| Threshold Reference | Thresholds applied with regulatory basis | +| Chronology | Timeline of assessment, cleaning, verification | +| Appendices | Lab reports, photos, field documentation | +| Standard of Care | Professional limitations | +| Standards Basis Statement | Per Section 6.2 | + +--- + +## Part 7: Validation Requirements + +### 7.1 Threshold Validation Status + +| Category | Status | Source | Validation | +|----------|--------|--------|------------| +| Metals (Pb, Cd, As) | **Verified** | BNL SOP IH75190 | Standards-based | +| Particulates | **Validated** | IHC + empirical data | 93.3% pass rate (n=45) | +| ACH requirements | **Verified** | NADCA ACR 2021 | Standards-based | +| Sample density | Professional Judgment | Internal guidance | Ongoing refinement | + +### 7.2 Validation Criteria + +Thresholds are validated when: +- >90% first-pass clearance rate with proper cleaning +- <5% false negatives +- Correlation with absence of occupant complaints post-restoration + +### 7.3 Ongoing Data Collection + +For threshold refinement, collect: +- Condition assessment + lab result + clearance outcome (paired) +- Surface type performance data +- Reclean frequency by surface type +- Control/background sample baselines + +--- + +## Part 8: System Architecture + +### 8.1 SmokeScan Implementation + +``` +FIELD DEVICE +├── Project/building/zone/room hierarchy +├── Zone classification with distance documentation +├── Surface inventory (type, material, condition, area) +├── Photo capture with metadata +├── Sample location documentation +└── Offline capability with sync + +CLOUD PLATFORM +├── Project data management +├── Lab result entry and threshold comparison +├── SOW calculations (quantities, labor, equipment) +├── Document generation +├── Pass/fail determination with threshold source flagging +└── Report export +``` + +### 8.2 Calculation Engine + +**Surface Area Aggregation:** +``` +Total by Type = Σ(Surface.area) WHERE Surface.type = [type] +Total by Disposition = Σ(Surface.area) WHERE Surface.disposition = [action] +``` + +**Equipment Sizing:** +``` +Air Scrubbers = (Total Volume × 4 ACH) / (Unit CFM × 60) +``` + +**Pass/Fail Determination:** +``` +FOR each Result: + Threshold = Lookup(Analyte, Classification) + ThresholdSource = Lookup(Analyte, Source) + IF Result < Threshold THEN Pass ELSE Fail + FLAG if ThresholdSource = "Professional Judgment" +``` + +--- + +## Part 9: Future Research + +### 9.1 Field Screening Methods + +**Optical Density Approach:** +Develop calibrated visual assessment correlating reflectance measurements to contamination levels. + +**Research Questions:** +- Can OD measurements correlate with tape lift particle counts? +- What calibration protocol provides reliable results? + +### 9.2 Control Sample Protocol + +**Decision Required:** Determine whether control/background samples should be mandatory for relative comparison, or if absolute thresholds are sufficient. + +**Options:** +- A: Mandatory control sample with relative pass/fail logic +- B: Control samples recommended but absolute thresholds authoritative +- C: Control samples required only for disputed results + +### 9.3 Surface-Specific Threshold Refinement + +With additional data collection, evaluate whether surface-specific thresholds are warranted (e.g., tighter thresholds for ceiling decks given higher failure rates). + +--- + +## Appendix A: Lab Result Interpretation Framework + +### A.1 Supported Laboratory Formats + +FDAM supports two primary laboratory reporting formats: + +**Format 1: Quantitative (particles/cm²)** +- Labs: Hayes Microbial, EMSL, others +- Direct comparison to FDAM thresholds +- Preferred format for pass/fail determination + +**Format 2: Semi-Quantitative (% particles per field at 400x)** +- Labs: N.G. Carlson Analytical, EAA Baxter methodology +- Requires interpretation guidance +- Methodological differences from Format 1 + +### A.2 Format 1: Quantitative Interpretation + +Direct threshold comparison: + +| Analyte | Result | Threshold | Determination | +|---------|--------|-----------|---------------| +| Ash/Char | [value]/cm² | < 150/cm² | PASS if < 150 | +| Aciniform Soot | [value]/cm² | < 500/cm² | PASS if < 500 | + +### A.3 Format 2: Semi-Quantitative Interpretation + +**Source:** EAA Air-O-Cell Method Guide & Particle Atlas (2018); EMSL Fire & Smoke Damage Guide 2021 + +| % per Field (400x) | Lab Interpretation | FDAM Guidance | +|--------------------|-------------------|---------------| +| < 1% | Typical low | Presumed PASS - consistent with clearance | +| < 3% | Upper background | Presumed PASS - within acceptable range | +| 3-10% | Moderate impact | Professional judgment required | +| > 10% | Significant impact | Presumed FAIL - additional cleaning likely required | + +**Methodological Caveat:** +Percentage-per-field and particles/cm² are fundamentally different analytical approaches. The guidance above represents professional correlation, not mathematical conversion. When results fall in the 3-10% range, consider: +- Visual condition at sample location +- Comparison to control samples +- Overall project context +- Retesting with quantitative methodology if determination is critical + +### A.4 Decision Logic + +``` +INPUT: Lab Result + Format + Facility Classification + +STEP 1: Identify Format + IF particles/cm² → Use A.2 direct comparison + IF % per field → Use A.3 interpretation guidance + +STEP 2: Determine Threshold + Metals → Per Facility Classification (Section 3.1) + Particulates → Standard thresholds (Section 1.6) + +STEP 3: Compare and Determine + IF Result < Threshold → PASS + IF Result > Threshold → FAIL + IF Semi-quantitative in judgment range → Flag for professional review + +STEP 4: Document + Record result, threshold, source, determination + Flag professional judgment thresholds +``` + +### A.5 Laboratory Selection Guidance + +When selecting laboratories: +- Confirm reporting format before submission +- Request particles/cm² format when available +- Ensure consistent methodology across PRA and PRV sampling +- Request differentiation notes if atypical particles observed + +### A.6 Unit Conversion Reference + +Laboratories may report surface particle concentrations in different units. Use the following conversions: + +**Area Conversions:** +``` +1 cm² = 100 mm² +cts/mm² × 100 = cts/cm² +cts/cm² ÷ 100 = cts/mm² +``` + +**Common Laboratory Unit Formats:** + +| Lab Format | Unit | Conversion to FDAM (cts/cm²) | +|------------|------|------------------------------| +| Hayes Microbial | cts/cm² | Direct comparison | +| EAA | cts/mm² | Multiply by 100 | +| N.G. Carlson | % per field | Use Appendix A.3 guidance | + +**Example Conversion:** +- EAA reports: 5.0 cts/mm² fire residue +- FDAM equivalent: 5.0 × 100 = 500 cts/cm² +- Threshold comparison: 500 cts/cm² vs <150 (Ash/Char) = FAIL + +**EAA Classification to FDAM Threshold Comparison:** + +| EAA Classification | EAA (cts/mm²) | Converted (cts/cm²) | FDAM Status | +|--------------------|---------------|---------------------|-------------| +| Low | <1.0 | <100 | PASS | +| Typical-low | 1.0-5.0 | 100-500 | Evaluate vs threshold | +| Low-moderate | 5.0-10 | 500-1,000 | Likely FAIL | +| Moderate | 10-50 | 1,000-5,000 | FAIL | +| High | >50 | >5,000 | FAIL | + +FDAM clearance thresholds (150 cts/cm² ash/char, 500 cts/cm² aciniform) fall within or at the upper boundary of EAA's "Typical-low" classification (100-500 cts/cm²), confirming FDAM thresholds are appropriately conservative for post-restoration clearance. + +--- + +## Appendix B: Empirical Validation Data + +### B.1 QVC Distribution Center Dataset + +**Project:** QVC Outbound Fire Loss Restoration +**Location:** Rocky Mount, NC +**Date:** March 2023 +**Sample Type:** Post-Restoration Verification (PRV) +**Sample Count:** 45 Bio-Tape samples (1.00 cm²) +**Laboratory:** Hayes Microbial Consulting +**Facility Classification:** Non-Operational Commercial + +### B.2 Results Summary + +**Aciniform-like Soot:** + +| Statistic | Value | +|-----------|-------| +| Non-Detect | 21 samples (46.7%) | +| Range (detected) | 1 - 2,200/cm² | +| Median (detected) | 4.5/cm² | +| 90th Percentile | 65/cm² | +| Pass Rate | 91.1% (41/45) | + +**Ash and Char:** + +| Statistic | Value | +|-----------|-------| +| Non-Detect | 2 samples (4.4%) | +| Range (detected) | 1 - 440/cm² | +| Median (detected) | 5/cm² | +| 90th Percentile | 60/cm² | +| Pass Rate | 97.8% (44/45) | + +**Combined Pass/Fail:** + +| Status | Count | Percentage | +|--------|-------|------------| +| Both Pass | 42 | 93.3% | +| Any Fail | 3 | 6.7% | + +### B.3 Surface Type Analysis + +| Surface Type | Samples | Pass Rate | +|--------------|---------|-----------| +| Ceiling Deck (CD) | 17 | 82.4% | +| Ceiling Joist (CJ) | 20 | 95.0% | +| Beam | 6 | 100% | +| Column | 1 | 100% | +| Pipe | 1 | 100% | + +**Finding:** Ceiling decks exhibit significantly lower pass rates, driving the ceiling deck emphasis protocol in Section 4.5. + +### B.4 Failed Sample Analysis + +| Sample | Location | Aciniform | Ash/Char | Failure | +|--------|----------|-----------|----------|---------| +| 02 | B2-C2 Grid - Ceiling Deck | 2,200/cm² | 4/cm² | Aciniform | +| 06 | D2-E2 Grid - Ceiling Deck | 1,320/cm² | 15/cm² | Aciniform | +| 12 | E3-F3 Grid - CJ Horizontal | 8/cm² | 440/cm² | Ash/Char | + +All failures were addressed through reclean/retest protocol and subsequently passed. + +### B.5 Laboratory Reference Ranges + +**Source:** Hayes Microbial Consulting, based on ASTM D6602 + +| Particle Type | Normal Surface Range | +|---------------|---------------------| +| Ash/Char | 0-300/cm² | +| Aciniform Soot | 0-800/cm² | +| Cellulose Fibers | 0-1,600/cm² | +| Synthetic Fibers | 0-1,600/cm² | +| Silicates | 0-2,800/cm² | + +These ranges represent typical environments, not post-fire clearance criteria. FDAM thresholds are set below these ranges to ensure demonstrably clean post-restoration conditions. + +### B.6 Our Lady of Victory Dataset + +**Project:** Our Lady of Victory (Catholic School) +**Location:** Minnesota +**Date:** February 2025 +**Sample Type:** Assessment +**Sample Count:** 55 tease-tape samples +**Laboratory:** N.G. Carlson Analytical +**Facility Classification:** Public-Childcare + +**Methodology:** Semi-quantitative (% particles per field at 400x) + +**Distribution by Impact Level:** + +| Impact Level | Samples | Percentage | +|--------------|---------|------------| +| No Char/No Soot | 14 | 27% | +| Typical Low (<1%) | 25 | 48% | +| Upper Background (<3%) | 7 | 13% | +| Moderate (3-10%) | 5 | 10% | +| Significant (>10%) | 1 | 2% | + +**Pattern Observation:** Basement and lower-level areas showed higher contamination, consistent with smoke stratification. + +--- + +## Appendix C: Deliverable Templates + +### C.1 Cleaning Specification / SOW - Key Language Blocks + +**Scope Statement:** +> [FACILITY] sustained fire damage on [DATE]. Industrial Hygiene Consulting, Corp. (IHC) conducted Pre-Restoration Assessment on [DATE]. Based on laboratory analysis and field assessment, the following cleaning specification establishes scope, methods, and acceptance criteria for fire residue restoration. + +**Zone Summary Table:** +``` +| Zone | Area (SF) | Condition | Disposition | +|------|-----------|-----------|-------------| +| [Zone ID] | [SF] | [Condition] | Clean/Remove | +``` + +**Air Filtration Calculation:** +> Work area volume: [SF] × [Height] = [CF] +> Required ACH: 4 (NADCA ACR 2021) +> Air scrubber capacity: [CFM] per unit +> Units required: ([CF] × 4) / ([CFM] × 60) = [Units] + +**Acceptance Criteria:** +> Post-restoration verification sampling will be conducted per FDAM methodology. Clearance thresholds: +> - Ash and Char: < 150 particles/cm² +> - Aciniform Soot: < 500 particles/cm² +> - Lead: [Threshold] µg/100cm² per [Classification] standards +> +> Surfaces exceeding thresholds require reclean and retest until passing. + +### C.2 Results Interpretation - Key Language Blocks + +**Purpose Statement:** +> IHC provides this results interpretation to establish applicable clearance thresholds for [FACILITY] based on facility classification and regulatory framework. + +**Classification Determination:** +> [Insert applicable regulatory justification block from Section 3.3] + +**Threshold Table:** +``` +| Analyte | Threshold | Unit | Source | +|---------|-----------|------|--------| +| Lead | [value] | µg/100cm² | [BNL/EPA-HUD] | +| Ash/Char | 150 | particles/cm² | IHC/FDAM | +| Aciniform | 500 | particles/cm² | IHC/FDAM | +``` + +**Pass/Fail Summary:** +> Of [N] samples collected, [X] passed all clearance thresholds. [Y] samples exceeded thresholds and require reclean/retest per Section 5.4. + +**Standards Basis Statement:** +> Metals thresholds are standards-based per BNL SOP IH75190 (Rev23, 06/23/17). Particulate thresholds represent professional judgment developed through IHC field experience with empirical validation (93.3% pass rate, n=45). + +### C.3 Executive Summary - Key Language Blocks + +**Clearance Statement:** +> Based on post-restoration verification testing conducted [DATE], all tested surfaces within [FACILITY] meet applicable clearance criteria. The fire residue restoration is complete and the facility is cleared for reoccupancy. + +**Testing Summary:** +> [N] tape lift samples and [N] surface wipe samples were collected from [AREAS]. All results were below applicable thresholds. + +**Threshold Reference:** +> Clearance thresholds applied: +> - Lead: [value] µg/100cm² (BNL SOP IH75190, Non-Operational) +> - Particulates: < 150/cm² ash/char, < 500/cm² aciniform (IHC/FDAM professional judgment with empirical validation) + +--- + +## Appendix D: Reference Standards Compendium + +### D.1 Primary Standards (Verified) + +| Standard | Title | Version | Application | +|----------|-------|---------|-------------| +| BNL SOP IH75190 | Surface Wipe Sampling for Metals | Rev23, 06/23/17 | Metals clearance thresholds | +| EPA/HUD Lead Dust Hazard Standards | Lead Dust Hazard Standards | October 2024 | Public-Childcare lead thresholds | +| NADCA ACR | Assessment, Cleaning and Restoration of HVAC Systems | 2021 Edition | Air filtration requirements | +| IICRC/RIA/CIRI Technical Guide | Technical Guide for Wildfire Restoration | December 2025 | Zone framework | +| Army/Air Force National Guard | Guidelines for Indoor Firing Range Rehabilitation | Current | Non-Operational lead alternative | + +### D.2 Referenced Standards + +| Standard | Application | +|----------|-------------| +| OSHA 29 CFR 1910.1025 | Lead housekeeping requirements | +| OSHA 29 CFR 1910.1018 | Arsenic housekeeping requirements | +| OSHA 29 CFR 1910.1027 | Cadmium housekeeping requirements | +| OSHA Technical Manual Section II Ch. 2 | Surface contaminant methodology | +| NIOSH Method 9100 | Surface wipe sampling procedures | +| IICRC S700 | Standard for Fire and Smoke Damage Restoration | +| IICRC S520 | Standard for Mold Remediation | +| ASTM D6602 | Sampling and Testing of Carbon Black | + +### D.3 Laboratory References + +| Reference | Application | +|-----------|-------------| +| Environmental Analysis Associates (EAA) Air-O-Cell Method Guide & Particle Atlas (2018) | Combustion particle definitions; classification ranges; unit conversion reference; semi-quantitative interpretation | +| EMSL Fire & Smoke Damage Guide 2021 | Sampling procedures | +| Hayes Microbial Normal Ranges | Reference comparison (ASTM D6602 based) | + +**Note on EAA:** Environmental Analysis Associates, founded by Daniel Baxter (inventor of the Air-O-Cell sampler), maintains 30+ years of indoor air quality data. Their classification system provides independent validation of FDAM threshold positioning. EAA reports in cts/mm² (convert to cts/cm² by multiplying by 100). + +--- + +*FDAM v4.0.1 — End of Document* diff --git a/RAG-KB/Fire Remediation Processes and Methodologies_ A Review of Industry-Endorsed Standards.md b/RAG-KB/Fire Remediation Processes and Methodologies_ A Review of Industry-Endorsed Standards.md new file mode 100644 index 0000000000000000000000000000000000000000..cfa00b4b42816e6414a323d935069606a4599b10 --- /dev/null +++ b/RAG-KB/Fire Remediation Processes and Methodologies_ A Review of Industry-Endorsed Standards.md @@ -0,0 +1,86 @@ +# Fire Remediation Processes and Methodologies: A Review of Industry-Endorsed Standards + +**Author:** Manus AI +**Date:** January 8, 2026 + +## 1. Introduction + +This report provides a comprehensive overview of industry-endorsed and published sources of domain knowledge for fire remediation processes and methodologies. The research project focused on identifying key standards, guidelines, and technical publications from major standards organizations, government agencies, and industry associations. The findings are intended to serve as a foundational resource for professionals in the fire restoration, insurance, and environmental health and safety sectors. + +The fire and smoke damage restoration industry relies on a robust framework of standards and best practices to ensure that remediation work is performed safely, effectively, and in a scientifically defensible manner. This report synthesizes information from a wide range of sources, including the Institute of Inspection, Cleaning and Restoration Certification (IICRC), the National Fire Protection Association (NFPA), ASTM International, the Restoration Industry Association (RIA), the U.S. Environmental Protection Agency (EPA), and the Occupational Safety and Health Administration (OSHA). + +## 2. Key Standards and Guidelines + +The following sections detail the most relevant standards and guidelines from leading organizations in the field of fire and smoke damage restoration. + +### 2.1. Institute of Inspection, Cleaning and Restoration Certification (IICRC) + +The IICRC is a key standards-setting body for the restoration industry. Its standards are ANSI-accredited and internationally recognized as best practices. + +**ANSI/IICRC S700: Standard for Professional Fire and Smoke Damage Restoration** [1] + +This is the cornerstone standard for the fire and smoke damage restoration industry. It provides a comprehensive framework for the assessment and remediation of fire and smoke damage in buildings. The S700 standard covers the principles, processes, and procedures for assessing fire residues and odors, and for the cleaning and restoration of building systems, structures, and contents. It is important to note that the S700 standard is currently under revision, with a new version expected in the near future. + +**ANSI/IICRC S590: Standard for Professional Assessment of HVAC Systems Following a Water, Fire, or Mold Damage Event** [2] + +This standard focuses specifically on the assessment of HVAC systems after a fire or other damaging event. It provides detailed procedures for inspecting and evaluating HVAC systems to determine the extent of damage and to develop a restoration plan. The S590 standard is critical for ensuring that HVAC systems are properly cleaned and decontaminated to prevent the spread of contaminants throughout a building. + +**IICRC/RIA/CIRI Technical Guide for Wildfire Restoration** [3] + +Published in December 2025, this technical guide provides a science-based framework for the restoration of properties impacted by wildfires. It was developed in collaboration with the Restoration Industry Association (RIA) and the Cleaning Industry Research Institute (CIRI). The guide outlines a four-step process for wildfire restoration, including pre-restoration evaluation, pre-restoration assessment, the restoration phase, and project completion. + +### 2.2. National Fire Protection Association (NFPA) + +The NFPA is a global nonprofit organization devoted to eliminating death, injury, property, and economic loss due to fire, electrical, and related hazards. The NFPA develops and publishes more than 300 consensus codes and standards intended to minimize the risk and effects of fire. + +**NFPA 921: Guide for Fire and Explosion Investigations** [4] + +NFPA 921 is the primary guide for the scientific investigation of fire and explosion incidents. It establishes a systematic, scientific method for fire investigation that is widely accepted in the legal and insurance communities. The guide provides detailed information on fire dynamics, evidence collection and preservation, and the analysis of fire patterns. + +**NFPA 1033: Standard for Professional Qualifications for Fire Investigator** [5] + +This standard establishes the minimum job performance requirements for fire investigators. It is a critical standard for ensuring that fire investigations are conducted by qualified professionals with the necessary knowledge, skills, and abilities. + +### 2.3. ASTM International + +ASTM International is a globally recognized leader in the development and delivery of voluntary consensus standards. ASTM standards are used around the world to improve product quality, enhance health and safety, strengthen market access and trade, and build consumer confidence. + +**ASTM E119: Standard Test Methods for Fire Tests of Building Construction and Materials** [6] + +This standard is used to evaluate the fire-resistance of building materials and assemblies. It provides a standardized method for testing how long building elements can withstand a fire and continue to perform their structural function. + +**ASTM C856: Standard Practice for Petrographic Examination of Hardened Concrete** [7] + +This standard is used to assess the condition of concrete after a fire. It provides a method for examining the microstructure of concrete to determine the extent of damage and to guide repair and restoration efforts. + +## 3. Government Agencies + +Government agencies such as the EPA and OSHA also play a role in the fire restoration industry by providing guidelines and regulations related to environmental protection and worker safety. + +### 3.1. U.S. Environmental Protection Agency (EPA) + +The EPA provides guidance on the cleanup of hazardous materials after a fire, as well as on the management of debris and waste from fire-damaged buildings. The EPA's guidelines are designed to protect human health and the environment from the potential hazards associated with fire and smoke damage. + +### 3.2. Occupational Safety and Health Administration (OSHA) + +OSHA sets and enforces standards to ensure safe and healthful working conditions for working men and women. OSHA's regulations cover a wide range of workplace hazards, including those associated with fire and smoke damage restoration. These regulations include requirements for personal protective equipment (PPE), respiratory protection, and hazard communication. + +## 4. Conclusion + +The fire remediation industry is governed by a complex and evolving set of standards, guidelines, and best practices. This report has provided an overview of the key organizations and documents that shape the industry. It is essential for professionals in the field to stay current with these standards to ensure that they are providing the highest quality of service to their clients and to protect the health and safety of workers and the public. + +## 5. References + +[1] Institute of Inspection, Cleaning and Restoration Certification. (n.d.). *ANSI/IICRC S700 Standard for Professional Fire and Smoke Damage Restoration*. Retrieved from https://iicrc.org/s700/ + +[2] Institute of Inspection, Cleaning and Restoration Certification. (n.d.). *ANSI/IICRC S590 Standard for Professional Assessment of HVAC Systems Following a Water, Fire, or Mold Damage Event*. Retrieved from https://iicrc.org/s590/ + +[3] IICRC, RIA, & CIRI. (2025, December). *Technical Guide for Wildfire Restoration*. Retrieved from https://iicrc.org/wp-content/uploads/2025/12/IICRC.RIA_.CIRI-Technical-Guide-for-Wildfire-Restoration-V2-Final-2025-12.09.pdf + +[4] National Fire Protection Association. (n.d.). *NFPA 921: Guide for Fire and Explosion Investigations*. Retrieved from https://www.nfpa.org/codes-and-standards/nfpa-921-standard-development/921 + +[5] National Fire Protection Association. (n.d.). *NFPA 1033: Standard for Professional Qualifications for Fire Investigator*. Referenced in industry documentation. + +[6] ASTM International. (2020). *ASTM E119-20: Standard Test Methods for Fire Tests of Building Construction and Materials*. Retrieved from https://www.astm.org/e0119-20.html + +[7] ASTM International. (n.d.). *ASTM C856: Standard Practice for Petrographic Examination of Hardened Concrete*. Referenced in industry practice. diff --git a/RAG-KB/Industrial Hygiene Lab Services Guide.md b/RAG-KB/Industrial Hygiene Lab Services Guide.md new file mode 100644 index 0000000000000000000000000000000000000000..9ab76417eb76e7fd6a918319ac9baa648a7de349 --- /dev/null +++ b/RAG-KB/Industrial Hygiene Lab Services Guide.md @@ -0,0 +1,369 @@ +# Industrial Hygiene Lab Services Guide + +**EMSL Analytical, Inc. - 2023 Edition** + +*Methods and Threshold Values Reference* + +--- + +## Table of Contents + +1. [About EMSL Analytical, Inc.](#about-emsl-analytical-inc) +2. [EMSL Diamond Standard](#emsl-diamond-standard) +3. [Locations and Network](#locations-and-network) +4. [Industrial Hygiene Testing Services](#industrial-hygiene-testing-services) +5. [Comprehensive Analyte List (A-Z)](#comprehensive-analyte-list-a-z) +6. [Group Profiles](#group-profiles) +7. [Rental Equipment](#rental-equipment) + +--- + +## About EMSL Analytical, Inc. + +EMSL Analytical, Inc. has been providing quality analytical services since 1981 as the nation's leading environmental testing firm. The company offers a wide array of analytical testing services to support environmental investigations focused on asbestos, microbiology, lead paint, environmental chemistry, indoor air quality, industrial hygiene and food testing. Additionally, EMSL provides materials testing, characterization, and forensic laboratory services for a wide range of commercial, industrial, regulatory, and law enforcement clients. + +The company's unmatched capacity coupled with a company-wide focus on customer satisfaction makes no project too large or too small. EMSL's corporate research and development capabilities allow them to bring new methodologies online quickly to meet new industry challenges and client needs. In recruiting and retaining talented and motivated scientists on a national scope, their expertise is marshaled throughout a nationwide network of analytical laboratories. EMSL is committed to providing reliable, defensible data in a standardized and user-friendly format. Rapid turnaround and competitive prices make the dependable results clients get that much more valuable. + +**Mission Statement:** "We're much more than another testing laboratory. We are your project partner!" + +### Overview of EMSL Service Divisions + +#### Asbestos +- Asbestos analysis of air, water, bulk, soil and/or dust samples +- Various methodologies including NIOSH, EPA, OSHA, ASTM, etc. +- Utilizing PCM, PLM, TEM, SEM, XRD, and STEM + +#### Lead and Metals +- Testing services include Flame AA, Graphite Furnace, and ICP +- Lead testing in paint chips, soil, wipes, drinking water, waste water, and air + +#### Microbiology +- Analysis of fungi (mold), bacteria (Legionella, E. coli, Salmonella, Listeria, etc.) +- Mycotoxins, endotoxins, allergens, pollen testing +- Particulates in air, swab, water, soil, bulk, dust, wipe, food, and consumer products + +#### Industrial Hygiene +- Testing services for air, wipe, and bulk matrices +- Extensive list of NIOSH, OSHA, ASTM, and EPA methods + +#### Environmental Chemistry +- Instrumental and classical wet chemistry +- ICP spectroscopy, microscopy, SEM and EDS analysis +- FTIR analysis and more + +#### Materials Science +- Materials testing, characterization, and forensic laboratory services +- Support for commercial, industrial, regulatory, and law enforcement clients +- Solutions for manufacturing challenges, quality assurance, and research and development + +#### Food +- Microbiology analysis, nutritional analysis +- Various food chemistry analysis +- Allergens, toxins, and adulteration analysis + +#### Radiochemistry +- Analysis of various matrices including food, water, soil, vegetation +- Other unique sample types for radioactivity +- Liberal radioactive materials license for most environmental radioactive needs + +#### Air Toxics +- Testing services for VOCs in air, water and soil +- Consumer products testing +- Chamber studies for consumer product off-gassing analyses +- Understanding what products are emitting and comply with regulations + +#### Pharmaceutical +- Microbiology testing services through MPL Laboratories +- Pharmaceutical, medical device, cosmetic, personal care, and food industries +- ISO/IEC 17025 accredited by PJLA, FDA and DEA registered, and NJDEP certified + +#### PCR-DNA +- DNA and PCR laboratory services +- Bacteria, ERMI, fungi, and mold testing +- Scientific, ecological, research, biological, microbiological, environmental, food, and botanical professionals + +#### Training +- Array of training including online educational courses +- Various laboratory services sampling videos +- In-person training + +#### Products +- Environmental products, equipment, and supplies for the field +- Support for each company division + +#### Legal Services +- Highly qualified and experienced professionals +- Chemists, geologists, physicists, mycologists, microbiologists, biologists, materials scientists, and industrial hygienists +- Available as-needed for legal support and expert witness testimony + +--- + +## EMSL Diamond Standard + +EMSL's diverse staff of approximately 1,000 employees possess a wide range of expertise, educational background, and capabilities. These dedicated employees follow the lead and standard of care demonstrated by the owner and founder of the company, Dr. Peter Frasca, who, as a hands-on owner maintains daily involvement in laboratory operations, and assures work is consistent with the **EMSL Diamond Standard**. + +### The Diamond Standard Includes: + +#### Quality Data +Track, manage, report, and verify that the data from all accredited testing services are accurate and reliable through quality programs and regulatory requirements. + +#### Customer Dedication +EMSL strives to create lasting, mutually beneficial relationships with all clients. The company solicits feedback from clients and is committed to responding quickly to any questions or concerns that may arise before, during, or after an assignment. + +#### Analytical Expertise +EMSL employs highly qualified and experienced chemists, geologists, physicists, mycologists, microbiologists, biologists, materials scientists, and industrial hygienists to enhance analytical abilities and expertise. + +#### Integrity and Ethics +EMSL insists that employees uphold the highest standard of ethics. The company maintains a "no-compromise" policy as it pertains to any ethical issue. + +#### Responsiveness +EMSL recognizes that the timeliness of a report is as important as the quality of the data. The company will not however, allow deadlines or the rush needs of a project to adversely impact quality objectives. + +#### Technology +EMSL recognizes the importance of new technology to better enable improved services. Online access to data, customized reports, sample control/processing through the Laboratory Information Management System (LIMS), and analytical instrumentation are continuously upgraded to enable continuous improvement of services and capabilities. + +#### Value +EMSL believes that a business relationship provides clients with excellent value. The company provides a complete value package that includes all the components of the EMSL Diamond Standard. + +--- + +## Locations and Network + +### Locally Focused, Nationally Recognized + +**Unmatched capacity from the collective strength of nationwide locations.** + +EMSL Analytical, Inc. has been fortunate to be able to maintain a solid history of stable growth and viability for over 40 years with a current network consisting of **48 laboratories and 2 service centers** across the United States and Canada. + +**Corporate Headquarters:** Cinnaminson, NJ USA (also home to LA Testing) + +--- + +## Industrial Hygiene Testing Services + +EMSL Analytical, Inc. provides Industrial Hygiene (IH) Laboratory Services for air, wipe, and bulk matrices on an extensive list of NIOSH, OSHA, ASTM, and EPA test methods, boasting five IH laboratory locations within North America: + +- **EMSL's Corporate Laboratory** - Cinnaminson, NJ +- **Indianapolis, IN** +- **Charlotte, NC** +- **Huntington Beach, CA** (LA Testing) +- **Toronto, ON** (Canadian location) + +### Professional Team + +The team of qualified and experienced professionals includes board-certified Industrial Hygienists (CIH), as well as highly trained project managers and analysts that welcome client interaction at project inception to ensure the laboratory data will meet all of the intended goals of the event, as well as communication during and after the event, as well as while samples are in-house. EMSL believes clear and concise communication is imperative to each project's success. + +### Accreditation and Certifications + +EMSL maintains **AIHA accreditation** for tests performed by the IH laboratories, which includes: +- On-site laboratory audits +- Formal document review program +- Staff experience and education criteria +- Proficiency Testing Program as part of the Accreditation process + +Additionally, as required by various states, EMSL IH laboratories hold most applicable state certifications for fields of testing for air samples. + +### Equipment and Quality Control + +EMSL has state of the art equipment within each of the five IH laboratory locations, including: +- GC-ECD/GC-FID/GC-MS +- LC, MS, MS/HPLC/LC/MS/IC/XRD/UV-VIS/ICP-AES +- OES/ICP-MS +- And more + +The analysis and reporting of each individual sample includes analysis of Quality Control (QC) samples, programs such as: +- Instrument QC controls +- Calibration standard checks +- Spiked media +- Reporting limit controls + +All to ensure the confidence limits of the data are within the acceptable range as specified by the method requirements and Quality Control Program. + +### Turnaround Times (TATs) + +Labs maintain normal business day operational hours with weekend scheduling availability as needed for critical response situations. Samples are received during regular business hours and turnaround times (TATs) are tracked on business days. + +**Available TATs:** +- Same day or next day +- 2 day +- 3 day +- 4 day +- 1 week +- 2 week Standard TATs + +Costs/rates are based on the TAT requested with the 2 week TAT rates being the most economically cost-effective for customers. + +### Laboratory Information Management System (LIMS) + +Sample control/processing (log-in, results data-entry, reporting) is facilitated by the Laboratory Information Management System (LIMS) which tracks the sample job (batch) and provides the laboratory with work log (due dates) to help ensure all the work is organized and processed in accordance with the client's needs. + +The LIMS includes security controls to ensure that information is controlled (locked) once the data has been documented and entered by the bench chemists. Reports are delivered at the choice of the customer which would include email, hard-copy regular mail, or both. + +Additionally, EMSL can provide: +- Electronic Data Deliverables (EDD) +- Various QC Data Packages (contact for package pricing) + +### Sampling Media and Pumps + +Regarding media and pumps, EMSL offers a **"free IH sampling pump program"** for clients, provided the analysis is performed by one of the IH laboratories. An extensive list of products and media for sale is available, including: pumps, badges, field equipment/monitors, etc., all of which can be viewed via the website. + +### Key Tests Available + +The following is a summary of key tests (but are not limited to): + +#### NIOSH Methods +- NIOSH 0500, 0600, 1003M, 1005M, 1007, 1013, 1019, 1024, 1300, 1301, 1400M, 1401, 1402, 1403, 1405, 1450, 1453, 1457, 1500M, 1501M, 1550M, 1603M +- NIOSH 1604, 1606M, 1610, 1612, 1615, 1616, 2000M, 2016M, 2500M, 2532, 2537, 2546M, 2551M, 3500, 5008M, 5026, 5040, 5041, 5042M, 5503M, 5506M, 5510M, 5523, 5524 +- NIOSH 5600, 5601M, 6004M, 6009M, 6010M, 6011, 6013, 6014, 6016, 7082, 7401, 7500, 7501, 7600, 7602, 7906, 7907, 7908, 7908M, 9111M + +#### OSHA Methods +- OSHA 42/47M, OSHA 5002M, OSHA 56, OSHA 58M, OSHA 64, OSHA 80, OSHA 83M, OSHA 91, OSHA 99M, OSHA 104, OSHA 109, OSHA 1007, OSHA 1008, OSHA 1010 V2, OSHA 1014, OSHA 1018, OSHA 1019, OSHA 103M, OSHA 5001, OSHA ID-113, OSHA ID-140 +- OSHA ID-145, OSHA ID-165SG, OSHA ID-182, OSHA ID-188M, OSHA ID-190, OSHA ID-214, OSHA ID-215 V2, OSHA PV2061, OSHA PV2111, OSHA PV2119 + +#### Other Methods +- 40CFR50, Appendix B +- 40CFR50, Appendix J +- 40CFR50, Appendix L +- AssayTech LP 575 +- ASTM D5504 +- EPA IP-10A +- EMSL In-House Methods + +**Note:** If you are looking for a method that is not listed, please contact EMSL immediately to confirm if they can perform. The list of services is being expanded regularly. + +*For a full list of tests offered and for pricing, call for details.* + +--- + +## Comprehensive Analyte List (A-Z) + +This section contains detailed information about each analyte tested by EMSL's Industrial Hygiene laboratories. The list includes CAS numbers, test methods, synonyms, sampling instructions, flow rates, media types, and occupational exposure limits (OELs) from various regulatory agencies. + +### Understanding the Analyte Table Columns + +- **CAS Number:** Chemical Abstracts Service registry number for unique identification +- **Test:** Common name of the analyte +- **Test Method:** Specific NIOSH, OSHA, ASTM, or EPA method used +- **Synonym(s):** Alternative names for the chemical +- **Test Code:** EMSL internal test identification code +- **OSHA PEL or Other Value:** Occupational Safety and Health Administration Permissible Exposure Limit or other regulatory values +- **Most Relevant OEL (Value):** Most applicable Occupational Exposure Limit with value +- **Default Reporting Limit:** Minimum detection limit for the test +- **Sampling Instructions:** Special handling or storage requirements +- **Flow Rate (lpm):** Liters per minute for air sampling +- **Volume (L):** Total air volume to be sampled +- **Media:** Collection media types (filters, sorbent tubes, etc.) +- **Pump Kit ID:** EMSL equipment identification numbers + +### Sample Analytes (Alphabetical) + +| CAS Number | Analyte | Test Method | Synonym(s) | Key OEL | +|:-----------|:--------|:------------|:-----------|:--------| +| 83-32-9 | Acenaphthene | NIOSH 5506M | Dihydroacenaphthylene | 0.2 mg/m³ OSHA PEL TWA | +| 208-96-8 | Acenaphthylene | NIOSH 5506M | Acenaphthalene | 0.2 mg/m³ OSHA PEL TWA | +| 75-07-0 | Acetaldehyde | NIOSH 2016M | Acetic Aldehyde; Ethyl Aldehyde | 200 ppm OSHA PEL TWA | +| 64-19-7 | Acetic Acid | NIOSH 1603M | Ethanoic Acid | 10 ppm OSHA PEL TWA | +| 513-86-0 | Acetoin | NIOSH 2558 | 3-Hydroxy-2-Butanone | Not Established | +| 67-64-1 | Acetone | NIOSH 2016M | Dimethyl Ketone | 1000 ppm OSHA PEL TWA | + +*Note: This is a representative sample. The complete guide contains hundreds of analytes from A-Z with full technical specifications, sampling parameters, and regulatory threshold values. Contact EMSL for the complete analyte database or specific chemical information.* + +### Special Notes for Sampling + +Many analytes require specific handling: +- **Light-sensitive compounds:** Protect from light and heat, wrap in foil +- **Volatile compounds:** Store in freezer, ship cold (5°C) +- **Temperature-sensitive:** Ship refrigerated (0°C) +- **Reactive compounds:** Special storage and shipping requirements noted + +--- + +## Group Profiles + +EMSL offers pre-configured test packages for common industrial hygiene scenarios. These group profiles streamline the testing process for frequently requested analyte combinations. + +*Detailed group profile information is available on pages 59-61 of the complete guide.* + +Common group profiles may include: +- **Volatile Organic Compounds (VOCs)** - Common workplace air contaminants +- **Metals Panel** - Comprehensive metals analysis for industrial settings +- **Welding Fumes** - Specific metals and compounds from welding operations +- **Solvent Mixtures** - Common solvent combinations in manufacturing +- **Diesel Particulate Matter** - Complete diesel exhaust characterization +- **Pharmaceutical Compounds** - Active pharmaceutical ingredients (APIs) + +Contact EMSL for current group profile offerings and pricing. + +--- + +## Rental Equipment + +EMSL offers a comprehensive rental program for industrial hygiene sampling equipment. This program supports clients who need temporary access to professional-grade sampling equipment. + +*Detailed rental equipment information is available on pages 62-63 of the complete guide.* + +### Available Equipment Categories + +- **Air Sampling Pumps** - Personal and area sampling pumps +- **Calibration Equipment** - Flow calibrators and verification devices +- **Monitoring Instruments** - Real-time detection and monitoring +- **Sample Collection Media** - Filters, cassettes, sorbent tubes, badges +- **Field Equipment** - Tripods, stands, and mounting accessories +- **Specialized Instruments** - Thermal imaging, particle counters, gas detectors + +### Free IH Sampling Pump Program + +EMSL offers a **"free IH sampling pump program"** for clients when the analysis is performed by one of EMSL's IH laboratories. This program provides access to calibrated sampling pumps without rental fees, making it easier and more cost-effective to conduct industrial hygiene sampling. + +--- + +## Contact Information + +For more information about EMSL Analytical, Inc. and their Industrial Hygiene Laboratory Services: + +- **Website:** Visit EMSL's website for the most current information +- **Phone:** Contact your nearest EMSL laboratory location +- **Email:** Reach out to customer service for quotes and technical support + +**Corporate Headquarters:** +EMSL Analytical, Inc. +Cinnaminson, NJ USA + +--- + +## Document Information + +- **Title:** Industrial Hygiene Lab Services Guide +- **Edition:** 2023 +- **Focus:** Methods and Threshold Values +- **Publisher:** EMSL Analytical, Inc. +- **Pages:** 63 pages (original document) +- **Format:** Reference guide for industrial hygiene professionals + +--- + +## Navigation Tips for LLM Agents + +This document is structured to facilitate easy navigation and information retrieval: + +1. **Use the Table of Contents** to jump to major sections +2. **Section headers** use standard Markdown hierarchy (##, ###, ####) +3. **Tables** organize complex data for easy parsing +4. **Bold text** highlights key terms and important information +5. **Lists** break down complex information into digestible items +6. **CAS numbers** provide unique identifiers for chemical lookups +7. **Cross-references** link related information throughout the document + +### Key Search Terms + +When searching this document, use these terms: +- Analyte names (e.g., "Acetone", "Benzene") +- CAS numbers (e.g., "67-64-1") +- Test methods (e.g., "NIOSH 2016M", "OSHA PV2119") +- Regulatory terms (e.g., "PEL", "TWA", "STEL", "Ceiling") +- Service types (e.g., "air sampling", "wipe sampling", "bulk analysis") +- Equipment (e.g., "pump", "media", "calibration") + +--- + +*This Markdown document was created from the EMSL Analytical, Inc. Industrial Hygiene Lab Services Guide (2023 Edition) to facilitate LLM agent navigation and information retrieval.* diff --git a/RAG-KB/Metals clearance criteria-QVC.md b/RAG-KB/Metals clearance criteria-QVC.md new file mode 100644 index 0000000000000000000000000000000000000000..e0b33fab6ee3269ee29c8aab5c3f13fc25d110eb --- /dev/null +++ b/RAG-KB/Metals clearance criteria-QVC.md @@ -0,0 +1,622 @@ +# BROOKHAVEN NATIONAL LABORATORY + +**Safety & Health Services Division - Industrial Hygiene Group** +**Standard Operating Procedure** + +| | | +|---|---| +| Number | IH75190 | +| Revision | Rev23 | +| Date | 06/23/17 | +| Page | 1 OF 16 | + +**Subject: Surface Wipe Sampling for Metals** + +--- + +*The only official copy is on-line at the SHSD website.* +*Before using a printed copy, verify that it is current by checking the document issue date on the website.* + +--- + +# IH75190 +# Surface Wipe Sampling for Metals + +## 1.0 Purpose & Scope + +This document describes a field procedure for taking wipe samples for metals on surfaces. It is based on methodology described in NIOSH 9100 "Lead in Surface Wipe Samples" of the NIOSH Manual of Analytical Methods. + +The goal of the procedure is to provide a uniform methodology to collect representative samples. Using this method will ensure repeatability between various sampling personnel and between surface configurations. It is used for characterizing surface levels for the following reasons: + +- Decommissioning operational areas +- Evaluating the effectiveness of clean-up of a spill +- Evaluating compliance with housekeeping levels in operational areas +- Characterizing a piece of equipment for release. + +## 2.0 Responsibilities + +**2.1 Demonstrated Competency:** This procedure is administered through persons who have demonstrated competency in performing this procedure in accordance with Section 7 are qualified to use this procedure. + +**2.2 Chain of Custody procedures:** The qualified sampler is responsible for samples until they have been properly transferred to the IH Group laboratory using the *IH51200 IH Laboratory Equipment & Sample Processing* procedure. + +**2.3 Hazard Analysis of the Sampling Task:** It is the responsibility of persons using this method and their supervisors to: + +- Use appropriate personal protective equipment; see section 5.3. +- Obtain required training and qualification for hazards in areas. +- Comply with all work planning and work permit system requirements. + +## 3.0 Definitions + +**Surface Wipe-** a technique for the determination of metal on surfaces conducted by wiping the loose dust from the surface with a cloth/paper media and analysis of the metal on the media by laboratory or XRF measurement. + +Definitions associated with surface wipe criteria are cited in Attachment 9.3 + +## 4.0 Prerequisites + +**Area Access:** + +4.1 Training for hazards may be needed for entry into areas with hazards, such as radiological areas.. + +4.2 Contact the appropriate Facility Support Representative or Technician to obtain approval to enter radiological areas. + +4.3 Review and sign the Work Permit or Radiological Work Permit if needed. + +4.4 Use appropriate PPE for area. + +## 5.0 Precautions + +**5.1 Hazard assessment:** Taking surface wipe samples may cause some exposure to health risks. Sampling may be performed in areas with metal, chemical or radiological contamination. These hazards must be assessed on a case-by-case basis by a competent individual knowledgeable of the hazards of the area. + +**5.2 Job Risk Assessment:** Consult the Job Risk Assessment SHSD-JRA-05 for the risk analysis of this operation based on the hazards and controls of this SOP. + +**5.3 Personal Protective Equipment:** Use appropriate personal protective equipment when implementing this procedure. + +- **Hand:** Use gloves in areas of known or suspected metal, chemical or radiological contamination. Exam-style, splash gloves are acceptable. Acceptable polymers are: Nitrile, PVC, and Natural Rubber. The gloves must have sufficient impermeability to the surface contaminant and solvent used on the collection media to allow safe handling. See Table 1. +- **Body:** Use a disposable suit if contact of the body with contaminated surfaces is anticipated. Acceptable chemical protective equipment materials include: Tyvek®, KleenGuard®, and cotton. Contact the ECR for disposable of garments. If personal clothing items become contaminated, they must be surrender for BNL cleaning or disposal. +- **Foot:** Use disposable shoe coverings, boots or booties if contact of the feet with contaminated surfaces is anticipated. Acceptable material include: Tyvek®, KleenGuard®, and rubber. If personal shoes become contaminated, they must be surrendered for BNL cleaning or disposal. +- **Respiratory:** Under normal use, respiratory protection is not required. Use a respirator in an area with the potential to exceed the OSHA, ACGIH, or DOE standards. The person collecting using respiratory protection must comply with the BNL Respiratory Protection Program. +- **Eye:** Use safety glasses with side shields in laboratories, construction, and general industry areas. + +**5.4 Radioactive Concerns:** It is possible that some surfaces to be tested may have radioactive contamination. In these cases, personal protective equipment and administrative controls must be implemented for the radiological contaminant hazard. + +In addition, the collected sample must be analyzed for the radiological hazard before it can be submitted to the IH Group for analysis. The radiological contamination must be below the permissible release limits to the general public. + +**5.5 Work Planning:** All requirements of work permits and work planning system reviews must be met in performing this procedure. + +**5.6 Personal Hygiene:** Remove PPE and wash hands after sampling and before eating or drinking. + +**5.7 Environmental Impact and Waste Disposal:** This technique does not have adverse impact on the environment. Based on WMD testing of similar PPE material, the templates and gloves can be disposed as normal trash. See Attachment 9.4. + +## 6.0 Procedure + +### 6.1 Equipment + +| Item | Description | +|------|-------------| +| **Sample container (either):** | Bag, plastic, sealable with "zip" type seal. | +| | Vial, glass or plastic. (Glass is needed for hexane solvents based samples). | +| **Sample media (any of these):** | Gauze: 2" x 2" or 4" x 4" cotton gauze | +| | Paper: Ashless quantitative filter paper (typical diameter is 1.5 to 4 inches) | +| | Pre-moistened wipe: manufacturer foil wrapped, solvent soaked disposable cloths (such as GhostWipes or LeadWipe | +| | • The type of wipe is dependent on the lab to be used. Check with the lab for appropriate media for the metals to be analyzed. | +| | • For multiple metals, check with the lab to ensure they can all be done on a single wipe | +| **Gloves** | Appropriate for contaminant and solvent (see Table 1) and site hazards. | +| **Solvent** | Distilled water, Isopropanol, ethanol, methanol, n-hexane, or pre-moistened. See Table 1 for recommended solvent for each contaminant. | +| **Template** | Plastic sheet or cardboard: See Table 1 for size needed | +| | • 100cm2: 10 cm x 10 cm square –or- circle of 11.24 cm diameter. | +| | • 1ft2: 1foot x 1 foot, or other shape totaling 144 in2. | + +### 6.2. Wipe Technique + +BNL SHSD IH Group has selected the NIOSH method of collecting wipe samples. For uniformity, this method should be used for all sampling surface to be sampled (Visually depicted in Figure A) + +**Figure A: NIOSH Surface Wipe Method** + +[Figure shows three-step wiping process: 1. First Wipe using whole pad in S-pattern, 2. Second Wipe using half pad (folded) in S-pattern at right angles, 3. Third Wipe using quarter pad (folded again) in S-pattern. With each step, fold the exposed surface inward. Final step 4 shows folding to put in bag/bottle with label.] + +**6.2.1** Use a moistened sample media or pre-moistened wipe (e.g. GhostWipe™). Apply only enough solvent to moisten approximately 80% of the area of the media. Avoid excess solvent on the filter or pad as it may cause drips and running on the surface thus diluting the sample. + +### Table 1 + +| Contaminant | Media(1) | Solvent(2) | PPE Glove(2) Disposable Style | Sample Size | +|-------------|----------|------------|-------------------------------|-------------| +| **Lead** | Gauze or Filter | 1 -2 ml Distilled Water | Natural Latex Rubber, Nitrile, PVC, or Polyethylene | 1 square foot, 100 cm2 requires advanced approval by IH professional verifying that sensitivity is adequate | +| | Pre-moistened Wipe (should be cut in half) (3) | n/a | | | +| **Beryllium** | Gauze or Filter | 1 - 2 ml Distilled Water Isopropanol, Methanol, Ethanol | Natural Latex Rubber, Nitrile, PVC, or Polyethylene | 1 square foot minimum needed always | +| | Pre-moistened Wipe (should be cut in half) (4) | n/a | | | +| **Arsenic, Cadmium** | Gauze or Filter | 1-2 ml of Distilled Water | Natural Latex Rubber, Nitrile, PVC, or Polyethylene | 100 cm2 typically acceptable | +| | Pre-moistened Wipe (should be cut in half) (4) | n/a | | | +| **Hexavalent Chromium** | Preferred Medias: See Attachment 9.2 | None: For chrome plating operations, see stabilizing solution in Attachment 9.2. | Powderless: Natural Latex Rubber, Nitrile, PVC, or Polyethylene | 100 cm2 typically acceptable | + +**Notes for Table 1:** + +(1) Some pre-moistened media may not be compatible is certain laboratory analytical equipment. Check with the laboratory analyzing the samples prior to sampling to ensure the brand of media is compatible. + +(2) Solvent: The solvent is not critical for lead, beryllium, and most heavy metals such as cadmium, nickel, and chromium. In doing wipes for these compounds, it is allowable to choose the solvent that will have the least impact (residues) on the owner of the equipment being sampled (i.e. some equipment is sensitive to water residues and an alcohol or other solvent may be preferred by the equipment owner.) + +(3) Selection criteria: Breakthrough time greater than 1 hour of continuous contact. Source of data is DOE Guidelines for the Selection of Chemical Protective Clothing, 1991. + +(4) The use of full size pre-moistened may cause the sample not to meet the minimum level of detection. To increase sensitivity, cut wipe in half to reduce the size of the wipe. + +**6.2.2** Place the template over the area to be sampled or measure out 1 ft2 or 100-cm2 surface area, as per Table 1. If the object has a total surface area of less than 1 ft2 or 100 cm2, sample the whole surface area, if possible, and record the surface area. If the surface does not allow the use of a template, carefully determine the dimensions that will equal 1 ft2 or 100 cm2. + +**6.2.3** Wipe the surface with firm pressure, using "S" strokes, covering the entire surface (edge to edge). If the surface is very rough (such as concrete), a dabbing action may be substituted for the full contact pressure rubbing of the media across the surface. When dabbing, make sure to completely cover the same area as in the S-stroke wipe. Indicate dabbing done on sample form. + +Fold the exposed side of the pad or filter inward (i.e. fold in half). + +**6.2.4** Using the once-folded media, wipe the same area S-strokes (see Figure A), starting at right angles to the first wipe. Fold the exposed side of the pad or filter inward. + +**6.2.5** Using the twice-folded media, wipe with S-strokes (see Figure A) starting at the original point and wipe in the same direction. Fold the exposed side of the pad or filter in. + +**6.2.6** Place the media in a plastic bag or vial. Seal the zip lock or vial. Record the sample identification on the bag or vial. + +**6.2.7** Thoroughly clean reusable templates or discard paper templates in preparation of the next sample. Based on WMD testing of similar material, templates can be disposed as normal trash. + +**6.2.8** Remove gloves by pulling them off inside-out and discard appropriately before handling the next filter or pad. + +**6.2.9** Record the sample identification, surface area sampled, and description of the sample and surface on the sample form (Attachment 9.5) in the electronic SHSD forms page Surface Wipe (Metals)- Field Sampling Records & Chain of Custody. + +**6.2.10** Include 1 blank filter or pad (moisten and placed in bags or vials) with each set of samples (provide 1 blank per 6 samples). + +### 6.3 Surface Wipe Technique for Hexavalent Chromium + +See Attachment 9.2. + +### 6.4 Determine HOW MANY samples to take + +It is not possible to provide definitive guidance on the number of samples to be taken in every case. Table 2 provides general guidance on which to base professional judgment determining the number of samples. Factors that should be considered in selecting the number of samples include: the size of the area to be tested, the predicted uniformity of contamination over the surface area, and the eventual fate of the surface area (disposal, remediation, background measurement, etc.) + +If more than six (6) samples are to be taken, it is suggested that at least one (1) duplicate sample be taken in close proximity to one other to verify the precision (repeatability) of the sampling. + +### Table 2: Statistical sampling plan + +| Surface Configuration | Minimum Number of Samples | Qualifier | +|-----------------------|---------------------------|-----------| +| Entire Surface is less than 100 cm2 (example: a small article) | 1 | If possible, sample the whole item, one sample is usually sufficient. | +| Surface Area of object or area is greater than 100 cm2 but only a few square feet (example: table top on which a process is done) | 1 | If only one sample is taken, select the area with highest potential contamination | +| Surface Area of object or area is greater than a few square feet (example: floor or wall of a room) | 1 - 3 | Ideally three samples are taken, but fewer samples may be taken depending on the purpose for sampling | +| Multiple surfaces in a large area with the same exposure potential to source (example, many rooms in a building with a common source such as the HVAC system) | 1 – 3 for each surface, 6 or more for the whole area | Assumes all the surfaces have similar exposure potential, else treat each area separately. | + +### 6.5 Determine WHAT KIND of samples (LOCATION) + +Consider these locations when characterizing levels of surface metals: + +- surfaces that are frequently accessed, +- surfaces that hazardous metal object rest on, +- surfaces that are infrequently cleaned or disturbed (such as top of cabinets or high shelves) +- sources of the contamination (such as process equipment, lab apparatus, site of known spills), +- areas where contamination is not expected (these serve as a control), and +- areas where contamination would not be permissible (such as lunch rooms). + +### 6.6 Results interpretation + +Normalize the units of sampling results from the laboratory to the base units of the Surface Level Criteria Requirements & Recommendations listed in Attachment 9.3. + +Conversion of data between various laboratory reporting units of measures: Data can be converted from the various regulatory reporting and laboratory reporting units of measure based on the following values: 1 sq.ft. = 929 cm2 1 mg = 1000 ug + +| Convert form: | Multiply by | +|--------------|-------------| +| ug/100 cm2 to ug/sq. ft | 9.29 | +| ug/sq. ft to ug/100 cm2 | 0.1076 | + +### 6.7 Posting equipment or areas + +Consult with Attachment 9.1 for recommended wording to be used for labelling equipment or areas when a warning is needed for toxic metal hazards. + +### 6.8 Reporting results + +Convey the assessment of results to the requestor of the sampling, in a written analysis documenting: sampling and analysis methods, contamination levels measured, compliance with regulatory and recommended levels, and recommended corrective actions (if necessary). + +## 7.0 Implementation and Training + +**Qualification Criteria:** Use of this SOP is limited to persons who have demonstrated the competency to satisfactorily use the procedure, as evidenced by experience and training. All persons must have demonstrated competency in the qualification criteria set in the Job Performance Measure (Attachment 9.6.) or e-Exam IH75190. Qualification on this JPM is required on a 3 year basis. + +## 8.0 References + +8.1 ACGIH: Threshold Limit Values 2005 + +8.2 DOE: 10CFR 850 Chronic Beryllium Disease Prevention Program + +8.3 EPA: Toxic Substance Control Act (TSCA) 40CFR745.227 + +8.4 Ness, S.A.; Surface and Dermal Monitoring for Toxic Exposures, Van Nostrand Reinhold, 1994. + +8.5 NIOSH: Manual of Analytical Method, Method 9100: Lead in Surface Wipe Samples. + +8.6 OSHA: 29CFR1910.1000 Table Z1, Z2; and 1910.1027. + +8.7 OSHA: Technical Manual Section II, Chapter 2. + +## 9.0 Attachments + +9.1 Sample of Signs for Areas and Equipment + +9.2 Wipe Sampling Technique for Hexavalent Chromium + +9.3 Surface Wipe Criteria Requirements & Recommendations + +9.4 Environmental Evaluation of Surface Wipe Sampling + +9.5 Sample of Surface Contamination Sampling Form + +9.6 SHSD Job Performance Measure (JPM) Completion Certificate + +## 10.0 Procedure Documentation + +**ISM Review - Hazard Categorization:** High; Moderate; Low/Skill of the craft + +**Validation:** Formal Walkthrough Desk Top Review SME Review + +### Revision Log + +| Rev | Description | +|-----|-------------| +| 0 | New document. Prepared By R. Selvey, CIH 02/25/2000; Technical Reviewed By: N. Bernholc, CIH 02/27/00; RCD Facility Support Approved By: 04/22/01 N. Foster Procedure Committee Review; QA Review : E. Tucker; SHSD Approved By: R. Selvey 03/02/2000 | +| 1 | Revised for minor correction noted in training classes. Reviewed By: R. Selvey 10/6/00 | +| 2 | Added new format, SBMS header and reviewed sections on Hazard assessment, PPE. Added Waste Disposal and Environmental Impact text. Reviewed By: R. Selvey 02/05/01 | +| 3 | Minor format change. Converted SOP number from IH-FP-3.2 to new system IH75190. Reviewed By: R. Selvey 03/09/01 | +| 4 | Revised to include RCD Facility Support Procedure Committee Review comments. Reviewed By: R. Selvey 04/22/01 | +| 5 | Updated Table 1 adding Arsenic and Cadmium Media. Update Table 3 with Arsenic and Cadmium Release Criteria and update EPA Lead Criteria. Reviewed By: R. Selvey 04/10/02 | +| 6 | Updated Table 1 to correct error in lead criteria. Insert Section 7 and transfer information from section 4. Renumbered attachments. Reviewed By: R. Selvey 4/17/02 | +| 7 | Added Best Management Practice release criteria for Arsenic and Cadmium to Table 3. Reviewed By R. Selvey 08/16/02: | +| 8 | Added Best Management Practice release criteria for Nickel to Table 3. Reviewed By: R. Selvey 10/17/02 | +| 9 | Full review of SOP. Significant text changes. Deleted OSHA Method for procedure & PCB criteria. Updated Attachments 9.1 and 9.2. Added Attachment 9.3. Reviewed By: R. Selvey 05/21/04 | +| 10 | Added reference and link to JRA-05 in 5.1. Added text to 6.2.2 to clarify using Table 1 to determine 100cm2 versus 1 sq ft. Changed "S-stroke" wording in 6.2.3.through 6.2.5 to avoid confusion with the S-stroke used the Health Physics terminology. The two patterns are different. Changed the qualification criteria in Section 7 to reflect the unified qualification policy. Updated the Sample form (Attachment 9.1) to reflect the Compliance Suite order of sample numbering. Reviewed By: R. Selvey 02/21/06 | +| 11 | Reworded the "S-stroke" wording in 6.2.3.through 6.2.5 to avoid confusion with the S-stroke used the Health Physics terminology. Passage on "dabbing" was modified to indicate that the dabbing action replacing pulling the media, but does not replace the S-pattern. Minor typo corrections in Section 5 and 6. Reviewed By: R. Selvey 02/21/06 | +| 12 | Section 6.3 was added with a reference to new Attachment 9.4; Table 1: was updated to include hexavalent chromium. Attachment 9.4 was added to include Liberty Mutual Wipe Sample Method. Liberty Mutual method was added. Section 8 References and Attachment 9.4 was added and included in Section 9.0 Attachments. Reviewed By: J. Peters 11/28/06; Reviewed By: R. Selvey 12/05/06 | +| 13 | Added Section 4.1, 4.2 and 5.6. Revised 5.2. Added document control to attachment 9.3 and 9.4. Reviewed By: R. Selvey 05/23/07 | +| 14 | Table 3: Updated to include Cobalt and description of calculation. Changed IH training link in Step 7.1. Reviewed By: M.Chuc 09/22/08 Reviewed By: R. Selvey 10/13/08 | +| 15 | Added Attachment 9.5. Reviewed By: R. Selvey 02/09/09 | +| 16 | Edited section 4.0 and 5.2 for brevity. Added definition for Release and Housekeeping Criteria. Changed Cr6 release level based on OSHA recommendation. Added ANSI Caution to Attachment 9.1 sign. Revised directions in Attachment 9.2. Reviewed By: R. Selvey 03/21/11 | +| 17 | Full review of steps 1 to 7. Expanded and revised Release and Housekeeping Criteria definitions in Section 3 and in Table3. Reviewed By: R. Selvey 04/27/11 | +| 18 | Corrected error in units in section 3: mg/100cm2 to ug/100 cm2. Reviewed By: R. Selvey 05/10/11 | +| 19 | Edited Section s 2 and 7 to remove reference to rescinded HP65100. Changed format of Section 9. Reviewer: R. Selvey 03/04/14 | +| 20 | Total review and revision. Replaced Table 3 with Appendix 9.3 and added OSHA Technical Manual ratio. Removed criteria for Al, Ba, Co, Cu, Hf, In, Mn, Mo, Pt, Rh, Se, Ag, Ta, Te, Tl, Sn, W, Y, Yt, and Zr. Added link to e-Exam and e-form. Added short-life disclaimer to Cr6 in Attachment 9.2. Revised by: R. Selvey 06/13/8/16 | +| 21 | Revised Attachment 9.3 to correct Cr+6. Added column for ug/sq ft. Corrected error in Table 1 Attachment 3. Revised by; R. Selvey 09/13/16. | +| 22 | Revised Attachment 9.3 to remove no-regulated Nickel and CrIII and adjusted values for Arsenic and CrVI to match OSHA Housekeeping philosophy. Added proposed changes for all release criteria to allow comments on impact. Revised by; R. Selvey 05/01/17. | +| 23 | Team reviewed revision to Attachment 9.3. Values aligned with OSHA, EPA/HUD and DOE policies. Approved by: R. Selvey 06/23/17 | + +--- + +# Attachment 9.1 +## Samples of Signs for Areas and Equipment + +--- + +### CAUTION + +**Cadmium Surface Contamination** + +Some surfaces in this area have Cadmium levels above BNL Guidelines + +- Do NOT perform operations that causes the dust to become airborne (such as using an air hose to clean surfaces or dry sweeping) +- Contact SHSD IH Group x-7475 prior to Building Renovations or Demolition +- Wash hands prior to eating, drinking, chewing gum, or smoking +- Do not eat or drink in this area. + +--- + +### CLEAN + +The material on this pallet is below (i.e. cleaner than) the SHSD Best Management Practice Surface Release Guidelines for Lead and Cadmium + +It is appropriate to be released and used anywhere at BNL without any specific precautions. + +--- + +### Exceeds Guidelines for Lead or Cadmium + +The material on this pallet is above (i.e. not cleaner than) the SHSD Best Management Practice Surface Release Guidelines for Lead and/or Cadmium + +Specific precautions are needed in areas where this material is used or stored. + +- No operations that cause airborne dust (such as air hoses, blowers, or dry sweeping) +- Wash hands prior to eating, drinking, chewing gums, or smoking. +- Do not eat or drink in this area. +- Notify occupants of the area of the presence of Lead/Cadmium on these surfaces. + +--- + +# Attachment 9.2 +## WIPE SAMPLING TECHNIQUE FOR HEXAVALENT CHROMIUM + +**Note:** Hexavalent Chromium has a short life on surfaces. Sampling and analyzed needs to be completed within a few days of generation. For sampling of long term dust accumulations, use Cr3 sampling. + +### Materials supplied by the lab: + +**Sampling media:** + +- For chrome plating: PVC or binderless quartz filter. All other operations: + - 5 um, 37-mm PVC filter for smooth surfaces + - 0.45 mm thick 37-or 47-mm binderless quartz fiber filter for rough surfaces (preferred media for both smooth and rough surfaces) +- Immediately after sampling, place the filter sample in a vial containing 10% Na2CO3 with 2% NaHCO3 to stabilize the Cr+6. +- Do not use Ghost wipe®, Whatman, mixed cellulose ester (MCE) or glass fiber filter as they convert Cr+6 to Cr+3. + +**Additional materials:** + +- Template (10 cm x 10 cm) +- Teflon coated or plastic tweezers +- Empty glass vials +- Glass vials containing 5 ml aqueous solution of 10% Na2CO3 with 2% NaHCO3 for chrome plating samples +- Powderless gloves + +### Sampling Technique: + +1. Prepare a sufficient number of vials, each labeled with a unique number. + +2. Sketch a diagram of the room or area to be sampled. + +3. Wear a new pair of clean gloves for each sample. DO NOT use powdered gloves. + +4. Record the sample vial number and location where the sample is taken. + +5. Remove the filter from the carrying container with a clean PTFE-coated tweezers or plastic tweezers. DO NOT use metal tweezers to handle the filters, as they could deposit Cr+6 onto the filters. + + **Note:** Surfaces should not be wetted with water as the water will allow any metal interference to interact with Cr+6 thereby affecting the results. + +6. Use firm pressure when wiping the surface. Start at the one corner moving to the opposite side then upward one wipe width and wipe back to the starting side. Repeat to cover the whole surface area. Fold inward and repeat wiping the entire surface again. Fold in and repeat a third time. + +7. After wiping, fold the filter with the contaminant side inward. Place the filter immediately in the sample vial and cap. Filter samples taken in chrome plating operation must be placed in a vial containing 10% Na2CO3 with 2% NaHCO3 to stabilize the Cr+6. + +8. Submit at least one blank wipe filter, treated in the same fashion, but without wiping. + +9. Sample results will be reported as ug/100cm2. OSHA's target concentration is 0.050ug/100 cm2. + +10. Ship samples immediately. If unable to ship immediately, keep cold then ship next day air to the lab. + +--- + +# Attachment 9.3 +## Required and Recommended Surface Wipe Criteria +### 06/26/17 + +| Compound | Criteria | | Criteria type | OSHA PEL | +|----------|----------|---|---------------|----------| +| | ug/100cm2 | ug/ft2 | R = Requirement; G= Guidance, Recommended, Non-regulatory | ug/m3 | +| **Arsenic (As) 29CFR1910.1018** | | | | | +| | 100 | 929 | G OSHA Regulated Areas [AFAP] & Operational Areas: Floors & accessible surfaces | 10 ug/m3 | +| | 6.7 | 62 | G Non-Operational Areas: Floors & accessible surfaces | | +| **Beryllium (Be) 10CFR850** | | | | | +| | 3.0 | 28 | R DOE Regulated Areas & Be Operational Areas: Floors & accessible surfaces [Housekeeping] | 2 ug/m3 | +| | 0.2 | 1.9 | G Non-Operational Areas & Public Areas: Floors & accessible surfaces | | +| | 3.0 | 28 | R Equipment Release to Be Operational Areas | | +| | 0.2 | 1.9 | R Equipment Release to Non-beryllium Area of a DOE facility & Public | | +| **Cadmium (Cd) 29CFR1910.1027** | | | | | +| | 50 | 465 | G OSHA Regulated Areas [AFAP] & Operational Areas: Floors & accessible surfaces | 5 ug/m3 [.1027] | +| | 3.3 | 31 | G Non-Operational Areas: Floors & accessible surfaces | 200 ug/m3 [Z.2] | +| **Chromium, hexavalent (Cr) VI 29CFR1910.1026** | | | | | +| | 50 | 465 | G OSHA Regulated Areas [AFAP] & Operational Areas: Floors & accessible surfaces | 5 ug/m3 | +| | 3.3 | 31 | G Non-Operational Areas: Floors & accessible surfaces | | +| **Lead (Pb) 29CFR1910.1025** | | | | | +| | 500 | 4645 | G Accelerator Operational Areas & OSHA Regulated Areas [AFAP]: Floors & accessible surfaces | 50 ug/m3 | +| | 50 | 465 | G Laboratory Operational Areas: Floors & accessible surfaces | | +| | 22 | 200 | G Non-Operational Areas: Floors & accessible surfaces | | +| | 22 | 200 | G OSHA 1926.62 Construction Sites: change areas, storage facilities, & lunchrooms [Housekeeping] | | +| | 4.3 | 40 | G Eating & food prep surfaces | | +| | 43 | 400 | G Public/Lodging/Childcare- Window troughs | | +| | 27 | 250 | G Public/Lodging/Childcare- Window sills | | +| | 4.3 | 40 | G Public/Lodging/Childcare- Floors, Eating & food prep surfaces | | +| **Acrylonitrile 29CFR1910.1045** | | | | | +| | 43 | 400 | G OSHA Regulated Areas [AFAP] & Operational Areas: Floors & accessible surfaces | [2 ppm] 4.3 ug/m3 | +| **Dibromodicloropropane 29CFR1910.1044** | | | | | +| | 1.0 | 9.3 | G OSHA Regulated Areas [AFAP] & Operational Areas: Floors & accessible surfaces | [1 ppb] 0.01 ug/m3 | +| **Methylenedianiline 29CFR1910.1050** | | | | | +| | 0.8 | 7.5 | G OSHA Regulated Areas [AFAP] & Operational Areas: Floors & accessible surfaces | [10 ppb] 0.08 ug/m3 | + +### Definition (for purposes of the table above): + +**AFAP:** As Free As Practicable; Housekeeping- All surfaces shall be maintained as free as practicable of accumulations of [OSHA Regulated Substances]: Arsenic: 1910.1018(k); Cadmium: 1910.1027(k); Chromium: 1910.1026(j); Lead: 1910.1025(h); Acrylonitrile: 1910.1045(k) DBCP: 1910.1044(k); MDA: 1910.1050(l). + +The enumerated guidance criteria level is based on: OSHA Technical Manual; Section II: Chapter 2 Surface Contaminants, Skin Exposure, Biological Monitoring and Other Analyses; III. Wipe Sampling, Field Portable X-Ray Fluorescence Sampling, Dermal Sampling and Biological Monitoring; A. Surface Wipe Sampling. + +**Accessible surfaces:** Surfaces that can reasonably be expected to be contacted during typical operations. This would include table tops, desks tops, and other surfaces where contact with hands, arms and body are likely. [BNL] + +**Eating & Food Prep Surfaces** = Surfaces on which food preparation, eating & drinking are done. This includes lunchroom counters/tables; kitchen counter tops, stove tops; water cooler surfaces; and tables/desks in offices/conference rooms where food and beverage consumption is permitted. [BNL] + +**Equipment Release to Operational Area [Beryllium]** = Maximum removable contamination on equipment that is being released to a facility using the beryllium. Equipment must be labeled and sealed in impermeable bag or container. [DOE 10CFR850.31] + +**Equipment Release to Operational Area [OSHA Regulated Substance]** = Maximum removable contamination on equipment that is being released to a facility using the regulated substance. [BNL] + +**Equipment Release to Non-Operational Area or Public [Beryllium]** = Maximum removable contamination on equipment that is being released to the general public or to a non-beryllium area of a DOE facility. Equipment release is conditioned on the recipient's commitment to implement controls that will prevent foreseeable beryllium exposure, considering the nature of the equipment or item and its future use and the nature of the beryllium contamination. [DOE 10CFR850.31] + +**Equipment Release to Non-Operational Area or Public [OSHA Regulated Substance]** = Maximum removable contamination on equipment that is being released to the general public or to a Non-Operational Area. [BNL] + +**Housekeeping** = Maximum level allowed on accessible surfaces in Operational Areas during Non-Operational periods. Surfaces contaminated with dusts and waste must not exceed a removable contamination level criterion during Non-Operational periods. This sampling would not include the interior of installed closed systems such as enclosures, glove boxes, chambers, or ventilation systems. [DOE 10CFR850.30] + +**Non-Beryllium Area** = Area where beryllium is not used in a DOE facility. [DOE 10CFR 850.31] + +**Non-Operational Area [Beryllium]** = Area where beryllium is not used and where workers are not trained in hazards and controls. Personal hygiene control practices are not in place (hand washing is not expected on exiting the area) and eating & drinking are permitted. [BNL] + +**Non-Operational Area [OSHA Regulated Substance]** = Area where an OSHA Regulated Substance is not used and where workers are not trained in hazards and controls. Personal hygiene control practices are not in place (hand washing is not expected on exiting the area) and eating & drinking are permitted. [BNL] + +**Operational Area [Beryllium]** = Area where workers are routinely in the presence of beryllium as part of their work activity. [DOE 10CFR850.3] + +**Operational Area [OSHA Regulated Substance]** = Area where workers are routinely in the presence of an OSHA Regulated Substance as part of their work activity. Workers who handle the substance have been trained in hazards and controls. Substances are routinely used, handled or stored and personal hygiene control practices are in place (e.g. eating, drinking are prohibited in the area; hand washing is expected on exiting the area). Examples: lead shielding blocks, shops, and accelerator areas using organic and inorganic metallic compounds. [BNL] + +**OSHA Regulated Substance** = A substance regulated in 29CFR1910.1003-1054 in the expanded health standards: + +- **Metals:** + - Arsenic 29CFR1910.1018; + - Cadmium 29CFR1910.1027; + - Chromium, hexavalent 29CFR1910.1026; + - Lead 29CFR1910.1025 + +- **Chemicals:** + - Acrylonitrile 29CFR1910.1045; + - Benzene 29CFR1910.1028; + - Dibromodicloro- propane 29CFR1910.1044; + - Formaldehyde 29CFR1910.1048; + - Methylenedianiline 29CFR1910.1050; + - Methylene Chloride 29CFR1910.1052; + +- **OSHA 13 carcinogens** = 4-Nitrobiphenyl, Chemical Abstracts Service Register Number (CAS No.) 92933; alpha-Naphthylamine, CAS No. 134327; methyl chloromethyl ether, CAS No. 107302; 3,3'-Dichlorobenzidine (and its salts) CAS No. 91941; bis-Chloromethyl ether, CAS No. 542881; beta-Naphthylamine, CAS No. 91598; Benzidine, CAS No. 92875; 4-Aminodiphenyl, CAS No. 92671; Ethyleneimine, CAS No. 151564; beta-Propiolactone, CAS No. 57578; 2-Acetylaminofluorene, CAS No. 53963; 4-Dimethylaminoazo-benzene, CAS No. 60117; and N-Nitrosodimethylamine, CAS No. 62759. [OSHA] + +**Public** = Persons who are not: DOE employees, BSA employees, contractors, sub-contractors, and persons with Student, Intern, User or Guest appointments. The public includes visitors and family members living in residence at Upton. They are not trained by BNL in hazards and controls of toxic substances. [BNL] + +**Public/ Lodging/Childcare Areas** = Area open to the public for periods longer than short visits or tours or areas intended for frequent access by visitors and/or family members. Eating and drinking is allowed in public areas. Occupants are not trained in the hazards of the metal or control measures. Hand washing is not expected on exit of the area. Public areas include: Science Museum (935), Coin Laundry (363), Berkner Hall (388), Swimming Pool (462), Gymnasium (461), Brookhaven Center (30), Research Support Building (400), BNL Upton on-site housing: Cavendish (153), Compton (170), Curie (258), Fleming (180), Guest House (257), Danish House (388), Apartments, Efficiencies; and areas with high occupancy by children: Child Development Center (370), Recreation Hall (317), School House (373) [BNL] + +**Regulated Area [Beryllium]** = Area demarcated by the responsible employer in which the airborne concentration of beryllium exceeds, or can reasonably be expected to exceed, the action level. [DOE 10CFR850.3] + +**Regulated Area [OSHA Regulated Substance]** = Area where an OSHA Regulated Substance is used in a manner that airborne exposure levels exceed the Permissible Exposure Limit. Area is formally demarcated and access to the area is controlled to those meeting the entry requirements in the OSHA regulation. Personal hygiene control practices are in place; eating and drinking are prohibited; hand washing is expected on exiting the area. OSHA standards require these areas to be "As Free As Practicable". The OSHA Technical Manual (G1) provides a recommended method to enumerate AFAP [BNL] + +--- + +# IH 75190 Attachment 9.4 +## Environmental Evaluation of Surface Wipe Sampling for Chemicals/Metals + +**Operation Description:** Field samples for potential metals or chemicals are collected on pre-moistened pads. This process concentrates toxic substances on the media. The wipes are either sent off-site for analysis or in some instances are analyzed at BNL by the IH Group using direct reading meters. + +**Frequency of Operation:** 10 to 20 times per year. + +**Environmental impact:** + +- The wipes sampled at BNL are consumed in the analysis at the end of test by the off-site lab. Conformance with proper wipe disposal by the off-site vendor laboratory is validated to BNL IH Group's satisfaction in the AHIA Accreditation process. +- PPE used during sampling and the paper templates are disposed of at the direction of the EPD ECR. The current policy is for disposal as non-hazardous waste. This is justified because the concentration is too low to be of concern (a few micrograms per wipe surface). + +**Waste Disposal:** + +- PPE and paper templates are disposed of as non-hazardous waste, unless otherwise directed by EPD. + +--- + +# Brookhaven National Laboratory +## Safety & Health Service Division +## Industrial Hygiene Group + +# Surface Contamination Sampling Form + +**BNL-IH75190 Attachment 9.5 Sample- Do not use** + +**Analyte:** + +_____ LEAD + +_____ BERYLLIUM + +_____ CADMIUM + +_____ Other: + +**DEPT:** + +**BUILDING:** + +**LOCATION NAME, ROOM NUMBER & DESCRIPTION:** + +--- + +**Sample Media:** | **Solvent:** | **Surface Area Measurement:** + +_____ Ghost Wipe | _____ Pre-Moistened | _____ Template + +_____ Cotton Gauze | _____ Distilled Water | _____ Measured Area + +Size: | _____ Hexane | _____ Estimated Area + +_____ Filter Paper | _____ Isopropanol | Other: + +Type & Size: | _____ Other: + +_____ Other: + +**REASON FOR SAMPLING:** + +_____ Area Characterization + +_____ Pre-Remediation + +_____ Post Remediation + +Other: + +--- + +### Sample Identification + +| Sample Number | Sample Location | Surface Type | Surface Area | +|---------------|-----------------|--------------|--------------| +| Bldg# MMDDYY Analyte Symbol Sample # | | Metal / Plastic / Glass /Painted Wood / Wood / Painted Concrete / Concrete | _____ 1 ft2 | +| | | | _____ 100 cm2 | +| | | | other: _____________________________ | +| | | | _____ 1 ft2 | +| | | | _____ 100 cm2 | +| | | | other: _____________________________ | +| | | | _____ 1 ft2 | +| | | | _____ 100 cm2 | +| | | | other: _____________________________ | +| | | | _____ 1 ft2 | +| | | | _____ 100 cm2 | +| | | | other: _____________________________ | + +_____ Additional Samples next page + +**Total Number of Samples:** ___________________ + +| SAMPLE DATE: | RELINQUISHED TO SHSD IH LAB BY: (SIGNATURE): | DATE /TIME: | +|--------------|---------------------------------------------|-------------| +| | | / | + +| SAMPLES TAKEN BY: (Print Name and Signature) | RECEIVED BY SHSD IH LAB EMPLOYEE (SIGNATURE): | DATE /TIME: | +|---------------------------------------------|----------------------------------------------|-------------| +| / | | / | + +*Sample of online form* +*Use e-Forms from SHSD web page current version* + +--- + +# IH75190 Attachment 9.6 + +## HP-IHP-75190 + +**Environmental, Safety, Health & Quality Directorate** +**SHSD Industrial Hygiene** + +# Surface Wipe Sampling for Metals +## Job Performance Measure (JPM) Completion Certificate + +| Candidate's Name | Life Number: | Qualification Number: | +|------------------|--------------|----------------------| +| | | HP-IHP- 75190 | + +--- + +### Knowledge of the Principles of Surface Wipe Sampling - Demonstrated by Written Exam + +| Criteria | Qualifying Standard | +|----------|---------------------| +| **Hazard Analysis** | Understands the need to perform a hazard analysis of the sampling area and potential exposure to the sampler. | +| **Personal Protective Equipment** | Understands the need to be aware of the potential surface contamination and airborne levels of contaminants and knows how to determine the need for PPE. | +| **Sampling Protocol** | Understands the exposure monitoring logic necessary to appropriately select sampling locations to accurately measure worker, public and environmental exposure potential. | +| **Analysis of data** | Understands the need to perform analysis on the sampling data to assess potential exposure to the sampler, worker, public and environment, and to recommend corrective actions as necessary. | + +--- + +### Practical Skill Evaluation: Demonstration of Surface Wipe Methodology + +| Criteria | Qualifying Performance Standard | Unsat. | Recov. | Satisf. | +|----------|--------------------------------|--------|--------|---------| +| **Sampling Equipment** | Knows where equipment needed for the procedure is located and how to properly sign it out. | | | | +| **Moistening Media** | a. Filter/gauze: Moistens media with the appropriate solvent. Applies solvent to moisten approximately 80% of the area of the media. Does not over moisten. b. For pre-moistened media, shows reduction in size of wipe. | | | | +| **Size of Area & Use of Template** | Understands the importance of quantifying the area sampled. Demonstrates placing template on surface or measuring the surface area. | | | | +| **Folding Media at each wipe step** | Demonstrates the inward folding of media after each wipe and placement of media into container so that surfaces loaded in the wiping are not exposed. | | | | +| **NIOSH Method wipe pattern** | Demonstrates the technique of three passes of wiping in "S" pattern, changing the direction on second pass, original direction on third pass. | | | | +| **Choose correct solvent** | Knows how to select correct solvent from Table 1. | | | | +| **Select the correct number of samples** | Knows how to choose the appropriate numbers of samples based on Table 2. | | | | +| **Record forms** | Shows how to correctly and completely fill all forms associated with this SOP. | | | | + +--- + +I accept the responsibility for performing this task as demonstrated within this JPM and the corresponding SOP. + +| Candidate Signature: | Date: | +|---------------------|-------| +| | | + +I certify the candidate has satisfactorily performed each of the above listed steps and is capable of performing the task unsupervised. + +| Evaluator Signature: | Date: | +|---------------------|-------| +| | | + +*SOP-IH75190 JPM Form (Revision Date: 06/13/16)* diff --git a/RAG-KB/Technical Guide for Wildfire Restoration - Key Information.md b/RAG-KB/Technical Guide for Wildfire Restoration - Key Information.md new file mode 100644 index 0000000000000000000000000000000000000000..1c531bcf4d2cf87f04cc314a5686bb95c262a182 --- /dev/null +++ b/RAG-KB/Technical Guide for Wildfire Restoration - Key Information.md @@ -0,0 +1,79 @@ +# Technical Guide for Wildfire Restoration - Key Information + +**Source:** IICRC/RIA/CIRI Technical Guide for Wildfire Restoration +**Version:** Version 2, December 9th 2025 +**URL:** https://iicrc.org/wp-content/uploads/2025/12/IICRC.RIA_.CIRI-Technical-Guide-for-Wildfire-Restoration-V2-Final-2025-12.09.pdf +**Organizations:** Institute of Inspection, Cleaning, and Restoration Certification (IICRC), Restoration Industry Association (RIA), Cleaning Industry Research Institute (CIRI) + +## Purpose and Scope + +This technical guide presents current and common methodology of prudent wildfire restoration practices. It represents thousands of restoration companies and professionals who have returned families to their homes safely using proven, science-based methodologies in accordance with peer-reviewed industry standards. + +## Key Message + +The guide addresses a growing unfounded sentiment that homes affected by wildfire smoke and its byproducts are categorically uncleanable and unrestorable. The guide emphasizes that: +- Wildfire smoke damage is a superficial occurrence that can generally be cleaned +- Specialized cleaning methodologies have been successfully used for decades +- Professional restoration is science-based and proven +- Categorical disposal of all materials and structures is inconsistent with science and industry standards + +## Four Core Procedural Principles + +### 1. Pre-Restoration Evaluation (PRE) +- Critical first step performed by the restorer +- Establishes degree of impact from wildfire event +- Goal: identify presence of wildfire-related combustion byproducts through visual and sensory inspection +- Identifies key risk factors +- Determines whether restoration can begin immediately or if formal assessment is needed + +### 2. Pre-Restoration Assessment (PRA) +- Formal, third-party process +- Typically performed by Industrial Hygienist (IH) or qualified OEHS professional +- Triggered by specific findings in PRE, stakeholder request, or AHJ requirements +- Uses scientific sampling and laboratory analysis +- Definitively characterizes type and extent of combustion byproducts +- Establishes data-driven, defensible scope of work + +### 3. The Restoration Phase +- Physical process of removing wildfire-related combustion byproducts +- Goal: return structure, systems, and contents to clean, safe, odor-free condition +- Includes detailed source-removal cleaning +- Indoor air quality management +- Proper documentation and disposal of non-salvageable items + +### 4. Project Completion +- Final critical phase +- Establishes success of restoration efforts +- Collects evidence that combustion byproducts have been effectively removed +- Two components: + - **Restoration Completion Evaluation (RCE)**: conducted by restorer + - **Post Restoration Verification (PRV)**: performed by independent third party when necessary + +## Key Terminology + +**Combustion By-Products (CBP):** Resulting substances (char, ash, smoke) created from a fire event + +**Combustion Byproducts of Concern (CBC):** Wildfire-related combustion byproducts that can pose potential for continued damage or elevated human health risks + +**Burn Zone:** Wildfire impact zone with direct flame impingement or significant radiant heat + +**Near-Field Zone:** Extends from fire perimeter to approximately 1-10 kilometers (0.6 to 6.2 miles); affected by hot, turbulent smoke plume forcing particulates and gaseous combustion byproducts (VOCs) into building envelope + +**Far-Field Zone:** Extends beyond Near-Field Zone, potentially for hundreds of miles; primary impact is infiltration of fine particulate matter (PM2.5); impact is often surface-level and highly correctable + +## Document Structure + +The guide includes: +- Introduction +- Combustion Byproducts of Concern (CBC) +- Impact Zones +- Pre-Restoration Evaluation and Assessment +- The Restoration Phase (health/safety, procedures, removal of unrestorable goods) +- Project Completion +- Glossary of Terms +- References + +## Related Reference + +The guide references the **AIHA Technical Guide for Wildfire Impact Assessments for the OEHS Professional**, 2nd edition (2025) for more information on assessment processes. + diff --git a/RAG-KB/air-o-cell-method-guide-atlas.md b/RAG-KB/air-o-cell-method-guide-atlas.md new file mode 100644 index 0000000000000000000000000000000000000000..642b1a875ba394515e3357f552cf4b427f79ce8b --- /dev/null +++ b/RAG-KB/air-o-cell-method-guide-atlas.md @@ -0,0 +1,2067 @@ +# AIRBORNE & SURFACE SAMPLE ANALYSIS METHOD GUIDE & PARTICLE ATLAS + +## Guidance for the analysis of dust samples using the Air-O-Cell and Bio-tape Samplers + +### Optical & Scanning Electron Microscopy + +**Environmental Analysis Associates** +Bay City Michigan & San Diego California, March, 2018 + +--- + +# ENVIRONMENTAL ANALYSIS ASSOCIATES, INC. + +## AIRBORNE AND SURFACE DUST ANALYSIS INTERPRETATION GUIDE + +### 2018-2 + +This guide provides information to assist in the interpretation of laboratory analysis reports provided directly by Environmental Analysis Associates, Inc. The suggested numerical guidelines may not directly apply to samples analyzed by other laboratories, nor should they be used by themselves as an indicator of "contamination". For information regarding testing services please contact Mr. Daniel M. Baxter at dbaxter@eaalab.com. + +--- + +## Environmental Analysis Associates + +**Michigan Environmental Laboratory** *(AIHA-LAP, LLC. accredited)* +306 5th Street, Suite 400 Bay City, MI 48708 +Phone: 989-895-4447 +Email: dbaxter@eaalab.com +Website: eaalab.com + +**California Forensic Materials Laboratory** +5290 Soledad Road, San Diego, CA 92109 +Phone: 858-272-7747 +Email: dbaxter@eaalab.com +Website: eaalab.com + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# CONSULTING & TESTING SERVICES + +Environmental Analysis Associates, Inc. is dedicated to providing state-of-the-art indoor air quality particle testing services using an integrated system of Optical Microscopy and automated Electron Microscopy analysis methods. When combined with our 30 years of field and consulting experience, we can fully support our clients in finding the source and solution to dust-related indoor air quality complaints. + +The Michigan Environmental Laboratory is AIHA-LAP accredited for mold analysis and specializes in the analysis of all types of surface and airborne mold, dust, and fire / combustion residue. + +The California Forensic Materials Laboratory specializes in trace particle analysis, product defect and failure analysis testing, and litigation support. + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# INTERPRETATION GUIDE BACKGROUND + +## Airborne and Surface Dust Analysis Using Optical Microscopy and Scanning Electron Microscopy + +Environmental Analysis Associates, Inc. (EAA) is one of only a few environmental testing laboratories in the country specializing in comprehensive dust and aerosol testing using the full range of Optical and Electron Microscopy methods. Our historical field experience also allows us to provide comprehensive interpretation support of the resulting data generated by our laboratory. Mr. Daniel Baxter is the owner of EAA, and inventor of the Air-O-Cell®, the most widely used airborne mold and dust sampler in the country. + +This new version of the method guide is designed to provide our clients with practical guidance for the identification, measurement, and interpretation of indoor dust samples using Optical and Electron Microscopy. The suggested Optical Microscopy guideline ranges and color-coded summary tables provided in this guide are based on historical data collected by EAA over the past 25 years. The data should be used for comparative purposes only, and are not industry standards that can be directly used for hazard assessment. These guidelines are based on airborne sampling using the Air-O-Cell® slit impaction sampler, and surface adhesive tape lift sampling using the Zefon Bio-tape® media or cellophane tape. EAA has systematically classified and quantified the most commonly occurring particle categories found both outdoors and indoors. The guide also integrates new Automated SEM/X-ray analysis procedures developed by EAA allowing the precise chemical and size analysis of particle assemblages, and the identification of indoor contamination source(s). + +Use of the data contained within our reports requires proper guidance, as industry standards for data interpretation do not currently exist. The data should be used as a "screening" tool to separate the difference between typical and atypical indoor dust conditions, and not as criteria for declaring an environment contaminated, safe, or unsafe, or intending to satisfying a government standard. This document (combined with analysis reports provided by EAA) should be used as a secondary information to supplement an onsite visual inspection and industry accepted tests where they are applicable. + +Although it is often not possible for the microscopist to precisely identify the particle, or specific emission source by analyzing the air or surface sample alone, identifying "atypical" particle concentrations is the first step used to identify and locate a potential contamination source. Identification and classification procedures use the full range of optical microscopy methods including transmitted light bright field (BF), polarized light microscopy (PLM), and reflected light/dark field microscopy (RLDF). Samples can be further analyzed for their elemental chemistry and size distribution (when warranted) by automated Scanning Electron Microscopy. A flow diagram for comprehensive analysis is given on page 5 of this guide. + +Over the past 5 years Environmental Analysis has refined Automated Scanning Electron Microscopy and Energy Dispersive X-ray analysis procedures to practically analyze the elemental "assemblage" chemistry and size distribution analysis of airborne and surface dust samples. The automated SEM/X-ray analysis methods developed by EAA make it routinely possible to obtain rapid and comprehensive chemical and statistical size analysis of airborne and surface dust samples. This information is compiled into unique and concise reports that fully characterize the sample and provide guidance on the most probable dust emission source. + +*Note: The use of the terms "Low", "Moderate", and "High" to describe airborne or surface concentrations are for comparative purposes, and the categories of "moderate" or "high" do not imply a hazard or "unsafe" conditions.* + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# THE DIFFERENCE BETWEEN INDOOR AND OUTDOOR AIR + +There are significant differences between indoor and outdoor dust particle "assemblages". A dust particle "assemblage" is a grouping of different types of particles that are found in association with each other within defined types of environments, or when found together may represent certain types of environmental conditions. Assemblage analysis is commonly used in archeology, and the dating of fossils or pollen. Until recently, it has not routinely been used as a standard method to identify potential indoor air quality problems. For the most part, existing "indoor air quality regulations" address ventilation, and exposure to industry regulated toxic, irritant, or volatile chemicals or particles. These standard methods work well when there is a defined odor and/or known exposure hazard that has been identified. This approach is less successful when used to solve nebulous complaints associated with perceived irritation or comfort. In other words, if the particles are not classified as hazardous or as an irritant, even if they are the most commonly occurring particulate found in buildings, they are not routinely assessed or monitored by traditional EPA, OSHA, or ASTM methods. A combined systematic evaluation of the concentration or distribution of particles that are representative of the operational building are usually helpful when standard or regulated material testing methods fail to resolve a complaint. EAA fills this testing gap by analyzing the differences in particle distributions that are generated by the operational conditions and particle generation within the building. The deviations from well filtered outside air are often responsible for irritation or comfort complaints, or indicative of adverse building "shedding" conditions that can be identified and resolved. Several illustrative examples of particle distributions and their relationship to a building environment are given on the following two pages. + +## Outdoor air – rural / natural background + +- Vegetation particles +- Pollen +- Mold spores +- Soil minerals +- Insect droppings + +## Outdoor air – city / urban / industrial influence + +- Outdoor air particles described above +- Road dust – asphalt & tire rubber +- Automotive combustion particles +- Soil minerals + +## Indoor office & residential environments + +- Primarily skin cells +- Clothing, furniture, & carpeting fibers +- Decayed biogenic debris +- Building generated HVAC & building materials + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# THE INDOOR DUST ENVIRONMENT + +Given below are some examples and photo-micrographs of the most common "atypical" dust conditions caused by "building generated" particles. + +## INDOOR PARTICLE "ASSEMBLAGES" + +### Biogenic particle shedding + +- Decayed bio-film particles +- Decayed vegetation +- Decayed skin cells +- Mold growth + +### Construction renovation dust + +- Gypsum drywall dust +- Carbonate patching compounds +- Paint +- Fiberglass insulation + +### HVAC corrosion dust + +- Al, Fe, Zn, Cu oxide metal flakes +- Salts- Na, Mg, K, Ca, Al, Fe chlorides +- Rubber belt / gasket / insulation particles + +### Fire / combustion residue + +- Soot / char / ash +- Burned soil particles +- Burned pollen grains +- Firestorm vegetation and soil particles + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# THE EAA PARTICLE CLASSIFICATION SYSTEM + +The EAA Particle Classification System uses particle morphology, optical properties, and assemblage association to classify common particles. In some cases the classification may not accurately represent the exact identity of an individual particle. Unusual particles can be placed in the "Other" category when found in elevated concentrations. The particles be loosely classified and generated by biological activity (biogenic), or inorganic processes. Fibrous particles can be generated by biological, inorganic, or man-made processes. A recommended flow analysis guideline is given on the following page. + +## BIOGENIC + +| Category | Description | +|----------|-------------| +| Mold Spores | and filamentous structures generated from fungal growth | +| Algae and protozoan organisms | Chlorophyll producing "algae" spores or filaments and other protozoans associated with biofilm generation | +| Pollen & fern spores | Reproductive spores generated by flowering plants and ferns | +| Skin cell fragments (Dander) | Skin cell fragments generated by human or animals | +| Insect parts | All particles associated with insects including leg parts, wing scales, and body chiton fragments | + +## FIBROUS + +| Category | Description | +|----------|-------------| +| Fibrous glass fibers (Isotropic) | Fibrous transparent glass fibers (fiberglass & mineral wool is used primarily as insulation materials and fillers in ceiling tiles) | +| Cellulosic fibers (Anisotropic) | Natural cellulosic fibrous materials used as clothing, paper, etc. | +| Synthetic fibers | Fibrous manufactured fibers used as clothing, bedding, drapes, carpeting, etc. (primarily nylon, rayon, etc.) | + +## INORGANIC / ANTHROPOGENIC + +| Category | Description | +|----------|-------------| +| Opaque particles | Particles that are optically opaque and appear as dark brown or black when using transmitted light microscopy. Particles are typically decayed biological material, corrosion particles, and paints / pigments. | +| Fire/combustion residue | Combustion particles including Soot, Char, Ash, and other burned plant or soil material including mineral grains, plant phytoliths, or pollen. Indoor fire residue will also include other plastics, furniture finishes, and construction materials | +| Anthropogenic/mineral particles | Crystalline soil mineral grains and/or construction materials | +| Other uncommon particles | Less common particles that may not directly fit the categories described above. These could include copier toner, starch grains, droplet-like particles, specific unique minerals, or corrosion particles. | + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# DUST ANALYSIS FLOW DIAGRAM GUIDE + +The EAA dust analysis method provides a systematic way of solving indoor dust complaints using an initial screening of airborne and surface dust samples using Optical Microscopy. When the exact source requires identification, automated SEM / X-ray particle analysis procedures can be performed. + +## Optical Microscopy – Mold and dust analysis (airborne and surface samples) + +### Mold / Pollen / Algae + +- **Not elevated** → No further action +- **Elevated** → Further investigation warranted + +### Cellulosic/Synthetic fibers + +- **Not elevated** → No further action +- **Elevated** → Further investigation warranted + +### Opaque dust + +- **Not elevated** → No further action +- **Elevated** → Further investigation warranted → Source ID / chemistry / size distribution + +### Crystalline Mineral Dust + +- **Not elevated** → No further action +- **Elevated** → Further investigation warranted → Source ID / chemistry / size distribution + +### Fire / Combustion residue + +- **Not elevated** → No further action +- **Elevated / interferences** → Further investigation warranted → Source ID / chemistry / size distribution + +## Automated Scanning Electron Microscopy (SEM) + +- Particle chemistry +- Size distribution +- Particle ID → **Source identification** + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# MOLD & FUNGI - ECOLOGY + +Elevated mold spore concentrations in both the indoor and outdoor environment are known to cause allergy symptoms and are occasionally responsible for respiratory illness in immuno-compromised individuals. Elevated mold spore concentrations in the indoor environment can be caused by outdoor infiltration or from indoor growth sources when elevated surface moisture and humidity are present. + +## Conditions under which indoor mold growth can occur + +- Historical flooding without proper cleanup +- Moisture intrusion occurring through sub-flooring, walls, windows, or roofs +- Plumbing, water line leak, toilet overflows or sewer backups +- Moisture condensation around windows +- Moisture condensation inside HVAC systems +- Persistent elevated relative humidity above 70%, and inadequate housekeeping + +## Ecology of molds and fungi + +Mold and fungi require three basic criteria to colonize the inside of a building: + +- A source of moisture +- A food source +- Lack of surface disturbance and/or air movement + +Moisture sources in buildings occur most commonly as water and/or sewer leaks, moisture intrusion through walls and foundations, or as condensation around windows or inside HVAC systems. For example, in some parts of the country such as the southeast United States, the relative humidity during certain times of the year is high enough to act as a significant moisture source on its own. + +Indoor food sources for mold can be any organic material provided by a flood, sewer backup, or cellulosic materials present in the building such as carpet backing, linoleum backing, drywall paper, or ceiling panels. The buildup of plant and/or skin cell fragments or debris on inorganic surfaces is also a common source. Skin cell fragments are a significant food and mold colonizing source in office buildings and homes where a high occupancy exists, or adequate housekeeping is not maintained. + +Molds colonize most readily where air disturbance is minimal and both the surface and airborne humidity can remain high. For this reason, mold colonization occurs most frequently in closed or concealed spaces such as closets, storerooms, basements, refrigeration units, or on the backside or underside surfaces of furniture. + +--- + +*EAA Michigan laboratory* + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# MOLD & FUNGI – HEALTH EFFECTS + +## Potential health effects from inhalation of mold and fungal spores: + +Based on the existing literature, it is generally accepted in the medical community that exposure to mold may result in symptoms consistent with a cold, flu, allergy hay fever, or asthma in some people. Other individuals may have no symptoms at all. It is generally accepted that there are no long term or permanent health effects from exposure to mold once the occupant is removed from the property, or the "elevated" condition has been corrected. The medical community also generally recognizes that those who are known to be allergic to molds and those with asthma may have a higher risk of allergic reactions and should take extra precautions when in such situations. Laboratory analysis of airborne or surface samples by themselves cannot determine the associated health risks in any specific environment. + +## Common outdoor molds + +Outdoor assemblages of mold spores are most commonly associated with the following genera (listed in approximate order of descending abundance): + +- Cladosporium +- Mushroom-like fungi (Ascospores and Basidiospores) +- Alternaria +- Rusts and Smuts (colonizing primary flower and leaf parts) +- Aspergillus & Penicillium (soil and moist cellulosic surfaces). + +All of the above mentioned mold genera colonize decaying vegetation and/or soil. + +## Common molds associated with indoor mold "growth" + +The most common molds associated with indoor amplification (over 90% of the typical mold growth found inside buildings) given in approximate order of descending abundance are listed below: + +- Penicillium +- Aspergillus (flavus, fumigatus, terrus, versicolor, niger) +- Cladosporium +- Chaetomium +- Stachybotrys +- Zygomycetes (Mucor & Rhizopus) +- Chaetomium +- Ulocladium +- Trichoderma + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# MOLD & FUNGI – GENERAL AIRBORNE BACKGROUND LEVELS + +When chronic moisture intrusion exists, or significant flooding occurs, elevated levels of primary colonizing molds can be present (e.g. Penicillium, Aspergillus, and Cladosporium). Secondary mold growth (e.g. Stachybotrys, Chaetomium, Ulocladium, and Trichoderma) can occur with the presence of chronic moisture. This can also facilitate the colonization of wood-destroying fungi (i.e. Serpula, Poria). Over time, these kinds of fungi can destroy structural wood components of a building and result in very high indoor airborne basidiospore concentrations. + +## Overview on the interpretation of mold spore concentrations + +A high variability in outdoor mold spore concentrations and their distribution exists on a hourly to daily basis. Levels are dependent on the quantity of local vegetation, the micro-climate, time of year, local weather patterns, and diurnal variation. As a result, caution must be used when simultaneously comparing limited data sets of inside and outside mold concentrations, or over generalizing any set of indoor/outdoor data to desert or snow covered environments. It is also generally accepted that "single-point" comparisons between indoor and outdoor concentrations should not be relied upon as the sole criteria for determining acceptable levels in buildings. + +The table given below summarizes the regional geographic outdoor background ranges and the most common conditions associated with elevated indoor mold spore levels. The term "clean" refers to the classification definition of buildings given in our AIHA 2005 Publication entitled *A Regional Comparison of Mold Spore Concentrations Outdoors and Inside "Clean" and "Mold Contaminated" Southern California Buildings*, 2005, JOEH. This paper is also available on the "News and Information" Page of the EAA website. The term "clean" used by EAA refers to a building found to have no evidence of historical water intrusion and no visible evidence of elevated moisture conditions or mold growth determined by a systematic and thorough visual inspection. + +## Typical Outdoor Mold Spore Concentration Ranges and Genera + +| Description / Condition | Spores (cts/m³) | As/ba | Cla | Oth | As/Pe | W.I. | +|-------------------------|-----------------|-------|-----|-----|-------|------| +| Arid / desert regions | 50 - 5,000 | C | C | C | L | T | +| Urban & coastal strip | 200 - 10,000 | C | C | C | L | T | +| Inland valley / native vegetation | 500 - 20,000 | P | P | C | L | T | +| Farms & heavy forestation | 5,000 - 50,000 | P | P | C | L | L | + +## Typical Indoor Mold Spore Concentration Ranges + +| Description / Condition | Spores (cts/m³) | As/ba | Cla | Oth | As/Pe | W.I. | +|-------------------------|-----------------|-------|-----|-----|-------|------| +| "Clean" non-HVAC supplied air | ND - 2,000 | C | C | C | L | T | +| "Clean" HVAC supplied air | ND - 500 | L | L | L | L | T | +| Possible Amplification | 1,000 - 5,000 | L | C | L | C | L | +| Amplification likely present | 5,000 - 10,000 | L | C | L | P | L | +| Chronic Amplification | 10,000 - 500,000 | C | C | L | P | C | +| Inadequate flood cleanup/demolition | 50,000 - 10,000,000 | C | C | C | P | C | + +### Genera present + +- **As/Ba** – Asco / basidiospores +- **Cla** – Cladosporium +- **Oth** – Other (Alternaria, Drecshlera, Rusts, Smuts, etc.) +- **As/Pe** – Aspergillus and/or Penicillium species +- **W.I.** – Water Indicating - including (Stachybotrys, Chaetomium, Ulocladium, Trichoderma) + +### Genera Distribution / Concentration + +- **ND** – Not detected +- **P** - Predominant (can comprise ~80% of the spore distribution) +- **C** – Commonly occurring (can comprise ~50% of the spore distribution) +- **L** - Low (comprises <10% of the spore distribution) +- **T** – Trace (comprises <5% of the spore distribution) + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# USING THE EAA LABORATORY REPORTS AND INTERPRETATION GUIDELINES + +Environmental Analysis Associates has developed concise and understandable laboratory reports for the analysis and classification of airborne and surface mold, dust, and fire residue. The particles found are systematically classified in a way that allows investigators to differentiate the dust generated by occupant activity, building renovation and maintenance, HVAC system corrosion, furnishings, and/or infiltration of outdoor dust. Interpretation guidelines and color-coded comparison summary tables are provided (in addition to the laboratory reports) that can easily be added into your own site inspection reports. The color-coded summary comparison tables are based on historical data collected from thousands of buildings over the past 25+ years. The guidelines for using the summary comparison tables and analysis data provided by our own laboratory are given in the following pages. + +## Classification Definitions + +| Classification | Description | +|----------------|-------------| +| **Low** | Concentration range found in the average "clean" non-impacted building | +| **Typical-low** | Concentration range found in the average building | +| **Low-moderate** | Concentration range found in buildings with infrequent cleaning or high occupancy | +| **Moderate** | Concentration range found in buildings with possible generating sources, infrequent cleaning, and/or inadequate filtration | +| **High** | Concentration range found in buildings with indoor generating sources and/or significant infiltration | + +## CLASSIFICATION GUIDELINES - Average Residential and Commercial Buildings (Concentration/m³) + +### Mold Spore Guidelines + +| Concentration Classification | Total Spores | Aspergillus/Penicillium | Chronic Water Indicating | Outdoor Fungi | Hyphae Fragments | +|------------------------------|--------------|-------------------------|--------------------------|---------------|------------------| +| Low | <500 | <500 | <50 | <200 | <100 | +| Typical-low | >500 | >500 | >50 | >200 | >100 | +| Low-moderate | >1000 | >750 | >100 | >500 | >500 | +| Moderate | >5000 | >1500 | >200 | >1000 | >1000 | +| High | >10000 | >5000 | >500 | >5000 | >2000 | + +### Genera Distribution % (Potential Indoor verses Outdoor sources) + +| Source Classification | Aspergillus/Penicillium % | Water Indicating ct/m³ | Outdoor Fungi % | +|-----------------------|---------------------------|------------------------|-----------------| +| Indoor - low | <20% | < 50 | <20% | +| Indoor - typical | >20% | > 50 | >20% | +| Possible amplification | >50% | > 100 | >50% | +| Indoor amplification | >80% | > 300 | <20% | +| Outdoor infiltration | <20% | <50 | >2,000 cts/m³ | + +## CLASSIFICATION GUIDELINES - Average Residential and Commercial Buildings (Concentration/m³) + +### Dust Guidelines + +| Classification | Pollen | Skin Cell Fragments | Fiberglass | Cellulose/Synthetic Fibers | Unidentified Opaque | Soil/Crystalline Minerals | Fire Residue | * Other | +|----------------|--------|---------------------|------------|---------------------------|---------------------|---------------------------|--------------|---------| +| Low | <30 | <1000 | <10 | <100 | <1000 | <4000 | <500 | <100 | +| Typical-low | >30 | >1000 | >10 | >100 | >1000 | >4000 | >500 | > 100 | +| Low-moderate | >50 | >5000 | >20 | >500 | >2000 | >10000 | >1000 | > 500 | +| Moderate | >75 | >10000 | >50 | >1000 | >5000 | >20000 | >2500 | > 1000 | +| High | >150 | >20000 | >100 | >1500 | >10000 | >100000 | >10000 | > 1500 | + +--- + +# MOLD & FUNGI – INDOOR GUIDELINES + +## Typical Indoor Mold Spore Concentration Ranges (total) + +| DESCRIPTION | Classification | Surface Cts/mm² | Airborne Cts/m³ | +|-------------|----------------|-----------------|-----------------| +| Inside air "clean" HVAC supplied buildings | Low | <0.1 | <500 | +| Inside air "typical" residential | Typical-Low | 0.1 - 1.0 | 500 -1,000 | +| Moderate dust / high settled spores | Low - Moderate | 1.0 - 10.0 | 1,000 - 5,000 | +| Mold growth possible | Moderate - High * | 10 - 50 | * 5000 - 20,000 | +| Mold growth significant | High | * >50 | * >20,000 | + +*\* Depends upon the genera / species present* + +As a general observation, the total indoor airborne spore concentrations in a typical "clean" HVAC supplied building are less than the average regional outside concentrations, or are less than approximately 1,000 cts/m³. Aspergillus /Penicillium and other hyaline (clear) spores are on average less than 700 cts/m³. Stachybotrys, Chaetomium, and Ulocladium (potential indicators of chronic surface moisture) are often recovered in low concentrations in indoor samples as a result of normal infiltration. Therefore, detection in low concentrations does not automatically indicate an indoor growth source. Remember, there is always a likely exception to every rule or generalization, and because there is no direct relationship between simultaneously collected indoor and outdoor samples, performing a direct comparison with a limited number of samples can be misleading. An expected range of variability of 5 to 10 fold differences should be used when comparing side-by-side sets of limited data. + +## Mold spores commonly found outdoors and indoors + +- Cladosporium +- Ascospores +- "Poria" dry rot spores +- Alternaria +- Bipolaris-like +- Epiccocum +- Curvularia +- Smut-like + +## Mold spores most commonly associated with indoor growth (amplification) + +- Penicillium/Aspergillus +- Stachybotrys +- Chaetomium +- Pithomyces (and Ulocladium) +- Trichoderma + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# POLLEN / FERN SPORES + +## Typical Indoor Pollen And Fern Spore Concentration Ranges + +| DESCRIPTION | Classification | Surface (cts/mm²) | Airborne (cts/m³) | +|-------------|----------------|-------------------|-------------------| +| Inside "clean" | Very low | ND - 0.1 | ND | +| Inside (low / typical) | Low | 0.1 - 0.3 | <30 | +| Inside (moderate) - Infiltration | Moderate | 0.3 - 1.0 | 30 - 100 | +| Inside (high) – Significant infiltration | High | >1.0 | > 100 | + +*Note: All concentrations refer to measurements obtained during the growing seasons reflecting local California data. Other geographic regions could be higher or lower than the approximate ranges given above.* + +The presence of pollen or fern spores in the indoor environment is almost always the result of air infiltration from the outdoor environment. In a typical HVAC air supplied building, airborne pollen concentrations will be very low (less than 10ct/m³) or not detected at all. Sensitive individuals can mistakenly attribute complaints to the interior of a building that are actually the result of exterior infiltration or other allergen sources. Landscaping in building courtyards can also be a factor with perceived indoor problems. The time of year, the home environment, and pathway to work, may also be significant sources for potential exposure. + +According to the literature, the individual allergy response to pollen exposure is highly variable. Some individuals with pollen allergies may begin to exhibit symptoms when airborne concentrations exceed approximately 50 cts/m³, especially with grass or highly allergenic pollen such as ragweed. Outdoor airborne levels can range from not detected to over 1,000 cts/m³ depending on the geographic location, local vegetation, and season. The time of day when symptoms are pronounced is extremely critical for proper source diagnosis. Because of the wide range and severity of individual pollen allergenicity, consultation with an Allergist may be warranted in the rare occasions where elevated indoor pollen concentrations have been measured. + +Pollen identification in the EAA analysis report is given as the genus when known, or as the taxonomic classification (e.g. inaperturate, triporate, tricolpate, etc.) when the pollen cannot be readily identified. Detailed analysis of pollen species within our reports is only provided upon special request. + +### Common Pollen Types: + +- Acacia +- Grass +- Fir +- Betula (Birch) +- Tricolporate (classification) +- Pinus +- Ragweed +- Fern spores + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# ALGAE & BIO-FILM ORGANISMS + +## Typical Algae and Other Bio-film Organism Concentration Ranges + +| DESCRIPTION | Classification | Cts/mm² | Cts/m³ | +|-------------|----------------|---------|--------| +| Outside | Wide range | Not applicable | 10 - 1,000 | +| Inside | Low | ND - 0.1 | ND - 50 | +| Inside | Moderate | 0.1 - 0.5 | 50 - 200 | +| Inside | High | >0.5 | > 200 | + +When algae, bio-film deposits, protozoan organisms, etc. are detected in any concentration in indoor samples, a stagnant water source is likely present somewhere in proximity to the air intake stream, or there are other potential nearby water reservoirs. Although significant information is not readily available regarding health effects, algae and bio-film organisms are potential indicators of persistent moisture and other potential bacteriological or protozoa reservoirs. + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# SKIN CELL FRAGMENTS – (DANDER) + +## Typical Skin Cell Fragment Concentration Ranges + +| DESCRIPTION | Classification | Surface Cts/mm² | Airborne Cts/m³ | +|-------------|----------------|-----------------|-----------------| +| Outside | Low | <0.1 | 50 - 1,000 | +| Inside air clean buildings | Low | 0.1 - 10.0 | 1,000 - 10,000 | +| Inside air high activity | Moderate | 10.0 - 100 | 10,000 - 20,000 | +| High activity / poor housekeeping | High | >100 | 20,000 - 100,000 | + +Dander or skin cell fragments are the most common source of particle debris in indoor samples. The skin cell fragment category includes particle concentrations greater than ~20um in diameter. One of the biggest differences between inside and outside air quality is the concentration of skin cell fragments and human-borne contaminants (i.e bacteria, viruses) riding as passengers on skin tissue. Skin fragments often comprise over 50% of the volume of identifiable particles in indoor air. It is not possible in a microscopic analysis to differentiate human dander from animal or pet dander. + +Although no direct health effects can be derived by their measurement, skin cell fragment concentrations are a good surrogate indicator of the total impact of fresh air transfer rates, occupant density, commensal bacteria potential, housekeeping and cleaning practices, and filtration of recirculated air in the building. + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# BIOLOGICAL, CELLULOSIC, & SYNTHETIC FIBERS + +## Typical Cellulosic / Synthetic Fiber Concentration Ranges + +| DESCRIPTION | Classification | Surface Cts/mm² | Airborne Cts/m³ | +|-------------|----------------|-----------------|-----------------| +| Outside--(Usually plant fragments) | Low | 0.1 - 5.0 | 100 - 1,000 | +| Inside (clean buildings) | Low | 0.1 - 5.0 | 100 - 1,000 | +| Inside (high activity) | Moderate | 5.0 - 10.0 | 1,000 - 5,000 | +| Inside (high activity/poor housekeeping) | High | >10.0 | 5,000 - 50,000 | + +The cellulosic / synthetic fiber category covers a wide range of carbonaceous fibers that are commonly found in indoor samples. Fibers in this category include biogenic fibers (derived from biological activity, e.g. leaf and twig fragments, trichomes, spider web silk, cellulosic fibers), feather fibrils, and common synthetic fibers such as nylon or rayon. Indoor fiber emission sources can include architectural finishes, cellulose insulation, paper products, clothing, and carpeting. These fibers for the most part are anisotropic (crystalline), and will appear yellow and/or blue depending on their orientation when examined using a polarized light microscope with a full wave plate inserted. Some synthetic fibers will appear yellow in all orientation directions, that is, the same light vibration in all directions. Biogenic fibers generated from biological sources (plant, insect, or animal) by themselves are not normally a cause of allergy or illness symptoms. Elevated biogenic and fabric fibers may be an indication of inadequate housekeeping ventilation, high biogenic sources, and/or high occupancy rates. + +### Common Fiber Types: + +- Down feather fibril +- Dog hair +- Spider web +- "Kleenex" tissue (PLM) +- Cardboard (BF) +- Cardboard (PLM) +- Nylon carpet (BF) +- Nylon carpet (PLM) + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# FIBERGLASS FIBERS + +## Typical Fiberglass Concentration Ranges + +| DESCRIPTION | Classification | Surface Cts/mm² | Airborne Cts/m³ | +|-------------|----------------|-----------------|-----------------| +| Inside clean buildings | Low | <0.1 | <10 | +| Inside low | Typical-Low | 0.1 - 0.5 | 10 - 20 | +| Inside low-moderate | Low-moderate | 0.5 - 0.7 | 20 - 50 | +| Inside high activity | Moderate | 0.7 - 1.0 | 50 - 100 | +| Inside inadequate housekeeping | High | >1.0 | >100 | + +Fiberglass fibers are composed of amorphous (non-crystalline) fibrous glass particles and are most commonly found in insulation products. Fibrous glass sources may include thermal or sound insulation, ceiling tiles, debris from renovation projects, or the degradation of HVAC system sound dampening insulation inside the ventilation ducting system. + +Because "fiberglass" and mineral wool are manufactured by different processes, they are morphologically different but may be chemically similar. Fiberglass fibers are uniform along the entire width of the fiber, while mineral wool is characterized by non-uniform width and the presence of bulbous and rounded ends. Both fiber categories are isotropic (non-crystalline) and by definition the refractive index does not change with orientation. As a result, fiberglass fibers when viewed in cross-polarized light become invisible without the use of a retardation (full) wave plate in addition to polarized light. When a full wave retardation plate is inserted, these fibers will appear colorless in all orientations. + +The macroscopic coloration of bulk insulation (e.g. yellow, pink, black) is due to the resin binder holding the insulation together and not the color of the glass fiber. The source and location of fiberglass insulation in a building can sometimes be differentiated by the resin droplet color used as a binding material on the glass fiber itself. + +### Common Fiberglass Types: + +- Black soundliner fiberglass +- Yellow "batt" insulation +- Pink "batt" insulation +- Yellow duct wrap insulation +- Mineral wool – ceiling tile +- Fiberglass (PLM-dispersion staining) + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# OPAQUE PARTICLES OVERVIEW + +*Initial analysis is performed by Optical Microscopy. Automated SEM analysis may be required to identify the exact composition of the dust and to identify the most likely source.* + +## Typical Opaque Particle Concentration Ranges (excluding fire / combustion residue) + +| DESCRIPTION | Classification | Surface Cts/mm² | Airborne Cts/m³ | +|-------------|----------------|-----------------|-----------------| +| Inside clean buildings | Low | <10 | <1000 | +| Inside typical buildings | Typical-Low | 10 - 20 | 1,000 - 2,000 | +| Low-moderate dust | Low - Moderate | 20 - 50 | 2,000 - 5,000 | +| Moderate - building infiltration likely | Moderate | 50 - 100 | 5,000 - 10,000 | +| High - infiltration or building shedding | High | >100 | >10,000 | + +The opaque particle category encompasses a wide range of unrelated particles that appear to be brown or black when observed using transmitted light microscopy. These optically opaque particles may visually be other colors to the naked eye or when examined using reflected light microscopy. These particles often require the use of reflected light microscopy (dark field), and/or SEM / X-ray analysis to identify the type, chemistry, or origin the particle. Commonly occurring optically opaque particles are generated from five major processes including: + +1. Infiltration of optically opaque naturally occurring soil particles, biological particles, asphaltic debris, and tire rubber +2. Biological / biogenic decay – Decayed skin cells, bio-films, insect droppings, oil residues +3. Corrosion – Degradation of metal HVAC components, pipes, paint, pigments +4. Friction/abrasion – Materials released as result of HVAC component vibration and moving parts +5. Combustion – Burning and heating of biogenic, organic, and other combustible materials + +Micrographs of these various types of opaque particles are given on the following pages. + +The most common outdoor sources of particles are soil, decayed vegetation, automobile emissions, insect droppings, and fire residue particles. + +The most common indoor generated particles include combustion emissions (soot & char), paint, binders from degrading sound liners in HVAC systems, biogenic debris (biological origin, e.g. insect droppings, decayed biological debris, etc.), fan belt rubber particles, oil residue/dust agglomerates, copier toner, and corrosion from HVAC components and metal ducting. Determining the particle chemistry and the generating source usually requires additional analysis by automated Scanning Electron Microscopy (SEM) / X-ray analysis. The airborne concentration of total "opaque" particles does not normally occur in concentrations exceeding approximately 5,000 cts/m³ in "clean" indoor environments. Identification of the particle origin is not always possible, however, should be investigated as a possible contributor to air quality complaints when airborne concentrations exceed ~10,000 cts/m³. + +From a morphological standpoint, biologically derived opaque particles can often be separated from other types of opaque particles. In some cases opaque particles cannot be morphologically differentiated from corrosion shedding particles without using additional analysis by Scanning Electron Microscopy / X-ray or chemical analysis. + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# OPAQUE PARTICLES (Primarily biogenic) + +## Typical Opaque Particle Concentration Ranges (excluding fire / combustion residue) + +| DESCRIPTION | Classification | Surface Cts/mm² | Airborne Cts/m³ | +|-------------|----------------|-----------------|-----------------| +| Inside clean buildings | Low | <10 | <1000 | +| Inside typical buildings | Typical-low | 10 - 20 | 1,000 - 2,000 | +| Low-moderate dust | Low-Moderate | 20 - 50 | 2,000 - 5,000 | +| Moderate | Moderate | 50 - 100 | 5,000 - 10,000 | +| High | High | >100 | >10,000 | + +Biogenic opaque black or brown debris are derived from the chemical or biological decomposition of organically derived debris. The most common indoor sources are dander, plant fragments, insect droppings, etc. From a morphological standpoint, biologically derived opaque particles can often be separated from other types of opaque particles. Most biogenic debris have irregular, rounded, and "fuzzy" edge definition and lack the presence of straight particle edges, cleavage planes, or fracture marks. They also have a variability in optical density and will show an irregular variation in color and/or light transmission near the edge and/or throughout the particle. Examples of high levels of airborne biogenic derived debris (i.e. >100,000 cts/m³) are given below: + +- Outdoor plant/soil debris & insect droppings +- HVAC duct residue +- Decaying fungal debris +- Floor sweepings from a garage + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# OPAQUE PARTICLES (Corrosion & friction) + +## Typical Opaque Particle Concentration Ranges + +| DESCRIPTION | Classification | Surface Cts/mm² | Airborne Cts/m³ | +|-------------|----------------|-----------------|-----------------| +| Inside clean buildings | Low | <10 | <1000 | +| Inside typical buildings | Typical-low | 10 - 20 | 1,000 - 2,000 | +| Low-moderate dust | Low-Moderate | 20 - 50 | 2,000 - 5,000 | +| Moderate - building shedding possible | Moderate | 50 - 100 | 5,000 - 10,000 | +| High – building shedding likely | High | >100 | >10,000 | + +Man-made and opaque corrosion particles are derived from chemical or physical degradation, corrosion, and shedding of mineral or resinous debris. The most common indoor sources are metal corrosion from HVAC system components, or pigment and paint shedding from building surfaces. These types of opaque particles can often be separated from other sources by using a combination of transmitted and reflected light microscopy. Exact identification and quantification may require automated SEM / X-ray analysis. + +Most non-biogenic opaque particles have angular and distinct edges, and a low variation in optical density from the edge to the center of the particle in transmitted light illumination. They can often be identified or classified using reflected light (dark field) microscopy (see bottom picture of HVAC corrosion). + +### Common Corrosion Particle Types: + +- Iron rust particles +- Copier toner +- Tire rubber particles +- HVAC system corrosion particles (Aluminum, Iron, and Zinc oxide particles) + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# OPAQUE PARTICLES – WILDFIRE COMBUSTION RESIDUE + +## Typical Wildfire / Combustion Particle Concentration Ranges + +| DESCRIPTION | Classification | Surface Ratio % | Surface Cts/mm² | Airborne cts/m³ | +|-------------|----------------|-----------------|-----------------|-----------------| +| Very low / clean | Typical-low | <1% | <1 | <500 | +| Low-Typical | Upper Background | <3% | 1-5 | 500 - 1,000 | +| Moderately elevated | Moderate | 3 - 10% | 10 - 50 | 1,000 - 10,000 | +| High / source present | Elevated | >10% | >50 | >10,000 | + +Wildfire combustion particles are a complex mixture of cellulose vegetation, burned soil, residual salts, and crystalline calcium and silica vegetation particles (phytoliths). Quantifying airborne and surface fire combustion contamination is a multi-step process requiring Optical Microscopy (Polarized Light & Reflected Light). Scanning Electron Microscopy / X-ray analysis can be utilized to differentiate look-alike interference particles from actual combustion residue. Wildfire combustion particles can be separated into three basic combustion categories (soot, char, and ash). There are also other indicator particles (e.g. burned soil particles, pollen, plant phytoliths) that can assist in the differentiation of wildfire residues from other types of combustion sources. + +### Combustion Categories: + +**Soot** – Residues from the combustion of organic resins and compounds + +**Char** – Incomplete combustion of cellulose vegetation material + +**Ash** – The residual mineral elements remaining after combustion (primarily Calcium, Sodium, Magnesium, and Potassium salts) + +### Wildfire Indicators: + +- Burned/carbonized mineral grains +- Burned pollen +- Burned plant phytoliths + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# INSECT PARTS + +## Typical Insect Part Concentration Ranges + +| DESCRIPTION | Classification | Surface Cts/mm² | Airborne Cts/m³ | +|-------------|----------------|-----------------|-----------------| +| Inside low background | Typical-low | <0.1 | <50 | +| Inside moderate | Moderate | 0.1 - 10.0 | 50 - 500 | +| Inside high and potential infestation | High | >10.0 | >500 | + +Most other recognizable biogenic particles are comprised of whole insects or insect fragments (e.g. body parts, antennae, legs, scales, body hairs, and wing fragments). Bird feather fibrils are occasionally detected as well. In clean indoor environments, insect parts are occasionally detected, however, airborne concentrations above ~100 cts/m³ in air samples are not routinely measured. Elevated concentrations of wings scales, body parts, or insect droppings found in airborne or surface samples may be an indicator of an infestation or inadequate building maintenance and/or air filtration. Occasionally dust mites are also found when inadequate housekeeping, high moisture levels, or extensive mold growth is present. The detection of dust or carpet mites, or parts of other types of organisms in surface or airborne samples may be indicative of an infestation. + +### Common Insect-Related Particles: + +- Dust & carpet mites +- Moth wing scale +- Insect body hair + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# CRYSTALLINE MINERAL DUST PARTICLES – Soil / construction dust + +## Typical Crystalline Mineral Particle Concentration Ranges + +| DESCRIPTION | Classification | Surface Cts/mm² | Airborne Cts/m³ | +|-------------|----------------|-----------------|-----------------| +| Inside low and clean | Low | <5 | <4,000 | +| Inside low / typical background | Low / typical | 5 - 10 | 4,000 - 10,000 | +| Inside low-moderate dust source | Low-moderate | 10 - 50 | 10,000 - 20,000 | +| Inside moderate dust source | Moderate | 50 - 100 | 20,000 - 100,000 | +| Inside high actively generating source | High | >100 | >100,000 | + +Crystalline mineral particles found indoors are generated by two primary sources, 1). Infiltrated and naturally occurring soil particles and, 2). Building construction and finish materials. Construction materials are composed mostly of carbonate and gypsum containing dust generated from the application and renovation of building components, drywall, patching compounds, flooring adhesives, and paint. Infiltrated soil minerals are mostly composed of naturally occurring aluminum silicate clays, quartz, and Calcium carbonates and sulfates. Mineral dust particles are categorized in the analysis as those particles that exhibit low to high birefringence in cross-polarized light. + +### Common Crystalline Mineral Types: + +- Drywall dust (gypsum) - 750x +- Calcium carbonate - 750x +- Quartz beach sand - dispersion staining - 100x +- Diatomite - 750x + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# SUGGESTED AIRBORNE MOLD GUIDELINES DEVELOPED BY ENVIRONMENTAL ANALYSIS + +## INDOOR AIRBORNE MOLD SPORE INTERPRETATION GUIDELINES + +*Developed by Environmental Analysis Associates, Inc. - 2003 - 2018* +*Laboratories located in Bay City, Michigan and San Diego, California* +*doc.rev.3 - 7/15/18* + +The indoor air sample interpretation guidelines given below are based on the average mold spore concentration ranges found in typical office and residential environments throughout the country. The classifications are divided into 5 ranges of "High", "Moderate", "Low-moderate", "Typical-low", and "Low", and are based on our 30 years of experience providing mold analysis from a wide range of residential, commercial, office, hospital, and industrial buildings. Exceptions to any guidelines should be expected based upon varied building construction, usage, and HVAC filtration. Site specific exterior climatic conditions can also have a direct impact on the infiltration rate and measured background of mold spores found inside buildings. The presence or absence of vegetation both regionally and in close proximity to a building (e.g. forested, snow covered, or desert / paved urban areas), can directly affect the concentration and distribution of mold spores infiltrating into the building. + +## Mold Spore Category Definitions + +| Category | Description / Definition | +|----------|-------------------------| +| Total Mold Spores | Total concentration of all enumerated mold spores | +| Aspergillus/Penicillium | Penicillium or Aspergillus morphology (the most common molds associated with indoor growth) | +| Chronic Water Indicating Fungi | Mold genera associated with "chronic" indoor moisture (Stachybotrys, Chaetomium, Ulocladium, Trichoderma) | +| Typical Outdoor Fungi | Mold genera commonly found in outdoor air (Asco/Basidiopores, Cladosporium, and other listed spores) | +| Hyphae Fragments | Mold growth structures including hyphae (mycelia), phialides, perithecia, etc. | + +*Note: Cladosporium may commonly grow indoors in sub-tropical climates as well as inside HVAC systems, and on window panes (from condensation). All molds genera listed can be found both indoors and outdoors. Finding low or isolated spores of any genera should be viewed as normal occurrence.* + +Importantly, there is no simultaneous short-term relationship between indoor and outdoor mold/fungal spore concentrations. Outdoor airborne concentrations can vary 10-100 fold (e.g. 100-10,000 cts/m³) on an hour-by-hour basis depending on the sampling location, the meteorological conditions at the time of sample collection, the time of day, wind velocity, and seasonal variability. The indoor environment typically has a fewer number of variable conditions and the mold spore concentrations will typically vary no more than 2-5 fold (e.g. 500-2,500 cts/m³) over an entire week. Existing peer reviewed literature clearly states that performing simultaneous indoor/outdoor comparisons using limited data by itself, and without considering both the variability and magnitude of measured concentrations, is scientifically unreliable. One must also consider that the sampling and analysis variability on any individual sample can vary ± 30%. Although indoor spore concentrations are typically lower than the seasonal average for outdoor levels, measuring higher mold spore concentrations indoors (even in the absence of indoor mold growth sources) can be a common and normal occurrence. This is especially true in geographic or climatic locations where minimal exterior vegetation is present. A comparison with outdoor mold spore measurements is often more useful to help determine if the indoor environment is being impacted by outdoor mold spore infiltration from a site specific condition, or historically over an extended period of time. Historical data collected by EAA was used to develop classification ranges for both the concentration and distribution of mold spore genera, and then classify the results as compared to average "clean" indoor environments. Indoor concentration ranges (regardless of outdoor concentrations) are first used to classify the genera of Aspergillus and Penicillium, Chronic Water Indicating Fungi, and Outdoor fungi as "High", "Moderate", "Low-moderate", "Typical-low", and "Low" based on ranges given in the table given below. The Aspergillus/Penicillium and Water Indicating Fungi categories are mold spore genera most commonly associated with the presence of indoor growth. The Outdoor Fungi category (i.e. mold genera commonly found growing on soil and outdoor vegetation) is used to determine if the distribution of mold spores may be the result of outdoor mold spore infiltration. The EAA air sampling results and classification ranges should be used as secondary information to support a thorough visual inspection. A proper assessment of building contamination cannot directly be determined using air sampling results alone. Determining the presence of actual indoor mold growth and if there is an elevated exposure to a moldy environment, requires a thorough visual inspection, visual quantification of the location and extent of surface growth, and documenting the potential routes of exposure. + +## CLASSIFICATION GUIDELINES - Average Residential and Commercial Buildings (Concentration/m³) + +### Concentration Classification Table + +| Concentration Classification | Total Spores | Aspergillus/Penicillium | Chronic Water Indicating | Outdoor Fungi | Hyphae Fragments | +|------------------------------|--------------|-------------------------|--------------------------|---------------|------------------| +| Low | <500 | <500 | <50 | <200 | <100 | +| Typical-low | >500 | >500 | >50 | >200 | >100 | +| Low-moderate | >1000 | >750 | >100 | >500 | >500 | +| Moderate | >5000 | >1500 | >200 | >1000 | >1000 | +| High | >10000 | >5000 | >500 | >5000 | >2000 | + +### Genera Distribution % (Potential Indoor verses Outdoor sources) + +| Source Classification | Aspergillus/Penicillium % | Water Indicating ct/m³ | Outdoor Fungi % | +|-----------------------|---------------------------|------------------------|-----------------| +| Indoor - low | <20% | < 50 | <20% | +| Indoor - typical | >20% | > 50 | >20% | +| Possible amplification | >50% | > 100 | >50% | +| Indoor amplification | >80% | > 300 | <20% | +| Outdoor infiltration | <20% | <50 | >2,000 cts/m³ | + +### Classification Definitions: + +- **Low** - Concentration range found in the average "clean" non-water impacted building +- **Typical-low** - Concentration range found in the average building (minimal history of water leaks or other water events) +- **Low-moderate** - Concentration range found in buildings with inadequate house-keeping and/or possible mold growth +- **Moderate** - Concentration range found in buildings with inadequate house-keeping and/or possible mold growth +- **High** - Concentration range found in buildings with evidence of significant mold growth + +Although no classification system used to estimate the potential for indoor mold growth, or acceptable indoor spore concentrations can be relied upon in all situations, the Classification Guidelines given above (and supported in principle by published research referenced (below) are based upon the measured variability of spore concentrations and the genera distribution found inside clean and mold contaminated buildings. Although not applicable to all types of building environments, our classification criteria are more reliable than using indoor / outdoor comparisons to assess potential contamination. *The classification guidelines given above cannot be used to assess wall cavities or confined spaces.* + +**References:** +- 1999 -ACGIH, *Bioaerosols: Assessment and Control (Chapter 14)* +- 2005 JOEH, 2: 8-18, Daniel M. Baxter, Jimmy L. Perkins, *"A Regional Comparison of Mold Spore Concentrations Outdoors and Inside "Clean" and "Mold Contaminated" Southern California Buildings."* + +--- + +# SUGGESTED AIRBORNE DUST GUIDELINES DEVELOPED BY ENVIRONMENTAL ANALYSIS + +## INDOOR AIRBORNE DUST INTERPRETATION GUIDELINES + +*Developed by Environmental Analysis Associates, Inc. - 2003 - 2018* +*Laboratories located in Bay City, Michigan and San Diego, California* +*doc.rev.3 - 7/15/18* + +The indoor airborne dust classification categories used by EAA provide a systematic way to measure and evaluate the full range of particles generated by building occupants, renovation and maintenance activities, HVAC corrosion and degradation, and the filtration efficacy of the building. This is accomplished by quantifying and understanding the origin of the most common types of airborne dust particle contaminants. Based on our own historical building inspection observations and analysis data, the measured dust particle concentrations are classified in 5 ranges of "high", "moderate", "low-moderate", "low - typical", and "low". These ranges are not direct indicators of safe or unsafe conditions, nor should they be confused with EPA or OSHA exposure guidelines. The origin and impact of each dust particle category on indoor air quality is described and illustrated in the "Airborne and Surface Dust Analysis Interpretation Guide" provided on the News and Information page of the eaalab.com website. Additional analysis of particle size distribution and inorganic particle chemistry can also be provided by automated SEM / X-ray analysis. The automated SEM/X-ray sampling and analysis methods are also described in the interpretation guide. + +## Particle Classification Definitions + +| CLASSIFICATION | DESCRIPTION | +|----------------|-------------| +| Pollen | Reproductive spores of flowers | +| Skin cell fragments | Epithelial cells / dander | +| Fiberglass | Man-made fibrous glass fibers (fiberglass, mineral wool) | +| Cellulose / Synthetic | Cellulosic, fabric, synthetic fibers (nylon, rayon, etc.) | +| Unidentified Opaque | Opaque debris (biological decay, tire rubber, corrosion, paint, etc.) | +| Soil / Mineral | Soil, crystalline minerals, construction dust particles | +| Fire residue | Combustion soot, ash, char, other assemblage indicator particles | +| * Other | Specific unusual and atypical particles | +| | *Examples: Copier toner, paint flakes, unusual fibers, feather fibrils, starch grains, etc.* | +| | *To be handled on a case-by-case basis* | + +No quantitative assessment criteria are used for the following: + +| Category | Assessment | +|----------|------------| +| Insect parts | *Concentration range similar to cellulose range* | +| Algae/Fern spores | *Concentration range similar to cellulose range* | + +## CLASSIFICATION GUIDELINES - Average Residential and Commercial Buildings (Concentration/m³) + +| Classification | Pollen | Skin Cell Fragments | Fiberglass | Cellulose/Synthetic Fibers | Unidentified Opaque | Soil/Crystalline Minerals | Fire Residue | * Other | +|----------------|--------|---------------------|------------|---------------------------|---------------------|---------------------------|--------------|---------| +| Low | <30 | <1000 | <10 | <100 | <1000 | <4000 | <500 | <100 | +| Typical-low | >30 | >1000 | >10 | >100 | >1000 | >4000 | >500 | > 100 | +| Low-moderate | >50 | >5000 | >20 | >500 | >2000 | >10000 | >1000 | > 500 | +| Moderate | >75 | >10000 | >50 | >1000 | >5000 | >20000 | >2500 | > 1000 | +| High | >150 | >20000 | >100 | >1500 | >10000 | >100000 | >10000 | > 1500 | + +*\* Reported individually under the "Special Comments Section" - Concentration ranges may vary by type of particle* + +### Classification Definitions: + +- **Low** - Concentration range found in the average "clean" non-impacted building. +- **Typical - low** - Concentration range found in the average building +- **Low-moderate** - Concentration range found in buildings with infrequent cleaning or high occupancy +- **Moderate** - Concentration range found in buildings with possible generating sources, infrequent cleaning, and/or inadequate filtration +- **High** - Concentration range found in buildings with indoor generating sources and/or significant infiltration + +*Note: Pollen level assessment criteria are based on the prevalence of pollen encountered by EAA in indoor environments and not by the general assessment criterion published by the National Allergy Bureau for outdoor airborne levels.* + +Although no classification system used to estimate potential contamination can cover all conditions, EAA's system follows industry accepted scientific guidelines outlined in Chapter 14.2.2 of the ACGIH 1999 document *Bioaerosols: Assessment and Control* for the comparison of indoor and outdoor data. Average levels measured inside buildings with very high occupant activity (auditoriums, classrooms, etc.), operations involving industrial activities, or buildings without routine HVAC supplied air will likely have significantly higher average ranges than indicated in the table above. These guidelines are not applicable for the evaluation of wall cavities, attics, crawl spaces, or other confined spaces. + +--- + +# SUGGESTED SURFACE MOLD GUIDELINES DEVELOPED BY ENVIRONMENTAL ANALYSIS + +## INDOOR SURFACE MOLD SPORE INTERPRETATION GUIDELINES + +*Developed by Environmental Analysis Associates, Inc. - 2003 - 2018* +*Laboratories located in San Diego, California & Bay City, Michigan* +*doc.rev.7 - 7/15/18* + +The surface mold interpretation guidelines given below are based on 30 years experience collecting and analyzing samples from a wide range of building environments. The classification ranges were developed based on data collected from known "clean" residential and commercial buildings, and buildings with a history of water damage and/or mold growth. Exceptions to any guidelines should always be anticipated, especially in locations or climatic conditions where a very high or very low density of vegetation is present. + +## Category Definitions + +| Category | Description / Definition | +|----------|-------------------------| +| Total Spores | Total concentration of all enumerated spores | +| Aspergillus/Penicillium | Spores with Penicillium or Aspergillus morphology | +| Chronic water indicating fungi | Spores indicating "chronic" moisture (Stachybotrys, Chaetomium, Ulocladium, Trichoderma) | +| Typical Outdoor Fungi | Spores commonly found in outdoor air (Asco/Basidiopores, Cladosporium, Other) | +| Hyphae Fragments | Fungal growth structures including hyphae (mycelia), phialides, perithecia, etc. | + +*Note: Cladosporium may commonly grow indoors in sub-tropical climates as well as inside HVAC systems, and with condensation on window panes.* + +There is no direct relationship between indoor and outdoor surface mold spore concentrations, and existing peer reviewed mold concentration literature typically refers to indoor/outdoor comparisons of air samples. Very little published literature is available for surface mold spore concentrations. Based on our own experience, the variability and magnitude of measured settled surface concentrations can naturally vary 10,000-fold from less than 0.1 spores/mm² to over 1,000 spores/mm² depending upon environmental factors, location, and the frequency of surface cleaning. When surface "growth" is present, the measured spore and growth "structure" concentrations (e.g. mycelia, arthroconidia, phialides, perithecia, etc.) can range from 100 fungal structures/mm² to over 100,000 fungal structures/mm². When high concentrations of fungal structures and/or spores are measured from a discolored surface of suspect mold growth, the results simply indicate the presence of mold growth. Furthermore, there is no correlation or direct relationship to how much surface area is impacted, nor can the results be used to determine if an airborne hazard is present. Analysis results (by themselves) simply indicate the presence or absence of surface mold growth and/or the concentration of settled spores. Construction lumber and building materials can inherently contain moderate to high surface mold growth structures as purchased from the store or lumberyard. As a result, care must be exercised when interpreting the data collected from lumber, wood products, or other materials stored outdoors prior to being used inside the building. Moderate or high mold concentrations may indicate a pre-existing condition, and not necessarily mold growth or excessive settling that occurred after the material was brought inside the building being tested. Determining the extent of actual indoor mold growth, or an elevated exposure to a moldy environment, requires a thorough visual inspection, visual quantification of the location and extent of surface growth, and evaluation of other environmental factors. + +## CLASSIFICATION GUIDELINES - Average Residential and Commercial Buildings (Concentration/mm²) + +### Data collected from horizontal surfaces + +| Concentration Classification | Total Spores | Aspergillus/Penicillium | Chronic Water Indicating | Typical Outdoor Fungi | Hyphae Fragments | +|------------------------------|--------------|-------------------------|--------------------------|----------------------|------------------| +| Low | <0.1 | <0.1 | <0.1 | <0.1 | <0.1 | +| Typical-low | >0.1 | >0.1 | >0.1 | >0.1 | >0.1 | +| Low-moderate | >1.0 | <10 | >1.0 | >1.0 | >1.0 | +| Moderate | >10 | <20 | >5.0 | >10 | >10 | +| High | >100 | <50 | >10 | >20 | >50 | + +### Classification Definitions: + +- **Low** - Concentration range found in the average "clean" non-water impacted building +- **Typical-low** - Concentration range found in the average building (minimal history of water leaks or other water events) +- **Low-moderate** - Concentration range found in buildings with inadequate house-keeping and/or possible mold growth +- **Moderate** - Concentration range found in buildings with inadequate house-keeping and/or possible mold growth +- ***High** - Concentration range found in buildings with evidence of significant mold growth* + +*\* Some construction lumber & materials may have a natural mold background and may not indicate a current settling or growth condition.* + +Historical indoor data collected by EAA is used to classify the results as compared to average "clean" office and residential environments. The concentration classification ranges (regardless of outdoor concentrations) are first used to categorize Aspergillus and Penicillium, and Chronic Water Indicating (W.I.) fungi categories as "High", "Moderate", "Low-moderate", "Typical-low", and "Low". The spore genera distribution can also be used as a possible indication of indoor growth or outdoor air infiltration and subsequent settling. Indoor growth may be indicated when high Aspergillus/Penicillium or Water Indicating spore concentrations are present, or a moderate concentration of hyphae or other types of mold growth structures are present. Occasionally, measuring moderate to high concentrations of Cladosporium, certain basidiospores, or other hyaline fungi may also be indicators of indoor mold growth. + +While no classification system used to estimate potential contamination or identify a mold growth source will be applicable to all individual building conditions, EAA's classification ranges are based sample analysis data from our own building inspections, and samples analyzed for other industrial hygiene firms from a wide range of building environments. **These concentration levels should not be used to assess wall cavities or confined spaces.** + +--- + +# SUGGESTED SURFACE DUST GUIDELINES DEVELOPED BY ENVIRONMENTAL ANALYSIS + +## INDOOR SURFACE DUST INTERPRETATION GUIDELINES + +*Developed by Environmental Analysis Associates, Inc. - 2003 - 2018* +*Laboratories located in San Diego, California & Bay City, Michigan* +*doc.rev.7 - 7/15/18* + +The particle classifications used by EAA (and shown below) provide a concentration ranking of the most common dust contaminants settling on horizontal surfaces inside buildings. These indicator categories provide a direct reflection of dust generated by occupant activity, shedding of building materials, building maintenance, HVAC systems, building furnishings, renovation activities, and/or outdoor infiltration. The concentration ranges and classifications of "High", "Moderate", "Low-moderate", "Typical-low", or "Low" levels cannot directly be used as indicators of safe or unsafe conditions, nor should they be confused with EPA, OSHA, or other governmental exposure guidelines. These guidelines are useful for the comparison of settled dust concentrations in buildings, determining the presence or absence of construction renovation related dust, or to identify other potentially irritant or allergenic particles. The analysis results can also assist maintenance personnel in the determination of the source or origin of indoor air quality complaints, or evaluate the relative cleanliness of content surfaces. The potential association of each particle classification with building related conditions is illustrated in the EAA "Airborne and Surface Dust Analysis Interpretation Guide" on the News and Information page at eaalab.com. + +## Category Definitions + +| Category | Description / Definition | +|----------|-------------------------| +| Pollen | Reproductive spores of flowers | +| Skin cell fragments | Epithelial cells / dander | +| Fiberglass | Man-made fibrous glass fibers (fiberglass, mineral wool) | +| Cellulose / Synthetic | Cellulosic, fabric, synthetic fibers (nylon, rayon, etc.) | +| Unidentified Opaque | Opaque debris (biological decay, tire rubber, corrosion, paint, etc.) | +| Soil / mineral | Soil, crystalline minerals, construction dust particles | +| Fire residue | Combustion soot, ash, char, other assemblage indicator particles | +| * Other | Specific unusual and atypical particles | +| | *Examples: Copier toner, paint flakes, unusual fibers, feather fibrils, starch grains, etc.* | +| | *To be handled on a case-by-case basis* | + +No quantitative assessment criteria are used for the following: + +| Category | Assessment | +|----------|------------| +| Insect parts | *Concentration range similar to cellulose range* | +| Algae/Fern spores | *Concentration range similar to cellulose range* | + +## CLASSIFICATION GUIDELINES - Average Residential and Commercial Buildings (Concentration/mm²) + +### Data collected from horizontal surfaces + +| Concentration Classification | Pollen | Skin Cell Fragments | Fiberglass | Cellulose/Synthetic Fibers | Unidentified Opaque | Soil/Crystalline Minerals | Fire Residue | * Other | +|------------------------------|--------|---------------------|------------|---------------------------|---------------------|---------------------------|--------------|---------| +| Low | <1.0 | <1.0 | <0.1 | <0.1 | <10 | <5.0 | <1.0 | <0.1 | +| Typical-low | >1.0 | >1.0 | >0.1 | >0.1 | >10 | >5.0 | >1.0 | >0.1 | +| Low-moderate | >2.0 | >10 | >0.5 | >1.0 | >20 | >10 | >5.0 | >1.0 | +| Moderate | >5.0 | >50 | >0.7 | >5.0 | >50 | >50 | >10 | >5.0 | +| High | >10 | >100 | >1.0 | >10 | >100 | >100 | >50 | > 10 | + +*\* Reported individually under the "Special Comments Section" - Concentration ranges may vary by type of particle* + +### Classification Definitions: + +- **Low** - Concentration range found in the average "clean" non-impacted building. +- **Typical-low** - Concentration range found in the average building +- **Low-moderate** - Concentration range found in buildings with inadequate house-keeping +- **Moderate** - Concentration range found in buildings with possible generating sources and/or inadequate filtration +- **High** - Concentration range found in buildings with indoor generating sources and/or significant infiltration + +EAA's classification system follows basic guidelines outlined in Chapter 14.2.2 of the ACGIH 1999 document *Bioaerosols: Assessment and Control* by accounting for average baseline data inside buildings. Average levels measured inside buildings without routine HVAC supplied air, or residential dwellings may be higher. No classification system used can be expected to cover all conditions. **These concentration levels should not be used to assess wall cavities or confined spaces.** + +--- + +# EXAMPLE DATA COMPARISON SUMMARY FOR THE AIRBORNE DUST REPORTS + +## ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 306 5th Street, Suite 400 - Bay City, MI 48708 + +### AIRBORNE MOLD AND DUST ANALYSIS +### (Data Comparison Summary - Cts/m³) + +**Client Name:** ABC Environmental +**Client Project #:** 18-1001 +**Requested by:** Mr. John Smith + +**Project description:** 123 Elm Street Offices +**EAA Project#:** 18-3000 + +**EAA Method #:** DUST-A01 +**Page 1 of 1** + +| Sample# Description | Mold Spore *Total | Aspergillus/Penicillium | Chronic W.I. Fungi | Outdoor Spores | Hyphae Fragments | Pollen | Skin cell Fragments | Fibrous Dust Min. wool/Fiberglass | Cellulose/Synthetic | Non-Fibrous dust Uniden. Opaque | Crystalline Mineral | Other Particles | +|---------------------|-------------------|-------------------------|--------------------|----------------|------------------|--------|---------------------|-----------------------------------|---------------------|--------------------------------|---------------------|-----------------| +| AOC-1 Supervisor's office | 870 | 229 | | 641 | 46 | 13 | 4,800 | 91 | 686 | 1,140 | 1,600 | 91 | +| AOC-2 Kitchen / break area | 12,500 | 11,400 | 137 | 960 | 229 | 13 | 9,140 | | 869 | 457 | 24,700 | 2,510 | +| AOC-3 Inside main cubical area 1 | 503 | 91 | | 411 | | 27 | 13,700 | 11 | 1,140 | 3,890 | 2,510 | 1,140 | +| AOC-4 Inside main cubical area 2 | 1,010 | 137 | | 869 | | | 8,000 | 21 | 2,060 | 2,060 | 1,600 | 686 | +| AOC-5 Outside front entrance | 14,000 | 549 | 46 | 13,398 | 549 | 107 | 137 | | 823 | 11,400 | 25,100 | | + +*\* Note: All individual particle category values are rounded to 3 decimal places. As a result, individually summed mold categories may be slightly different than the "Total" value. Chronic water indicating fungi (W.I.), include the genera Chaetomium, Stachybotrys, Ulocladium. The hyphae fragments category includes hyphae (mycelia), phialides, perithecia, etc.* + +*The qualitative classification ranges for "Low", "Typical-low", "Low-moderate", "Moderate", and "High" should be used for initial comparison purposes only and are based on the average or "typical" concentration ranges found in residential and commercial buildings. The classifications cannot be directly used as an indicator of indoor mold growth, or of a safe or unsafe environment.* + +### Interpretation Guidelines Color Key: + +| Color | Classification | +|-------|----------------| +| Green | Low | +| Light Green | Typical-low | +| Yellow | Low-Moderate | +| Orange | Moderate | +| Red | High | + +--- + +# EXAMPLE DATA COMPARISON SUMMARY FOR THE SURFACE DUST REPORTS + +## ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 306 5th Street, Suite 400 - Bay City, MI 48708 + +### SURFACE MOLD AND DUST ANALYSIS +### (Data Comparison Summary - Cts/mm²) + +**Client Name:** ABC Environmental +**Client Project #:** 18-1001 +**Requested by:** Mr. John Smith + +**Project description:** 123 Elm Street Offices +**EAA Project#:** 18-3000 + +**EAA Method #:** DUST-A01 +**Page 1 of 1** + +| Sample# Description | Mold Spore *Total | Aspergillus/Penicillium | Chronic W.I. Fungi | Outdoor Spores | Hyphae Fragments | Pollen | Skin cell Fragments | Fibrous Dust Min. wool/Fiberglass | Cellulose/Synthetic | Non-Fibrous dust Uniden. Opaque | Crystalline Mineral | Other Particles | +|---------------------|-------------------|-------------------------|--------------------|----------------|------------------|--------|---------------------|-----------------------------------|---------------------|--------------------------------|---------------------|-----------------| +| TL-1 Supervisor's office-discolored window ledge | 220.0 | 180.0 | | 40.3 | 61.3 | 14.4 | 39.6 | 0.7 | 10.8 | 25.2 | 166.0 | | +| TL-2 Kitchen / break area - Under the sink | 98.7 | 93.7 | 0.7 | 4.3 | | 0.7 | 10.8 | | 7.2 | 8.7 | 13.0 | | +| TL-3 Desk 5 - Main cubical area 1 | 10.1 | | 0.7 | 9.4 | 0.7 | 3.6 | 108.0 | 0.2 | 18.0 | 10.8 | 57.7 | | +| TL-4 Desk 1 - Cubical area 2 | 15.9 | 1.4 | 0.7 | 13.7 | | 11.5 | 180.0 | | 18.0 | 10.8 | 180.0 | | +| TL-5 Desk 5 - Main cubical area 1 - window ledge | 317.0 | 300.0 | 9.6 | 7.2 | 252.0 | 2.4 | 60.1 | | 12.0 | 36.0 | 505.0 | | + +*\* Note: All individual particle category values are rounded to 3 decimal places. As a result, individually summed mold categories may be slightly different than the "Total" value. Chronic water indicating fungi (W.I.), include the genera Chaetomium, Stachybotrys, Ulocladium. The hyphae fragments category includes hyphae (mycelia), phialides, perithecia, etc.* + +*The qualitative classification ranges for "Low", "Typical-low", "Low-moderate", "Moderate", and "High" should be used for initial comparison purposes only and are based on the average or "typical" concentration ranges found in residential and commercial buildings. The classifications cannot be directly used as an indicator of indoor mold growth, or of a safe or unsafe environment.* + +### Interpretation Guidelines Color Key: + +| Color | Classification | +|-------|----------------| +| Green | Low | +| Light Green | Typical-low | +| Yellow | Low-Moderate | +| Orange | Moderate | +| Red | High | + +--- + +# EXAMPLE AIRBORNE MOLD AND DUST REPORT + +## ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 306 5th Street, Suite 400 - Bay City, MI 48708 + +### AIRBORNE MOLD AND DUST ANALYSIS + +**EAA Method #:** DUST-A01 +**Data Page 1 of 2** + +**Client Name:** ABC Environmental +**Client Project #:** 18-1001 +**Requested by:** Mr. John Smith +**EAA Project#:** 18-3000 + +**Project description:** 123 Elm Street Offices +**Date collected:** 7/6/18 +**Sample received:** 7/7/18 + +**Sample condition:** Acceptable as received + +### Client Sample Information + +| Client Sample# | Sample Description / Location | General Comments | +|----------------|------------------------------|------------------| +| AOC-1 | Supervisor's office | Low-moderate dust, low mold spore concentrations | +| AOC-2 | Kitchen / break area | Moderate dust, high mold spore concentrations | +| AOC-3 | Inside main cubical area 1 | Moderate dust, low mold spore concentrations | +| AOC-4 | Inside main cubical area 2 | Moderate dust, high cellulose fibers, low mold spore concentrations | +| AOC-5 | Outside front entrance | High dust, high mold spore concentrations | + +### AIRBORNE MOLD SPORE CONCENTRATIONS (Cts./m³) -- Spore Trap Sample Analysis + +*High mag. used 500X* + +| Category Sample # --> | AOC-1 | AOC-2 | AOC-3 | AOC-4 | AOC-5 | +|-----------------------|-------|-------|-------|-------|-------| +| **Total Mold Spores (Cts/m³)** | **870** | **12500** | **503** | **1010** | **14000** | +| Alternaria | | | 46 | | 274 | +| Aspergillus/Penicillium | 229 | 11400 | 91 | 137 | 549 | +| Ascospores | 229 | 46 | 137 | 686 | 1600 | +| Basidiospores | 229 | 137 | 183 | 46 | 6860 | +| Botrytis | | | | | | +| Chaetomium | | | | | | +| Cladosporium | 137 | 686 | | 137 | 3250 | +| Curvularia | | | | | | +| Drechslera/Bipolaris | | | | | 46 | +| Epicoccum | | | | | 46 | +| Fusarium | | | | | | +| Nigrospora | | | | | | +| Oidium/Peronospora | | | | | | +| Pithomyces | | | | | | +| Rusts | | | | | 46 | +| Smuts / Myxomycetes / Periconia | | | | | 1140 | +| Stachybotrys | | 137 | | | 46 | +| Stemphylium | | | | | | +| Torula | | | | | | +| Ulocladium | | | | | | +| Other Hyaline Fungi | 46 | 91 | | | 91 | +| Other Fungi | | | | | | +| Unidentified Fungi | | | 46 | | 46 | +| Hyphae fragments | 46 | 229 | | | 549 | +| Algal / fern spores | | | | | | +| Insect parts | | | 46 | | 91 | +| **POLLEN (Total cts/m³)** | **13** | **13** | **27** | **not detected** | **107** | +| Not specified | | 13 | 27 | | 40 | +| Pinus | 13 | | | | 67 | +| **COMMON AEROSOLS (cts/m3)** | | | | | | +| Skin cell fragments | 4800 | 9140 | 13700 | 8000 | 137 | +| Fiberglass fibers | 91 | | 11 | 21 | | +| Cellulosic / fabric fibers | 686 | 869 | 1140 | 2060 | 823 | +| Unidentified opaque | 1140 | 457 | 3890 | 2060 | 11400 | +| Soil / mineral dust | 1600 | 24700 | 2510 | 1600 | 25100 | +| **OTHER PARTICLES (cts/m3)** | **91** | **2510** | **1140** | **686** | **not detected** | +| Starch grains | 91 | 2510 | 1140 | 686 | | + +### Statistical Parameters + +| Parameter | AOC-1 | AOC-2 | AOC-3 | AOC-4 | AOC-5 | +|-----------|-------|-------|-------|-------|-------| +| Vol. analyzed (m3)-high mag - 500x | 0.022 | 0.022 | 0.022 | 0.022 | 0.022 | +| Detect limit(Cts/m³)-high magnification | 45.7 | 45.7 | 45.7 | 45.7 | 45.7 | +| % sample analyzed-high magnification | 29% | 29% | 29% | 29% | 29% | +| Vol. analyzed(m³)/entire sple 150-300x | 0.075 | 0.075 | 0.075 | 0.075 | 0.075 | +| * Detection limit (Cts/m³)/entire sple | 13.3 | 13.3 | 13.3 | 13.3 | 13.3 | +| Sample flow rate (lpm) | 15.0 | 15.0 | 15.0 | 15.0 | 15.0 | +| Sample trace length (mm) | 14.40 | 14.40 | 14.40 | 14.40 | 14.40 | +| Microscope field diameter (mm) | 0.420 | 0.420 | 0.420 | 0.420 | 0.420 | + +*\* Note: The "entire sample" detection limit applies to the "large" particle categories analyzed during the low magnification examination of the entire sample* + +*Note: Sample results are only applicable to the items or locations tested* + +*Raw/extrapolated count data are given on a separate page.* + +**Authorized / data reviewed by:** Daniel M. Baxter +**Report date:** 7/14/18 + +*doc.rev.3 - 7/15/18* + +--- + +# EXAMPLE SURFACE MOLD AND DUST REPORT + +## ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 306 5th Street, Suite 400 - Bay City, MI 48708 + +### SURFACE MOLD AND DUST ANALYSIS + +**EAA Method #:** DUST-D01 +**Data Page 2 of 2** +*end of data report* + +**Client Name:** ABC Environmental +**Client Project #:** 18-1001 +**Requested by:** Mr. John Smith +**EAA Project#:** 18-3000 + +**Project:** 123 Elm Street Offices +**Date collected:** 7/6/18 +**Date received:** 7/7/18 + +**Sample condition:** Acceptable as received +**Magnification 500X** + +### Client Sample Information + +| Client Sample# | Sample Description / Location | Analysis Comments | +|----------------|------------------------------|-------------------| +| TL-1 | Supervisor's office-discolored window ledge | High dust pollen, & Penicillium mold growth | +| TL-2 | Kitchen / break area - Under the sink | High Aspergillus / Penicillium mold growth and high dust | +| TL-3 | Desk 5 - Main cubical area 1 | High dust, moderate mold spore concentrations | +| TL-4 | Desk 1 - Cubical area 2 | High dust, moderate mold spore concentrations | +| TL-5 | Desk 5 - Main cubical area 1 - window ledge | High Aspergillus / Penicillium mold growth and high dust | + +### SURFACE MOLD SPORE CONCENTRATIONS (Cts./mm²) + +| Category Sample # --> | TL-1 | TL-2 | TL-3 | TL-4 | TL-5 | +|-----------------------|------|------|------|------|------| +| **Total Mold Spores (Cts/mm²)** | **220.0** | **98.7** | **10.1** | **15.9** | **317.0** | +| Alternaria | 0.7 | | 0.7 | 0.7 | | +| Aspergillus/Penicillium | 180.0 | 93.7 | | 1.4 | 300.0 | +| Ascospores | 3.6 | 1.4 | 2.2 | 7.2 | | +| Basidiospores | 7.2 | 0.7 | 3.6 | 3.6 | | +| Botrytis | | | | | | +| Chaetomium | | | 0.7 | 0.7 | | +| Cladosporium | 25.2 | 2.2 | 2.2 | 0.7 | | +| Curvularia | | | | | | +| Drechslera/Bipolaris | 0.7 | | | | | +| Epicoccum | | | | | | +| Fusarium | | | | | | +| Nigrospora | | | | | | +| Oidium/Peronospora | | | | | | +| Pithomyces | | | | | | +| Rusts | 0.7 | | | 0.7 | | +| Smuts / Myxomycetes / Periconia | | | 0.7 | 0.7 | | +| Stachybotrys | | 0.7 | | | 9.6 | +| Stemphylium | | | | | | +| Torula | | | | | | +| Ulocladium | | | | | | +| Other Hyaline Fungi | 1.4 | | | | 7.2 | +| Other Fungi | | | | | | +| Unidentified Fungi | 0.7 | | | | | +| Hyphae fragments | 61.3 | | 0.7 | | 252.0 | +| Algal / fern spores | | | | | | +| Insect parts | 0.7 | | 0.7 | | | +| **POLLEN (Total cts/mm²)** | **14.4** | **0.7** | **3.6** | **11.5** | **2.4** | +| Not specified | 10.8 | 0.7 | 1.4 | 7.9 | | +| Pinus | 3.6 | | 2.2 | 3.6 | 2.4 | +| **COMMON AEROSOLS (cts/mm2)** | | | | | | +| Skin cell fragments | 39.6 | 10.8 | 108.0 | 180.0 | 60.1 | +| Fiberglass fibers | 0.7 | | | 0.2 | | +| Cellulosic / fabric fibers | 10.8 | 7.2 | 18.0 | 18.0 | 12.0 | +| Unidentified opaque | 25.2 | 8.7 | 10.8 | 10.8 | 36.0 | +| Soil / mineral dust | 166.0 | 13.0 | 57.7 | 180.0 | 505.0 | +| **OTHER AEROSOLS (cts/mm2)** | **not detected** | **not detected** | **not detected** | **not detected** | **not detected** | + +### Statistical Parameters + +| Parameter | TL-1 | TL-2 | TL-3 | TL-4 | TL-5 | +|-----------|------|------|------|------|------| +| Area analyzed (mm²)–mold/aerosols | 1.39 | 1.39 | 1.39 | 1.39 | 0.42 | +| Detect limit(Cts/mm²)–mold/aerosols | 0.72 | 0.72 | 0.72 | 0.72 | 2.40 | +| Raw Count Conversion Factor | 1.39 | 1.39 | 1.39 | 1.39 | 0.42 | +| Microscopic fields counted | 10 | 10 | 10 | 10 | 3 | +| Microscope field area (mm²) | 0.14 | 0.14 | 0.14 | 0.14 | 0.14 | + +*Results only apply to the items or areas tested.* + +**Authorized / data reviewed by:** Daniel M. Baxter +**Date:** 7/14/18 + +*doc.rev.3 - 7/15/18* + +--- + +# EXAMPLE DATA COMPARISON SUMMARY FOR FIRE RESIDUE REPORTS + +## ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 306 5th Street, Suite 400 - Bay City, MI 48708 + +### Fire/Combustion Residue Data Summary Table + +**Client:** ABC Environmental +**Client Project #:** 18-1050 +**Client Project Description:** Office building +**EAA Project #:** 18-0066 + +**Summary pg 1 of 1** + +### Fire / Combustion Particle Concentrations + +| Sample# | Sample Description | Total | Soot | Char | Ash-like | Oth. Indicator Particles | * Surface Density (Cts/mm2) | Are large fire residue particles present? | Is dust loading or other type of interference present? | Are possible wildland fire indicators present? | +|---------|-------------------|-------|------|------|----------|--------------------------|----------------------------|-------------------------------------------|-------------------------------------------------------|-----------------------------------------------| +| T1 | Source- I Beam | 99.2 | 84.3 | 14.5 | 0.4 | | not analyzed | Yes | Yes | | +| T2 | Source- I Beam-2 | 57.3 | 47.9 | 9.4 | not detected | | 763.8 | Yes | Yes | | +| T3 | 2R- I Beam | 77.0 | 69.0 | 8.0 | not detected | | not analyzed | Yes | Yes | | +| T4 | 2R Cubicles- Pipe insulation exterior | 4.5 | 0.4 | 4.1 | not detected | | not analyzed | Yes | Yes | | +| T5 | Dark room (LL225)- Shelf on wall | 5.6 | 3.2 | 2.4 | not detected | | 9.8 | Yes | Yes | | +| T6 | Dark room (LL227)- Access panel | 85.9 | 80.1 | 5.8 | not detected | | 14.4 | Yes | Yes | | +| T7 | Common equip. room- Light fixture | 15.5 | 10.0 | 5.5 | not detected | | not analyzed | Yes | Yes | | +| T8 | Common equip. room- Wall cavity | 45.5 | 40.2 | 5.3 | not detected | | not analyzed | Yes | Yes | | +| T9 | 2nd floor men's bathroom-Ceiling cavity | 35.9 | 27.6 | 8.3 | not detected | | 186.4 | Yes | Yes | | +| T10 | 2nd floor men's bathroom- Ceiling access | 7.4 | 5.9 | 1.5 | not detected | | 99.9 | Yes | Yes | | +| T11 | 3rd floor- Metal stud exterior cavity | 84.6 | 70.3 | 14.3 | not detected | | not analyzed | Yes | Yes | | +| T12 | Blank | not detected | not detected | not detected | not detected | | not detected | | | | + +*The "Estimated Area Ratio %" is the numerical "size/area adjusted" ratio between all particle categories based on the average estimated area of each particle category.* + +*The "Surface density (Cts/mm2)" of fire residue particles is the numerical surface particle concentration independent of the amount or ratio of background dust.* + +*\* Note: If the surface density of fire residue particles (cts/mm2) is not displayed, it was not analyzed due to significant sample overloading, or calculated on tape lift samples that are not "overloaded" with dust, or on filter samples collected from a known surface area and calculated serial dilution.* + +*\* The summary guidelines for "Low", "upper background "Upper Bkg.", "Moderate", and "High" concentrations are based on the variance of quantitative background levels (area ratio% and cts/mm2) measured by EAA in buildings. The local geographic background, site specific conditions, and other potential combustion sources must be taken into account in order to determine if an elevated or atypical fire/combustion residue condition is present.* + +### Fire / Combustion Residue Concentrations For Buildings + +| Ratio % & Surface Concentrations | Classification Range | Total Fire Residue (ratio%) | Total Fire Residue (ct/mm2) | +|----------------------------------|---------------------|----------------------------|----------------------------| +| | Low | <1% | <1 | +| | Upper Bkg. | 1-3% | 1-5 | +| | Moderate | 3-10% | 5-50 | +| | High | >10% | >50 | + +--- + +# EXAMPLE FIRE RESIDUE ANALYSIS REPORT + +## ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 306 5th Street, Suite 400 - Bay City, MI 48708 + +### FIRE/COMBUSTION RESIDUE & DUST ANALYSIS - Optical Microscopy + +**Method: FIRE-D02** +**Data page 2 of 12** + +**Client Name:** ABC Environmental +**Client Project #:** 18-1050 +**Requested by:** Mr. John Smith +**Project Description:** Office building +**Client Sample #:** T2 +**Client sample description:** Source- I Beam-2 +**Sample collected:** 7/10/18 +**Sample received:** 7/11/18 +**Sample media:** tape + +**EAA Project #:** 18-0066 +**EAA Sample #:** T2 + +### SUMMARY CONCLUSIONS: + +* Fire/combustion residue concentration measured above typical background concentrations +* Qualitative observations indicate the potential presence of fire/combustion particles +* Interferences present - Results may be higher or lower than reported + +### QUALITATIVE ASSEMBLAGE OBSERVATIONS - Reflected Light Microscopy (10-200x) / Polarized Light (100-600x) + +| Observation | Result | +|-------------|--------| +| Lab sample description (color /texture) | Isolated areas of black powdery dust | +| Is a smoke or fire odor present? | No | +| Are large char particles observed in reflected or polarized light? | Yes | +| Are large ash-like particles observed in reflected or polarized light? | No | +| Are "burned" soil particles, pollen, or plant phytoliths observed? | No | + +### FIRE / COMBUSTION RESIDUE CONSTITUENTS + +| | Particle Concentration Cts/area (mm2) | Estimated Area Ratio % | +|---|--------------------------------------|------------------------| +| **Totals** | **763.8** | **57.3 %** | +| Aciniform / soot-like fine particles | 675.7 | 47.9 | +| Char (Pyrolized plant material) | 88.1 | 9.4 | +| Ash-like mineral residue particles | not detected | not detected | + +### INORGANIC CONSTITUENTS + +| Category | | Concentration | Ratio % | +|----------|---|---------------|---------| +| Fibrous Constituents | Cellulose/Synthetics | not detected | not detected | +| | Fiberglass/Mineral wool | not detected | not detected | +| Non-fibrous Constituents | Inorganic mineral dust / soil | 440.7 | 40.8 | +| | Other opaque debris | 10.7 | 1.5 | + +### BIOAEROSOLS + +| Category | | Concentration | Ratio % | +|----------|---|---------------|---------| +| Mold Spores / Structures | Unspecified | not detected | not detected | +| Pollen | Unspecified | 0.5 | 0.1 | +| Plant fragments | Flower parts, trichomes, etc. | not detected | not detected | +| Animal fragments | Dander / skin cells | 2.1 | 0.3 | +| Miscellaneous | Insect parts | not detected | not detected | + +### OTHER CONSTITUENTS + +| Category | | Concentration | Ratio % | +|----------|---|---------------|---------| +| Biogenic / organic debris | Biogenic / other amorphous dust | not detected | not detected | + +**Raw/extrapolated particle count:** 2280 +**Area adjusted factored count:** 687 +**Detection Limit (Area ratio %):** 0.1 +**Detection Limit Cts/mm2:** 0.5 + +**Authorized / data reviewed by:** Daniel M. Baxter +**Date:** 07/12/18 + +*Note: Sample results are only applicable to the items or locations tested.* + +*\* The SUMMARY CONCLUSIONS describing fire/combustion residue concentrations are based on both the "qualitative indicators" present, and the variance of "quantitative" background levels measured by EAA in typical buildings. The local geographic background, site specific conditions, and other potential combustion sources must be taken into account in order to determine if an elevated or atypical condition is present. The estimated surface particle concentrations per unit surface area (Cts/mm2) can only be calculated on surface tape lift samples.* + +*doc.rev.12 - 2/8/18* + +--- + +# AUTOMATED SEM / X-RAY DUST ANALYSIS PROCEDURES + +## Specialized testing offered by Environmental Analysis Associates + +Environmental Analysis Associates, Inc. operates two laboratory facilities located in Bay City, Michigan, and San Diego, California. Both facilities are equipped with automated Scanning Electron Microscopes and specialized X-ray particle analysis software specifically designed to identify the source and cause of the indoor air quality complaints. + +The data collected by the SEM and EDAX™ "Particle™" X-ray software is converted into a statistical report format developed by EAA. The analysis reports provide particle size distribution and elemental chemistry analysis designed for use by environmental health professionals. The reports provide direct estimates of quantitative sample chemistry, mass and size distribution, including mass estimates of respirable and inhalable sized dust (e.g. PM2.5 and PM10). + +## SAMPLE COLLECTION METHODS + +Different types of sample collection media can be used depending on the type of sample being analyzed. Bulk, vacuum, or adhesive tape lift media can be used to collect surface dust samples. The direct preparation of adhesive tape media is the preferred procedure to evaluate settled dust samples. Water samples can be filtered using 0.4µm polycarbonate filter media. Airborne samples can be collected using polycarbonate filters or Zefon™ Air-O-Cell CSI™ slit impaction samplers that contain adhesive media compatible with the SEM and Dispersive X-ray analysis. + +## ANALYSIS METHOD SUMMARY + +The SEM analysis method is utilized as a semi-quantitative diagnostic testing procedure to estimate the size and Elemental distribution of individual particles within a surface dust, airborne dust, or water sample. The method is well-suited to simultaneously provide gravimetric mass measurements when chemistry information cannot be collected by using conventional methods. Optical Microscopy methods are recommended for the analysis of biological fibers and particles (mold, pollen, etc.) and not SEM analysis. A flow diagram for the suggested use this method is given on page 4 of this document. + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# AUTOMATED SEM/X-RAY DUST ANALYSIS PROCEDURES + +## Table of the most common sources and classification of indoor inorganic dust particles + +The following are the most common examples of how materials are classified in the automated SEM report: + +| Material Description | Naturally Occurring? | Building Source/Composition | Common X-ray Classifications | +|---------------------|---------------------|----------------------------|------------------------------| +| **Common Building Components** | | | | +| Carbonaceous | Y - common | Biological synthetic particles fibers | M carbon ** | +| Asphaltic | N | Roofing / patching | S carbon | +| Quartz | Y - common | Concrete, sand, plasters | Quartz-like, Si oxide | +| Mixed silicate clays | Y - common | Soil infiltration, plasters, insulation | M Al silicate ** | +| Vermiculite | N | Spray on insulation (variable) | M Al silicate (special)** | +| Calcium sulfate | Y - rare | Drywall board / compounds | Ca sulfate | +| Calcium carbonate | Y - low | Concrete, patching compounds | Ca carbonate | +| Calcium silicate | Y - low | Concrete, patching compound, plasters | Ca silicate | +| Magnesium silicate | N | Concrete, patching compound, plasters | Mg silicate | +| Calcium / Magnesium silicate | N | Concrete, patching compound, plasters | MgCa silicate | +| Titanium Paints | N | Coatings - Wall, ceiling tiles, etc | M Ti oxide ** | +| **Corrosion Particles** | | | | +| Iron oxide | Y - moderate | Pipes, motors, HVAC components | Fe oxide | +| Al oxide | Y - moderate | HVAC, ducting, brackets, windows | Al oxide | +| Zn oxide | N | Galvanized HVAC coatings | Zn oxide | +| AlZn oxide | N | HVAC ducting / components | AlZn oxide | +| Mixed Aluminum/Iron oxide | N | HVAC components / drip pans | AlFe oxide | +| Mixed Aluminum/Iron/Copper oxide | N | Mixed HVAC components | AlFeCu oxide | +| Mixed Iron/Chromium Oxide | N | Steel corrosion particles | CrFe oxide | +| Cu oxide | N | Copper piping | Cu oxide | +| **Combustion Residue Particles** | | | | +| Soot / char particles | Y | Heated carbonaceous components | H carbon | +| Vegetation ash | Y | Residual mineral salts–combustion | Ca,Mg,K oxides | +| Plant phytoliths | Y | Outdoor infiltration – vegetation | Ca, Si oxides | + +It is important to note that most materials are not "pure" and minor amounts (1-5%) of other common elements are usually found in association with each classification. + +*\* The particle minor element chemistry and morphology occasionally needs to be considered to classify the particles appropriately* + +*\*\* An "M" prefix refers to "mixed" element classification (e.g. M carbon for mixed carbon)* + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# SEM / X-RAY PARTICLE CLASSIFICATION SYSTEM + +## BASIC PARTICLE "CLASSIFICATION" RULES FOR COMMON DUST SAMPLES + +| CLASSIFICATION | DESCRIPTION | Primary | Secondary | +|----------------|-------------|---------|-----------| +| **CARBONACEOUS** | **Biogenic and organic** | | | +| H carbon | High carbon (only minor amounts of other elements) | C >80% | All other <3% (except O) | +| M carbon | Moderate/mixed carbon (only minor amounts of other elements) | C >50% | All other <10% (except O) | +| N carbon | Carbon (minor amount Nitrogen >5%) | C >50% | N > 5% | +| "Cl,Si,Ba,S," carbon | Moderate carbon with 2 or less element combinations | C >50% | Other >5% | +| **SILICATES** | **Construction materials / soil minerals** | | | +| Quartz-like | Quartz / Quartz-like - Predominant Si & O / low carbon | Si >20%, O>20% | Other <5% | +| M Al silicate | Aluminum Silicates - Predominant Al Si | Al >3%, Si >10% | Other <5% | +| Fe Al silicate | Aluminum silicate - Significant Iron present | Al>3%, Si>10% | Fe >5% | +| Ca silicate | Calcium silicate - Ca / Si wi. absence of significant carbon | Al>3%, Si>10% | Ca >5% | +| K Al silicate | Possible feldspar minerals (Orthoclase) / other | Al>3%, Si>10% | K >5% | +| Ca Al silicate | Possible feldspar minerals (Plagioclase) / other | Al>3%, Si>10% | Ca >5% | +| M silicate | Mixed silicate with 3 or more cation elements other than Si | Si >10%, O>20% | Cations >5% | +| **CARBONATE** | **Construction materials / soil minerals** | | | +| Ca Carbonate | Calcium Carbonate | Ca>15%, | C<50% | +| MgCa Carbonate | Magnesium Calcium Carbonate (2 predominant) | Ca / Mg >10% | C<50% | +| Ca oxide | Calcium oxide / oxalate | Ca>30% | C<20%, O >25% | +| M carbonate | Carbonate - Mixed with 3 or more elements none predominant | All cations 3-5% | C>30%, O>20% | +| **SULFATE** | **Construction materials / precipitated salts** | | | +| Ca sulfate | Calcium sulfate (drywall dust) | Ca>10%, S>5% | Other <3% | +| Na sulfate | Sodium sulfate - efflouresence salts | Na>10%, S>5% | Other <3% | +| MgCa sulfate | Magnesium/Calcium sulfate (2 predominant) | Mg/Ca>10%, S>10% | Other <3% | +| Ba sulfate | Barium sulfate | Ba>10%,S>10% | Other <3% | +| Zn sulfate | Zinc sulfate (Zinc, Sulfur and Oxygen) | Zn>10%,S>10% | Other <3% | +| M sulfate | Sulfate - Mixed with 3 or more elements none predominant | S>10% | Other cations >5% | +| **SULFIDE** | **Reducing enviroment particles (Low oxygen)** | | | +| C sulfide | Carbon sulfide (very low oxygen) | C>50%, S>10% | Other <3% | +| Na sulfide | Sodium sulfide | Na>10%, S>10% | O <20%, Other <3% | +| Zn sulfide | Zinc sulfide | Zn>10%, S>10% | O <20% | +| M sulfide | Sulfide - Mixed with 3 or more elements not predominant | Cation>10%, S>5% | Other cations <5% | +| **CHLORIDE** | **Evaporated salts or water induced metal corrosion** | | | +| Na chloride | Sodium chloride | Na>10%, Cl>10% | C & O <20% | +| NaMg chloride | Sodium / magnesium salts (2 predominant) | Na/Mg >10% | C & O <20% | +| M chloride | Chloride - Mixed with 3 or more elements none predominant | Cation>10%, Cl>5% | C & O <20% | +| **OXIDE** | **Corrosion particles / possible fire "ash"** (see next page) | | | +| Quartz (Si oxide) | See silicate category | | | +| Ca oxide | Calcium oxide - Construction materials / oxalate fire ash | Ca>30%, O>20% | C < 20% | +| Na oxide | Likely evaporated sodium hydroxide | Na>30%, O>20% | C < 20% | +| Al oxide | Aluminum oxide - pos. corrosion / mineral | Al>30%, O>20% | C < 30% | +| Fe oxide | Iron oxide - pos. corrosion / mineral | Fe>15%, O>20% | C < 50% | +| Zn oxide | Zinc oxide - Corrosion | Zn>15%, O>20% | C < 50% | +| AlZn oxide | Aluminum and Zinc oxide - Pos. corrosion | Al / Zn >15% | C < 50% | +| Cu oxide | Copper oxide | Cu>15%, O>20% | C < 50% | +| M Al,Fe,Zn, oxide | 3 specific metals present | All >5% | C < 50% | +| M oxide | Oxide - Mixed with 3 or more elements none predominant | 3 or more cations >5% | | +| **UNCLASSIFIED / MIXED ELEMENTS** | | | | +| Unclassified | Composition not identifiable | | | +| M composition | Mixed composition 5 + elements/mixed agglomerate composition (i.e. mixed carbonate/sulfate/silicate) | | | + +*\* Wt% guidelines can vary based on particle geometry and background of the carbon substrate.* + +*Note: This classification system is designed as a way to generally categorize (classify), and define the gross composition of an individual particle. The "classification" is first assigned based on the visual rank order elemental predominance in the X-ray spectrum. A chi-square classification fit of 65-75% is used. The name given to the "classified" particle is based on the most likely mineralogy found in the natural or indoor environment. The "classification" combinations may not always correctly define the exact composition of a particle, or always correctly represent the rank order quantitative elemental chemistry. Multiple sets of elemental ratio rules are used for "small" verses "large" particles due to increased beam penetration in particles smaller than 5um into the Carbon/Oxygen adhesive substrate. This limitation affects the measured apparent elemental stoichiometry. A 2nd manual review of particle spectra is conducted to verify particle ID.* + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# AUTOMATED SEM ANALYSIS REPORT + +## Example Data Summary Page + +### ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 5290 Soledad Road - San Diego, CA 92109 - (858) 272-7747 + +### Automated Scanning Electron Microscopy Dust Analysis - Summary Report + +### Surface/Bulk Dust Analysis - Quantitative + +**Page 1 of 9** + +**Analysis Method:** SEM-D01 + +**Client Name:** EAA - Standard sample +**Contact:** Mr. Daniel Baxter +**Client Project#:** Oak fire ash sample +**Client Sample #:** Fire ash +**Sample Description:** Michigan fire pit sample (Oak -7/2016) +**Sample media / type code:** Surface/Bulk dust analysis +**Analysis Magnification:** 497 +**Scale (µm/div.):** 1 +**Total particles counted:** 236 + +**Sample collected:** 7/3/16 +**Sample received:** +**EAA Project #:** R&D +**EAA Sample #:** Fire ash +**Fields / passes counted:** 4 +**Field area counted (mm²):** 0.142 + +**Particles / mm²:** 1659 +**Particles/sampled area:** 240 + +**Min./Max. size range (um):** 3.0 / 500 + +**Est. particle thickness ratio (S:I):** 0.8 + +### SUMMARY CONCLUSIONS + +The filtered sample is primarily composed of Calcium oxide / carbonate plant phytoliths (see micrographs on page 2), and mixed silicate clay minerals. + +*The analysis fraction consists of water filtered (and <62um sieved) non-soluble particles.* + +### Numerical & Mass % Concentration Summary + +| Particle Classification | # Cted | Mean (um) | Num. % | *Calc Mass % | *Spec Grav | * Part. / sampled area | Part./ mm² | *Theoretical ug / mm2 | Calc.Mass ug / cm2 | +|------------------------|--------|-----------|--------|--------------|------------|----------------------|-----------|---------------------|-------------------| +| M carbon | 13 | 4.6 | 5.5% | 0.2% | 1.50 | 13 | 91 | 0.0 | 0.9 | +| Quartz-like | 2 | 5.4 | 0.8% | 0.1% | 2.00 | 2 | 14 | 0.0 | 0.3 | +| M Al silicate | 70 | 7.3 | 29.7% | 24.5% | 2.00 | 71 | 492 | 1.0 | 95.8 | +| AlK silicate | 1 | 3.2 | 0.4% | 0.0% | 2.00 | 1 | 7 | 0.0 | 0.0 | +| MgCa silicate | 3 | 5.1 | 1.3% | 0.1% | 2.00 | 3 | 21 | 0.0 | 0.4 | +| M Ca silicate | 11 | 7.5 | 4.7% | 1.4% | 2.00 | 11 | 77 | 0.1 | 5.4 | +| Ca carbonate | 53 | 6.2 | 22.5% | 6.2% | 2.00 | 54 | 372 | 0.2 | 24.2 | +| M Ca carbonate | 44 | 7.6 | 18.6% | 17.3% | 2.00 | 45 | 309 | 0.7 | 67.6 | +| MgCa carbonate | 2 | 6.0 | 0.8% | 0.2% | 2.00 | 2 | 14 | 0.0 | 0.6 | +| Ca oxide | 31 | 13.6 | 13.1% | 37.2% | 2.00 | 32 | 218 | 1.5 | 145.4 | +| M Ca oxide | 3 | 21.6 | 1.3% | 6.9% | 2.00 | 3 | 21 | 0.3 | 27.1 | +| MgCa oxide | 1 | 7.3 | 0.4% | 0.1% | 2.00 | 1 | 7 | 0.0 | 0.3 | +| M Ti oxide | 1 | 31.3 | 0.4% | 5.8% | 2.00 | 1 | 7 | 0.2 | 22.6 | +| Unclassified | 1 | 3.0 | 0.4% | 0.0% | 2.00 | 1 | 7 | 0.0 | 0.0 | +| **TOTALS** | **236** | | | | | **240** | **1659** | | **390.0** | + +*\* The theoretical calculated mass is based on the sum total of each particle volume & theoretical specific gravity.* + +*Calculations assume an estimated thickness ratio and should be used as rough comparative mass estimates only.* + +*All "classifications" are presumptive, and represent the most likely common mineral classifications present.* + +*All calculated values are rounded to 3 significant figures, and should be considered accurate to 2 significant figures.* + +**Authorized / data reviewed by:** Daniel M. Baxter +**Date:** 5/10/17 + +*The results only apply to the location and materials tested.* + +*doc.rev.7 - 5-1-17* + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# AUTOMATED SEM ANALYSIS REPORT + +## Example Photo Page + +### ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 5290 Soledad Road - San Diego, CA 92109 - (858) 272-7747 + +### Automated Scanning Electron Microscopy - Dust Analysis Photo Report + +**Page 2 of 9** + +**Client Name:** EAA - Standard sample +**Contact:** Mr. Daniel Baxter +**Client Project#:** Oak fire ash sample +**Client Sample #:** Fire ash +**Sample Description:** Michigan fire pit sample (Oak -7/2016) +**Analysis Method:** Surface/Bulk dust analysis + +**Sample received:** 1/1/04 +**EAA Project #:** R&D +**EAA Sample #:** Fire ash + +**Backscatter electron image** +**Sample Magnification 497** + +[Four SEM images showing Fields 1-4 of the sample] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# AUTOMATED SEM ANALYSIS REPORT + +## Graphical report + +### ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 5290 Soledad Road - San Diego, CA 92109 - (858) 272-7747 + +### Automated Scanning Electron Microscopy - Graphical Report - Mass & Size Distribution + +**Page 3 of 9** + +**Client Name:** EAA - Standard sample +**Contact:** Mr. Daniel Baxter +**Client Project#:** Oak fire ash sample +**Client Sample #:** Fire ash +**Sample Description:** Michigan fire pit sample (Oak -7/2016) +**Analysis Method:** Surface/Bulk dust analysis + +**Sample received:** 1/1/04 +**EAA Project #:** R&D +**EAA Sample #:** Fire ash +**EAA Method #:** SEM-D01 + +### Estimated Mass % + +[Pie chart showing mass distribution]: +- Ca oxide, 37.2% +- M Al silicate, 24.5% +- M Ca carbonate, 17.3% +- M Ca oxide, 6.9% +- Ca carbonate, 6.2% +- M Ti oxide, 5.8% +- M Ca silicate, 1.4% +- M carbon, 0.2% +- MgCa carbonate, 0.2% +- Quartz-like, 0.1% +- MgCa silicate, 0.1% +- MgCa oxide, 0.1% +- AlK silicate, 0.0% +- Unclassified, 0.0% + +### Individual Numerical % + +[Bar chart showing particle size distribution by classification across size ranges: 0.2, 0.3, 0.6, 1.3, 2.5, 5.0, 10.0, 20.0, 40.0, 80.0, 160.0 (um greater than stated size)] + +Legend: +- Unclassified +- M Ti oxide +- MgCa oxide +- M Ca oxide +- Ca oxide +- MgCa carbonate +- M Ca carbonate +- Ca carbonate +- M Ca silicate +- MgCa silicate +- AlK silicate +- M Al silicate +- Quartz-like +- M carbon + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# AUTOMATED SEM ANALYSIS REPORT + +## Example Graphical X-ray Data Page + +### ENVIRONMENTAL ANALYSIS ASSOCIATES, Inc. - 5290 Soledad Road - San Diego, CA 92109 - (858) 272-7747 + +### PARTICLE CHEMISTRY - GRAPHICAL REPORT + +**(Elemental Composition - Weight %)** + +*Cambridge S-240 SEM equipped with EDAX Octane SDD detector* + +**Page 4 of 9** + +**Client Name:** EAA - Standard sample +**EAA Project #:** R&D +**Accelerating voltage 20 KV** + +**Client Sample #:** Fire ash +*"K" shell X-ray peak used for quantification* + +[Five bar charts showing elemental composition (Weight %) for Particles 1-50, 51-100, 101-150, 151-200, and 201-250] + +Elements analyzed: CK, NK, OK, NaK, MgK, AlK, SiK, PK, SK, ClK, KK, CaK, TiK, CrK, FeK, NiK, CuK, ZnK + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# COMMON EXAMPLE PARTICLES + +## Example particle images & X-ray spectra + +### Oak camp fire ash residue – K rich salt given a classification between M Al silicate and AlK silicate + +[SEM image at x522 magnification with corresponding X-ray spectrum showing Si, O, K, Al, Mg, C, Ca, Fe peaks] + +### Oak camp fire ash – Calcium oxalate phytolith (Ca oxide / oxalate) + +[SEM image at x6120 magnification with corresponding X-ray spectrum showing Ca, O, C peaks] + +### Fossiliferous beach sand Tourmaline Beach, San Diego - >125um sieved size fraction + +[SEM image at 100µm scale with corresponding X-ray spectrum showing Si, O, Al, C, Na, Mg, Ca, K, Fe peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# COMMON EXAMPLE PARTICLES + +## Example particles images & X-ray spectra – Fiberglass insulation fibers + +### Wrap-on Fiberglass insulation #16550. High Na, low Ca glass + +[SEM image with corresponding X-ray spectrum showing Si, O, Na, C, Mg, Al, Ca peaks] + +### Owens Corning Pink insulation R-19. High Na, moderate Ca glass + +[SEM image at 5µm scale with corresponding X-ray spectrum showing Si, O, Na, C, Mg, Al, Ca, K peaks] + +### Soundliner fiberglass from HVAC system mixing box (optical microscopy) - 600x + +[Optical microscopy image with corresponding X-ray spectrum showing Si, O, Na, C, Mg, Al, Cl, Ca, K, Fe peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# COMMON EXAMPLE PARTICLES + +## Example particles images & X-ray spectra – Drop ceiling tile dust + +### Fiberglass fibers in ceiling tiles. High Ca, low Na + +[SEM image with corresponding X-ray spectrum showing Si, O, Ca, Mg, Al peaks] + +### "Crushed" perlite material from ceiling tile + +[SEM image with corresponding X-ray spectrum showing Si, O, Al, C, Na, S, K, Ca peaks] + +### Paint from ceiling tile surface + +[SEM image at 10µm scale with corresponding X-ray spectrum showing Ca, O, Si, Ti, Al, C, Mg, Na, Cl, Ti peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# COMMON EXAMPLE PARTICLES + +## Example particles images & X-ray spectra – Drywall patching compounds + +### PC – 1 Drywall patch – 736x + +[SEM image at 20µm scale with corresponding X-ray spectrum showing Si, O, C, S, Ca, Al, Na, K peaks] + +### Sheetrock Easy Sand Brand 736x + +[SEM image at 20µm scale with corresponding X-ray spectrum showing Si, O, C, S, Ca, Al, Na, K peaks] + +### Stucco Patch – Custom Builders – 736x + +[SEM image at 20µm scale with corresponding X-ray spectrum showing C, Ca, O, Si, Al, Na, S, K peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# COMMON EXAMPLE PARTICLES + +## Example particles images & X-ray spectra – Drywall Material + +### US Gypsum (Red label) - Calcium Sulfate – 400x + +[SEM image at 20µm scale with corresponding X-ray spectrum showing O, S, Ca, Au, C peaks] + +### Drywall dust (Red Label) – Optical Microscopy - ~700x + +[Optical microscopy image showing crystalline gypsum particles] + +### US Gypsum (Green label). 12,000X showing the crystal structure. + +[SEM image at 1µm scale with corresponding X-ray spectrum showing S, O, Ca, Si, C peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# COMMON EXAMPLE PARTICLES + +## Example particles images & X-ray spectra – Different concrete mixes + +### Quik krete 1102 – 3050x + +[SEM image at 5µm scale with corresponding X-ray spectrum showing Ca, Si, O, C, Mg, Al peaks] + +### Quik krete 1104 – 1520x + +[SEM image at 5µm scale with corresponding X-ray spectrum showing Ca, O, Si, Al, C, Mg, Fe peaks] + +### Quik krete 1124 – 1520x + +[SEM image at 5µm scale with corresponding X-ray spectrum showing Ca, Si, O, C, Al, Mg, S, K, Fe peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# COMMON EXAMPLE PARTICLES + +## Example particles images & X-ray spectra – Carbonaceous road related + +### Road asphalt + +[SEM image at 20µm scale with optical microscopy inset, with corresponding X-ray spectrum showing C, S, O, Mg, Si, Na, Al, Ca peaks] + +### Tire rubber – Big-O tires – 3000x + +[SEM image at 5µm scale with optical microscopy inset, with corresponding X-ray spectrum showing C, Si, O, Al, Fe, Na, Mg, Ca, K, Fe, Ni peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# COMMON EXAMPLE PARTICLES + +## Example particles images & X-ray spectra – Corrosion particles + +### Aluminum HVAC system oxide + +[SEM image at 20µm scale with corresponding X-ray spectrum showing O, Al, C, Na, Mg, Si, Ca peaks] + +### Iron oxide / chloride – Water corrosion + +[SEM image at 20µm scale with corresponding X-ray spectrum showing O, Na, Cl, C, Si, Mg, Al, K, Ca, Ti, Fe peaks] + +### Zinc oxide – Galvanized ducting corrosion + +[SEM image at 20µm scale with optical microscopy inset, with corresponding X-ray spectrum showing Zn, O, C, Cl, Ni, Zn peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# EXAMPLE X-RAY Automated SEM PARTICLE CLASSIFICATIONS + +## Common indoor / outdoor particle chemistry + +### H carbon (Possible combustion char / soot) +[X-ray spectrum showing dominant C peak with minor Ca peak] + +### M carbon (biogenic / cellulosic materials) +[X-ray spectrum showing dominant C peak with O, minor Cu, Na, Si, Al, S, Ca, Sb, Ti, Fe, Ni, Cu, Zn peaks] + +### Quartz-like / Si oxide +[X-ray spectrum showing dominant Si peak with O, C, and minor element peaks] + +### M Al silicate +[X-ray spectrum showing O, Al, C, Si, Mg peaks with minor elements] + +### AlK silicate +[X-ray spectrum showing Si, C, O, Al, K peaks with minor elements] + +### Mg silicate +[X-ray spectrum showing O, Si, Mg, C peaks with minor elements] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# EXAMPLE X-RAY Automated SEM PARTICLE CLASSIFICATIONS + +## Common indoor / outdoor particle chemistry + +### Ca carbonate +[X-ray spectrum showing C, Ca, O peaks with minor Na, Zn, Cu, Si, Al, Cl peaks] + +### M Ca carbonate +[X-ray spectrum showing C, O, Ca, Al, Si peaks with minor elements] + +### Ca oxide / Ca Oxalate +[X-ray spectrum showing Ca, O, Si peaks with minor Ti, Cu, Ni, Na, Fe, Zn, Al, Mg, S, Cl peaks] + +### Ca sulfate +[X-ray spectrum showing Ca, S, O peaks with minor Na, Fe, Zn, Si, Al, Mg, Cl, K peaks] + +### M Ca sulfate +[X-ray spectrum showing Ca, S, O, Al, C peaks with minor Cu, Ni, Fe, Na, Mg, Si, Cl peaks] + +### M Ca sulfate (Monokote fireproofing) +[X-ray spectrum showing C, O, S, Ca peaks with Mg, Si, Al, Na, Cl peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# EXAMPLE X-RAY Automated SEM PARTICLE CLASSIFICATIONS + +## Common indoor / outdoor particle chemistry + +### Ca silicate +[X-ray spectrum showing Si, C, O, Ca, Na peaks with minor Fe, Zn, Al, Cu, Mg, S, Cl peaks] + +### Na chloride (<10um particle) +[X-ray spectrum showing C, Na, Cl, Zn, Cu peaks with minor O, Ni, Fe, Si, Al, Mg, S, Ca peaks] + +### M CaTi oxide (paint) +[X-ray spectrum showing Ca, Ti, C, O, Ba, S, Na, Zn, Al, Si, Cu, Mg peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# EXAMPLE X-RAY Automated SEM PARTICLE CLASSIFICATIONS + +## Common HVAC corrosion particle chemistry + +### Al metal +[X-ray spectrum showing dominant Al peak with minor O, Cu, P, Si, Zr, Ca, Sb, Ti, Fe, Ni, Cu, Zn peaks] + +### Al oxide +[X-ray spectrum showing Al, O peaks with C, Cu, Mg, Si, P, S, Sb, Ti, Fe, Ni, Cu, Zn peaks] + +### AlCl oxide (water corrosion) +[X-ray spectrum showing Al, O, Cl peaks with C, Cu, Ni, Fe, Na, Mg, P, S, Si, Zr, Ag, K, Sb, Ti, Ba, Cr, Fe, Ni, Cu, Zn peaks] + +### Fe metal +[X-ray spectrum showing dominant Fe peak with Fe, F, Na, O, Zn, Cu, Al, Si, Ni, Mg, Zr, P, S, Cl, Ag, K, Ca, Sb, Ti, Ba, Cr, Ni, Cu, Zn peaks] + +### Fe oxide +[X-ray spectrum showing O, Fe peaks with F, Na, Ti, Zn, Cu, Al, Si, Ni, P, S, Cl, Ag, K, Ca, Sb, Ti, Ba, Cr, Ni, Cu, Zn peaks] + +### Zn oxide +[X-ray spectrum showing dominant Zn peak with O, C, Cl, Ti, Ni, Fe, Al, Mg, Zr, Si, P, S, Ag, K, Sb, Ca, Ba, Cr, Fe, Ni, Cu peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# EXAMPLE X-RAY Automated SEM PARTICLE CLASSIFICATIONS + +## Common HVAC corrosion particle chemistry + +### AlCl oxide (Water / electrolytic corrosion) +[X-ray spectrum showing Al, O, Cl peaks with C, Cu, Ni, Fe, Na, Mg, P, S, Si, Zr, Ag, K, Sb, Ti, Ba, Cr, Fe, Ni, Cu, Zn peaks] + +### AlFe oxide +[X-ray spectrum showing O, Al, C, Fe peaks with F, Na, Zn, Zr, Cu, Si, Mg, P, Cl, Ag, Ca, Sb, K, Ti, Ba, Cr, Ni, Cu, Zn peaks] + +### M AlZn oxide +[X-ray spectrum showing O, Al, Na, Zn, Ca peaks with C, Cu, Ni, Zn, P, Fe, Na, Si, Zr, Mg, S, Cl, Ag, K, Sb, Ti, Ba, Cr, Fe, Ni, Cu peaks] + +### M ZnCl oxide +[X-ray spectrum showing Zn, Na, C, O, Cl, S peaks with Cu, Ni, Ti, Fe, Si, Zr, Al, P, Ag, K, Ca, Sb, Ti, Ba, Cr, Fe, Ni, Cu peaks] + +### FeCuZn oxide +[X-ray spectrum showing O, Na, Zn, C, Cu, Fe peaks with F, Zr, Ti, Ni, Mg, Al, Si, S, Cl, Ag, K, Ca, Sb, Ti, Ba, Cr, Ni peaks] + +### M Cu oxide +[X-ray spectrum showing Cu, O, C peaks with Fe, Na, Si, F, Zn, Al, Mg, S, Zr, P, Cl, Ag, K, Ca, Sb, Ti, Ba, Cr, Fe, Ni, Cu peaks] + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* + +--- + +# Contact Information + +**Michigan Environmental Laboratory** *(AIHA-LAP, LLC. accredited)* +306 5th Street, Suite 400 Bay City, MI 48708 +Phone: 989-895-4447 +Email: dbaxter@eaalab.com +Website: eaalab.com + +**California Forensic Materials Laboratory** +5290 Soledad Road, San Diego, CA 92109 +Phone: 858-272-7747 +Email: dbaxter@eaalab.com +Website: eaalab.com + +--- + +*© All information & photos property of Environmental Analysis Associates, Inc.* diff --git a/RAG-KB/wildfire_soot_particulate_removal_full_text_extraction.md b/RAG-KB/wildfire_soot_particulate_removal_full_text_extraction.md new file mode 100644 index 0000000000000000000000000000000000000000..7ce68ee3be5bc11a3a7f3862dcbd62f96a097ac6 --- /dev/null +++ b/RAG-KB/wildfire_soot_particulate_removal_full_text_extraction.md @@ -0,0 +1,134 @@ +SOOT PARTICLES: +A Procedural Guide for Containing and Removing Wildfire-Caused Soot in Buildings + +By Patrick J. Moffett, REA, CHMM +Environmental Management & Engineering, Inc. +Huntington Beach, California + +Copyright © 1997, 2002, 2008 +All Rights Reserved + + +SOOT PARTICLES: +A Procedural Guide for Containing and Removing Wildfire-Caused Soot in Buildings + +By Patrick J. Moffett, REA, CHMM +Environmental Management & Engineering, Inc. +Huntington Beach, California + +Copyright © 1997, 2002, 2008 +All Rights Reserved + + +COMMENTARY + +The purpose of this paper is to provide a procedural guide for the restoration of buildings and contents contaminated with wildfire-caused soot. This paper was written primarily for restorers, insurance adjusters, and building owners who are dealing with extensive wildfire-caused soot contamination. This paper is not intended to be a comprehensive restoration manual for all smoke and soot contamination conditions. This paper focuses on wildfire-caused soot, ash, and odor contamination, and addresses worker and occupant safety and health issues; and in 2008, the paper was updated to address new concerns regarding ultrafine particles. + +Worker Safety + +In recent years, many restoration workers have been involved in cleaning wildfire-caused soot contamination. During these projects, workers often were observed wearing little or no respiratory protection. In some cases, workers were observed wearing simple dust masks or N95 respirators while performing soot cleaning activities. In other cases, workers were observed wearing N100 respirators or half-face respirators equipped with HEPA cartridges. In some cases, workers were observed wearing full-face respirators equipped with HEPA and organic vapor cartridges. + +The question arises: What type of respiratory protection is appropriate for wildfire soot cleanup? In order to answer this question, it is important to understand the nature of wildfire-caused soot, including the size of the soot particles, the chemical composition of the soot, and the potential health hazards associated with exposure to soot. + +PART I +Particles and Chemicals in Smoke and Soot + +Wildfire Smoke + +Smoke is a complex mixture of gases and particles produced by the incomplete combustion of organic materials. Wildfire smoke contains numerous chemicals, including carbon monoxide, nitrogen oxides, hydrocarbons, aldehydes, ketones, alcohols, benzo[a]pyrene, and organic acids. The composition of wildfire smoke varies depending on the type of fuel burned, the combustion temperature, and the availability of oxygen. + +Hot, flaming combustion tends to produce black smoke composed primarily of elemental carbon particles. Cooler, smoldering combustion tends to produce white or gray smoke composed of incompletely combusted organic materials. + +Soot + +Soot is composed primarily of carbon particles produced by incomplete combustion. Soot particles are often coated with organic chemicals, including polycyclic aromatic hydrocarbons (PAHs) and other combustion byproducts. The chemical composition of soot varies depending on the type of fuel burned. + +Vegetation fires tend to produce gray or light-colored ash and soot composed primarily of inorganic ash and partially combusted organic materials. Fires involving petroleum products, plastics, roofing materials, and synthetic furnishings tend to produce black, oily soot composed primarily of carbon black. + +Particle Size + +Soot particles vary widely in size. Candle soot particles typically range from approximately 0.06 to 0.1 micrometers (µm) in diameter. Wildfire-caused soot particles may range from less than 0.1 µm to more than 30 µm in diameter. Larger particles, including embers, may be several inches in diameter. + +Particle Deposition + +Soot particles may be deposited on building surfaces by a variety of mechanisms, including gravity settling, impaction, diffusion, thermophoresis, and electrostatic attraction. Thermophoresis causes particles to move from warmer air toward cooler surfaces. Electrostatic attraction causes charged particles to be attracted to oppositely charged surfaces. + +As a result of these mechanisms, soot often deposits preferentially on cooler surfaces, such as exterior walls, window frames, and surfaces near air leaks. Moist surfaces also tend to attract soot particles. + +Firestorms and Convection + +Large wildfires can generate intense convection currents, sometimes referred to as firestorms. These convection currents can create strong winds, dust devils, and fire whirls that carry smoke, ash, and soot over long distances. Buildings located near wildfires may be subjected to complex airflow patterns that influence the deposition of soot on interior and exterior surfaces. + +PART II +Environmental and Human Health Concerns + +Chemical Composition + +Soot typically contains approximately 60 percent carbon by weight. The remaining portion consists of a complex mixture of organic and inorganic chemicals, including PAHs and heavy metals such as arsenic, cadmium, chromium, and nickel. Thousands of individual compounds may be present in soot, many of which can be identified only by gas chromatography/mass spectrometry (GC/MS) analysis. + +Health Hazards + +Soot has been recognized as a human carcinogen. Occupational exposure to soot has been associated with an increased risk of skin cancer, lung cancer, and other health effects. Historically, chimney sweeps were known to suffer high rates of cancer due to soot exposure. + +Workers involved in wildfire soot cleanup may be exposed to high concentrations of soot particles and associated chemicals. In some cases, these exposures may be comparable to or greater than those experienced by chimney sweeps and other workers historically exposed to soot. + +Ultrafine Particles + +In recent years, increased attention has been focused on ultrafine particles (particles smaller than 0.1 µm). Ultrafine particles are capable of penetrating deep into the lungs and entering the bloodstream. These particles may cause inflammation, oxidative stress, and other adverse health effects. + +Wildfire smoke and soot contain large numbers of ultrafine particles. As a result, wildfire soot cleanup workers may be at risk of exposure to ultrafine particles unless appropriate respiratory protection is used. + +Respiratory Protection + +Respiratory protection for wildfire soot cleanup should be selected based on the size of the particles present and the presence of gaseous contaminants. Simple dust masks and N95 respirators are not adequate to protect against fine and ultrafine soot particles. + +P100 respirators provide a minimum filtration efficiency of 99.97 percent for oil-based particles and are suitable for protection against fine and ultrafine soot particles. However, P100 particulate filters do not provide protection against gaseous contaminants such as carbon monoxide and organic vapors. + +In situations where organic vapors or other gases are present, respirators equipped with both P100 particulate filters and organic vapor cartridges may be required. Full-face respirators provide additional protection for the eyes and face. + +PART III +Procedures for Removing Wildfire Soot from Contents + +General Principles + +The removal of wildfire-caused soot from contents should be approached systematically to minimize the spread of contamination and protect workers and occupants. Contents should be evaluated to determine whether they can be cleaned or must be discarded. + +Dry Cleaning Methods + +Dry cleaning methods are often preferred for removing soot from contents because they minimize the spread of contamination and reduce the risk of driving soot deeper into porous materials. Examples of dry cleaning methods include HEPA vacuuming, dry sponging, and the use of specialized dry cleaning compounds. + +Wet Cleaning Methods + +Wet cleaning methods may be used when dry methods are not effective. Wet cleaning should be performed carefully to avoid spreading contamination. Detergents and cleaning agents should be selected based on the type of material being cleaned and the nature of the soot. + +Electronics + +Electronics contaminated with wildfire soot require special handling. Soot particles can cause corrosion and electrical shorts. In many cases, electronics should be evaluated by qualified technicians and may require specialized cleaning or replacement. + +PART IV +Procedures for Removing Wildfire Soot from Buildings + +Containment + +Containment is critical to prevent the spread of soot during cleaning activities. Affected areas should be isolated using plastic sheeting and negative air pressure where feasible. + +Surface Cleaning + +Building surfaces should be cleaned using methods appropriate for the type of surface and the degree of contamination. Dry cleaning methods should be used whenever possible. Wet cleaning may be used when necessary, with care taken to avoid spreading soot. + +HVAC Systems + +Heating, ventilation, and air conditioning (HVAC) systems can become contaminated with wildfire soot. HVAC systems should be inspected and cleaned as necessary to prevent the redistribution of soot throughout the building. + +Post-Cleaning Verification + +After cleaning, surfaces should be inspected to verify that soot has been removed. In some cases, surface sampling or air monitoring may be used to confirm the effectiveness of cleaning. + +Author + +Patrick J. Moffett, REA, CHMM, is the principal of Environmental Management & Engineering, Inc., based in Huntington Beach, California. He has extensive experience in environmental health and safety, industrial hygiene, and hazardous materials management. + +References + +[References as listed in the original document] + diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..0b57a21ab4f40c3607db975c442583472c0ea18e --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +--- +title: FDAM AI Pipeline +emoji: "\U0001F525" +colorFrom: red +colorTo: yellow +sdk: gradio +sdk_version: "6.3.0" +app_file: app.py +pinned: false +suggested_hardware: l4x4 +--- + +# FDAM AI Pipeline + +**Fire Damage Assessment Methodology v4.0.1** - An AI-powered system that generates professional Cleaning Specifications / Scope of Work documents for fire damage restoration. + +## Features + +- **AI-Powered Image Analysis**: Uses Qwen3-VL vision model to detect fire damage zones, conditions, and materials +- **FDAM Compliant**: Implements Fire Damage Assessment Methodology v4.0.1 standards +- **Automated Calculations**: Air filtration, sample density, labor estimates per FDAM formulas +- **Professional PDF Output**: Generates ready-to-use Scope of Work documents +- **Session Persistence**: Save and resume assessments via browser localStorage + +## How to Use + +1. **Project Info**: Enter project details, facility classification, and assessor information +2. **Building/Rooms**: Add rooms with dimensions (length, width, ceiling height) +3. **Images**: Upload fire damage photos and associate with rooms +4. **Observations**: Record qualitative observations (odor, soot, char, etc.) +5. **Generate**: Click "Generate Assessment" to run AI analysis and produce documents + +## Technical Details + +### Model Stack (~90GB VRAM) +- **Vision**: Qwen3-VL-30B-A3B-Instruct (~58GB) +- **Embeddings**: Qwen3-VL-Embedding-8B (~16GB) +- **Reranker**: Qwen3-VL-Reranker-8B (~16GB) + +### Zone Classifications +- **Burn Zone**: Direct fire involvement, structural damage +- **Near-Field**: Adjacent to burn zone, heavy smoke/heat exposure +- **Far-Field**: Smoke migration only, light deposits + +### Condition Levels +- **Background**: No visible contamination +- **Light**: Faint discoloration, minimal deposits +- **Moderate**: Visible film/deposits +- **Heavy**: Thick deposits, surface texture obscured +- **Structural Damage**: Physical damage requiring repair + +## Development + +```bash +# Local development (mock models) +MOCK_MODELS=true python app.py + +# Run tests +pytest tests/ -v +``` + +## Requirements + +- Python 3.10+ +- 96GB GPU memory for real model inference (4x L4 or equivalent) +- See `requirements.txt` for full dependencies + +## License + +Proprietary - For authorized use only. diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..416fcc29a4c6b7bb630671e3b272bac19a497df1 --- /dev/null +++ b/app.py @@ -0,0 +1,428 @@ +"""FDAM AI Pipeline - Fire Damage Assessment Methodology v4.0.1 + +Main Gradio application entry point with session state and tab validation. +""" + +import gradio as gr + +from config.settings import settings +from models.loader import get_models +from ui.state import SessionState, create_new_session, session_to_json, session_from_json +from ui.storage import get_head_html +from ui.tabs import project, rooms, images, observations, results + + +def create_app() -> gr.Blocks: + """Create the main Gradio application.""" + + # Initialize models at startup + model_stack = get_models() + + # Note: head parameter moved to launch() in Gradio 6.0 + # localStorage JS will be injected there + with gr.Blocks( + title="FDAM AI Pipeline - Fire Damage Assessment", + ) as app: + # Session state (stored in Gradio State component) + session_state = gr.State(value=create_new_session()) + + # Header + gr.Markdown( + """ + # FDAM AI Pipeline + ## Fire Damage Assessment Methodology v4.0.1 + + Upload images and project information to generate a professional + Cleaning Specification / Scope of Work. + """ + ) + + # Mode indicator + if settings.mock_models: + gr.Markdown( + """ + > **Development Mode**: Using mock models for testing. + > Set `MOCK_MODELS=false` for production inference. + """ + ) + + # Tab navigation + with gr.Tabs() as tabs: + # Tab 1: Project Information + with gr.Tab("1. Project Info", id=0): + tab1 = project.create_tab() + + # Tab 2: Building/Rooms + with gr.Tab("2. Building/Rooms", id=1): + tab2 = rooms.create_tab() + + # Tab 3: Images + with gr.Tab("3. Images", id=2): + tab3 = images.create_tab() + + # Tab 4: Observations + with gr.Tab("4. Observations", id=3): + tab4 = observations.create_tab() + + # Tab 5: Generate Results + with gr.Tab("5. Generate Results", id=4): + tab5 = results.create_tab() + + # --- Event Handlers --- + + # Tab 1: Project Info + tab1["validate_btn"].click( + fn=project.validate_and_continue, + inputs=[ + session_state, + tab1["project_name"], + tab1["address"], + tab1["city"], + tab1["state"], + tab1["zip_code"], + tab1["client_name"], + tab1["fire_date"], + tab1["assessment_date"], + tab1["facility_classification"], + tab1["construction_era"], + tab1["assessor_name"], + tab1["assessor_credentials"], + ], + outputs=[ + session_state, + tab1["validation_status"], + tabs, + ], + ) + + # Tab 2: Building/Rooms + tab2["add_room_btn"].click( + fn=rooms.add_room, + inputs=[ + session_state, + tab2["room_name"], + tab2["room_floor"], + tab2["room_length"], + tab2["room_width"], + tab2["room_height"], + ], + outputs=[ + session_state, + tab2["rooms_table"], + tab2["validation_status"], + tab2["room_count"], + tab2["total_area"], + tab2["total_volume"], + tab2["room_name"], + tab2["room_floor"], + tab2["room_length"], + tab2["room_width"], + tab2["room_height"], + ], + ) + + tab2["clear_form_btn"].click( + fn=lambda: ("", "", None, None, None), + outputs=[ + tab2["room_name"], + tab2["room_floor"], + tab2["room_length"], + tab2["room_width"], + tab2["room_height"], + ], + ) + + tab2["remove_last_btn"].click( + fn=rooms.remove_last_room, + inputs=[session_state], + outputs=[ + session_state, + tab2["rooms_table"], + tab2["validation_status"], + tab2["room_count"], + tab2["total_area"], + tab2["total_volume"], + ], + ) + + tab2["clear_all_btn"].click( + fn=rooms.clear_all_rooms, + inputs=[session_state], + outputs=[ + session_state, + tab2["rooms_table"], + tab2["validation_status"], + tab2["room_count"], + tab2["total_area"], + tab2["total_volume"], + ], + ) + + tab2["validate_btn"].click( + fn=rooms.validate_and_continue, + inputs=[session_state], + outputs=[ + session_state, + tab2["validation_status"], + tabs, + ], + ) + + tab2["back_btn"].click( + fn=lambda: 0, + outputs=[tabs], + ) + + # Tab 3: Images + # Update room dropdown when entering tab + tabs.select( + fn=lambda session, selected: ( + images.update_room_choices(session) if selected == 2 else gr.update() + ), + inputs=[session_state, tabs], + outputs=[tab3["room_select"]], + ) + + tab3["add_image_btn"].click( + fn=images.add_image, + inputs=[ + session_state, + tab3["image_upload"], + tab3["room_select"], + tab3["image_description"], + ], + outputs=[ + session_state, + tab3["images_gallery"], + tab3["validation_status"], + tab3["image_count"], + tab3["image_upload"], + tab3["image_description"], + tab3["room_select"], + ], + ) + + tab3["clear_upload_btn"].click( + fn=lambda: (None, ""), + outputs=[ + tab3["image_upload"], + tab3["image_description"], + ], + ) + + tab3["remove_last_btn"].click( + fn=images.remove_last_image, + inputs=[session_state], + outputs=[ + session_state, + tab3["images_gallery"], + tab3["validation_status"], + tab3["image_count"], + ], + ) + + tab3["clear_all_btn"].click( + fn=images.clear_all_images, + inputs=[session_state], + outputs=[ + session_state, + tab3["images_gallery"], + tab3["validation_status"], + tab3["image_count"], + ], + ) + + tab3["validate_btn"].click( + fn=images.validate_and_continue, + inputs=[session_state], + outputs=[ + session_state, + tab3["validation_status"], + tabs, + ], + ) + + tab3["back_btn"].click( + fn=lambda: 1, + outputs=[tabs], + ) + + # Tab 4: Observations + tab4["validate_btn"].click( + fn=observations.validate_and_continue, + inputs=[ + session_state, + tab4["smoke_odor"], + tab4["odor_intensity"], + tab4["visible_soot"], + tab4["soot_description"], + tab4["large_char"], + tab4["char_density"], + tab4["ash_residue"], + tab4["ash_description"], + tab4["surface_discoloration"], + tab4["discoloration_description"], + tab4["dust_interference"], + tab4["dust_notes"], + tab4["wildfire_indicators"], + tab4["wildfire_notes"], + tab4["additional_notes"], + ], + outputs=[ + session_state, + tab4["validation_status"], + tabs, + ], + ) + + tab4["back_btn"].click( + fn=lambda: 2, + outputs=[tabs], + ) + + # Tab 5: Generate Results + # Update preflight check when entering tab + tabs.select( + fn=lambda session, selected: ( + results.check_preflight(session) if selected == 4 else "" + ), + inputs=[session_state, tabs], + outputs=[tab5["preflight_status"]], + ) + + tab5["generate_btn"].click( + fn=results.generate_assessment, + inputs=[session_state], + outputs=[ + session_state, + tab5["processing_status"], + tab5["progress_html"], + tab5["annotated_gallery"], + tab5["stats_output"], + tab5["sow_output"], + tab5["download_md"], + tab5["download_pdf"], + ], + ) + + tab5["regenerate_btn"].click( + fn=results.generate_assessment, + inputs=[session_state], + outputs=[ + session_state, + tab5["processing_status"], + tab5["progress_html"], + tab5["annotated_gallery"], + tab5["stats_output"], + tab5["sow_output"], + tab5["download_md"], + tab5["download_pdf"], + ], + ) + + tab5["back_btn"].click( + fn=lambda: 3, + outputs=[tabs], + ) + + # --- Session Resume Handlers --- + # Load form data when navigating to tabs + + # Tab 1 (Project): Load project form fields + tabs.select( + fn=lambda session, selected: ( + project.load_form_from_session(session) if selected == 0 + else tuple([gr.update()] * 12) + ), + inputs=[session_state, tabs], + outputs=[ + tab1["project_name"], + tab1["address"], + tab1["city"], + tab1["state"], + tab1["zip_code"], + tab1["client_name"], + tab1["fire_date"], + tab1["assessment_date"], + tab1["facility_classification"], + tab1["construction_era"], + tab1["assessor_name"], + tab1["assessor_credentials"], + ], + ) + + # Tab 2 (Rooms): Load room table and stats + tabs.select( + fn=lambda session, selected: ( + rooms.load_from_session(session) if selected == 1 + else (gr.update(), gr.update(), gr.update(), gr.update()) + ), + inputs=[session_state, tabs], + outputs=[ + tab2["rooms_table"], + tab2["room_count"], + tab2["total_area"], + tab2["total_volume"], + ], + ) + + # Tab 3 (Images): Load gallery and count (room dropdown already handled above) + tabs.select( + fn=lambda session, selected: ( + images.load_from_session(session) if selected == 2 + else (gr.update(), gr.update(), gr.update()) + ), + inputs=[session_state, tabs], + outputs=[ + tab3["images_gallery"], + tab3["image_count"], + tab3["resume_warning"], + ], + ) + + # Tab 4 (Observations): Load observation form fields + tabs.select( + fn=lambda session, selected: ( + observations.load_form_from_session(session) if selected == 3 + else tuple([gr.update()] * 15) + ), + inputs=[session_state, tabs], + outputs=[ + tab4["smoke_odor"], + tab4["odor_intensity"], + tab4["visible_soot"], + tab4["soot_description"], + tab4["large_char"], + tab4["char_density"], + tab4["ash_residue"], + tab4["ash_description"], + tab4["surface_discoloration"], + tab4["discoloration_description"], + tab4["dust_interference"], + tab4["dust_notes"], + tab4["wildfire_indicators"], + tab4["wildfire_notes"], + tab4["additional_notes"], + ], + ) + + return app + + +def main(): + """Entry point for the application.""" + print(f"Starting FDAM AI Pipeline...") + print(f"Mock models: {settings.mock_models}") + print(f"Server: {settings.server_host}:{settings.server_port}") + + app = create_app() + app.launch( + server_name=settings.server_host, + server_port=settings.server_port, + share=False, + head=get_head_html(), # Inject localStorage JavaScript + ) + + +if __name__ == "__main__": + main() diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/config/inference.py b/config/inference.py new file mode 100644 index 0000000000000000000000000000000000000000..9e596bbd92870f21a0bc1d8880ee833ca3d3bd63 --- /dev/null +++ b/config/inference.py @@ -0,0 +1,34 @@ +"""Model inference configuration parameters.""" + +from dataclasses import dataclass + + +@dataclass +class VisionInferenceConfig: + """Configuration for vision model inference.""" + + max_new_tokens: int = 4096 + temperature: float = 0.1 + top_p: float = 0.9 + do_sample: bool = True + + +@dataclass +class EmbeddingConfig: + """Configuration for embedding model.""" + + embedding_dimension: int = 768 + normalize: bool = True + + +@dataclass +class RerankerConfig: + """Configuration for reranker model.""" + + top_k: int = 5 + + +# Default configurations +vision_config = VisionInferenceConfig() +embedding_config = EmbeddingConfig() +reranker_config = RerankerConfig() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..53b3536fb9d4a53e6c10465fde4047815e4876a0 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,45 @@ +"""Application settings with environment variable support.""" + +from typing import Literal +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """FDAM AI Pipeline configuration.""" + + # Environment + environment: Literal["development", "production"] = "development" + + # Model loading - set MOCK_MODELS=true for local dev on RTX 4090 + mock_models: bool = True + + # Model paths (for production on HuggingFace Spaces) + vision_model: str = "Qwen/Qwen3-VL-30B-A3B-Instruct" + embedding_model: str = "Qwen/Qwen3-VL-Embedding-8B" + reranker_model: str = "Qwen/Qwen3-VL-Reranker-8B" + + # Fallback vision model if VRAM issues + vision_model_fallback: str = "Qwen/Qwen3-VL-8B-Instruct" + + # ChromaDB + chroma_persist_dir: str = "./chroma_db" + + # Knowledge base + knowledge_base_dir: str = "./RAG-KB" + + # Gradio server (0.0.0.0 required for WSL) + server_host: str = "0.0.0.0" + server_port: int = 7860 + + # Assessment limits + max_images_per_assessment: int = 20 + + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="", + case_sensitive=False, + ) + + +# Singleton instance +settings = Settings() diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/models/loader.py b/models/loader.py new file mode 100644 index 0000000000000000000000000000000000000000..457771131b252af70b480731c10cd6ba267a73ac --- /dev/null +++ b/models/loader.py @@ -0,0 +1,37 @@ +"""Model loading with mock/real switching based on environment.""" + +from typing import Union + +from config.settings import settings + +# Type alias for model stack +ModelStack = Union["MockModelStack", "RealModelStack"] # noqa: F821 + +# Lazy singleton +_model_stack: ModelStack | None = None + + +def get_model_stack() -> ModelStack: + """Get model stack based on environment configuration.""" + if settings.mock_models: + from models.mock import MockModelStack + + return MockModelStack().load_all() + else: + from models.real import RealModelStack + + return RealModelStack().load_all() + + +def get_models() -> ModelStack: + """Get or create the singleton model stack.""" + global _model_stack + if _model_stack is None: + _model_stack = get_model_stack() + return _model_stack + + +def reset_models() -> None: + """Reset the model stack (useful for testing).""" + global _model_stack + _model_stack = None diff --git a/models/mock.py b/models/mock.py new file mode 100644 index 0000000000000000000000000000000000000000..1682b14ead4a26cca40ef640a1da661613ae7e3f --- /dev/null +++ b/models/mock.py @@ -0,0 +1,157 @@ +"""Mock model implementations for local development on RTX 4090.""" + +import random +from typing import Any +from PIL import Image + + +class MockVisionModel: + """Mock vision model that returns realistic JSON responses.""" + + ZONES = ["burn", "near-field", "far-field"] + CONDITIONS = ["background", "light", "moderate", "heavy", "structural-damage"] + MATERIALS = [ + {"type": "steel", "category": "non-porous"}, + {"type": "concrete", "category": "non-porous"}, + {"type": "glass", "category": "non-porous"}, + {"type": "cmu", "category": "non-porous"}, + {"type": "drywall-painted", "category": "semi-porous"}, + {"type": "wood-sealed", "category": "semi-porous"}, + {"type": "drywall-unpainted", "category": "porous"}, + {"type": "carpet", "category": "porous"}, + {"type": "insulation-fiberglass", "category": "porous"}, + {"type": "acoustic-tile", "category": "porous"}, + {"type": "ductwork-rigid", "category": "hvac"}, + {"type": "ductwork-flexible", "category": "hvac"}, + ] + + def analyze_image(self, image: Image.Image, context: str = "") -> dict[str, Any]: + """Return mock vision analysis matching the spec schema.""" + selected_zone = random.choice(self.ZONES) + selected_condition = random.choice(self.CONDITIONS) + + # Generate 2-4 random materials + num_materials = random.randint(2, 4) + materials = [] + for _ in range(num_materials): + mat = random.choice(self.MATERIALS).copy() + mat.update( + { + "confidence": round(random.uniform(0.75, 0.95), 2), + "location_description": "Visible in image", + "bounding_box": { + "x": round(random.uniform(0.1, 0.3), 2), + "y": round(random.uniform(0.1, 0.3), 2), + "width": round(random.uniform(0.2, 0.5), 2), + "height": round(random.uniform(0.2, 0.5), 2), + }, + } + ) + materials.append(mat) + + soot_visible = random.choice([True, False]) + char_visible = random.choice([True, False]) + ash_visible = random.choice([True, False]) + + return { + "zone": { + "classification": selected_zone, + "confidence": round(random.uniform(0.7, 0.95), 2), + "reasoning": f"Mock analysis detected {selected_zone} zone characteristics based on visible damage patterns", + }, + "condition": { + "level": selected_condition, + "confidence": round(random.uniform(0.65, 0.90), 2), + "reasoning": f"Surface shows {selected_condition} contamination levels", + }, + "materials": materials, + "combustion_indicators": { + "soot_visible": soot_visible, + "soot_pattern": "Visible deposition on horizontal surfaces" + if soot_visible + else None, + "char_visible": char_visible, + "char_description": "Angular black particles visible" + if char_visible + else None, + "ash_visible": ash_visible, + "ash_description": "Gray powdery residue on surfaces" + if ash_visible + else None, + }, + "structural_concerns": [], + "access_issues": [], + "recommended_sampling_locations": [ + { + "description": "Center of visible contamination", + "sample_type": "tape_lift", + "priority": "high", + }, + { + "description": "Comparison area with less contamination", + "sample_type": "surface_wipe", + "priority": "medium", + }, + ], + "flags_for_review": [], + } + + +class MockEmbeddingModel: + """Mock embedding model that returns random vectors.""" + + def __init__(self, dimension: int = 768): + self.dimension = dimension + + def embed(self, text: str) -> list[float]: + """Return mock embedding vector.""" + # Use hash of text for reproducibility + random.seed(hash(text) % (2**32)) + embedding = [random.uniform(-1, 1) for _ in range(self.dimension)] + random.seed() # Reset seed + return embedding + + def embed_batch(self, texts: list[str]) -> list[list[float]]: + """Return mock embeddings for a batch of texts.""" + return [self.embed(text) for text in texts] + + +class MockRerankerModel: + """Mock reranker that returns random scores.""" + + def rerank(self, query: str, documents: list[str]) -> list[float]: + """Return mock reranking scores.""" + # Higher scores for documents that share more words with query + scores = [] + query_words = set(query.lower().split()) + for doc in documents: + doc_words = set(doc.lower().split()) + overlap = len(query_words & doc_words) + base_score = overlap / max(len(query_words), 1) + noise = random.uniform(-0.1, 0.1) + scores.append(min(1.0, max(0.0, base_score + noise))) + return scores + + +class MockModelStack: + """Mock model stack for local development.""" + + def __init__(self): + self.vision = MockVisionModel() + self.embedding = MockEmbeddingModel() + self.reranker = MockRerankerModel() + self.loaded = False + + def load_all(self) -> "MockModelStack": + """Simulate model loading.""" + print("[MOCK] Loading mock models for local development...") + print("[MOCK] Vision model: MockVisionModel") + print("[MOCK] Embedding model: MockEmbeddingModel") + print("[MOCK] Reranker model: MockRerankerModel") + self.loaded = True + print("[MOCK] All mock models loaded successfully.") + return self + + def is_loaded(self) -> bool: + """Check if models are loaded.""" + return self.loaded diff --git a/models/real.py b/models/real.py new file mode 100644 index 0000000000000000000000000000000000000000..7a9169bfb9521c7c963f1185e514fc30de346ef6 --- /dev/null +++ b/models/real.py @@ -0,0 +1,439 @@ +"""Real model loading for production (HuggingFace Spaces with 4xL4 GPUs). + +This module loads the actual Qwen3-VL models for production use. +Requires ~90GB VRAM (4xL4 with 96GB total). +""" + +import json +import logging +import re +import torch +from typing import Any +from PIL import Image + +from config.settings import settings + +logger = logging.getLogger(__name__) + + +class RealModelStack: + """Real model stack for production on HuggingFace Spaces.""" + + def __init__(self): + self.models: dict[str, Any] = {} + self.processors: dict[str, Any] = {} + self.loaded = False + + def load_all(self) -> "RealModelStack": + """Load all models with device_map='auto' for multi-GPU distribution.""" + from transformers import AutoModel, AutoProcessor + + print(f"Loading models on {'cuda' if torch.cuda.is_available() else 'cpu'}...") + + # Vision model (~58GB in BF16) + print(f"Loading vision model: {settings.vision_model}...") + try: + from transformers import Qwen3VLMoeForConditionalGeneration + + self.models["vision"] = Qwen3VLMoeForConditionalGeneration.from_pretrained( + settings.vision_model, + torch_dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True, + ) + self.processors["vision"] = AutoProcessor.from_pretrained( + settings.vision_model, + trust_remote_code=True, + ) + except Exception as e: + print(f"Failed to load 30B vision model: {e}") + print(f"Falling back to {settings.vision_model_fallback}...") + self.models["vision"] = Qwen3VLMoeForConditionalGeneration.from_pretrained( + settings.vision_model_fallback, + torch_dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True, + ) + self.processors["vision"] = AutoProcessor.from_pretrained( + settings.vision_model_fallback, + trust_remote_code=True, + ) + + # Embedding model (~16GB in BF16) + print(f"Loading embedding model: {settings.embedding_model}...") + self.models["embedding"] = AutoModel.from_pretrained( + settings.embedding_model, + torch_dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True, + ) + self.processors["embedding"] = AutoProcessor.from_pretrained( + settings.embedding_model, + trust_remote_code=True, + ) + + # Reranker model (~16GB in BF16) + print(f"Loading reranker model: {settings.reranker_model}...") + self.models["reranker"] = AutoModel.from_pretrained( + settings.reranker_model, + torch_dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True, + ) + self.processors["reranker"] = AutoProcessor.from_pretrained( + settings.reranker_model, + trust_remote_code=True, + ) + + self.loaded = True + print("All models loaded successfully.") + return self + + def is_loaded(self) -> bool: + """Check if models are loaded.""" + return self.loaded + + +class RealVisionModel: + """Wrapper for real vision model inference.""" + + # Analysis prompt template for FDAM fire damage assessment + ANALYSIS_PROMPT = """Analyze this fire damage image and return a JSON response with the following structure: + +{ + "zone": { + "classification": "burn" | "near-field" | "far-field", + "confidence": 0.0-1.0, + "reasoning": "explanation" + }, + "condition": { + "level": "background" | "light" | "moderate" | "heavy" | "structural-damage", + "confidence": 0.0-1.0, + "reasoning": "explanation" + }, + "materials": [ + { + "type": "material type (e.g., drywall, concrete, steel, wood)", + "category": "non-porous" | "semi-porous" | "porous" | "hvac", + "confidence": 0.0-1.0, + "location_description": "where in image", + "bounding_box": {"x": 0.0-1.0, "y": 0.0-1.0, "width": 0.0-1.0, "height": 0.0-1.0} + } + ], + "combustion_indicators": { + "soot_visible": true/false, + "soot_pattern": "description or null", + "char_visible": true/false, + "char_description": "description or null", + "ash_visible": true/false, + "ash_description": "description or null" + }, + "structural_concerns": ["list of structural issues if any"], + "access_issues": ["list of access problems if any"], + "recommended_sampling_locations": [ + { + "description": "where to sample", + "sample_type": "tape_lift" | "surface_wipe" | "air_sample", + "priority": "high" | "medium" | "low" + } + ], + "flags_for_review": ["any items requiring human review"] +} + +Zone definitions: +- burn: Direct fire involvement, visible charring, structural damage +- near-field: Adjacent to burn zone, heavy smoke/heat exposure, discoloration +- far-field: Smoke migration only, light deposits, no structural damage + +Condition definitions: +- background: No visible contamination +- light: Faint discoloration, minimal deposits +- moderate: Visible film/deposits, surface color altered +- heavy: Thick deposits, surface texture obscured +- structural-damage: Physical damage requiring repair before cleaning + +IMPORTANT: Return ONLY valid JSON, no additional text.""" + + def __init__(self, model, processor): + self.model = model + self.processor = processor + + def analyze_image(self, image: Image.Image, context: str = "") -> dict[str, Any]: + """Analyze an image and return structured results.""" + try: + from qwen_vl_utils import process_vision_info + except ImportError: + logger.warning("qwen_vl_utils not available, using basic processing") + process_vision_info = None + + # Build the analysis prompt + prompt = self.ANALYSIS_PROMPT + if context: + prompt = f"Context: {context}\n\n{prompt}" + + # Prepare messages in Qwen-VL format + messages = [ + { + "role": "user", + "content": [ + {"type": "image", "image": image}, + {"type": "text", "text": prompt}, + ], + } + ] + + try: + # Apply chat template + text = self.processor.apply_chat_template( + messages, tokenize=False, add_generation_prompt=True + ) + + # Process vision info if available + if process_vision_info: + image_inputs, video_inputs = process_vision_info(messages) + inputs = self.processor( + text=[text], + images=image_inputs, + videos=video_inputs, + return_tensors="pt", + padding=True, + ) + else: + # Fallback: basic image processing + inputs = self.processor( + text=[text], + images=[image], + return_tensors="pt", + padding=True, + ) + + # Move inputs to model device + inputs = {k: v.to(self.model.device) for k, v in inputs.items()} + + # Generate response + with torch.no_grad(): + outputs = self.model.generate( + **inputs, + max_new_tokens=2048, + do_sample=False, + temperature=None, + top_p=None, + ) + + # Decode response + response_text = self.processor.decode( + outputs[0], skip_special_tokens=True + ) + + # Parse JSON from response + return self._parse_vision_response(response_text) + + except Exception as e: + logger.error(f"Vision analysis failed: {e}") + return self._get_fallback_response(str(e)) + + def _parse_vision_response(self, response: str) -> dict[str, Any]: + """Parse JSON response from vision model.""" + try: + # Try to extract JSON from response + # Look for JSON block in various formats + json_match = re.search(r'\{[\s\S]*\}', response) + if json_match: + json_str = json_match.group() + return json.loads(json_str) + else: + logger.warning("No JSON found in vision response") + return self._get_fallback_response("No JSON in response") + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse vision JSON: {e}") + return self._get_fallback_response(f"JSON parse error: {e}") + + def _get_fallback_response(self, reason: str) -> dict[str, Any]: + """Return fallback response when analysis fails.""" + return { + "zone": { + "classification": "far-field", + "confidence": 0.3, + "reasoning": f"Fallback due to: {reason}", + }, + "condition": { + "level": "light", + "confidence": 0.3, + "reasoning": f"Fallback due to: {reason}", + }, + "materials": [ + { + "type": "general-surface", + "category": "semi-porous", + "confidence": 0.3, + "location_description": "Unable to determine", + "bounding_box": {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}, + } + ], + "combustion_indicators": { + "soot_visible": False, + "soot_pattern": None, + "char_visible": False, + "char_description": None, + "ash_visible": False, + "ash_description": None, + }, + "structural_concerns": [], + "access_issues": [], + "recommended_sampling_locations": [], + "flags_for_review": [f"Analysis failed: {reason}"], + "_fallback_used": True, + } + + +class RealEmbeddingModel: + """Wrapper for real embedding model inference.""" + + def __init__(self, model, processor): + self.model = model + self.processor = processor + + def embed(self, text: str) -> list[float]: + """Generate embedding for text using mean pooling.""" + try: + # Tokenize input + inputs = self.processor( + text, + return_tensors="pt", + padding=True, + truncation=True, + max_length=512, + ) + + # Move to model device + inputs = {k: v.to(self.model.device) for k, v in inputs.items()} + + # Generate embeddings + with torch.no_grad(): + outputs = self.model(**inputs) + + # Use mean pooling over sequence dimension + # outputs.last_hidden_state shape: (batch, seq_len, hidden_dim) + attention_mask = inputs.get("attention_mask") + if attention_mask is not None: + # Mask-weighted mean pooling + mask_expanded = attention_mask.unsqueeze(-1).expand( + outputs.last_hidden_state.size() + ).float() + sum_embeddings = torch.sum( + outputs.last_hidden_state * mask_expanded, dim=1 + ) + sum_mask = torch.clamp(mask_expanded.sum(dim=1), min=1e-9) + embeddings = sum_embeddings / sum_mask + else: + # Simple mean if no attention mask + embeddings = outputs.last_hidden_state.mean(dim=1) + + # Normalize + embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1) + + return embeddings[0].cpu().tolist() + + except Exception as e: + logger.error(f"Embedding generation failed: {e}") + # Return zero vector as fallback + hidden_size = getattr(self.model.config, "hidden_size", 4096) + return [0.0] * hidden_size + + def embed_batch(self, texts: list[str]) -> list[list[float]]: + """Generate embeddings for a batch of texts.""" + return [self.embed(text) for text in texts] + + +class RealRerankerModel: + """Wrapper for real reranker model inference.""" + + def __init__(self, model, processor): + self.model = model + self.processor = processor + + def rerank(self, query: str, documents: list[str]) -> list[float]: + """Rerank documents by relevance to query. + + Returns a list of relevance scores for each document. + Higher scores indicate more relevant documents. + """ + if not documents: + return [] + + scores = [] + for doc in documents: + try: + score = self._score_pair(query, doc) + scores.append(score) + except Exception as e: + logger.warning(f"Reranking failed for document: {e}") + scores.append(0.0) + + return scores + + def _score_pair(self, query: str, document: str) -> float: + """Score a single query-document pair.""" + # Format as query-document pair for cross-encoder + # Truncate document if too long + max_doc_len = 400 + if len(document) > max_doc_len: + document = document[:max_doc_len] + "..." + + pair_text = f"Query: {query}\n\nDocument: {document}" + + try: + inputs = self.processor( + pair_text, + return_tensors="pt", + padding=True, + truncation=True, + max_length=512, + ) + + # Move to model device + inputs = {k: v.to(self.model.device) for k, v in inputs.items()} + + with torch.no_grad(): + outputs = self.model(**inputs) + + # Use CLS token representation for scoring + # Take mean of last hidden state as a simple relevance score + cls_embedding = outputs.last_hidden_state[:, 0, :] + + # Normalize and take mean as score + score = cls_embedding.norm(dim=-1).mean().item() + + # Normalize score to 0-1 range (approximate) + # This is heuristic; actual reranker models have specific score heads + score = min(1.0, max(0.0, score / 100.0)) + + return score + + except Exception as e: + logger.error(f"Reranker scoring failed: {e}") + return 0.0 + + def rerank_with_indices( + self, query: str, documents: list[str], top_k: int = None + ) -> list[tuple[int, float]]: + """Rerank and return sorted (index, score) tuples. + + Args: + query: The search query + documents: List of documents to rerank + top_k: Optional limit on number of results + + Returns: + List of (original_index, score) tuples, sorted by score descending + """ + scores = self.rerank(query, documents) + + # Create (index, score) pairs and sort by score descending + indexed_scores = list(enumerate(scores)) + indexed_scores.sort(key=lambda x: x[1], reverse=True) + + if top_k is not None: + indexed_scores = indexed_scores[:top_k] + + return indexed_scores diff --git a/pipeline/__init__.py b/pipeline/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cfbedb955cf546b40a70f847e5b0d17bc7bf4e6c --- /dev/null +++ b/pipeline/__init__.py @@ -0,0 +1,23 @@ +"""FDAM Pipeline - Fire Damage Assessment Processing. + +This module provides the core processing pipeline for generating +fire damage assessment reports using AI vision analysis and +RAG-enhanced methodology lookup. +""" + +from .calculations import FDAMCalculator +from .dispositions import DispositionEngine +from .generator import DocumentGenerator +from .main import FDAMPipeline, PipelineResult +from .pdf_generator import PDFGenerator, PDFResult, generate_sow_pdf + +__all__ = [ + "FDAMCalculator", + "DispositionEngine", + "DocumentGenerator", + "FDAMPipeline", + "PipelineResult", + "PDFGenerator", + "PDFResult", + "generate_sow_pdf", +] diff --git a/pipeline/calculations.py b/pipeline/calculations.py new file mode 100644 index 0000000000000000000000000000000000000000..afceb3f4f6bc3c321d0165371ec490cfbf97bf9c --- /dev/null +++ b/pipeline/calculations.py @@ -0,0 +1,325 @@ +"""FDAM Calculations Module. + +Implements deterministic calculations from FDAM v4.0.1: +- Air filtration requirements (ACH per NADCA ACR 2021) +- Sample density guidelines +- Regulatory flags +- Metals thresholds lookup +""" + +import math +from dataclasses import dataclass, field +from typing import Literal, Optional + +from ui.state import SessionState + + +@dataclass +class AirFiltrationResult: + """Air filtration calculation results.""" + + total_volume_cf: float + required_ach: int + unit_cfm: int + units_required: int + calculation_notes: str + + +@dataclass +class SampleDensityResult: + """Sample density calculation results.""" + + total_area_sf: float + tape_lifts_min: int + tape_lifts_max: int + surface_wipes_min: int + surface_wipes_max: int + ceiling_deck_samples: int + notes: list[str] = field(default_factory=list) + + +@dataclass +class RegulatoryFlags: + """Regulatory requirements based on building characteristics.""" + + lbp_survey_required: bool = False + acm_survey_required: bool = False + acm_survey_recommended: bool = False + enhanced_childcare_thresholds: bool = False + notes: list[str] = field(default_factory=list) + + +@dataclass +class MetalsThresholds: + """Metals clearance thresholds for a facility type.""" + + lead_ug_100cm2: float + cadmium_ug_100cm2: float + arsenic_ug_100cm2: float + chromium_vi_ug_100cm2: float + beryllium_ug_100cm2: float + facility_type: str + source: str = "BNL SOP IH75190, Attachment 9.3" + + +# Threshold lookup tables from BNL SOP IH75190 +METALS_THRESHOLDS = { + "non-operational": MetalsThresholds( + lead_ug_100cm2=22.0, + cadmium_ug_100cm2=3.3, + arsenic_ug_100cm2=6.7, + chromium_vi_ug_100cm2=3.3, + beryllium_ug_100cm2=0.2, + facility_type="Non-Operational", + ), + "operational": MetalsThresholds( + lead_ug_100cm2=500.0, + cadmium_ug_100cm2=50.0, + arsenic_ug_100cm2=100.0, + chromium_vi_ug_100cm2=50.0, + beryllium_ug_100cm2=3.0, + facility_type="Operational", + ), + "public-childcare": MetalsThresholds( + lead_ug_100cm2=4.3, # EPA/HUD October 2024 for floors + cadmium_ug_100cm2=3.3, # Use non-operational as baseline + arsenic_ug_100cm2=6.7, + chromium_vi_ug_100cm2=3.3, + beryllium_ug_100cm2=0.2, + facility_type="Public/Childcare", + source="EPA/HUD October 2024 + BNL SOP IH75190", + ), +} + +# Particulate thresholds from EAA Method Guide +PARTICULATE_THRESHOLDS = { + "ash_char": { + "clearance": 150, # cts/cm² + "unit": "cts/cm²", + "source": "EAA Method Guide / FDAM §1.5", + }, + "aciniform_soot": { + "clearance": 500, # cts/cm² + "unit": "cts/cm²", + "source": "EAA Method Guide / FDAM §1.5", + }, +} + + +class FDAMCalculator: + """Calculator for FDAM deterministic formulas.""" + + # Default air scrubber specifications + DEFAULT_UNIT_CFM = 2000 + DEFAULT_ACH = 4 # Per NADCA ACR 2021 + + def calculate_air_filtration( + self, + total_area_sf: float, + avg_ceiling_height_ft: float, + unit_cfm: int = DEFAULT_UNIT_CFM, + required_ach: int = DEFAULT_ACH, + ) -> AirFiltrationResult: + """Calculate air filtration requirements per NADCA ACR 2021. + + Formula: Units = (Volume CF × ACH) / (Unit CFM × 60) + + Args: + total_area_sf: Total floor area in square feet + avg_ceiling_height_ft: Average ceiling height in feet + unit_cfm: CFM rating of air scrubber units (default 2000) + required_ach: Required air changes per hour (default 4) + + Returns: + AirFiltrationResult with calculation details + """ + total_volume_cf = total_area_sf * avg_ceiling_height_ft + + # Formula from FDAM §5.3 + units_required = math.ceil( + (total_volume_cf * required_ach) / (unit_cfm * 60) + ) + + # Minimum 1 unit + units_required = max(1, units_required) + + calculation_notes = ( + f"({total_volume_cf:,.0f} CF × {required_ach} ACH) / " + f"({unit_cfm} CFM × 60) = {units_required} units" + ) + + return AirFiltrationResult( + total_volume_cf=total_volume_cf, + required_ach=required_ach, + unit_cfm=unit_cfm, + units_required=units_required, + calculation_notes=calculation_notes, + ) + + def calculate_sample_density( + self, + total_area_sf: float, + has_ceiling_deck: bool = True, + surface_types_count: int = 3, + ) -> SampleDensityResult: + """Calculate sample density per FDAM §2.3. + + Args: + total_area_sf: Total floor area in square feet + has_ceiling_deck: Whether ceiling deck surfaces are present + surface_types_count: Number of distinct surface types + + Returns: + SampleDensityResult with recommended sample counts + """ + notes = [] + + # Base sample density by area size + if total_area_sf < 5000: + tape_min, tape_max = 3, 5 + wipe_min, wipe_max = 3, 5 + notes.append("Small area (<5,000 SF): standard sampling density") + elif total_area_sf <= 25000: + tape_min, tape_max = 5, 10 + wipe_min, wipe_max = 5, 10 + notes.append("Medium area (5,000-25,000 SF): moderate sampling density") + else: + # Scale for larger areas + tape_min, tape_max = 10, 15 + wipe_min, wipe_max = 10, 15 + notes.append("Large area (>25,000 SF): enhanced sampling density") + + # Multiply by surface types + tape_min *= surface_types_count + tape_max *= surface_types_count + wipe_min *= surface_types_count + wipe_max *= surface_types_count + + # Ceiling deck enhanced sampling (1 per 2,500 SF per FDAM §4.5) + ceiling_deck_samples = 0 + if has_ceiling_deck: + ceiling_deck_samples = max(1, math.ceil(total_area_sf / 2500)) + notes.append( + f"Ceiling deck: {ceiling_deck_samples} samples " + f"(1 per 2,500 SF per FDAM §4.5)" + ) + + return SampleDensityResult( + total_area_sf=total_area_sf, + tape_lifts_min=tape_min, + tape_lifts_max=tape_max, + surface_wipes_min=wipe_min, + surface_wipes_max=wipe_max, + ceiling_deck_samples=ceiling_deck_samples, + notes=notes, + ) + + def get_regulatory_flags( + self, + construction_era: Literal["pre-1980", "1980-2000", "post-2000"], + facility_classification: Literal["operational", "non-operational", "public-childcare"], + ) -> RegulatoryFlags: + """Determine regulatory requirements based on building characteristics. + + Args: + construction_era: Building construction era + facility_classification: Facility type + + Returns: + RegulatoryFlags with applicable requirements + """ + flags = RegulatoryFlags() + + # Lead-based paint (pre-1978) + if construction_era == "pre-1980": + flags.lbp_survey_required = True + flags.notes.append("LBP survey required (pre-1978 construction)") + + # Asbestos (pre-1980 required, 1980-2000 recommended) + if construction_era == "pre-1980": + flags.acm_survey_required = True + flags.notes.append("ACM survey required (pre-1980 construction)") + elif construction_era == "1980-2000": + flags.acm_survey_recommended = True + flags.notes.append("ACM survey recommended (1980-2000 construction)") + + # Enhanced thresholds for public/childcare + if facility_classification == "public-childcare": + flags.enhanced_childcare_thresholds = True + flags.notes.append( + "Enhanced lead thresholds apply (EPA/HUD October 2024): " + "4.3 µg/100cm² for floors" + ) + + return flags + + def get_metals_thresholds( + self, + facility_classification: Literal["operational", "non-operational", "public-childcare"], + ) -> MetalsThresholds: + """Get metals clearance thresholds for facility type. + + Args: + facility_classification: Facility type + + Returns: + MetalsThresholds with applicable limits + """ + return METALS_THRESHOLDS.get( + facility_classification, + METALS_THRESHOLDS["non-operational"], + ) + + def calculate_from_session(self, session: SessionState) -> dict: + """Run all calculations from a session state. + + Args: + session: Current session state with rooms and project info + + Returns: + Dictionary with all calculation results + """ + # Calculate totals from rooms + total_area = sum(r.length_ft * r.width_ft for r in session.rooms) + total_volume = sum( + r.length_ft * r.width_ft * r.ceiling_height_ft + for r in session.rooms + ) + avg_ceiling = ( + total_volume / total_area if total_area > 0 else 10.0 + ) + + # Air filtration + air_filtration = self.calculate_air_filtration( + total_area_sf=total_area, + avg_ceiling_height_ft=avg_ceiling, + ) + + # Sample density + sample_density = self.calculate_sample_density( + total_area_sf=total_area, + has_ceiling_deck=True, # Assume present + surface_types_count=3, # Default assumption + ) + + # Regulatory flags + regulatory = self.get_regulatory_flags( + construction_era=session.project.construction_era or "post-2000", + facility_classification=session.project.facility_classification or "non-operational", + ) + + # Metals thresholds + thresholds = self.get_metals_thresholds( + facility_classification=session.project.facility_classification or "non-operational", + ) + + return { + "total_area_sf": total_area, + "total_volume_cf": total_volume, + "avg_ceiling_height_ft": avg_ceiling, + "air_filtration": air_filtration, + "sample_density": sample_density, + "regulatory_flags": regulatory, + "metals_thresholds": thresholds, + "particulate_thresholds": PARTICULATE_THRESHOLDS, + } diff --git a/pipeline/dispositions.py b/pipeline/dispositions.py new file mode 100644 index 0000000000000000000000000000000000000000..f0758cde54e802eab32ff53398a73df5b20410eb --- /dev/null +++ b/pipeline/dispositions.py @@ -0,0 +1,364 @@ +"""FDAM Dispositions Module. + +Determines cleaning dispositions based on zone classification, +condition level, and RAG-retrieved methodology context. +""" + +import logging +from dataclasses import dataclass, field +from typing import Literal, Optional + +from rag import FDAMRetriever, ChromaVectorStore + +logger = logging.getLogger(__name__) + + +# Disposition matrix from FDAM §4.3 +DISPOSITION_MATRIX = { + # (zone, condition) -> (disposition, protocol) + ("any", "background"): ("no-action", "Document only"), + ("far-field", "light"): ("clean", "Standard protocol"), + ("far-field", "moderate"): ("clean", "Full protocol"), + ("far-field", "heavy"): ("clean", "Aggressive protocol"), + ("near-field", "light"): ("clean", "Full protocol"), + ("near-field", "moderate"): ("clean", "Aggressive protocol, multiple passes"), + ("near-field", "heavy"): ("clean", "Aggressive protocol with verification sampling"), + ("burn-zone", "light"): ("clean", "Post-structural repair; full protocol"), + ("burn-zone", "moderate"): ("clean", "Post-structural repair; aggressive protocol"), + ("burn-zone", "heavy"): ("clean", "Post-structural repair; aggressive protocol"), + ("any", "structural-damage"): ("remove-repair", "Beyond cleaning scope"), +} + +# Protocol details +CLEANING_PROTOCOLS = { + "standard": { + "name": "Standard Protocol", + "steps": [ + "HEPA vacuum all surfaces", + "Wet wipe with appropriate cleaner", + "Allow to dry", + "Visual inspection", + ], + "passes": 1, + }, + "full": { + "name": "Full Protocol", + "steps": [ + "HEPA vacuum all surfaces (2 passes)", + "Wet wipe with degreaser/cleaner", + "Rinse wipe", + "Allow to dry", + "Visual inspection", + "Verification sampling if required", + ], + "passes": 2, + }, + "aggressive": { + "name": "Aggressive Protocol", + "steps": [ + "HEPA vacuum all surfaces (minimum 3 passes)", + "Apply cleaning solution, allow dwell time", + "Agitate with appropriate brush/pad", + "Wet wipe extraction", + "Rinse wipe", + "Repeat cleaning cycle if needed", + "Verification sampling required", + ], + "passes": 3, + }, +} + + +@dataclass +class DispositionResult: + """Result of disposition determination.""" + + zone: str + condition: str + disposition: Literal["no-action", "clean", "evaluate", "remove", "remove-repair"] + protocol: str + protocol_details: Optional[dict] = None + confidence: float = 1.0 + rag_context: Optional[str] = None + notes: list[str] = field(default_factory=list) + + +@dataclass +class SurfaceDisposition: + """Disposition for a specific surface.""" + + surface_type: str + room_name: str + zone: str + condition: str + disposition: str + cleaning_method: str + notes: list[str] = field(default_factory=list) + + +class DispositionEngine: + """Determines cleaning dispositions using FDAM methodology and RAG.""" + + def __init__(self, retriever: Optional[FDAMRetriever] = None): + """Initialize disposition engine. + + Args: + retriever: Optional RAG retriever. If None, uses default. + """ + self._retriever = retriever + + @property + def retriever(self) -> FDAMRetriever: + """Get or create RAG retriever.""" + if self._retriever is None: + try: + vs = ChromaVectorStore(persist_directory="chroma_db") + self._retriever = FDAMRetriever(vectorstore=vs) + except Exception as e: + # Fall back to in-memory if no persistent store + logger.warning(f"ChromaDB init failed, using fallback retriever: {e}") + self._retriever = FDAMRetriever() + return self._retriever + + def determine_disposition( + self, + zone: Literal["burn-zone", "near-field", "far-field"], + condition: Literal["background", "light", "moderate", "heavy", "structural-damage"], + surface_type: Optional[str] = None, + use_rag: bool = True, + ) -> DispositionResult: + """Determine disposition for a zone/condition combination. + + Args: + zone: Zone classification + condition: Condition level + surface_type: Optional surface type for specific guidance + use_rag: Whether to retrieve additional context from RAG + + Returns: + DispositionResult with disposition and protocol + """ + notes = [] + + # Handle background condition (any zone) + if condition == "background": + return DispositionResult( + zone=zone, + condition=condition, + disposition="no-action", + protocol="Document only", + confidence=1.0, + notes=["No visible contamination - document and proceed"], + ) + + # Handle structural damage (any zone) + if condition == "structural-damage": + return DispositionResult( + zone=zone, + condition=condition, + disposition="remove-repair", + protocol="Beyond cleaning scope", + confidence=1.0, + notes=["Structural damage requires repair before cleaning assessment"], + ) + + # Look up in disposition matrix + key = (zone, condition) + if key in DISPOSITION_MATRIX: + disposition, protocol = DISPOSITION_MATRIX[key] + else: + # Fallback for unexpected combinations + disposition = "evaluate" + protocol = "Professional judgment required" + notes.append("Combination not in standard matrix - requires evaluation") + + # Determine protocol details + protocol_details = None + if "standard" in protocol.lower(): + protocol_details = CLEANING_PROTOCOLS["standard"] + elif "aggressive" in protocol.lower(): + protocol_details = CLEANING_PROTOCOLS["aggressive"] + elif "full" in protocol.lower(): + protocol_details = CLEANING_PROTOCOLS["full"] + + # Get RAG context if enabled + rag_context = None + if use_rag: + try: + results = self.retriever.retrieve_disposition( + zone=zone, + condition=condition, + material_type=surface_type, + ) + if results: + rag_context = results[0].text[:500] # First result, truncated + notes.append(f"RAG context from: {results[0].source}") + except Exception as e: + notes.append(f"RAG lookup unavailable: {e}") + + return DispositionResult( + zone=zone, + condition=condition, + disposition=disposition, + protocol=protocol, + protocol_details=protocol_details, + confidence=0.9 if disposition != "evaluate" else 0.6, + rag_context=rag_context, + notes=notes, + ) + + def get_cleaning_method( + self, + surface_type: str, + condition: Literal["light", "moderate", "heavy"], + use_rag: bool = True, + ) -> dict: + """Get recommended cleaning method for a surface type. + + Args: + surface_type: Type of surface (e.g., "drywall", "concrete") + condition: Contamination level + use_rag: Whether to retrieve from RAG + + Returns: + Dictionary with cleaning method details + """ + # Default cleaning methods by surface type (from FDAM §5.2) + default_methods = { + "drywall": "HEPA vacuum → Dry sponge OR wet wipe", + "painted-drywall": "HEPA vacuum → Wet wipe with degreaser", + "concrete": "Scrubber machine + alkaline cleaner", + "concrete-floor": "Scrubber machine + alkaline cleaner", + "cmu": "HEPA vacuum → Wet wipe OR power wash", + "cmu-walls": "HEPA vacuum → Wet wipe OR power wash", + "metal": "Wet wipe → Rinse", + "metal-doors": "Wet wipe → Rinse", + "wood": "HEPA vacuum → Appropriate wood cleaner", + "glass": "Glass cleaner with lint-free cloth", + "carpet": "HEPA vacuum → Hot water extraction", + "hvac-ductwork": "Per NADCA ACR standards", + "ceiling-deck": "HEPA vacuum → Wet wipe (enhanced sampling required)", + } + + # Normalize surface type + surface_lower = surface_type.lower().replace(" ", "-") + + # Find best match + method = None + for key, value in default_methods.items(): + if key in surface_lower or surface_lower in key: + method = value + break + + if method is None: + method = "HEPA vacuum → Wet wipe (consult IH professional)" + + # Enhance method based on condition + if condition == "heavy": + method = f"{method} (multiple passes, verification sampling)" + elif condition == "moderate": + method = f"{method} (consider additional pass)" + + result = { + "surface_type": surface_type, + "condition": condition, + "method": method, + "source": "FDAM §5.2", + } + + # Get RAG context for additional detail + if use_rag: + try: + rag_results = self.retriever.retrieve_cleaning_method( + surface_type=surface_type, + condition=condition, + ) + if rag_results: + result["rag_context"] = rag_results[0].text[:300] + result["rag_source"] = rag_results[0].source + except Exception as e: + logger.warning(f"RAG retrieval failed for cleaning method: {e}") + + return result + + def process_vision_results( + self, + vision_results: dict, + room_mapping: dict, + ) -> list[SurfaceDisposition]: + """Process vision analysis results into surface dispositions. + + Args: + vision_results: Dictionary of image_id -> vision result + room_mapping: Dictionary of image_id -> room info + + Returns: + List of SurfaceDisposition for each analyzed surface + """ + dispositions = [] + + for image_id, result in vision_results.items(): + room_info = room_mapping.get(image_id, {}) + room_name = room_info.get("name", "Unknown Room") + + # Extract zone and condition with fallback tracking + zone_data = result.get("zone", {}) + zone = zone_data.get("classification") if zone_data else None + condition_data = result.get("condition", {}) + condition = condition_data.get("level") if condition_data else None + + # Track if fallbacks were used (affects confidence scoring) + fallback_used = False + if zone is None: + zone = "far-field" + fallback_used = True + logger.warning(f"Image {image_id}: Using fallback zone 'far-field'") + if condition is None: + condition = "light" + fallback_used = True + logger.warning(f"Image {image_id}: Using fallback condition 'light'") + + # Flag for confidence scoring + if fallback_used: + result["_fallback_used"] = True + + # Get materials detected + materials = result.get("materials", []) + if not materials: + materials = [{"type": "general-surface", "confidence": 0.8}] + result["_fallback_used"] = True + + for material in materials: + material_type = material.get("type", "unknown") + + # Get disposition + disp_result = self.determine_disposition( + zone=zone, + condition=condition, + surface_type=material_type, + use_rag=True, + ) + + # Get cleaning method + if condition != "background" and disp_result.disposition == "clean": + method_info = self.get_cleaning_method( + surface_type=material_type, + condition=condition, + ) + cleaning_method = method_info["method"] + else: + cleaning_method = disp_result.protocol + + dispositions.append( + SurfaceDisposition( + surface_type=material_type, + room_name=room_name, + zone=zone, + condition=condition, + disposition=disp_result.disposition, + cleaning_method=cleaning_method, + notes=disp_result.notes, + ) + ) + + return dispositions diff --git a/pipeline/generator.py b/pipeline/generator.py new file mode 100644 index 0000000000000000000000000000000000000000..c7a4f370c47259a64c8a78e89adee256f1890e6c --- /dev/null +++ b/pipeline/generator.py @@ -0,0 +1,466 @@ +"""FDAM Document Generator. + +Generates Cleaning Specification / Scope of Work documents +with RAG-enhanced content from the FDAM knowledge base. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from ui.state import SessionState +from rag import FDAMRetriever, ChromaVectorStore +from .calculations import FDAMCalculator, AirFiltrationResult, SampleDensityResult, RegulatoryFlags +from .dispositions import DispositionEngine, SurfaceDisposition + + +@dataclass +class GeneratedDocument: + """A generated assessment document.""" + + markdown: str + title: str + generated_at: str + word_count: int + sections: list[str] + + +class DocumentGenerator: + """Generates FDAM assessment documents with RAG enhancement.""" + + def __init__( + self, + calculator: Optional[FDAMCalculator] = None, + disposition_engine: Optional[DispositionEngine] = None, + retriever: Optional[FDAMRetriever] = None, + ): + """Initialize document generator. + + Args: + calculator: FDAM calculator instance + disposition_engine: Disposition engine instance + retriever: RAG retriever instance + """ + self.calculator = calculator or FDAMCalculator() + self.disposition_engine = disposition_engine or DispositionEngine() + self._retriever = retriever + + @property + def retriever(self) -> FDAMRetriever: + """Get or create RAG retriever.""" + if self._retriever is None: + try: + vs = ChromaVectorStore(persist_directory="chroma_db") + self._retriever = FDAMRetriever(vectorstore=vs) + except Exception: + self._retriever = FDAMRetriever() + return self._retriever + + def generate_sow( + self, + session: SessionState, + vision_results: dict, + surface_dispositions: list[SurfaceDisposition], + calculations: dict, + ) -> GeneratedDocument: + """Generate Scope of Work document. + + Args: + session: Current session state + vision_results: Vision analysis results by image ID + surface_dispositions: List of surface dispositions + calculations: Calculation results from FDAMCalculator + + Returns: + GeneratedDocument with markdown content + """ + sections = [] + + # Header + header = self._generate_header(session) + sections.append(header) + + # Project Information + project_info = self._generate_project_info(session) + sections.append(project_info) + + # Scope Summary + scope_summary = self._generate_scope_summary(session, calculations) + sections.append(scope_summary) + + # Room Inventory + room_inventory = self._generate_room_inventory(session) + sections.append(room_inventory) + + # Vision Analysis Summary + vision_summary = self._generate_vision_summary(session, vision_results) + sections.append(vision_summary) + + # Field Observations + observations = self._generate_observations(session) + sections.append(observations) + + # Disposition Summary + disposition_summary = self._generate_disposition_summary(surface_dispositions) + sections.append(disposition_summary) + + # Cleaning Specifications + cleaning_specs = self._generate_cleaning_specs(surface_dispositions, calculations) + sections.append(cleaning_specs) + + # Air Filtration Requirements + air_filtration = self._generate_air_filtration(calculations) + sections.append(air_filtration) + + # Sampling Plan + sampling_plan = self._generate_sampling_plan(calculations, session) + sections.append(sampling_plan) + + # Regulatory Requirements + regulatory = self._generate_regulatory_section(calculations) + sections.append(regulatory) + + # Clearance Thresholds + thresholds = self._generate_thresholds_section(calculations) + sections.append(thresholds) + + # Disclaimer and Footer + footer = self._generate_footer() + sections.append(footer) + + # Combine all sections + markdown = "\n\n---\n\n".join(sections) + + return GeneratedDocument( + markdown=markdown, + title=f"SOW - {session.project.project_name}", + generated_at=datetime.now().isoformat(), + word_count=len(markdown.split()), + sections=[ + "Header", "Project Info", "Scope Summary", "Room Inventory", + "Vision Analysis", "Observations", "Dispositions", + "Cleaning Specs", "Air Filtration", "Sampling Plan", + "Regulatory", "Thresholds", "Footer" + ], + ) + + def _generate_header(self, session: SessionState) -> str: + """Generate document header.""" + return f"""# Cleaning Specification / Scope of Work + +**Project:** {session.project.project_name} +**Prepared For:** {session.project.client_name} +**Date:** {datetime.now().strftime('%B %d, %Y')} +**Document Version:** FDAM v4.0.1""" + + def _generate_project_info(self, session: SessionState) -> str: + """Generate project information section.""" + p = session.project + return f"""## Project Information + +| Field | Value | +|-------|-------| +| **Project Name** | {p.project_name} | +| **Address** | {p.address}, {p.city}, {p.state} {p.zip_code} | +| **Client** | {p.client_name} | +| **Fire Date** | {p.fire_date} | +| **Assessment Date** | {p.assessment_date} | +| **Facility Classification** | {p.facility_classification or 'Not specified'} | +| **Construction Era** | {p.construction_era or 'Not specified'} | +| **Assessor** | {p.assessor_name} {p.assessor_credentials or ''} |""" + + def _generate_scope_summary(self, session: SessionState, calculations: dict) -> str: + """Generate scope summary section.""" + air = calculations.get("air_filtration") + sample = calculations.get("sample_density") + + return f"""## Scope Summary + +| Metric | Value | +|--------|-------| +| **Total Rooms/Areas** | {len(session.rooms)} | +| **Total Floor Area** | {calculations['total_area_sf']:,.0f} SF | +| **Total Volume** | {calculations['total_volume_cf']:,.0f} CF | +| **Images Analyzed** | {len(session.images)} | +| **Air Scrubbers Required** | {air.units_required if air else 'N/A'} units | +| **Est. Tape Lifts** | {sample.tape_lifts_min}-{sample.tape_lifts_max if sample else 'N/A'} | +| **Est. Surface Wipes** | {sample.surface_wipes_min}-{sample.surface_wipes_max if sample else 'N/A'} |""" + + def _generate_room_inventory(self, session: SessionState) -> str: + """Generate room inventory table.""" + lines = ["## Room Inventory", ""] + lines.append("| Room/Area | Dimensions | Area (SF) | Volume (CF) |") + lines.append("|-----------|------------|-----------|-------------|") + + for room in session.rooms: + area = room.length_ft * room.width_ft + volume = area * room.ceiling_height_ft + lines.append( + f"| {room.name} | {room.length_ft:.0f}' × {room.width_ft:.0f}' × " + f"{room.ceiling_height_ft:.0f}' | {area:,.0f} | {volume:,.0f} |" + ) + + return "\n".join(lines) + + def _generate_vision_summary(self, session: SessionState, vision_results: dict) -> str: + """Generate AI vision analysis summary.""" + lines = ["## AI Vision Analysis Summary", ""] + + if not vision_results: + lines.append("*No images analyzed.*") + return "\n".join(lines) + + lines.append("| Image | Zone | Condition | Confidence |") + lines.append("|-------|------|-----------|------------|") + + for img_meta in session.images: + result = vision_results.get(img_meta.id, {}) + zone = result.get("zone", {}) + condition = result.get("condition", {}) + + zone_class = zone.get("classification", "N/A") + zone_conf = zone.get("confidence", 0) + cond_level = condition.get("level", "N/A") + cond_conf = condition.get("confidence", 0) + + lines.append( + f"| {img_meta.filename} | {zone_class} ({zone_conf:.0%}) | " + f"{cond_level} ({cond_conf:.0%}) | {(zone_conf + cond_conf) / 2:.0%} |" + ) + + return "\n".join(lines) + + def _generate_observations(self, session: SessionState) -> str: + """Generate field observations section.""" + obs = session.observations + lines = ["## Field Observations", ""] + + items = [] + if obs.smoke_fire_odor: + items.append(f"- **Smoke/Fire Odor:** {obs.odor_intensity or 'Present'}") + if obs.visible_soot_deposits: + items.append(f"- **Visible Soot:** {obs.soot_pattern_description or 'Present'}") + if obs.large_char_particles: + items.append(f"- **Char Particles:** {obs.char_density_estimate or 'Present'}") + if obs.ash_like_residue: + items.append(f"- **Ash Residue:** {obs.ash_color_texture or 'Present'}") + if obs.surface_discoloration: + items.append(f"- **Discoloration:** {obs.discoloration_description or 'Present'}") + if obs.wildfire_indicators: + items.append(f"- **Wildfire Indicators:** {obs.wildfire_notes or 'Present'}") + if obs.dust_loading_interference: + items.append(f"- **Dust/Debris:** {obs.dust_notes or 'Present'}") + if obs.additional_notes: + items.append(f"- **Additional Notes:** {obs.additional_notes}") + + if items: + lines.extend(items) + else: + lines.append("*No significant observations noted.*") + + return "\n".join(lines) + + def _generate_disposition_summary(self, dispositions: list[SurfaceDisposition]) -> str: + """Generate disposition summary table.""" + lines = ["## Disposition Summary", ""] + + if not dispositions: + lines.append("*No dispositions determined.*") + return "\n".join(lines) + + lines.append("| Room | Surface | Zone | Condition | Disposition |") + lines.append("|------|---------|------|-----------|-------------|") + + for disp in dispositions: + lines.append( + f"| {disp.room_name} | {disp.surface_type} | {disp.zone} | " + f"{disp.condition} | {disp.disposition.upper()} |" + ) + + return "\n".join(lines) + + def _generate_cleaning_specs( + self, + dispositions: list[SurfaceDisposition], + calculations: dict, + ) -> str: + """Generate cleaning specifications section.""" + lines = ["## Cleaning Specifications", ""] + + # Group by disposition + by_disposition = {} + for disp in dispositions: + key = disp.disposition + if key not in by_disposition: + by_disposition[key] = [] + by_disposition[key].append(disp) + + for disposition, items in by_disposition.items(): + lines.append(f"### {disposition.upper().replace('-', ' ')} Surfaces") + lines.append("") + + for item in items: + lines.append(f"**{item.room_name} - {item.surface_type}:**") + lines.append(f"- Method: {item.cleaning_method}") + if item.notes: + lines.append(f"- Notes: {'; '.join(item.notes)}") + lines.append("") + + return "\n".join(lines) + + def _generate_air_filtration(self, calculations: dict) -> str: + """Generate air filtration requirements section.""" + air: AirFiltrationResult = calculations.get("air_filtration") + + if not air: + return "## Air Filtration Requirements\n\n*Calculation unavailable.*" + + return f"""## Air Filtration Requirements + +Per NADCA ACR 2021, Section 3.6: + +| Parameter | Value | +|-----------|-------| +| **Required ACH** | {air.required_ach} air changes per hour | +| **Total Volume** | {air.total_volume_cf:,.0f} CF | +| **Unit Capacity** | {air.unit_cfm:,} CFM | +| **Units Required** | {air.units_required} | + +**Calculation:** {air.calculation_notes} + +**Placement Notes:** +- Distribute units evenly throughout work area +- Ensure adequate negative air pressure +- Exhaust to exterior when possible""" + + def _generate_sampling_plan(self, calculations: dict, session: SessionState) -> str: + """Generate sampling plan section.""" + sample: SampleDensityResult = calculations.get("sample_density") + + if not sample: + return "## Sampling Plan\n\n*Calculation unavailable.*" + + lines = ["## Sampling Plan", ""] + lines.append("### Pre-Cleaning Characterization") + lines.append("") + lines.append("| Sample Type | Quantity | Notes |") + lines.append("|-------------|----------|-------|") + lines.append( + f"| Tape Lifts | {sample.tape_lifts_min}-{sample.tape_lifts_max} | " + "Per surface type, per room" + ) + lines.append( + f"| Surface Wipes | {sample.surface_wipes_min}-{sample.surface_wipes_max} | " + "Metals analysis" + ) + if sample.ceiling_deck_samples > 0: + lines.append( + f"| Ceiling Deck | {sample.ceiling_deck_samples} | " + "Enhanced per FDAM §4.5" + ) + lines.append("") + + if sample.notes: + lines.append("**Notes:**") + for note in sample.notes: + lines.append(f"- {note}") + lines.append("") + + lines.append("### Post-Cleaning Verification (PRV)") + lines.append("") + lines.append("PRV sampling locations should mirror pre-cleaning characterization.") + lines.append("Minimum 50% of original sample locations for initial clearance attempt.") + + return "\n".join(lines) + + def _generate_regulatory_section(self, calculations: dict) -> str: + """Generate regulatory requirements section.""" + flags: RegulatoryFlags = calculations.get("regulatory_flags") + + lines = ["## Regulatory Requirements", ""] + + if not flags or not flags.notes: + lines.append("*No specific regulatory flags identified.*") + return "\n".join(lines) + + for note in flags.notes: + lines.append(f"- {note}") + + if flags.lbp_survey_required: + lines.append("") + lines.append( + "**Lead-Based Paint:** Per 29 CFR 1926.62, LBP survey must be completed " + "prior to disturbance of painted surfaces in pre-1978 construction." + ) + + if flags.acm_survey_required or flags.acm_survey_recommended: + lines.append("") + action = "required" if flags.acm_survey_required else "recommended" + lines.append( + f"**Asbestos:** ACM survey {action} per NESHAP regulations. " + "No disturbance of suspect materials until survey complete." + ) + + return "\n".join(lines) + + def _generate_thresholds_section(self, calculations: dict) -> str: + """Generate clearance thresholds section.""" + thresholds = calculations.get("metals_thresholds") + particulates = calculations.get("particulate_thresholds", {}) + + lines = ["## Clearance Thresholds", ""] + lines.append(f"**Facility Type:** {thresholds.facility_type if thresholds else 'N/A'}") + lines.append("") + + if thresholds: + lines.append("### Metals (Surface Wipe)") + lines.append("") + lines.append("| Metal | Threshold | Unit |") + lines.append("|-------|-----------|------|") + lines.append(f"| Lead (Pb) | {thresholds.lead_ug_100cm2} | µg/100cm² |") + lines.append(f"| Cadmium (Cd) | {thresholds.cadmium_ug_100cm2} | µg/100cm² |") + lines.append(f"| Arsenic (As) | {thresholds.arsenic_ug_100cm2} | µg/100cm² |") + lines.append(f"| Chromium VI | {thresholds.chromium_vi_ug_100cm2} | µg/100cm² |") + lines.append(f"| Beryllium (Be) | {thresholds.beryllium_ug_100cm2} | µg/100cm² |") + lines.append("") + lines.append(f"*Source: {thresholds.source}*") + lines.append("") + + if particulates: + lines.append("### Particulates (Tape Lift)") + lines.append("") + lines.append("| Particle Type | Threshold | Unit |") + lines.append("|---------------|-----------|------|") + ash_char = particulates.get("ash_char", {}) + soot = particulates.get("aciniform_soot", {}) + lines.append( + f"| Ash/Char | <{ash_char.get('clearance', 150)} | " + f"{ash_char.get('unit', 'cts/cm²')} |" + ) + lines.append( + f"| Aciniform Soot | <{soot.get('clearance', 500)} | " + f"{soot.get('unit', 'cts/cm²')} |" + ) + lines.append("") + lines.append(f"*Source: {ash_char.get('source', 'FDAM §1.5')}*") + + return "\n".join(lines) + + def _generate_footer(self) -> str: + """Generate document footer with disclaimer.""" + return f"""## Disclaimer + +This document was generated using AI-assisted analysis per the Fire Damage Assessment +Methodology (FDAM) v4.0.1. All recommendations should be reviewed by a qualified +industrial hygienist before implementation. + +**Important Notes:** +- Visual assessments require laboratory confirmation for definitive particle identification +- Threshold values are subject to regulatory updates +- Site-specific conditions may require deviation from standard protocols +- Reclean/retest procedures apply per FDAM §4.7 if clearance is not achieved + +--- + +*Generated by FDAM AI Pipeline v4.0.1* +*{datetime.now().strftime('%Y-%m-%d %H:%M')}*""" diff --git a/pipeline/main.py b/pipeline/main.py new file mode 100644 index 0000000000000000000000000000000000000000..6874856a7fb1c15e17f838eae73a6096636bb738 --- /dev/null +++ b/pipeline/main.py @@ -0,0 +1,334 @@ +"""FDAM Pipeline Orchestrator. + +Coordinates the 6-stage processing pipeline: +1. Input Validation +2. Vision Analysis +3. RAG Retrieval +4. FDAM Logic (Dispositions) +5. Calculations +6. Document Generation +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Callable, Optional +from PIL import Image +import io + +from ui.state import SessionState +from ui.components import image_store +from models.loader import get_models + +logger = logging.getLogger(__name__) +from rag import FDAMRetriever, ChromaVectorStore + +from .calculations import FDAMCalculator +from .dispositions import DispositionEngine, SurfaceDisposition +from .generator import DocumentGenerator, GeneratedDocument + + +@dataclass +class PipelineProgress: + """Progress information for pipeline execution.""" + + stage: int + total_stages: int + stage_name: str + percent: float + message: str + + +@dataclass +class VisionResult: + """Result from vision analysis of a single image.""" + + image_id: str + filename: str + room_id: str + zone: dict + condition: dict + materials: list[dict] + bounding_boxes: list[dict] + raw_response: dict + + +@dataclass +class PipelineResult: + """Complete result from pipeline execution.""" + + success: bool + session: SessionState + vision_results: dict[str, VisionResult] + dispositions: list[SurfaceDisposition] + calculations: dict + document: Optional[GeneratedDocument] + annotated_images: list[tuple] # (PIL.Image, caption) + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + execution_time_seconds: float = 0.0 + + +ProgressCallback = Callable[[PipelineProgress], None] + + +class FDAMPipeline: + """Main FDAM processing pipeline.""" + + STAGES = [ + "Validating inputs", + "Analyzing images", + "Retrieving context", + "Applying FDAM logic", + "Running calculations", + "Generating documents", + ] + + def __init__( + self, + calculator: Optional[FDAMCalculator] = None, + disposition_engine: Optional[DispositionEngine] = None, + generator: Optional[DocumentGenerator] = None, + retriever: Optional[FDAMRetriever] = None, + ): + """Initialize pipeline with optional component overrides. + + Args: + calculator: FDAM calculator instance + disposition_engine: Disposition engine instance + generator: Document generator instance + retriever: RAG retriever instance + """ + self.calculator = calculator or FDAMCalculator() + self._retriever = retriever + self.disposition_engine = disposition_engine or DispositionEngine( + retriever=self._retriever + ) + self.generator = generator or DocumentGenerator( + calculator=self.calculator, + disposition_engine=self.disposition_engine, + retriever=self._retriever, + ) + + @property + def retriever(self) -> FDAMRetriever: + """Get or create RAG retriever.""" + if self._retriever is None: + try: + vs = ChromaVectorStore(persist_directory="chroma_db") + self._retriever = FDAMRetriever(vectorstore=vs) + except Exception as e: + logger.warning(f"ChromaDB init failed, using fallback retriever: {e}") + self._retriever = FDAMRetriever() + return self._retriever + + def execute( + self, + session: SessionState, + progress_callback: Optional[ProgressCallback] = None, + ) -> PipelineResult: + """Execute the full FDAM pipeline. + + Args: + session: Session state with all input data + progress_callback: Optional callback for progress updates + + Returns: + PipelineResult with all outputs + """ + start_time = datetime.now() + errors = [] + warnings = [] + + def report_progress(stage: int, message: str = ""): + if progress_callback: + progress_callback( + PipelineProgress( + stage=stage, + total_stages=len(self.STAGES), + stage_name=self.STAGES[stage - 1] if stage > 0 else "Starting", + percent=stage / len(self.STAGES), + message=message, + ) + ) + + # Stage 1: Input Validation + report_progress(1, "Validating inputs...") + can_generate, validation_errors = session.can_generate() + + # Check images in store + expected_ids = [img.id for img in session.images] + missing_ids = image_store.get_missing_ids(expected_ids) + + if not can_generate or missing_ids: + errors.extend(validation_errors) + if missing_ids: + errors.append(f"{len(missing_ids)} image(s) need to be re-uploaded") + + return PipelineResult( + success=False, + session=session, + vision_results={}, + dispositions=[], + calculations={}, + document=None, + annotated_images=[], + errors=errors, + execution_time_seconds=(datetime.now() - start_time).total_seconds(), + ) + + # Stage 2: Vision Analysis + report_progress(2, "Analyzing images with AI...") + model_stack = get_models() + vision_results = {} + annotated_images = [] + room_mapping = {} + + for i, img_meta in enumerate(session.images): + img_bytes = image_store.get(img_meta.id) + if not img_bytes: + warnings.append(f"Image {img_meta.filename} not found in store") + continue + + try: + pil_image = Image.open(io.BytesIO(img_bytes)) + + # Run vision analysis + result = model_stack.vision.analyze_image( + pil_image, + img_meta.description or "", + ) + + vision_result = VisionResult( + image_id=img_meta.id, + filename=img_meta.filename, + room_id=img_meta.room_id, + zone=result.get("zone", {}), + condition=result.get("condition", {}), + materials=result.get("materials", []), + bounding_boxes=result.get("bounding_boxes", []), + raw_response=result, + ) + vision_results[img_meta.id] = vision_result + + # Build room mapping + room_info = next( + (r for r in session.rooms if r.id == img_meta.room_id), + None, + ) + room_mapping[img_meta.id] = { + "name": room_info.name if room_info else "Unknown", + "id": img_meta.room_id, + } + + # Create annotated image caption + zone_class = result.get("zone", {}).get("classification", "N/A") + zone_conf = result.get("zone", {}).get("confidence", 0) + caption = f"{img_meta.filename}\nZone: {zone_class} ({zone_conf:.0%})" + annotated_images.append((pil_image, caption)) + + report_progress( + 2, + f"Analyzed {i + 1}/{len(session.images)}: {img_meta.filename}", + ) + + except Exception as e: + warnings.append(f"Error analyzing {img_meta.filename}: {e}") + + # Stage 3: RAG Retrieval + report_progress(3, "Retrieving FDAM methodology context...") + # RAG is integrated into disposition engine, just verify connection + try: + _ = self.retriever.retrieve("test connection", top_k=1) + except Exception as e: + warnings.append(f"RAG retrieval unavailable: {e}") + + # Stage 4: FDAM Logic (Dispositions) + report_progress(4, "Applying disposition logic...") + + # Convert vision results to dict format for disposition engine + vision_dict = { + img_id: { + "zone": vr.zone, + "condition": vr.condition, + "materials": vr.materials, + } + for img_id, vr in vision_results.items() + } + + dispositions = self.disposition_engine.process_vision_results( + vision_results=vision_dict, + room_mapping=room_mapping, + ) + + # Stage 5: Calculations + report_progress(5, "Running FDAM calculations...") + calculations = self.calculator.calculate_from_session(session) + + # Stage 6: Document Generation + report_progress(6, "Generating documents...") + document = self.generator.generate_sow( + session=session, + vision_results=vision_dict, + surface_dispositions=dispositions, + calculations=calculations, + ) + + # Update session + session.has_results = True + session.results_generated_at = datetime.now().isoformat() + session.update_timestamp() + + execution_time = (datetime.now() - start_time).total_seconds() + + return PipelineResult( + success=True, + session=session, + vision_results=vision_results, + dispositions=dispositions, + calculations=calculations, + document=document, + annotated_images=annotated_images, + errors=errors, + warnings=warnings, + execution_time_seconds=execution_time, + ) + + def generate_stats_dict(self, result: PipelineResult) -> dict: + """Generate statistics dictionary for UI display. + + Args: + result: Pipeline execution result + + Returns: + Dictionary with stats for JSON display + """ + calc = result.calculations + air = calc.get("air_filtration") + sample = calc.get("sample_density") + reg = calc.get("regulatory_flags") + thresholds = calc.get("metals_thresholds") + + # Count dispositions by type + disp_counts = {} + for d in result.dispositions: + disp_counts[d.disposition] = disp_counts.get(d.disposition, 0) + 1 + + return { + "project_name": result.session.project.project_name, + "facility_classification": result.session.project.facility_classification, + "construction_era": result.session.project.construction_era, + "total_rooms": len(result.session.rooms), + "total_images": len(result.session.images), + "images_analyzed": len(result.vision_results), + "total_floor_area_sf": f"{calc.get('total_area_sf', 0):,.0f}", + "total_volume_cf": f"{calc.get('total_volume_cf', 0):,.0f}", + "air_scrubbers_required": air.units_required if air else 0, + "tape_lifts_recommended": f"{sample.tape_lifts_min}-{sample.tape_lifts_max}" if sample else "N/A", + "surface_wipes_recommended": f"{sample.surface_wipes_min}-{sample.surface_wipes_max}" if sample else "N/A", + "disposition_counts": disp_counts, + "regulatory_flags": reg.notes if reg else [], + "lead_threshold": f"{thresholds.lead_ug_100cm2} µg/100cm²" if thresholds else "N/A", + "execution_time": f"{result.execution_time_seconds:.1f}s", + "warnings": result.warnings, + } diff --git a/pipeline/pdf_generator.py b/pipeline/pdf_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..da45db5c35f53a793bb4ccd2959c222cbd73bbfb --- /dev/null +++ b/pipeline/pdf_generator.py @@ -0,0 +1,315 @@ +"""PDF Generator using WeasyPrint. + +Converts Markdown SOW documents to professional PDF format. +Uses markdown → HTML → PDF pipeline with WeasyPrint. +""" + +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Optional +import markdown + + +@dataclass +class PDFResult: + """Result of PDF generation.""" + + success: bool + pdf_path: Optional[str] + error_message: Optional[str] = None + file_size_bytes: int = 0 + + +# Professional CSS styling for SOW documents +SOW_CSS = """ +@page { + size: letter; + margin: 0.75in; + @top-center { + content: "FDAM Assessment Report"; + font-size: 9pt; + color: #666; + } + @bottom-center { + content: "Page " counter(page) " of " counter(pages); + font-size: 9pt; + color: #666; + } +} + +body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 11pt; + line-height: 1.5; + color: #333; +} + +h1 { + font-size: 20pt; + color: #1a1a1a; + border-bottom: 2px solid #0066cc; + padding-bottom: 8px; + margin-top: 0; +} + +h2 { + font-size: 14pt; + color: #0066cc; + margin-top: 20px; + border-bottom: 1px solid #ddd; + padding-bottom: 4px; +} + +h3 { + font-size: 12pt; + color: #333; + margin-top: 15px; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; + font-size: 10pt; +} + +th { + background-color: #0066cc; + color: white; + padding: 8px 10px; + text-align: left; + font-weight: bold; +} + +td { + padding: 6px 10px; + border-bottom: 1px solid #ddd; +} + +tr:nth-child(even) { + background-color: #f8f9fa; +} + +tr:hover { + background-color: #e9ecef; +} + +ul, ol { + margin: 10px 0; + padding-left: 25px; +} + +li { + margin: 4px 0; +} + +strong { + color: #1a1a1a; +} + +code { + background-color: #f4f4f4; + padding: 2px 5px; + border-radius: 3px; + font-size: 10pt; +} + +hr { + border: none; + border-top: 1px solid #ddd; + margin: 20px 0; +} + +.disclaimer { + background-color: #fff3cd; + border: 1px solid #ffc107; + padding: 12px; + border-radius: 4px; + font-size: 10pt; + margin-top: 20px; +} + +em { + color: #666; +} +""" + + +class PDFGenerator: + """Generates PDF documents from Markdown using WeasyPrint.""" + + def __init__(self, custom_css: Optional[str] = None): + """Initialize PDF generator. + + Args: + custom_css: Optional custom CSS to override default styling + """ + self.css = custom_css or SOW_CSS + self._weasyprint_available = None + + @property + def weasyprint_available(self) -> bool: + """Check if WeasyPrint is available.""" + if self._weasyprint_available is None: + try: + from weasyprint import HTML + self._weasyprint_available = True + except ImportError: + self._weasyprint_available = False + return self._weasyprint_available + + def markdown_to_html(self, markdown_content: str) -> str: + """Convert Markdown to HTML with styling. + + Args: + markdown_content: Markdown text + + Returns: + Complete HTML document with CSS + """ + # Convert markdown to HTML + md = markdown.Markdown( + extensions=[ + "tables", + "fenced_code", + "toc", + ] + ) + html_body = md.convert(markdown_content) + + # Wrap in complete HTML document with CSS + html = f""" + +
+ + + + +{html_body} + +""" + return html + + def generate_pdf( + self, + markdown_content: str, + output_path: Optional[str] = None, + ) -> PDFResult: + """Generate PDF from Markdown content. + + Args: + markdown_content: Markdown text to convert + output_path: Optional output file path. If None, uses temp file. + + Returns: + PDFResult with success status and file path + """ + if not self.weasyprint_available: + return PDFResult( + success=False, + pdf_path=None, + error_message="WeasyPrint is not installed. Run: pip install weasyprint", + ) + + try: + from weasyprint import HTML + + # Convert markdown to styled HTML + html_content = self.markdown_to_html(markdown_content) + + # Determine output path + if output_path is None: + output_file = tempfile.NamedTemporaryFile( + suffix=".pdf", + delete=False, + prefix="SOW_", + ) + output_path = output_file.name + output_file.close() + + # Generate PDF + HTML(string=html_content).write_pdf(output_path) + + # Verify file was created + pdf_path = Path(output_path) + if not pdf_path.exists(): + return PDFResult( + success=False, + pdf_path=None, + error_message="PDF file was not created", + ) + + return PDFResult( + success=True, + pdf_path=str(pdf_path), + file_size_bytes=pdf_path.stat().st_size, + ) + + except Exception as e: + return PDFResult( + success=False, + pdf_path=None, + error_message=f"PDF generation failed: {str(e)}", + ) + + def generate_html( + self, + markdown_content: str, + output_path: Optional[str] = None, + ) -> tuple[bool, Optional[str], Optional[str]]: + """Generate HTML from Markdown (fallback if PDF fails). + + Args: + markdown_content: Markdown text + output_path: Optional output path + + Returns: + Tuple of (success, file_path, error_message) + """ + try: + html_content = self.markdown_to_html(markdown_content) + + if output_path is None: + output_file = tempfile.NamedTemporaryFile( + mode="w", + suffix=".html", + delete=False, + prefix="SOW_", + encoding="utf-8", + ) + output_path = output_file.name + output_file.write(html_content) + output_file.close() + else: + with open(output_path, "w", encoding="utf-8") as f: + f.write(html_content) + + return True, output_path, None + + except Exception as e: + return False, None, str(e) + + +def generate_sow_pdf( + markdown_content: str, + project_name: str, + output_path: Optional[str] = None, +) -> PDFResult: + """Convenience function to generate SOW PDF. + + Args: + markdown_content: SOW markdown content + project_name: Project name for filename + output_path: Optional output path + + Returns: + PDFResult with success status + """ + generator = PDFGenerator() + return generator.generate_pdf( + markdown_content=markdown_content, + output_path=output_path, + ) diff --git a/rag/__init__.py b/rag/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b98bc6947c7d4a39f8f1674b5db38df7ceda17d9 --- /dev/null +++ b/rag/__init__.py @@ -0,0 +1,16 @@ +"""RAG (Retrieval Augmented Generation) module for FDAM AI Pipeline. + +This module provides document chunking, vector storage, and retrieval +for the FDAM knowledge base. +""" + +from .chunker import SemanticChunker, Chunk +from .vectorstore import ChromaVectorStore +from .retriever import FDAMRetriever + +__all__ = [ + "SemanticChunker", + "Chunk", + "ChromaVectorStore", + "FDAMRetriever", +] diff --git a/rag/chunker.py b/rag/chunker.py new file mode 100644 index 0000000000000000000000000000000000000000..ebe98805805429a28c9b9202173fa4f1fa39cc61 --- /dev/null +++ b/rag/chunker.py @@ -0,0 +1,432 @@ +"""Semantic chunker with table preservation for FDAM knowledge base. + +Chunking rules: +- Keep markdown tables intact (never split) +- Preserve headers with content for context +- Target 400-600 tokens per chunk +- Include metadata (source, category, section, priority) +""" + +import re +from dataclasses import dataclass, field +from typing import Literal +from pathlib import Path + + +@dataclass +class Chunk: + """A chunk of text with metadata for RAG indexing.""" + + id: str + text: str + source: str # Filename + category: Literal[ + "methodology", + "thresholds", + "lab-methods", + "cleaning-procedures", + "wildfire", + "safety", + ] + section: str # Section header path (e.g., "4.1 Zone Classification") + priority: Literal["primary", "reference-threshold", "reference-narrative"] + content_type: Literal["narrative", "table", "list", "mixed"] + keywords: list[str] = field(default_factory=list) + + def to_metadata(self) -> dict: + """Convert to metadata dict for ChromaDB.""" + return { + "source": self.source, + "category": self.category, + "section": self.section, + "priority": self.priority, + "content_type": self.content_type, + "keywords": ",".join(self.keywords), + } + + +class SemanticChunker: + """Chunks markdown documents while preserving tables and semantic structure.""" + + # Approximate tokens per character (conservative estimate) + CHARS_PER_TOKEN = 4 + TARGET_MIN_TOKENS = 400 + TARGET_MAX_TOKENS = 600 + + def __init__(self): + self.target_min_chars = self.TARGET_MIN_TOKENS * self.CHARS_PER_TOKEN + self.target_max_chars = self.TARGET_MAX_TOKENS * self.CHARS_PER_TOKEN + + def chunk_document( + self, + text: str, + source: str, + category: Literal[ + "methodology", + "thresholds", + "lab-methods", + "cleaning-procedures", + "wildfire", + "safety", + ], + priority: Literal["primary", "reference-threshold", "reference-narrative"], + ) -> list[Chunk]: + """Chunk a markdown document into semantic units. + + Args: + text: Full document text (markdown format) + source: Source filename + category: Document category + priority: Document priority level + + Returns: + List of Chunk objects ready for indexing + """ + # Split into sections by headers + sections = self._split_by_headers(text) + + chunks = [] + chunk_counter = 0 + + # Accumulator that persists across sections + current_chunk_text = "" + current_content_types: set[str] = set() + current_section = "Introduction" # Track primary section for metadata + + for section_header, section_content in sections: + # Split section into blocks (paragraphs, tables, lists) + blocks = self._split_into_blocks(section_content) + + for block_text, block_type in blocks: + block_len = len(block_text) + + # Tables are never split - flush current and add table as own chunk + if block_type == "table": + # Flush current chunk if it meets minimum size + if current_chunk_text.strip() and len(current_chunk_text) >= self.target_min_chars: + chunks.append( + self._create_chunk( + chunk_id=f"{source}_{chunk_counter}", + text=current_chunk_text.strip(), + source=source, + category=category, + section=current_section, + priority=priority, + content_types=current_content_types, + ) + ) + chunk_counter += 1 + current_chunk_text = "" + current_content_types = set() + current_section = section_header + elif current_chunk_text.strip(): + # Below minimum - prepend to table context + pass # Keep accumulating, table will have its own chunk + + # Add table as its own chunk (tables always standalone) + table_text = f"{section_header}\n\n{block_text}".strip() + # If we have small accumulated content, prepend it to give context + if current_chunk_text.strip() and len(current_chunk_text) < self.target_min_chars: + table_text = current_chunk_text.strip() + "\n\n" + table_text + current_chunk_text = "" + current_content_types = set() + + chunks.append( + self._create_chunk( + chunk_id=f"{source}_{chunk_counter}", + text=table_text, + source=source, + category=category, + section=section_header, + priority=priority, + content_types={"table"}, + ) + ) + chunk_counter += 1 + current_section = section_header + continue + + # Check if adding this block exceeds target max + potential_len = len(current_chunk_text) + block_len + len(section_header) + 4 + + if potential_len > self.target_max_chars and len(current_chunk_text) >= self.target_min_chars: + # Flush current chunk - it's large enough + chunks.append( + self._create_chunk( + chunk_id=f"{source}_{chunk_counter}", + text=current_chunk_text.strip(), + source=source, + category=category, + section=current_section, + priority=priority, + content_types=current_content_types, + ) + ) + chunk_counter += 1 + # Start new chunk with section header + current_chunk_text = f"{section_header}\n\n" + current_content_types = set() + current_section = section_header + + # Add section header if starting fresh or new section + if not current_chunk_text.strip(): + current_chunk_text = f"{section_header}\n\n" + current_section = section_header + elif section_header != current_section and section_header not in current_chunk_text: + # Add new section header inline for context + current_chunk_text += f"\n{section_header}\n\n" + + current_chunk_text += block_text + "\n\n" + current_content_types.add(block_type) + + # Flush remaining content (regardless of size - it's the end) + if current_chunk_text.strip(): + chunks.append( + self._create_chunk( + chunk_id=f"{source}_{chunk_counter}", + text=current_chunk_text.strip(), + source=source, + category=category, + section=current_section, + priority=priority, + content_types=current_content_types, + ) + ) + + return chunks + + def _split_by_headers(self, text: str) -> list[tuple[str, str]]: + """Split document by markdown headers (## and ###). + + Returns list of (header, content) tuples. + """ + # Match ## or ### headers + header_pattern = r"^(#{2,3}\s+.+)$" + lines = text.split("\n") + + sections = [] + current_header = "Introduction" + current_content = [] + + for line in lines: + if re.match(header_pattern, line): + # Save previous section + if current_content: + sections.append((current_header, "\n".join(current_content))) + current_header = line.strip() + current_content = [] + else: + current_content.append(line) + + # Save final section + if current_content: + sections.append((current_header, "\n".join(current_content))) + + return sections + + def _split_into_blocks(self, text: str) -> list[tuple[str, str]]: + """Split section content into blocks (paragraphs, tables, lists). + + Returns list of (block_text, block_type) tuples. + """ + blocks = [] + lines = text.split("\n") + current_block = [] + current_type = "narrative" + in_table = False + + for line in lines: + # Detect table start/end + if line.strip().startswith("|") and "|" in line[1:]: + if not in_table: + # Flush current block + if current_block: + block_text = "\n".join(current_block).strip() + if block_text: + blocks.append((block_text, current_type)) + current_block = [] + in_table = True + current_type = "table" + current_block.append(line) + elif in_table: + # Table ended + block_text = "\n".join(current_block).strip() + if block_text: + blocks.append((block_text, "table")) + current_block = [line] if line.strip() else [] + in_table = False + current_type = "narrative" + elif line.strip().startswith(("- ", "* ", "1. ", "2. ", "3. ")): + # List item + if current_type != "list" and current_block: + block_text = "\n".join(current_block).strip() + if block_text: + blocks.append((block_text, current_type)) + current_block = [] + current_type = "list" + current_block.append(line) + elif line.strip() == "" and current_block: + # Paragraph break + if not in_table: + block_text = "\n".join(current_block).strip() + if block_text: + blocks.append((block_text, current_type)) + current_block = [] + current_type = "narrative" + else: + if current_type == "list" and not line.strip().startswith( + ("- ", "* ", " ") + ): + # End of list + block_text = "\n".join(current_block).strip() + if block_text: + blocks.append((block_text, "list")) + current_block = [] + current_type = "narrative" + current_block.append(line) + + # Flush remaining + if current_block: + block_text = "\n".join(current_block).strip() + if block_text: + blocks.append((block_text, current_type)) + + return blocks + + def _create_chunk( + self, + chunk_id: str, + text: str, + source: str, + category: str, + section: str, + priority: str, + content_types: set[str], + ) -> Chunk: + """Create a Chunk object with extracted keywords.""" + # Determine primary content type + if "table" in content_types: + content_type = "table" + elif "list" in content_types and "narrative" in content_types: + content_type = "mixed" + elif "list" in content_types: + content_type = "list" + else: + content_type = "narrative" + + # Extract keywords from text + keywords = self._extract_keywords(text) + + return Chunk( + id=chunk_id, + text=text, + source=source, + category=category, + section=section, + priority=priority, + content_type=content_type, + keywords=keywords, + ) + + def _extract_keywords(self, text: str) -> list[str]: + """Extract relevant keywords from chunk text.""" + # Domain-specific keywords to look for + domain_terms = [ + # Zone classifications + "burn zone", + "near-field", + "far-field", + # Condition levels + "background", + "light", + "moderate", + "heavy", + "structural damage", + # Dispositions + "no action", + "clean", + "evaluate", + "remove", + "remove/repair", + # Materials + "soot", + "char", + "ash", + "particulate", + "aciniform", + # Thresholds + "lead", + "cadmium", + "arsenic", + "metals", + "µg/100cm²", + "cts/cm²", + # Facility types + "operational", + "non-operational", + "public", + "childcare", + # Standards + "ach", + "nadca", + "epa", + "hud", + "osha", + # Sampling + "sampling", + "wipe", + "bulk", + "air", + "clearance", + # Lab methods + "plm", + "icp-ms", + "xrf", + "tapelift", + # Actions + "hepa", + "vacuum", + "deodorization", + "encapsulation", + ] + + text_lower = text.lower() + found_keywords = [] + + for term in domain_terms: + if term in text_lower: + found_keywords.append(term) + + return found_keywords[:10] # Limit to top 10 + + +def chunk_file( + filepath: Path, + category: Literal[ + "methodology", + "thresholds", + "lab-methods", + "cleaning-procedures", + "wildfire", + "safety", + ], + priority: Literal["primary", "reference-threshold", "reference-narrative"], +) -> list[Chunk]: + """Convenience function to chunk a markdown file. + + Args: + filepath: Path to markdown file + category: Document category + priority: Document priority level + + Returns: + List of Chunk objects + """ + chunker = SemanticChunker() + text = filepath.read_text(encoding="utf-8") + return chunker.chunk_document( + text=text, + source=filepath.name, + category=category, + priority=priority, + ) diff --git a/rag/index_builder.py b/rag/index_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..4c778a11d6ed1711f8c9e2b8d6caba9666473071 --- /dev/null +++ b/rag/index_builder.py @@ -0,0 +1,187 @@ +"""Index builder for FDAM RAG knowledge base. + +Processes markdown documents from RAG-KB/ and indexes them in ChromaDB. + +Usage: + python -m rag.index_builder [--rebuild] +""" + +import argparse +from pathlib import Path + +from rag.chunker import SemanticChunker, Chunk +from rag.vectorstore import ChromaVectorStore + + +# Document configuration: filename -> (category, priority) +DOCUMENT_CONFIG = { + # PRIMARY - FDAM Methodology (authoritative source) + "FDAM_v4_METHODOLOGY.md": ("methodology", "primary"), + # REFERENCE - Threshold Tables (critical for metals clearance) + "Metals clearance criteria-QVC.md": ("thresholds", "reference-threshold"), + # REFERENCE - Narrative (supporting documentation) + "air-o-cell-method-guide-atlas.md": ("lab-methods", "reference-narrative"), + "Industrial Hygiene Lab Services Guide.md": ("lab-methods", "reference-narrative"), + "Fire Remediation Processes and Methodologies_ A Review of Industry-Endorsed Standards.md": ( + "cleaning-procedures", + "reference-narrative", + ), + "Technical Guide for Wildfire Restoration - Key Information.md": ( + "wildfire", + "reference-narrative", + ), + "wildfire_soot_particulate_removal_full_text_extraction.md": ( + "wildfire", + "reference-narrative", + ), +} + +# Files to skip (per user decision) +SKIP_FILES = { + "Lead Contamination in Indoor Firing_Gun Ranges _ Atlantic Environmental.pdf", +} + + +def get_rag_kb_path() -> Path: + """Get path to RAG-KB directory.""" + # Try relative to this file first + this_dir = Path(__file__).parent + rag_kb = this_dir.parent / "RAG-KB" + if rag_kb.exists(): + return rag_kb + + # Try from current working directory + rag_kb = Path("RAG-KB") + if rag_kb.exists(): + return rag_kb + + raise FileNotFoundError("Could not find RAG-KB directory") + + +def get_chroma_path() -> Path: + """Get path to ChromaDB persistence directory.""" + this_dir = Path(__file__).parent + chroma_path = this_dir.parent / "chroma_db" + return chroma_path + + +def build_index(rebuild: bool = False) -> dict: + """Build the RAG index from RAG-KB documents. + + Args: + rebuild: If True, clear existing index before building + + Returns: + Statistics about the indexing operation + """ + rag_kb_path = get_rag_kb_path() + chroma_path = get_chroma_path() + + print(f"RAG-KB path: {rag_kb_path}") + print(f"ChromaDB path: {chroma_path}") + + # Initialize components + chunker = SemanticChunker() + vectorstore = ChromaVectorStore(persist_directory=str(chroma_path)) + + if rebuild: + print("Rebuilding index - clearing existing data...") + vectorstore.clear() + + stats = { + "documents_processed": 0, + "documents_skipped": 0, + "chunks_created": 0, + "errors": [], + } + + # Process markdown files + for md_file in rag_kb_path.glob("*.md"): + filename = md_file.name + + # Skip files not in config or in skip list + if filename in SKIP_FILES: + print(f"Skipping (excluded): {filename}") + stats["documents_skipped"] += 1 + continue + + if filename not in DOCUMENT_CONFIG: + print(f"Skipping (not configured): {filename}") + stats["documents_skipped"] += 1 + continue + + category, priority = DOCUMENT_CONFIG[filename] + print(f"Processing: {filename} ({category}, {priority})") + + try: + # Read and chunk document + text = md_file.read_text(encoding="utf-8") + chunks = chunker.chunk_document( + text=text, + source=filename, + category=category, + priority=priority, + ) + + # Check if source already indexed (for incremental updates) + existing_count = vectorstore.delete_by_source(filename) + if existing_count > 0: + print(f" Replaced {existing_count} existing chunks") + + # Add to vectorstore + added = vectorstore.add_chunks(chunks) + print(f" Added {added} chunks") + + stats["documents_processed"] += 1 + stats["chunks_created"] += added + + except Exception as e: + error_msg = f"Error processing {filename}: {e}" + print(f" ERROR: {e}") + stats["errors"].append(error_msg) + + # Report on PDFs that need conversion + for pdf_file in rag_kb_path.glob("*.pdf"): + if pdf_file.name not in SKIP_FILES: + print(f"Note: PDF needs conversion to .md: {pdf_file.name}") + + # Print summary + print("\n" + "=" * 50) + print("Index Build Complete") + print("=" * 50) + print(f"Documents processed: {stats['documents_processed']}") + print(f"Documents skipped: {stats['documents_skipped']}") + print(f"Total chunks created: {stats['chunks_created']}") + + if stats["errors"]: + print(f"Errors: {len(stats['errors'])}") + for err in stats["errors"]: + print(f" - {err}") + + # Print collection stats + collection_stats = vectorstore.get_stats() + print(f"\nCollection stats:") + print(f" Total chunks in DB: {collection_stats['total_chunks']}") + print(f" Categories: {collection_stats['categories']}") + print(f" Priorities: {collection_stats['priorities']}") + + return stats + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Build FDAM RAG knowledge base index" + ) + parser.add_argument( + "--rebuild", + action="store_true", + help="Clear existing index and rebuild from scratch", + ) + args = parser.parse_args() + + build_index(rebuild=args.rebuild) + + +if __name__ == "__main__": + main() diff --git a/rag/retriever.py b/rag/retriever.py new file mode 100644 index 0000000000000000000000000000000000000000..079c54b475468be709afe46a5400a677d51f80de --- /dev/null +++ b/rag/retriever.py @@ -0,0 +1,380 @@ +"""FDAM retriever with priority weighting and reranking. + +Implements tiered retrieval: +1. Vector similarity search +2. Priority weighting (primary > reference-threshold > reference-narrative) +3. Optional reranking for production +""" + +from typing import Optional +from dataclasses import dataclass + +from config.settings import settings +from .vectorstore import ChromaVectorStore + + +@dataclass +class RetrievalResult: + """A single retrieval result with relevance score.""" + + chunk_id: str + text: str + source: str + category: str + section: str + priority: str + content_type: str + keywords: list[str] + similarity_score: float # 0-1, higher is better + weighted_score: float # After priority weighting + final_score: float # After reranking (if applied) + + def to_dict(self) -> dict: + """Convert to dictionary.""" + return { + "chunk_id": self.chunk_id, + "text": self.text, + "source": self.source, + "category": self.category, + "section": self.section, + "priority": self.priority, + "content_type": self.content_type, + "keywords": self.keywords, + "similarity_score": self.similarity_score, + "weighted_score": self.weighted_score, + "final_score": self.final_score, + } + + +class MockReranker: + """Mock reranker for local development. + + Simply returns scores based on keyword overlap. + """ + + def rerank( + self, + query: str, + documents: list[str], + ) -> list[float]: + """Score documents based on keyword overlap with query. + + Args: + query: Query text + documents: List of document texts + + Returns: + List of scores (0-1) for each document + """ + query_words = set(query.lower().split()) + scores = [] + + for doc in documents: + doc_words = set(doc.lower().split()) + # Jaccard-like overlap score + overlap = len(query_words & doc_words) + total = len(query_words | doc_words) + score = overlap / total if total > 0 else 0.0 + scores.append(score) + + return scores + + +class RealReranker: + """Real reranker using Qwen3-VL-Reranker-8B. + + Loaded on-demand when MOCK_MODELS=false. + """ + + def __init__(self): + self.model = None + self.tokenizer = None + + def _load_model(self): + """Lazy load the reranker model.""" + if self.model is not None: + return + + import torch + from transformers import AutoModelForSequenceClassification, AutoTokenizer + + model_name = "Qwen/Qwen3-VL-Reranker-8B" + print(f"Loading reranker model: {model_name}") + + self.tokenizer = AutoTokenizer.from_pretrained( + model_name, + trust_remote_code=True, + ) + self.model = AutoModelForSequenceClassification.from_pretrained( + model_name, + torch_dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True, + ) + self.model.eval() + + def rerank( + self, + query: str, + documents: list[str], + ) -> list[float]: + """Score documents using the reranker model. + + Args: + query: Query text + documents: List of document texts + + Returns: + List of scores for each document + """ + self._load_model() + + import torch + + scores = [] + with torch.no_grad(): + for doc in documents: + inputs = self.tokenizer( + query, + doc, + return_tensors="pt", + truncation=True, + max_length=512, + padding=True, + ) + inputs = {k: v.to(self.model.device) for k, v in inputs.items()} + + outputs = self.model(**inputs) + # Sigmoid to get 0-1 score + score = torch.sigmoid(outputs.logits).squeeze().item() + scores.append(score) + + return scores + + +def get_reranker(): + """Get appropriate reranker based on settings.""" + if settings.mock_models: + return MockReranker() + return RealReranker() + + +class FDAMRetriever: + """FDAM-specific retriever with priority weighting. + + Priority weights: + - primary: 1.0 (FDAM methodology) + - reference-threshold: 0.9 (Threshold tables) + - reference-narrative: 0.8 (Supporting documentation) + """ + + PRIORITY_WEIGHTS = { + "primary": 1.0, + "reference-threshold": 0.9, + "reference-narrative": 0.8, + } + + def __init__( + self, + vectorstore: Optional[ChromaVectorStore] = None, + reranker=None, + use_reranking: bool = True, + ): + """Initialize retriever. + + Args: + vectorstore: ChromaDB vector store instance. + If None, creates default instance. + reranker: Reranker instance. If None, uses appropriate default. + use_reranking: Whether to apply reranking step. + """ + self.vectorstore = vectorstore or ChromaVectorStore() + self.reranker = reranker if reranker is not None else get_reranker() + self.use_reranking = use_reranking + + def retrieve( + self, + query: str, + top_k: int = 5, + category_filter: Optional[str] = None, + priority_filter: Optional[str] = None, + include_scores: bool = True, + ) -> list[RetrievalResult]: + """Retrieve relevant chunks for a query. + + Args: + query: Query text + top_k: Number of results to return + category_filter: Optional category to filter by + priority_filter: Optional priority to filter by + include_scores: Whether to include score details + + Returns: + List of RetrievalResult objects, sorted by final_score descending + """ + # Build metadata filter + where_filter = None + if category_filter or priority_filter: + where_filter = {} + if category_filter: + where_filter["category"] = category_filter + if priority_filter: + where_filter["priority"] = priority_filter + + # Fetch more results than needed for reranking + fetch_k = top_k * 3 if self.use_reranking else top_k + + # Query vector store + raw_results = self.vectorstore.query( + query_text=query, + n_results=fetch_k, + where=where_filter, + ) + + if not raw_results: + return [] + + # Convert to RetrievalResult objects with priority weighting + results = [] + for r in raw_results: + # Convert distance to similarity (cosine distance: 0 = identical) + similarity = 1.0 - r["distance"] + + # Apply priority weight + priority = r["metadata"].get("priority", "reference-narrative") + weight = self.PRIORITY_WEIGHTS.get(priority, 0.8) + weighted_score = similarity * weight + + # Parse keywords + keywords_str = r["metadata"].get("keywords", "") + keywords = keywords_str.split(",") if keywords_str else [] + + results.append( + RetrievalResult( + chunk_id=r["id"], + text=r["document"], + source=r["metadata"].get("source", "unknown"), + category=r["metadata"].get("category", "unknown"), + section=r["metadata"].get("section", "unknown"), + priority=priority, + content_type=r["metadata"].get("content_type", "narrative"), + keywords=keywords, + similarity_score=similarity, + weighted_score=weighted_score, + final_score=weighted_score, # Will be updated by reranking + ) + ) + + # Apply reranking if enabled + if self.use_reranking and results: + documents = [r.text for r in results] + rerank_scores = self.reranker.rerank(query, documents) + + # Combine weighted score with rerank score + # Final = 0.6 * weighted + 0.4 * rerank + for i, result in enumerate(results): + rerank_score = rerank_scores[i] + result.final_score = 0.6 * result.weighted_score + 0.4 * rerank_score + + # Sort by final score (descending) and take top_k + results.sort(key=lambda x: x.final_score, reverse=True) + return results[:top_k] + + def retrieve_for_context( + self, + query: str, + top_k: int = 5, + ) -> str: + """Retrieve and format chunks as context string for LLM. + + Args: + query: Query text + top_k: Number of chunks to include + + Returns: + Formatted context string with source citations + """ + results = self.retrieve(query, top_k=top_k) + + if not results: + return "No relevant context found." + + context_parts = [] + for i, r in enumerate(results, 1): + context_parts.append( + f"[{i}] Source: {r.source} | Section: {r.section}\n{r.text}" + ) + + return "\n\n---\n\n".join(context_parts) + + def retrieve_thresholds( + self, + material_type: str, + facility_type: str, + ) -> list[RetrievalResult]: + """Retrieve threshold values for a specific material and facility type. + + Convenience method for threshold lookups. + + Args: + material_type: Type of material (e.g., "lead", "soot", "char") + facility_type: Facility classification + + Returns: + Relevant threshold results + """ + query = f"{material_type} threshold {facility_type} clearance criteria" + return self.retrieve( + query=query, + top_k=3, + category_filter="thresholds", + ) + + def retrieve_disposition( + self, + zone: str, + condition: str, + material_type: Optional[str] = None, + ) -> list[RetrievalResult]: + """Retrieve disposition guidance for zone/condition combination. + + Convenience method for disposition lookups. + + Args: + zone: Zone classification (burn-zone, near-field, far-field) + condition: Condition level (background, light, moderate, heavy, structural-damage) + material_type: Optional material type for specific guidance + + Returns: + Relevant disposition results + """ + query = f"disposition {zone} {condition}" + if material_type: + query += f" {material_type}" + query += " cleaning recommendation" + + return self.retrieve( + query=query, + top_k=5, + priority_filter="primary", # Prefer FDAM methodology + ) + + def retrieve_cleaning_method( + self, + surface_type: str, + condition: str, + ) -> list[RetrievalResult]: + """Retrieve cleaning method recommendations. + + Args: + surface_type: Type of surface (e.g., "drywall", "concrete", "metal") + condition: Condition level + + Returns: + Relevant cleaning method results + """ + query = f"cleaning method {surface_type} {condition} procedure hepa" + return self.retrieve( + query=query, + top_k=5, + ) diff --git a/rag/vectorstore.py b/rag/vectorstore.py new file mode 100644 index 0000000000000000000000000000000000000000..61c353850b8fd34013a3bd969ff3fe311ccd26b5 --- /dev/null +++ b/rag/vectorstore.py @@ -0,0 +1,287 @@ +"""ChromaDB vector store for FDAM knowledge base. + +Provides embedding and storage with metadata support. +Uses mock embeddings when MOCK_MODELS=true for local development. +""" + +import hashlib +from typing import Optional +from pathlib import Path + +import chromadb +from chromadb.config import Settings + +from config.settings import settings +from .chunker import Chunk + + +class MockEmbeddingFunction: + """Mock embedding function for local development. + + Generates deterministic pseudo-embeddings based on text hash. + Produces 384-dimensional vectors (matches common embedding models). + """ + + EMBEDDING_DIM = 384 + + def __call__(self, input: list[str]) -> list[list[float]]: + """Generate mock embeddings for a list of texts.""" + return [self._embed_text(text) for text in input] + + def _embed_text(self, text: str) -> list[float]: + """Generate a deterministic pseudo-embedding from text. + + Uses SHA-256 hash expanded to fill embedding dimensions. + Not semantically meaningful but provides consistent behavior. + """ + # Hash the text + text_hash = hashlib.sha256(text.encode("utf-8")).digest() + + # Expand hash to fill embedding dimensions + embedding = [] + for i in range(self.EMBEDDING_DIM): + # Use hash bytes cyclically, normalized to [-1, 1] + byte_val = text_hash[i % len(text_hash)] + normalized = (byte_val / 127.5) - 1.0 + embedding.append(normalized) + + return embedding + + +class RealEmbeddingFunction: + """Real embedding function using Qwen3-VL-Embedding-8B. + + Loaded on-demand when MOCK_MODELS=false. + """ + + EMBEDDING_DIM = 4096 # Qwen embedding dimension + + def __init__(self): + self.model = None + self.tokenizer = None + + def _load_model(self): + """Lazy load the embedding model.""" + if self.model is not None: + return + + import torch + from transformers import AutoModel, AutoTokenizer + + model_name = "Qwen/Qwen3-VL-Embedding-8B" + print(f"Loading embedding model: {model_name}") + + self.tokenizer = AutoTokenizer.from_pretrained( + model_name, + trust_remote_code=True, + ) + self.model = AutoModel.from_pretrained( + model_name, + torch_dtype=torch.bfloat16, + device_map="auto", + trust_remote_code=True, + ) + self.model.eval() + + def __call__(self, input: list[str]) -> list[list[float]]: + """Generate embeddings for a list of texts.""" + self._load_model() + + import torch + + embeddings = [] + with torch.no_grad(): + for text in input: + inputs = self.tokenizer( + text, + return_tensors="pt", + truncation=True, + max_length=512, + padding=True, + ) + inputs = {k: v.to(self.model.device) for k, v in inputs.items()} + + outputs = self.model(**inputs) + # Use mean pooling over sequence + embedding = outputs.last_hidden_state.mean(dim=1).squeeze() + embeddings.append(embedding.cpu().float().tolist()) + + return embeddings + + +def get_embedding_function(): + """Get appropriate embedding function based on settings.""" + if settings.mock_models: + return MockEmbeddingFunction() + return RealEmbeddingFunction() + + +class ChromaVectorStore: + """ChromaDB-based vector store for FDAM knowledge base.""" + + COLLECTION_NAME = "fdam_knowledge_base" + + def __init__( + self, + persist_directory: Optional[str] = None, + embedding_function=None, + ): + """Initialize vector store. + + Args: + persist_directory: Directory for ChromaDB persistence. + If None, uses in-memory storage. + embedding_function: Custom embedding function. + If None, uses appropriate default. + """ + self.persist_directory = persist_directory + + # Initialize ChromaDB client + if persist_directory: + persist_path = Path(persist_directory) + persist_path.mkdir(parents=True, exist_ok=True) + self.client = chromadb.PersistentClient( + path=str(persist_path), + settings=Settings(anonymized_telemetry=False), + ) + else: + self.client = chromadb.Client( + settings=Settings(anonymized_telemetry=False), + ) + + # Set up embedding function + self.embedding_function = embedding_function or get_embedding_function() + + # Get or create collection + self.collection = self.client.get_or_create_collection( + name=self.COLLECTION_NAME, + metadata={"hnsw:space": "cosine"}, + ) + + def add_chunks(self, chunks: list[Chunk]) -> int: + """Add chunks to the vector store. + + Args: + chunks: List of Chunk objects to add + + Returns: + Number of chunks added + """ + if not chunks: + return 0 + + ids = [chunk.id for chunk in chunks] + documents = [chunk.text for chunk in chunks] + metadatas = [chunk.to_metadata() for chunk in chunks] + + # Generate embeddings + embeddings = self.embedding_function(documents) + + # Add to collection + self.collection.add( + ids=ids, + embeddings=embeddings, + documents=documents, + metadatas=metadatas, + ) + + return len(chunks) + + def query( + self, + query_text: str, + n_results: int = 5, + where: Optional[dict] = None, + where_document: Optional[dict] = None, + ) -> list[dict]: + """Query the vector store. + + Args: + query_text: Query text to search for + n_results: Number of results to return + where: Metadata filter (e.g., {"priority": "primary"}) + where_document: Document content filter + + Returns: + List of result dicts with keys: id, document, metadata, distance + """ + # Generate query embedding + query_embedding = self.embedding_function([query_text])[0] + + # Query collection + results = self.collection.query( + query_embeddings=[query_embedding], + n_results=n_results, + where=where, + where_document=where_document, + include=["documents", "metadatas", "distances"], + ) + + # Format results + formatted = [] + if results["ids"] and results["ids"][0]: + for i, chunk_id in enumerate(results["ids"][0]): + formatted.append( + { + "id": chunk_id, + "document": results["documents"][0][i], + "metadata": results["metadatas"][0][i], + "distance": results["distances"][0][i], + } + ) + + return formatted + + def get_stats(self) -> dict: + """Get collection statistics.""" + count = self.collection.count() + + # Get category distribution + categories = {} + priorities = {} + + if count > 0: + # Sample all documents to get metadata distribution + all_results = self.collection.get(include=["metadatas"]) + for metadata in all_results["metadatas"]: + cat = metadata.get("category", "unknown") + pri = metadata.get("priority", "unknown") + categories[cat] = categories.get(cat, 0) + 1 + priorities[pri] = priorities.get(pri, 0) + 1 + + return { + "total_chunks": count, + "categories": categories, + "priorities": priorities, + "collection_name": self.COLLECTION_NAME, + "persist_directory": self.persist_directory, + } + + def clear(self): + """Clear all data from the collection.""" + self.client.delete_collection(self.COLLECTION_NAME) + self.collection = self.client.get_or_create_collection( + name=self.COLLECTION_NAME, + metadata={"hnsw:space": "cosine"}, + ) + + def delete_by_source(self, source: str) -> int: + """Delete all chunks from a specific source. + + Args: + source: Source filename to delete + + Returns: + Number of chunks deleted + """ + # Get IDs of chunks from this source + results = self.collection.get( + where={"source": source}, + include=[], + ) + + if results["ids"]: + self.collection.delete(ids=results["ids"]) + return len(results["ids"]) + + return 0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..bb5c59c682ad025240db993ce4bb33f028638a04 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +# Core ML/AI +torch +transformers>=4.57.0 +accelerate +qwen-vl-utils>=0.0.14 +torchvision + +# UI +gradio + +# RAG/Vector Store +chromadb + +# Data Validation +pydantic +pydantic-settings + +# Image Processing +pillow + +# PDF Processing +pdfplumber +weasyprint>=60.0 +markdown>=3.5 + +# Utilities +numpy + +# Testing +pytest +pytest-asyncio diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..02bde47fb8dd89d24726ddba9f2b1069be561e09 --- /dev/null +++ b/schemas/__init__.py @@ -0,0 +1,109 @@ +"""FDAM AI Pipeline Pydantic schemas. + +Exports all input and output models for convenient imports. +""" + +from .input import ( + # Type definitions + FacilityClassification, + ConstructionEra, + ZoneType, + ConditionLevel, + MaterialType, + MaterialCategory, + Disposition, + OdorIntensity, + CharDensity, + SampleType, + Priority, + # Helper functions + get_material_category, + # Input models + ProjectInfo, + Dimensions, + Surface, + Room, + BoundingBox, + ImageAnnotation, + ImageMetadata, + QualitativeObservations, + AssessmentInput, +) + +from .output import ( + # Vision analysis + ZoneAnalysis, + ConditionAnalysis, + DetectedMaterial, + CombustionIndicators, + SamplingRecommendation, + VisionAnalysisResult, + # Calculations + RoomAreaSummary, + SurfaceAreas, + AirFiltration, + SampleDensity, + LaborEstimate, + EquipmentRequirements, + RegulatoryFlag, + RegulatoryFlags, + CalculationResults, + # Documents + GeneratedDocuments, + # Confidence + FlaggedItem, + ConfidenceReport, + # Final output + AssessmentOutput, +) + +__all__ = [ + # Type definitions + "FacilityClassification", + "ConstructionEra", + "ZoneType", + "ConditionLevel", + "MaterialType", + "MaterialCategory", + "Disposition", + "OdorIntensity", + "CharDensity", + "SampleType", + "Priority", + # Helper functions + "get_material_category", + # Input models + "ProjectInfo", + "Dimensions", + "Surface", + "Room", + "BoundingBox", + "ImageAnnotation", + "ImageMetadata", + "QualitativeObservations", + "AssessmentInput", + # Vision analysis + "ZoneAnalysis", + "ConditionAnalysis", + "DetectedMaterial", + "CombustionIndicators", + "SamplingRecommendation", + "VisionAnalysisResult", + # Calculations + "RoomAreaSummary", + "SurfaceAreas", + "AirFiltration", + "SampleDensity", + "LaborEstimate", + "EquipmentRequirements", + "RegulatoryFlag", + "RegulatoryFlags", + "CalculationResults", + # Documents + "GeneratedDocuments", + # Confidence + "FlaggedItem", + "ConfidenceReport", + # Final output + "AssessmentOutput", +] diff --git a/schemas/input.py b/schemas/input.py new file mode 100644 index 0000000000000000000000000000000000000000..1da5d1a9e5f8c9c61a85989f44cee9c504990f0f --- /dev/null +++ b/schemas/input.py @@ -0,0 +1,255 @@ +"""Pydantic input models for FDAM AI Pipeline. + +Uses Literal unions instead of Enums per project code style. +""" + +from datetime import date +from typing import Literal, Optional + +from pydantic import BaseModel, Field, field_validator, model_validator + + +# --- Type Definitions (Literal unions) --- + +FacilityClassification = Literal["operational", "non-operational", "public-childcare"] +ConstructionEra = Literal["pre-1980", "1980-2000", "post-2000"] +ZoneType = Literal["burn", "near-field", "far-field"] +ConditionLevel = Literal["background", "light", "moderate", "heavy", "structural-damage"] + +# Material categories +MaterialType = Literal[ + # Non-porous + "steel", + "concrete", + "glass", + "metal", + "cmu", + # Semi-porous + "drywall-painted", + "drywall-unpainted", + "wood-sealed", + "wood-unsealed", + # Porous + "carpet", + "carpet-pad", + "insulation-fiberglass", + "insulation-other", + "acoustic-tile", + "upholstery", + # HVAC + "ductwork-rigid", + "ductwork-flexible", + "hvac-interior-insulation", +] + +MaterialCategory = Literal["non-porous", "semi-porous", "porous", "hvac"] + +Disposition = Literal["no-action", "clean", "evaluate", "remove", "remove-repair"] + +OdorIntensity = Literal["none", "faint", "moderate", "strong"] +CharDensity = Literal["sparse", "moderate", "dense"] +SampleType = Literal["tape_lift", "surface_wipe", "both"] +Priority = Literal["high", "medium", "low"] + + +# --- Helper Functions --- + +def get_material_category(material: MaterialType) -> MaterialCategory: + """Get the category for a material type.""" + non_porous = {"steel", "concrete", "glass", "metal", "cmu"} + semi_porous = {"drywall-painted", "drywall-unpainted", "wood-sealed", "wood-unsealed"} + porous = {"carpet", "carpet-pad", "insulation-fiberglass", "insulation-other", "acoustic-tile", "upholstery"} + hvac = {"ductwork-rigid", "ductwork-flexible", "hvac-interior-insulation"} + + if material in non_porous: + return "non-porous" + elif material in semi_porous: + return "semi-porous" + elif material in porous: + return "porous" + elif material in hvac: + return "hvac" + else: + return "porous" # Conservative default + + +# --- Project Level --- + +class ProjectInfo(BaseModel): + """Project-level information.""" + + project_name: str = Field(..., min_length=1, description="Project or facility name") + address: str = Field(..., min_length=1, description="Full street address") + city: str = Field(..., min_length=1) + state: str = Field(..., min_length=2, max_length=2) + zip_code: str = Field(..., min_length=5) + + client_name: str = Field(..., min_length=1) + client_contact: Optional[str] = None + client_email: Optional[str] = None + client_phone: Optional[str] = None + + fire_date: date = Field(..., description="Date of fire incident") + assessment_date: date = Field(..., description="Date of assessment") + + facility_classification: FacilityClassification + construction_era: ConstructionEra + + assessor_name: str = Field(..., min_length=1, description="Industrial hygienist name") + assessor_credentials: Optional[str] = Field(None, description="CIH, CSP, etc.") + + +# --- Room/Area Level --- + +class Dimensions(BaseModel): + """Room dimensions for calculations.""" + + length_ft: float = Field(..., gt=0, le=10000, description="Length in feet") + width_ft: float = Field(..., gt=0, le=10000, description="Width in feet") + ceiling_height_ft: float = Field(..., gt=0, le=500, description="Ceiling height in feet") + + @property + def area_sf(self) -> float: + """Calculate floor area in square feet.""" + return self.length_ft * self.width_ft + + @property + def volume_cf(self) -> float: + """Calculate volume in cubic feet.""" + return self.area_sf * self.ceiling_height_ft + + +class Surface(BaseModel): + """Individual surface within a room.""" + + id: str = Field(..., min_length=1, description="Unique surface identifier") + material: MaterialType = Field(..., description="Material type") + description: str = Field(..., min_length=1, description="e.g., 'North wall drywall'") + area_sf: float = Field(..., gt=0, description="Surface area in square feet") + + zone: Optional[ZoneType] = Field(None, description="Can be set by AI or user") + condition: Optional[ConditionLevel] = Field(None, description="Can be set by AI or user") + disposition: Optional[Disposition] = Field(None, description="Calculated by system") + + ai_detected: bool = Field(False, description="Was this detected by AI from images?") + confidence: Optional[float] = Field(None, ge=0, le=1, description="AI confidence score") + + @property + def category(self) -> MaterialCategory: + """Get the material category.""" + return get_material_category(self.material) + + +class Room(BaseModel): + """Room or area within the building.""" + + id: str = Field(..., min_length=1, description="Unique room identifier") + name: str = Field(..., min_length=1, description="e.g., 'Warehouse Bay A'") + floor: Optional[str] = Field(None, description="e.g., 'Ground Floor'") + + dimensions: Dimensions + + zone_classification: Optional[ZoneType] = Field(None, description="AI-determined or user override") + zone_confidence: Optional[float] = Field(None, ge=0, le=1) + zone_user_override: bool = Field(False) + + surfaces: list[Surface] = Field(default_factory=list) + image_ids: list[str] = Field(default_factory=list, description="Associated image IDs") + + +# --- Image Level --- + +class BoundingBox(BaseModel): + """Bounding box for detected elements in an image.""" + + x: float = Field(..., ge=0, le=1, description="X coordinate (normalized 0-1)") + y: float = Field(..., ge=0, le=1, description="Y coordinate (normalized 0-1)") + width: float = Field(..., gt=0, le=1, description="Width (normalized 0-1)") + height: float = Field(..., gt=0, le=1, description="Height (normalized 0-1)") + + +class ImageAnnotation(BaseModel): + """Annotation for a detected element in an image.""" + + label: str + bounding_box: BoundingBox + confidence: Optional[float] = Field(None, ge=0, le=1) + + +class ImageMetadata(BaseModel): + """Metadata for uploaded image.""" + + id: str = Field(..., min_length=1) + filename: str = Field(..., min_length=1) + room_id: str = Field(..., min_length=1, description="Associated room ID") + description: Optional[str] = Field(None, description="User description of image") + + # AI-populated fields + detected_materials: list[MaterialType] = Field(default_factory=list) + detected_zone: Optional[ZoneType] = None + zone_confidence: Optional[float] = Field(None, ge=0, le=1) + detected_condition: Optional[ConditionLevel] = None + condition_confidence: Optional[float] = Field(None, ge=0, le=1) + + # Bounding box annotations (for UI overlay) + annotations: list[ImageAnnotation] = Field(default_factory=list) + + analysis_complete: bool = Field(False) + + +# --- Qualitative Observations --- + +class QualitativeObservations(BaseModel): + """Qualitative observation checklist per FDAM 2.3.""" + + smoke_fire_odor: bool = Field(..., description="Smoke/fire odor present?") + odor_intensity: Optional[OdorIntensity] = None + + visible_soot_deposits: bool = Field(..., description="Visible soot deposits?") + soot_pattern_description: Optional[str] = None + + large_char_particles: bool = Field(..., description="Large char particles observed?") + char_density_estimate: Optional[CharDensity] = None + + ash_like_residue: bool = Field(..., description="Ash-like residue present?") + ash_color_texture: Optional[str] = None + + surface_discoloration: bool = Field(..., description="Surface discoloration?") + discoloration_description: Optional[str] = None + + dust_loading_interference: bool = Field(..., description="Dust loading or interference?") + dust_notes: Optional[str] = None + + wildfire_indicators: bool = Field(..., description="Burned soil/pollen/vegetation indicators?") + wildfire_notes: Optional[str] = None + + additional_notes: Optional[str] = None + + +# --- Complete Assessment Input --- + +class AssessmentInput(BaseModel): + """Complete input for FDAM AI assessment.""" + + project: ProjectInfo + rooms: list[Room] = Field(..., min_length=1) + images: list[ImageMetadata] = Field(default_factory=list, max_length=20) + observations: QualitativeObservations + + @field_validator("rooms") + @classmethod + def validate_room_ids(cls, rooms: list[Room]) -> list[Room]: + """Ensure room IDs are unique.""" + ids = [r.id for r in rooms] + if len(ids) != len(set(ids)): + raise ValueError("Room IDs must be unique") + return rooms + + @model_validator(mode="after") + def validate_image_rooms(self) -> "AssessmentInput": + """Ensure all images reference valid room IDs.""" + room_ids = {r.id for r in self.rooms} + for img in self.images: + if img.room_id not in room_ids: + raise ValueError(f"Image {img.id} references unknown room {img.room_id}") + return self diff --git a/schemas/output.py b/schemas/output.py new file mode 100644 index 0000000000000000000000000000000000000000..bfe6c4f39ed2e47c9210683c45ecff3d96c7ca6c --- /dev/null +++ b/schemas/output.py @@ -0,0 +1,238 @@ +"""Pydantic output models for FDAM AI Pipeline. + +Contains vision analysis results, calculation outputs, and final assessment output. +""" + +from typing import Optional + +from pydantic import BaseModel, Field + +from .input import ( + AssessmentInput, + BoundingBox, + ConditionLevel, + MaterialCategory, + MaterialType, + Priority, + SampleType, + ZoneType, +) + + +# --- Vision Analysis Output --- + +class ZoneAnalysis(BaseModel): + """Zone classification from vision analysis.""" + + classification: ZoneType + confidence: float = Field(..., ge=0, le=1) + reasoning: str + + +class ConditionAnalysis(BaseModel): + """Condition assessment from vision analysis.""" + + level: ConditionLevel + confidence: float = Field(..., ge=0, le=1) + reasoning: str + + +class DetectedMaterial(BaseModel): + """Material detected in image by vision model.""" + + type: MaterialType + category: MaterialCategory + confidence: float = Field(..., ge=0, le=1) + location_description: Optional[str] = None + bounding_box: Optional[BoundingBox] = None + + +class CombustionIndicators(BaseModel): + """Combustion particle indicators from vision analysis.""" + + soot_visible: bool = False + soot_pattern: Optional[str] = None + char_visible: bool = False + char_description: Optional[str] = None + ash_visible: bool = False + ash_description: Optional[str] = None + + +class SamplingRecommendation(BaseModel): + """Recommended sampling location from vision analysis.""" + + description: str + sample_type: SampleType + priority: Priority + + +class VisionAnalysisResult(BaseModel): + """Complete vision analysis result for a single image. + + Matches the VISION_OUTPUT_SCHEMA from the technical spec. + """ + + zone: ZoneAnalysis + condition: ConditionAnalysis + materials: list[DetectedMaterial] = Field(default_factory=list) + combustion_indicators: CombustionIndicators + structural_concerns: list[str] = Field(default_factory=list) + access_issues: list[str] = Field(default_factory=list) + recommended_sampling_locations: list[SamplingRecommendation] = Field(default_factory=list) + flags_for_review: list[str] = Field(default_factory=list) + + +# --- Calculation Results --- + +class RoomAreaSummary(BaseModel): + """Area summary for a single room.""" + + floor_area: float + surface_area: float + volume: float + + +class SurfaceAreas(BaseModel): + """Surface area calculations by various groupings.""" + + by_type: dict[str, float] = Field(default_factory=dict) + by_disposition: dict[str, float] = Field(default_factory=dict) + by_zone: dict[str, float] = Field(default_factory=dict) + by_room: dict[str, RoomAreaSummary] = Field(default_factory=dict) + total_floor_sf: float = 0 + total_surface_sf: float = 0 + total_volume_cf: float = 0 + + +class AirFiltration(BaseModel): + """Air filtration calculation results per NADCA ACR 2021.""" + + total_volume_cf: float + required_ach: int = 4 + unit_cfm: int = 2000 + units_required: int + calculation: str + standard_reference: str = "NADCA ACR 2021, Section 3.6" + + +class SampleDensity(BaseModel): + """Sample density recommendations per FDAM 2.3.""" + + total_sf: float + size_category: str + surface_types_count: int + surface_types: list[str] = Field(default_factory=list) + tape_lifts_per_type: str + surface_wipes_per_type: str + recommended_tape_lifts: int + recommended_surface_wipes: int + ceiling_deck_note: Optional[str] = None + control_samples_recommended: bool = True + control_sample_note: str = "Control samples from unaffected areas recommended for baseline comparison" + + +class LaborEstimate(BaseModel): + """Labor hour estimates by task.""" + + hepa_vacuum: float = 0 + wet_wipe: float = 0 + dry_sponge: float = 0 + power_wash: float = 0 + scrubber: float = 0 + removal: float = 0 + hvac_cleaning: float = 0 + total_hours: float = 0 + + +class EquipmentRequirements(BaseModel): + """Equipment requirements for the project.""" + + air_scrubbers: int = 0 + hepa_vacuums: int = 0 + negative_air_machines: int = 0 + dehumidifiers: int = 0 + notes: list[str] = Field(default_factory=list) + + +class RegulatoryFlag(BaseModel): + """Regulatory flag for potential hazards.""" + + flag_type: str + description: str + recommendation: str + reference: str + + +class RegulatoryFlags(BaseModel): + """Regulatory flags based on construction era and facility type.""" + + lead_paint_flag: Optional[RegulatoryFlag] = None + acm_flag: Optional[RegulatoryFlag] = None + other_flags: list[RegulatoryFlag] = Field(default_factory=list) + + +class CalculationResults(BaseModel): + """All calculation results from FDAM logic engine.""" + + surface_areas: SurfaceAreas + air_filtration: AirFiltration + sample_density: SampleDensity + labor_estimate: LaborEstimate + equipment: EquipmentRequirements + regulatory_flags: RegulatoryFlags + + +# --- Document Output --- + +class GeneratedDocuments(BaseModel): + """Generated document outputs.""" + + cleaning_specification_md: str = Field(..., description="Cleaning Specification / SOW in Markdown") + sampling_plan_md: Optional[str] = Field(None, description="Sampling plan recommendations in Markdown") + confidence_report_md: Optional[str] = Field(None, description="Confidence report in Markdown") + + +# --- Confidence Report --- + +class FlaggedItem(BaseModel): + """Item flagged for professional review.""" + + type: str + room: Optional[str] = None + surface: Optional[str] = None + image_id: Optional[str] = None + confidence: Optional[float] = None + recommendation: str + + +class ConfidenceReport(BaseModel): + """Confidence report for assessment.""" + + flagged_items: list[FlaggedItem] = Field(default_factory=list) + overall_confidence: float = Field(..., ge=0, le=1) + review_required: bool = False + + +# --- Complete Assessment Output --- + +class AssessmentOutput(BaseModel): + """Complete output from FDAM AI assessment pipeline.""" + + # Original input (with AI-enriched fields) + input: AssessmentInput + + # Vision analysis results (by image ID) + vision_results: dict[str, VisionAnalysisResult] = Field(default_factory=dict) + + # Calculation results + calculations: CalculationResults + + # Generated documents + documents: GeneratedDocuments + + # Confidence report + confidence_report: ConfidenceReport + + # Processing metadata + processing_time_seconds: Optional[float] = None + model_versions: dict[str, str] = Field(default_factory=dict) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/test_pdf_generator.py b/tests/test_pdf_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..8ea66e8d0bbad5edaba2d28e69424038679ac7b5 --- /dev/null +++ b/tests/test_pdf_generator.py @@ -0,0 +1,246 @@ +"""Tests for PDF generation module.""" + +import pytest +import tempfile +from pathlib import Path + +from pipeline.pdf_generator import PDFGenerator, PDFResult, generate_sow_pdf, SOW_CSS + + +class TestPDFGenerator: + """Test PDF generator functionality.""" + + @pytest.fixture + def generator(self): + """Create PDF generator instance.""" + return PDFGenerator() + + @pytest.fixture + def sample_markdown(self): + """Sample markdown for testing.""" + return """# Test Document + +## Section One + +This is a test paragraph with **bold** and *italic* text. + +| Column A | Column B | +|----------|----------| +| Value 1 | Value 2 | +| Value 3 | Value 4 | + +## Section Two + +- Bullet point one +- Bullet point two +- Bullet point three + +--- + +*Generated by test* +""" + + def test_weasyprint_available(self, generator): + """Test that WeasyPrint is detected as available.""" + assert generator.weasyprint_available is True + + def test_markdown_to_html(self, generator, sample_markdown): + """Test markdown to HTML conversion.""" + html = generator.markdown_to_html(sample_markdown) + + assert "" in html + assert "" in html + assert "