Day 23 – Weekly Project: BIM Data Analyser CLI


 

🏗️ Day 23 – Weekly Project: BIM Data Analyser CLI

1. Learning Objectives

By the end of Day 23, you will be able to:

  • Build a complete command‑line tool that integrates all Phase 3 concepts (generators, decorators, context managers, IFC processing, numpy, visualisation).
  • Read an IFC model and extract building elements (walls, slabs, columns, beams) with their properties.
  • Compute quantities (volumes, areas) grouped by type and by storey using generators for memory efficiency.
  • Perform a basic clash check by testing bounding box intersections between element pairs.
  • Export results as a CSV report and an interactive HTML dashboard using plotly.
  • Package the tool as a self‑contained Python script that accepts command‑line arguments.

2. Concept Explanation

2.1 What We’re Building

The BIM Data Analyser CLI is a command‑line tool that processes an IFC file and produces:

  • A quantity take‑off (volumes, areas, counts) grouped by element type and storey.
  • A clash detection report that flags pairs of elements whose bounding boxes intersect.
  • An interactive dashboard (HTML) for visualising the results.

This tool mirrors real‑world BIM automation workflows used in design offices for early‑stage coordination and quantity estimation.

2.2 Architecture

bim_analyser.py
  ├── CLI entry point (argparse)
  ├── IFC reader (generators)
  ├── Quantity calculator (type/storey grouping)
  ├── Clash detector (bounding box test)
  ├── Report writer (CSV + plotly HTML)
  └── Main orchestrator with decorators (timing, logging)

2.3 Design Decisions

  • Generators (Day 16) – lazy iteration over thousands of IFC elements without loading all into memory.
  • Decorators (Day 17) – timing and logging for each analysis stage.
  • Context managers (Day 18) – safe file handling for CSV and HTML output.
  • IfcOpenShell (Day 19) – reading IFC entities and property sets.
  • numpy (Day 20) – bounding box calculations.
  • plotly (Day 21) – interactive dashboard.

3. Code Examples

3.1 Project Structure

bim_analyser/
    __init__.py
    cli.py              # argparse entry point
    ifc_reader.py       # generator for IFC elements
    quantities.py       # quantity aggregation
    clash.py            # bounding box clash detection
    report.py           # CSV + HTML report generation
    utils.py            # decorators, context managers

3.2 IFC Reader (Generator)

# ifc_reader.py
import ifcopenshell
from ifcopenshell.util import element as ifc_element

def read_elements_lazy(ifc_path):
    """Generator that yields IFC elements one at a time with key properties."""
    model = ifcopenshell.open(ifc_path)
    # Get all storeys first for lookup
    storeys = {s.GlobalId: s.Name for s in model.by_type("IfcBuildingStorey")}
    
    for etype in ["IfcWall", "IfcSlab", "IfcColumn", "IfcBeam", "IfcWindow", "IfcDoor"]:
        elements = model.by_type(etype)
        for elem in elements:
            # Find storey
            storey_name = "Unknown"
            for rel in getattr(elem, "ContainedInStructure", []):
                if rel.RelatingStructure.is_a("IfcBuildingStorey"):
                    storey_name = rel.RelatingStructure.Name
                    break
            
            # Get quantity set
            qsets = ifc_element.get_psets(elem, psets_only=False)
            qto_name = f"Qto_{etype.replace('Ifc','')}BaseQuantities"
            volume = None
            area = None
            if qto_name in qsets:
                volume = qsets[qto_name].get("GrossVolume")
                area = qsets[qto_name].get("GrossArea")
            
            yield {
                "global_id": elem.GlobalId,
                "type": etype.replace("Ifc", ""),
                "name": elem.Name or "",
                "storey": storey_name,
                "volume": volume or 0.0,
                "area": area or 0.0
            }

3.3 Quantity Aggregator

# quantities.py
def aggregate_by_type(elements):
    """elements is a generator – returns dict of type -> (count, total_volume, total_area)"""
    result = {}
    for elem in elements:
        etype = elem["type"]
        if etype not in result:
            result[etype] = {"count": 0, "volume": 0.0, "area": 0.0}
        result[etype]["count"] += 1
        result[etype]["volume"] += elem["volume"]
        result[etype]["area"] += elem["area"]
    return result

def aggregate_by_storey(elements):
    """Returns dict of storey -> dict of type -> (count, volume, area)"""
    result = {}
    for elem in elements:
        storey = elem["storey"]
        etype = elem["type"]
        if storey not in result:
            result[storey] = {}
        if etype not in result[storey]:
            result[storey][etype] = {"count": 0, "volume": 0.0, "area": 0.0}
        result[storey][etype]["count"] += 1
        result[storey][etype]["volume"] += elem["volume"]
        result[storey][etype]["area"] += elem["area"]
    return result

3.4 Clash Detection (Bounding Box)

# clash.py
import numpy as np

def get_bounding_box(elem):
    """Extract bounding box from IFC element geometry (simplified)."""
    # In a full implementation, use ifcopenshell.geometry to get the shape
    # For this simplified version, we use the quantity dimensions
    # Returns (xmin, ymin, zmin, xmax, ymax, zmax) or None
    try:
        placement = elem.ObjectPlacement
        if not placement:
            return None
        # Simplified: assume we have width, depth, height from quantities
        # This is a placeholder – real implementation requires geometry parsing
        return None  # Simplified for this exercise
    except:
        return None

def check_clashes_naive(elements):
    """Simple clash check using element names (demonstration)."""
    # In real practice, this would use bounding box overlap tests.
    # For this exercise, we'll flag elements on the same storey with similar names.
    clashes = []
    elem_list = list(elements)  # Need to consume generator
    for i, e1 in enumerate(elem_list):
        for j, e2 in enumerate(elem_list):
            if i >= j:
                continue
            # Simple heuristic: same type and same storey = potential clash
            if e1["type"] == e2["type"] and e1["storey"] == e2["storey"]:
                clashes.append({
                    "element_1": e1["name"] or e1["global_id"],
                    "element_2": e2["name"] or e2["global_id"],
                    "storey": e1["storey"],
                    "type": e1["type"]
                })
    return clashes

3.5 Report Writer (CSV + HTML Dashboard)

# report.py
import csv
from contextlib import contextmanager
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pandas as pd

@contextmanager
def export_report(output_prefix):
    """Context manager for report export."""
    print(f"Exporting reports with prefix: {output_prefix}")
    yield output_prefix
    print(f"Reports saved to {output_prefix}_*")

def write_quantity_csv(qty_by_type, filename):
    with open(filename, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["Type", "Count", "Volume (m³)", "Area (m²)"])
        for etype, data in sorted(qty_by_type.items()):
            writer.writerow([etype, data["count"], f"{data['volume']:.2f}", f"{data['area']:.2f}"])
    print(f"Quantity CSV: {filename}")

def write_clash_csv(clashes, filename):
    with open(filename, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["Element 1", "Element 2", "Storey", "Type"])
        for clash in clashes:
            writer.writerow([clash["element_1"], clash["element_2"], clash["storey"], clash["type"]])
    print(f"Clash CSV: {filename}")

def create_dashboard(qty_by_type, qty_by_storey, clashes, output_html):
    """Create an interactive HTML dashboard with plotly."""
    # Quantity by type bar chart
    df_type = pd.DataFrame([
        {"Type": t, "Count": d["count"], "Volume": d["volume"], "Area": d["area"]}
        for t, d in qty_by_type.items()
    ])
    fig1 = px.bar(df_type, x="Type", y="Volume", title="Volume by Element Type",
                   hover_data=["Count", "Area"], color="Type")
    
    # Quantity by storey pie chart (total volume per storey)
    storey_volumes = {}
    for storey, types in qty_by_storey.items():
        storey_volumes[storey] = sum(d["volume"] for d in types.values())
    df_storey = pd.DataFrame([
        {"Storey": s, "Volume": v} for s, v in storey_volumes.items()
    ])
    fig2 = px.pie(df_storey, values="Volume", names="Storey", title="Volume Distribution by Storey")
    
    # Clash table as a heatmap (if clashes exist)
    if clashes:
        clash_df = pd.DataFrame(clashes)
        clash_summary = clash_df.groupby(["storey", "type"]).size().reset_index(name="clash_count")
        fig3 = px.scatter(clash_summary, x="storey", y="type", size="clash_count", 
                          title="Clash Summary (bubble size = count)")
    else:
        fig3 = go.Figure()
        fig3.add_annotation(text="No clashes detected", x=0.5, y=0.5, showarrow=False)
    
    # Combine into dashboard
    fig = make_subplots(rows=2, cols=2, 
                        subplot_titles=("Volume by Type", "Volume by Storey", "Clash Summary", ""),
                        specs=[[{"type": "bar"}, {"type": "pie"}], [{"type": "scatter"}, {}]])
    
    for trace in fig1.data:
        fig.add_trace(trace, row=1, col=1)
    for trace in fig2.data:
        fig.add_trace(trace, row=1, col=2)
    for trace in fig3.data:
        fig.add_trace(trace, row=2, col=1)
    
    fig.update_layout(height=800, width=1200, title_text="BIM Analysis Dashboard")
    fig.write_html(output_html)
    print(f"Dashboard: {output_html}")

3.6 Decorators for Timing and Logging

# utils.py
import time
from datetime import datetime

def timer(func):
    """Decorator that prints execution time."""
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"[TIMER] {func.__name__} took {elapsed:.2f}s")
        return result
    return wrapper

def log_call(func):
    """Decorator that logs function calls."""
    def wrapper(*args, **kwargs):
        ts = datetime.now().strftime("%H:%M:%S")
        print(f"[LOG {ts}] Calling {func.__name__}...")
        result = func(*args, **kwargs)
        print(f"[LOG {ts}] {func.__name__} completed.")
        return result
    return wrapper

3.7 CLI Entry Point

# cli.py
import argparse
from .ifc_reader import read_elements_lazy
from .quantities import aggregate_by_type, aggregate_by_storey
from .clash import check_clashes_naive
from .report import write_quantity_csv, write_clash_csv, create_dashboard, export_report
from .utils import timer, log_call

@timer
@log_call
def run_analysis(ifc_path, output_prefix):
    """Main analysis pipeline."""
    print(f"\n=== BIM Analyser: {ifc_path} ===\n")
    
    # Step 1: Read elements (lazy generator)
    elements = read_elements_lazy(ifc_path)
    
    # Step 2: Compute quantities (need to consume generator – use list for multi-pass)
    # In production, you might stream to disk or use itertools.tee
    elem_list = list(elements)
    print(f"Total elements loaded: {len(elem_list)}")
    
    qty_by_type = aggregate_by_type(elem_list)
    qty_by_storey = aggregate_by_storey(elem_list)
    
    # Print summary
    print("\n--- Quantity Summary by Type ---")
    for etype, data in sorted(qty_by_type.items()):
        print(f"  {etype}: {data['count']} elements, {data['volume']:.2f} m³, {data['area']:.2f} m²")
    
    # Step 3: Clash detection
    clashes = check_clashes_naive(elem_list)
    print(f"\n--- Clashes: {len(clashes)} detected ---")
    
    # Step 4: Export reports
    with export_report(output_prefix):
        write_quantity_csv(qty_by_type, f"{output_prefix}_quantities.csv")
        write_clash_csv(clashes, f"{output_prefix}_clashes.csv")
        create_dashboard(qty_by_type, qty_by_storey, clashes, f"{output_prefix}_dashboard.html")
    
    print("\n=== Analysis Complete ===")

def main():
    parser = argparse.ArgumentParser(description="BIM Data Analyser CLI")
    parser.add_argument("ifc_file", help="Path to IFC file")
    parser.add_argument("-o", "--output", default="bim_report", help="Output prefix")
    args = parser.parse_args()
    run_analysis(args.ifc_file, args.output)

if __name__ == "__main__":
    main()

4. Hands‑on Exercises (3–5 Problems)

Problem 1 – Element count generator
Write a generator that iterates over an IFC file and yields only the element type and GlobalId. Use it to count how many walls are in the model without loading all elements.

Problem 2 – Volume accumulator with @timer
Decorate the aggregate_by_type function with the @timer decorator. Run it on an IFC file and observe the timing.

Problem 3 – Basic clash by name matching
Extend the clash detector to flag elements whose names contain the same number (e.g., "Wall-101" and "Column-101" on the same storey).

Problem 4 – CSV report context manager
Write a context manager csv_writer(filename, headers) that opens a CSV file, writes headers, yields a writer object, and auto‑closes on exit.

Problem 5 – Dashboard with storey filter
Modify the dashboard to include a dropdown that filters the bar chart by storey. (Use plotly dropdown update menus.)

Solutions (attempt first):

# P1
def element_ids(ifc_path):
    model = ifcopenshell.open(ifc_path)
    for etype in ["IfcWall", "IfcSlab", "IfcColumn", "IfcBeam"]:
        for elem in model.by_type(etype):
            yield elem.is_a(), elem.GlobalId

count = 0
for etype, gid in element_ids("model.ifc"):
    if etype == "IfcWall":
        count += 1
print(f"Walls: {count}")

# P2 – already decorated in cli.py

# P3
def clash_by_name(elements):
    elem_list = list(elements)
    clashes = []
    for i, e1 in enumerate(elem_list):
        for j, e2 in enumerate(elem_list):
            if i >= j: continue
            # Extract numbers from names
            import re
            nums1 = re.findall(r'\d+', e1.get("name",""))
            nums2 = re.findall(r'\d+', e2.get("name",""))
            if nums1 and nums2 and nums1[0] == nums2[0]:
                clashes.append({"e1": e1["name"], "e2": e2["name"]})
    return clashes

# P4
@contextmanager
def csv_writer(filename, headers):
    with open(filename, "w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(headers)
        yield writer

# P5 – (requires plotly dash or dropdowns – advanced)

5. Applied Challenge Task (The Full Project)

🏗️ Complete BIM Data Analyser CLI

Build the full tool as described in the code examples above. Your tool must:

  1. Accept an IFC file path as a command‑line argument (use argparse).

  2. Read elements lazily using a generator that yields dictionaries with type, name, storey, volume, area.

  3. Compute quantities aggregated by type and by storey.

  4. Perform basic clash detection – at minimum, flag elements of the same type on the same storey (or implement bounding box overlap using ifcopenshell.geometry if you have it).

  5. Export:

    • A CSV file with quantities by type.
    • A CSV file with clash results.
    • An interactive HTML dashboard with at least three charts.
  6. Use decorators for timing (each major step) and logging (start/end messages).

  7. Use a context manager for the report export workflow.

  8. Print a clear summary to the console.

Bonus challenges:

  • Add a --filter-storey argument to analyse only one storey.
  • Implement real bounding box clash detection using ifcopenshell.geometry (requires pythonocc-core).
  • Generate a PDF report using reportlab or weasyprint.
  • Package the tool with setuptools so it can be installed with pip install.

Example invocation:

python -m bim_analyser.cli Duplex_A_20110907.ifc -o duplex_report

Expected console output:

=== BIM Analyser: Duplex_A_20110907.ifc ===

[LOG 14:30:01] Calling read_elements_lazy...
Total elements loaded: 342
[LOG 14:30:02] read_elements_lazy completed.

--- Quantity Summary by Type ---
  Beam: 12 elements, 0.00 m³, 0.00 m²
  Column: 24 elements, 0.00 m³, 0.00 m²
  Slab: 6 elements, 72.50 m³, 290.00 m²
  Wall: 42 elements, 85.20 m³, 0.00 m²
  Window: 18 elements, 0.00 m³, 36.00 m²
  Door: 12 elements, 0.00 m³, 24.00 m²

--- Clashes: 3 detected ---
Exporting reports with prefix: duplex_report
Quantity CSV: duplex_report_quantities.csv
Clash CSV: duplex_report_clashes.csv
Dashboard: duplex_report_dashboard.html
Reports saved to duplex_report_*

[TIMER] run_analysis took 4.23s

=== Analysis Complete ===

6. Phase 3 Review Summary

Over Days 16–23, you have learned:

DayTopicKey AEC Skill
16Iterators & GeneratorsLazy traversal of large IFC models
17DecoratorsTiming, logging, caching for analysis functions
18Context ManagersSafe file handling, temporary settings
19BIM Data with IfcOpenShellRead IFC, extract walls, query properties
20Advanced numpy & scipySolve structural systems, optimise trusses
21Advanced visualisationInteractive 3D plots, dashboards with plotly
22Scripting CAD environmentsAutomate Rhino/Grasshopper/Dynamo modelling
23Weekly ProjectIntegrated BIM data analyser CLI

You are now capable of:

  • Reading and analysing real BIM models programmatically.
  • Performing engineering calculations with numpy/scipy.
  • Building interactive visualisations and dashboards.
  • Scripting CAD tools for automated geometry generation.
  • Structuring robust, reusable code with advanced Python features.

Key takeaway:
Phase 3 has given you professional‑grade skills for AEC computation. You can now work with real project data, perform analysis, and produce shareable outputs – all from Python.


7. Preview of Phase 4 (Days 24–30)

Tomorrow we begin Phase 4 – Professional AEC Automation. You will cover:

DayTopicWhat You'll Build
24Code optimisation & profilingOptimise geometry algorithms for speed
25Data structures & algorithms for AECGraphs for connectivity, octrees for search
26Design patterns for AEC toolsVisitor for model traversal, Composite
27Building a web API (Flask/FastAPI)API that returns deflection given inputs
28GUI development (Streamlit/Tkinter)Energy model parameter editor
29Testing, packaging, Gitpytest, setuptools, CI/CD for AEC tools
30Capstone ProjectIntegrated design‑analysis automation system

Prepare to build production‑ready, deployable tools that integrate with real engineering workflows.

Comments

EARN AT THE COMFORT OF YOUR HOME

Sponsored content

Popular posts from this blog

Day 5 – Functions, Scope, and Docstrings

Day 10 – Exception Handling and Debugging

Day 1 – Python Foundations for AEC Professionals