ArcGIS Blog

Architecture, Engineering, and Construction

ArcGIS Pro

Update a 4D simulation with a scene layer and hosted 3D object - Part 2

By David Alvarez and Jared Kohler, Brett Heist and Daniel Chantlos

4D simulation - Construction delay
4D simulation - Construction delay

As outlined in Part 1 of this blog series, 4D simulation integrates time‑based data into a building information model (BIM), transforming a static 3D model into a dynamic representation of the construction process. By linking the model to the project schedule, teams can visualize how activities progress over time and gain deeper insight into sequencing, dependencies, and potential risks.

In this scenario, we focus on a schedule delay caused by a severe weather event. This disruption impacts Phase Two and all subsequent phases, shifting the overall timeline by several weeks. Through the 4D simulation, we can clearly illustrate how this delay propagates through the project and affects downstream activities.

Key points of 4D simulation

  • Improved planning—4D simulation turns static schedules into dynamic visuals, helping teams spot sequencing issues and create more accurate timelines.
  • Better communication—Visual schedules make it easier for all stakeholders to understand the construction process.
  • Risk mitigation—By simulating activities in advance, teams can detect clashes and conflicts early, reducing rework and delays.
  • Optimized resources—More accurate sequencing leads to better allocation of labor, equipment, and materials.

Before you begin

What you need

  • ArcGIS Notebook included with this post
  • ArcGIS Pro 3.2 or later
  • ArcGIS Online

The core of 4D simulation lies in synchronizing BIM models with the project schedule. By importing detailed 3D models and linking them directly to timeline data, 4D BIM enables a step‑by‑step visualization of how construction will progress. This alignment ensures that planned activities correspond with actual construction sequences, ultimately improving coordination and overall project efficiency.

Updated the 4D simulation with schedule changes manually

1. Data preparation

  • Start ArcGIS Pro.
  • Navigate to your portal.  Add and open scene created in Part 1.

Repeat this step for each layer.

  • Using the Add Join geoprocessing tool, join the service to the spreadsheet using Sched_TaskID as the primary key and Task ID on the spreadsheet as foreign key.

The schedule coming from the spreadsheet must be formatted so each column represents each of the fields added on the prior step.

  • Populate the fields with schedule information using the Calculate Fields (multiple) geoprocessing tool.

2. Rebuild the cache

  • Go to ArcGIS Online.
  • Navigate to the scene that was created in Part 1.
  • Click the scene.
  • Navigate to the content section.
Content section (3D Scene layer) in AGOL

Repeat the flowing steps for each of the layers.

  • Click the Roof Scene layer.
  • Click Settings.
Scene layer - 3D object (hosted) setting section
  • Click Scene layer – 3D object (hosted).
Scene Layer 3D object hosted - Setting
  • Click Rebulid Cache.
  • Select Full cache.
Rebuild the scene layer cache to update any changes made in the feature layer used to publish this scene layer
  • Click Rebuild cache.

After the cache has been rebuilt for each of the scene layers, the scene will display the updated 4D simulation.

Updated the 4D simulation with schedule changes automatically

The following code shows the workflow updated with the 4D simulation using a Scene layer – 3D object (hosted) and a construction schedule. This is an overview of the code, and the provided ArcGIS Notebook contains further details and explanations of the code.


"""4D BIM Schedule Update
This notebook pushes schedule updates from an Excel spreadsheet into the hosted feature layers backing a Web Scene, then refreshes the associated Scene Layer caches.

**Workflow:**
1. Load the updated construction schedule from Excel
2. Connect to ArcGIS Online
3. Read the Web Scene JSON to find all scene layers it contains
4. For each scene layer, discover its associated feature layer via `related_items`
5. Update feature layer attributes (start date, end date, duration)
6. Refresh the scene layer cache
"""

import re
import datetime as dt

import openpyxl
from arcgis.gis import GIS
from arcgis.features import FeatureLayer
from arcgis.layers import SceneLayer

# User Configuration¶
# Set the path to your Excel schedule and the Web Scene item id.


# Excel schedule file
EXCEL_PATH  = r"Schedules\tower_schedule_updated.xlsx"  # update to your file path
SHEET_NAME  = "Construction Schedule"
HEADER_ROW  = 4   # 1-based row containing column headers

# Web Scene item id on ArcGIS Online
WEB_SCENE_ITEM_ID = "your_web_scene_item_id_here"

# Field Definitions
# Column names in the Excel sheet and their corresponding field names on the hosted feature layers.

# Excel column names
TASK_ID_COL   = "Task ID"
START_DATE_COL = "Start Date"
END_DATE_COL   = "End Date"
DURATION_COL   = "Duration (d)"

# Feature layer field names
FIELD_TASK_ID    = "Sched_TaskID"
FIELD_START_DATE = "Sched_StartDate"
FIELD_END_DATE   = "Sched_EndDate"
# Duration field name varies per layer — we search for any field containing "duration"

# Rows to skip in the Excel sheet
PHASE_HEADER_PATTERN = re.compile(r"^\s*\u25b6")   # rows starting with ▶
SKIP_TASK_IDS = {"TOTALS"}

# Step 1 — Load Schedule from Excel
# Read the Construction Schedule sheet and build a lookup by Task ID.

wb = openpyxl.load_workbook(EXCEL_PATH, data_only=True)
ws = wb[SHEET_NAME]

# Read headers from the configured header row
headers = {}
for col in range(1, ws.max_column + 1):
    val = ws.cell(row=HEADER_ROW, column=col).value
    if val:
        headers[val.strip()] = col

# Build schedule lookup: {task_id: {start_date, end_date, duration}}
schedule = {}
for row in range(HEADER_ROW + 1, ws.max_row + 1):
    task_id = ws.cell(row=row, column=headers[TASK_ID_COL]).value
    if task_id is None:
        continue
    task_id = str(task_id).strip()
    if PHASE_HEADER_PATTERN.match(task_id) or task_id in SKIP_TASK_IDS:
        continue

    schedule[task_id] = {
        "start_date": ws.cell(row=row, column=headers[START_DATE_COL]).value,
        "end_date":   ws.cell(row=row, column=headers[END_DATE_COL]).value,
        "duration":   ws.cell(row=row, column=headers[DURATION_COL]).value,
    }

print(f"Loaded {len(schedule)} task rows from schedule.")

# Step 2 — Connect to ArcGIS Online
# Sign in using the active ArcGIS Pro session.

gis = GIS("pro", verify_cert=True)
print(f"Connected as: {gis.properties.user.username}")

# Step 3 — Discover Scene Layers from the Web Scene
# Read the Web Scene JSON and extract the scene layer item ids from its operational layers.

def to_epoch_ms(value):
    """Convert a date or datetime to epoch milliseconds (UTC) for AGOL date fields."""
    if value is None:
        return None
    if isinstance(value, dt.datetime):
        d = value
    elif isinstance(value, dt.date):
        d = dt.datetime(value.year, value.month, value.day)
    else:
        return None
    return int(d.replace(tzinfo=dt.timezone.utc).timestamp() * 1000)


# Get the Web Scene item and read its JSON
web_scene_item = gis.content.get(WEB_SCENE_ITEM_ID)
web_scene_data = web_scene_item.get_data()

# Extract scene layer item ids from the operational layers
scene_layer_items = []
for lyr in web_scene_data.get("operationalLayers", []):
    item_id = lyr.get("itemId")
    if item_id:
        item = gis.content.get(item_id)
        if item and item.type == "Scene Service":
            scene_layer_items.append(item)
            print(f"  found scene layer: {item.title} ({item.id})")

print(f"\n{len(scene_layer_items)} scene layers found in Web Scene.")

# Step 4 — Update Feature Layers & Refresh Caches
# For each scene layer, discover its associated feature layer, push the schedule updates, then refresh the scene cache.

summary = []

for scene_item in scene_layer_items:
    print(f"\n=== {scene_item.title} ===")

    # Discover the associated feature layer
    related = scene_item.related_items(rel_type="Service2Service", direction="reverse")
    fl_item = next((i for i in related if i.type == "Feature Service"), None)

    if fl_item is None:
        print("  [warn] no associated feature layer found — skipping")
        summary.append({"layer": scene_item.title, "status": "feature layer not found"})
        continue

    print(f"  feature layer: {fl_item.title} ({fl_item.id})")
    flayer = FeatureLayer.fromitem(fl_item)
    oid_field = flayer.properties.objectIdField

    # Find the duration field (name varies per layer)
    duration_field = next(
        (f["name"] for f in flayer.properties.fields if "duration" in f["name"].lower()),
        None
    )
    if duration_field:
        print(f"  duration field: {duration_field}")
    else:
        print("  [warn] no duration field found — duration will be skipped")

    # Query all features and build updates
    fset = flayer.query(where="1=1", out_fields=[oid_field, FIELD_TASK_ID], return_geometry=False)
    updates = []
    for feature in fset.features:
        task_id = feature.attributes.get(FIELD_TASK_ID)
        if not task_id:
            continue
        row = schedule.get(str(task_id).strip())
        if row is None:
            continue

        attrs = {
            oid_field:        feature.attributes[oid_field],
            FIELD_START_DATE: to_epoch_ms(row["start_date"]),
            FIELD_END_DATE:   to_epoch_ms(row["end_date"]),
        }
        if duration_field and row["duration"] is not None:
            attrs[duration_field] = row["duration"]
        updates.append({"attributes": attrs})

    if updates:
        flayer.edit_features(updates=updates)
        print(f"  updated {len(updates)} features")
    else:
        print("  [warn] no matching records found")

    # Rebuild the scene layer cache
    print("  rebuilding scene layer cache...")
    scene_layer = SceneLayer(scene_item.url, gis=gis)
    cache_job = scene_layer.manager.rebuild_cache(layers=0)
    print(f"  cache rebuild submitted: {cache_job}")

    # Poll for completion
    if hasattr(cache_job, 'url'):
        job_id = cache_job.url.rsplit("/", 1)[-1]
        import time
        for _ in range(60):  # up to 10 minutes
            detail = gis._con.get(f"{scene_layer.manager.url}/jobs/{job_id}", {"f": "json"})
            status = detail.get("status", "unknown")
            if status in ("Completed", "Failed"):
                print(f"  cache rebuild {status}")
                break
            time.sleep(10)
        else:
            status = "timeout"
            print("  cache rebuild timed out (10 min)")
    else:
        status = str(cache_job)

    summary.append({"layer": scene_item.title, "status": "ok", "updates": len(updates), "cache": status})
    
# Step 5 — Update Web Scene Time Slider
# Patch the Web Scene JSON with a time slider reflecting the schedule's date range.

import json

# Compute time range from schedule
all_starts = [v["start_date"] for v in schedule.values() if v.get("start_date")]
all_ends   = [v["end_date"]   for v in schedule.values() if v.get("end_date")]
start_ms = int(min(all_starts).timestamp() * 1000) if all_starts else 0
end_ms   = int(max(all_ends).timestamp() * 1000)   if all_ends   else 0
total_days = (end_ms - start_ms) / (1000 * 60 * 60 * 24)
interval_days = max(1, int(total_days / 40))

# Patch the web scene JSON
data = web_scene_item.get_data()

data["widgets"] = {
    "timeSlider": {
        "properties": {
            "startTime": start_ms,
            "endTime": end_ms,
            "currentTimeExtent": [None, end_ms],
            "thumbCount": 1,
            "thumbMovingRate": 300,
            "timeStopInterval": {
                "interval": interval_days,
                "units": "esriTimeUnitsDays"
            }
        }
    }
}

if "initialState" not in data:
    data["initialState"] = {}
data["initialState"]["timeExtent"] = [None, end_ms]

web_scene_item.update(item_properties={}, data=json.dumps(data))
print(f"Time slider updated: {min(all_starts)}{max(all_ends)} ({interval_days}-day intervals over {int(total_days)} days)")

# Summary
# Review the results for each layer.

print("\n=== SUMMARY ===")
for entry in summary:
    layer  = entry["layer"]
    status = entry["status"]
    if status != "ok":
        print(f"  {layer}: {status}")
    else:
        updates = entry["updates"]
        cache   = entry.get("cache", "not attempted")
        print(f"  {layer}: {updates} features updated, cache → {cache}")

Figure 1 shows an overview of the steps necessary to create the 4D simulation using Scene layer – 3D object (hosted) and how to keep it up to date.

4D simulation for a scene layer and hosted 3D object
Figure 1. Workflow to create a 4D simulation for a scene layer and hosted 3D object

Conclusion

The schedule stops being a spreadsheet someone must interpret and becomes something the whole team can watch, the structure rising phase by phase, right where it will stand. By using ArcGIS Online for a dynamic and interactive representation of the construction process, 4D simulation enables project teams and stakeholders to have a more up-to-date vision of the execution of the project.
This illustrates how 4D simulation can enhance BIM projects and provide significant advantages in project planning and scheduling, communication and collaboration, risk mitigation, and resource management.

Download the ArcGIS Notebook

Share this article

Leave a Reply