Schedule Sync & Critical Path Integration
Construction project tracking breaks the moment the schedule stops being data. A general contractor’s cost ledger, RFI log, and submittal register can all be modeled, validated, and audited, but the schedule that gives every one of those records a deadline usually lives only inside a Primavera P6 or Microsoft Project file that no program ever opens. The scheduler updates it once a month, exports a PDF, emails it, and the automation that governs the rest of the project treats the finish date as a rumor. When a change order adds fourteen days to a structural sequence, nobody’s system notices; the number moves inside a .xer file and stays there until the next update meeting, by which point the float it consumed is already gone. This layer closes that gap. It parses P6 and MS Project exports into typed activity records, binds each activity to the same work breakdown structure the cost ledger uses, computes the critical path and total float, diffs every update against both the prior update and the contract baseline, and writes a durable schedule-impact record whenever a change order or a late RFI response moves a date that matters.
Framing the problem by the failures a manual schedule process produces makes each design decision downstream easier to justify, because every one of these is a missing-integration problem rather than a scheduling mistake.
- Undetected critical-path shifts. An approved change order extends a foundation activity, the finish date slides four days to the right, and because no program parsed the new export against the last one, no one logs the shift until the monthly update — long after the contractual notice window for a delay claim would have opened. Deterministic critical path delta detection reads the change the instant the file lands, not at the next meeting.
- Total-float erosion that surfaces too late. Float is consumed quietly. A near-critical activity loses a week of total float across two updates and becomes the new controlling path, but without a programmatic comparison against the accepted plan the erosion only appears when the activity finally goes critical and the recovery options are gone. Comparing every update against the baseline is the province of schedule baseline variance.
- Out-of-sequence progress corrupting earned value. A crew reports an activity as started before its predecessor finished, the parser accepts the raw actual dates uncritically, and the earned-value roll-up computes a percent-complete that no longer reconciles with physical progress. Capturing that as a first-class, reviewable event rather than a silent arithmetic error is what schedule impact logging exists to do.
- RFI delays that become slippage with no documented basis. A request for information about a structural connection sits unanswered for three weeks; the affected activity slips, but because the schedule was never linked to the RFI log, the general contractor cannot point to the specific activity, the specific float it burned, and the specific date the delay began — exactly the evidence a time-extension request needs. Binding the RFI stream from automated document ingestion to the affected activities, using the same discipline attribution defined in RFI schema design, turns a lost argument into a documented one.
Each of these is fixed the same way: treat the schedule export as a typed, versioned artifact that a pipeline parses, binds, diffs, and logs deterministically, rather than a file a human interprets once a month.
Architecture Overview
The schedule layer sits downstream of the scheduling tools and the construction data architecture and taxonomy, and upstream of every date-driven report. An export arrives — a P6 XER dump or a Microsoft Project XML save — and the pipeline parses it into typed activities, binds each activity to a canonical WBS element, computes the critical path and total float, and diffs the result against both the previous update and the accepted baseline. The deltas that fall out of that diff are not committed blindly; they are routed by the same confidence bands the rest of the site uses, because binding a free-text activity name to a WBS node is itself a fuzzy match that can be wrong. The change-order and RFI feeds enter from the document pipeline and are correlated against the affected activities, so a moved date can be attributed to a cause rather than left unexplained.
Read top to bottom, the pipeline mirrors the taxonomy layer’s discipline: parsing decides whether the export is legible, WBS binding decides where each activity belongs in the project, critical-path computation decides which activities actually control the finish date, the diff decides what moved relative to the plan, and the confidence bands decide how much to trust the machine’s attribution before a record is committed. A malformed export never silently produces zero activities; a delta whose WBS binding is a shaky guess is held for a person rather than logged as fact; and every date that moves is written to an audit trail that can answer, months later, exactly when it moved and why.
The Typed Activity Contract
The backbone of the layer is a single typed record: the schedule activity. P6 and MS Project both model a schedule as a set of activities with durations, relationships, and dates, but they name and encode those fields completely differently, so the first job of the pipeline is to collapse both dialects into one canonical shape that the rest of the system can reason about. That shape binds each activity to the same PROJ-NNN-DIV-NN work breakdown element the cost ledger uses — the crosswalk built in WBS mapping — so a slipped activity can be tied directly to the budget scope it threatens. Discipline is a closed Literal vocabulary rather than a free string, dates are timezone-aware because a project with a site in one zone and a home office in another cannot do float arithmetic on naive timestamps, and percent-complete is a Decimal because earned-value math that touches money must never inherit binary floating-point drift. Total float is carried as an integer count of work days and is_critical is a boolean the model refuses to let disagree with it — a zero-float activity that claims not to be critical is a contradiction the contract rejects at construction time.
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from decimal import Decimal
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(message)s")
logger = logging.getLogger("schedule.activity")
Discipline = Literal["ARCH", "STR", "MEP", "CIV", "ELEC", "PLMB"]
ScheduleSource = Literal["p6_xer", "msp_xml"]
WBS_PATTERN = r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$" # e.g. PROJ-014-STR-02
class ScheduleActivity(BaseModel):
"""One activity, canonicalized from a P6 XER or MS Project XML export."""
schema_version: Literal["1.0"] = "1.0"
activity_id: str = Field(min_length=1, max_length=48) # A1010, or P6 task_code
wbs_node: str # PROJ-NNN-DIV-NN
discipline: Discipline
name: str = Field(min_length=2, max_length=200)
source: ScheduleSource
planned_start: datetime
planned_finish: datetime
actual_start: Optional[datetime] = None
actual_finish: Optional[datetime] = None
total_float_days: int # work days; may be negative
is_critical: bool
percent_complete: Decimal = Field(ge=Decimal("0"), le=Decimal("100"), decimal_places=2)
@field_validator("wbs_node")
@classmethod
def validate_wbs(cls, v: str) -> str:
if not re.fullmatch(WBS_PATTERN, v):
raise ValueError("wbs_node must follow 'PROJ-NNN-DIV-NN' (e.g. PROJ-014-STR-02)")
return v
@field_validator("planned_start", "planned_finish", "actual_start", "actual_finish")
@classmethod
def require_tz_aware(cls, v: Optional[datetime]) -> Optional[datetime]:
# Float and delay math must be zone-safe; a naive stamp is refused, not guessed.
if v is not None and v.tzinfo is None:
raise ValueError("schedule datetimes must be timezone-aware (ISO 8601)")
return v.astimezone(timezone.utc) if v is not None else None
@model_validator(mode="after")
def check_coherence(self) -> "ScheduleActivity":
if self.planned_finish < self.planned_start:
raise ValueError("planned_finish precedes planned_start")
# An activity with no positive float IS on the critical path, by definition.
expected_critical = self.total_float_days <= 0
if self.is_critical != expected_critical:
raise ValueError(
f"is_critical={self.is_critical} contradicts total_float_days="
f"{self.total_float_days} (critical iff float <= 0)"
)
return selfBecause the WBS node is regex-validated, the discipline is a Literal, and criticality is cross-checked against float in a model_validator, a parser cannot emit an activity that is internally inconsistent — the failure is raised where the bad record is built and routed deterministically, rather than discovered when a forecast stops reconciling. With the contract in place, deciding whether an activity is on the controlling path becomes a pure function over typed data. The helper below classifies criticality and near-criticality against a configurable float threshold, using Decimal to convert an early-versus-late finish gap into whole work days precisely and emitting a structured log line for every activity that has gone critical since it was last seen.
NEAR_CRITICAL_DAYS = 5 # activities within a work week of critical get flagged
SECONDS_PER_WORKDAY = Decimal("28800") # an 8-hour construction work day
def total_float_from_dates(early_finish: datetime, late_finish: datetime) -> int:
"""Floor the late-minus-early gap to whole work days using Decimal, never float."""
delta_seconds = Decimal((late_finish - early_finish).total_seconds())
return int(delta_seconds / SECONDS_PER_WORKDAY) # truncates toward zero
def classify_criticality(activities: list[ScheduleActivity]) -> dict[str, str]:
"""Tag each activity critical / near_critical / has_float and log the critical set."""
verdicts: dict[str, str] = {}
for act in activities:
if act.total_float_days <= 0:
verdicts[act.activity_id] = "critical"
logger.info(
"activity.critical",
extra={"activity": act.activity_id, "wbs": act.wbs_node,
"float_days": act.total_float_days},
)
elif act.total_float_days <= NEAR_CRITICAL_DAYS:
verdicts[act.activity_id] = "near_critical"
else:
verdicts[act.activity_id] = "has_float"
return verdictsSubsystem Survey
The layer is assembled from focused subsystems, each owning one part of turning a raw schedule file into a trusted, attributable record of what moved. The paragraphs below survey each one and link to its detailed implementation guide.
Primavera P6 export parsing. P6 does not hand out a tidy document; the XER export is a set of tab-delimited tables — PROJECT, TASK, TASKPRED, PROJWBS — concatenated into one file with %T and %R row markers. Primavera P6 export parsing covers reading those tables without a P6 license, joining tasks to their WBS rows, and resolving P6’s internal float and constraint fields into the canonical activity contract so a P6-native schedule and an MS Project schedule become the same typed objects downstream.
MS Project schedule parsing. Microsoft Project exports a nested XML document whose <Task> elements carry outline structure, <PredecessorLink> children, and a mix of scheduled and actual dates. MS Project schedule parsing walks that tree with the standard-library ElementTree parser, flattens the outline into activities, and maps Project’s duration and constraint encodings onto the same fields the P6 path produces — so the diff engine never has to know which tool authored the update.
Critical path delta detection. Once two updates exist as typed activities, the interesting question is what changed. Critical path delta detection computes the controlling path for the current and prior updates, identifies activities that gained or lost float, detects when the critical path itself re-routes through a different chain of work, and flags a moved project finish date the moment it appears rather than at the next update meeting.
Schedule impact logging. A detected delta is only useful if it is recorded as a durable, attributable event. Schedule impact logging writes an immutable record for every material change — how many work days the finish moved, which activity drove it, and the correlated cause, whether an approved change order or a late RFI response — so an earned-value report and a time-extension request both cite the same source of truth instead of a reconstructed timeline.
Schedule baseline variance. The prior update tells you what changed last month; the accepted baseline tells you how far the project has drifted from the plan the contract was signed against. Schedule baseline variance compares the current schedule to that frozen baseline, surfacing cumulative slip, float erosion on near-critical paths, and out-of-sequence progress before an activity quietly becomes the new controlling constraint.
Parsing Exports Deterministically
The two parser subsystems share one entry point, because everything after the parse assumes a single canonical activity shape. The dispatcher below sniffs the source format — P6 XER files begin with an ERMHDR header record, MS Project saves are XML — and routes to the matching parser, each of which is responsible for producing validated ScheduleActivity objects or raising so the export lands in the dead-letter queue with its original bytes intact. The router itself does no parsing; keeping format detection separate from field extraction is what lets a new source format be added later without touching the code that already works, and lets each parser be tested in isolation against a captured sample export.
from __future__ import annotations
import xml.etree.ElementTree as ET
from pathlib import Path
class UnparseableExport(RuntimeError):
"""Raised when an export cannot be parsed; the caller dead-letters the file."""
def detect_source(raw: bytes) -> ScheduleSource:
head = raw[:256].lstrip()
if head.startswith(b"ERMHDR"):
return "p6_xer" # Primavera XER files open with an ERMHDR record
if head.startswith(b"<?xml") or head.startswith(b"<Project"):
return "msp_xml" # MS Project saves an XML document
raise UnparseableExport("unrecognized schedule export; no XER or XML signature")
def parse_schedule_export(path: Path) -> list[ScheduleActivity]:
"""Detect the source format and dispatch to the matching parser."""
raw = path.read_bytes()
source = detect_source(raw)
logger.info("schedule.parse.start", extra={"file": path.name, "source": source})
try:
if source == "p6_xer":
activities = _parse_p6_xer(raw.decode("cp1252")) # XER is Windows-encoded
else:
activities = _parse_msp_xml(raw)
except UnparseableExport:
raise
except Exception as exc: # noqa: BLE001 - any parser fault becomes a dead-letter
raise UnparseableExport(f"{source} parse failed: {exc}") from exc
logger.info("schedule.parse.done",
extra={"file": path.name, "source": source, "activities": len(activities)})
return activities
def _parse_p6_xer(text: str) -> list[ScheduleActivity]:
"""Walk the tab-delimited %T table blocks and lift the TASK rows.
The full XER join across TASK, PROJWBS, and TASKPRED is covered in the
Primavera P6 export parsing guide; this shows the table-walking spine.
"""
activities: list[ScheduleActivity] = []
columns: list[str] = []
current_table = ""
for line in text.splitlines():
cells = line.split("\t")
marker = cells[0]
if marker == "%T": # table header, e.g. ['%T', 'TASK']
current_table = cells[1]
elif marker == "%F": # field names for the current table
columns = cells[1:]
elif marker == "%R" and current_table == "TASK":
row = dict(zip(columns, cells[1:]))
activities.append(_activity_from_xer_row(row))
return activities
def _parse_msp_xml(raw: bytes) -> list[ScheduleActivity]:
"""Flatten the MS Project <Task> tree into activities via ElementTree."""
ns = {"p": "http://schemas.microsoft.com/project"}
root = ET.fromstring(raw)
activities: list[ScheduleActivity] = []
for task in root.findall(".//p:Tasks/p:Task", ns):
# Summary/outline rows have no work and are skipped, not emitted as activities.
if (task.findtext("p:Summary", default="0", namespaces=ns)) == "1":
continue
activities.append(_activity_from_msp_task(task, ns))
return activitiesThe two _activity_from_* builders (detailed in each parser’s own guide) are where a source-specific date, duration, and float encoding is mapped onto the canonical contract and validated. Because both paths converge on ScheduleActivity, the diff engine, the criticality classifier, and the impact logger are written exactly once and are indifferent to which scheduling tool produced the update.
Consuming Change Orders and the WBS Taxonomy
This layer is a consumer, not an island. It depends on two upstream authorities and correlates their output against the schedule. The first is automated document ingestion: approved change orders and RFI responses arrive from that pipeline already parsed, validated against their change order schema validation rules, and carrying the WBS node and discipline they affect. When the diff engine sees an activity’s finish date move, it looks for a change order or a resolved RFI bound to the same WBS node within the update window and attaches it as the delta’s basis — the difference between “the foundation slipped four days” and “the foundation slipped four days because approved change order CO-2026-041 added a footing, effective the fourteenth.” The second authority is the construction data architecture and taxonomy, which owns the WBS element catalog and the discipline vocabulary this layer’s activity contract binds to; the schedule does not invent its own scope identifiers, it resolves activity names against the same canonical crosswalk the cost ledger uses, so a slipped activity, its budget line, and its governing directive all name the same element.
That correlation is where the site-wide confidence bands earn their place in the schedule layer. Binding a free-text activity name like "Level 2 slab pour — east" to a WBS node is a fuzzy match, and the same three canonical bands govern it: a match of 0.92 or above auto-logs the delta against the resolved node, a 0.75–0.92 match logs it but flags the binding for a scheduler to confirm, and anything below 0.75 is quarantined so a moved date is never attributed to the wrong scope. Attribution against a guessed WBS node is worse than no attribution — it would put a delay on the wrong budget line — so the bands keep the machine honest exactly where it is most tempted to overreach.
Failure Modes and Observability
A schedule pipeline that fails silently is worse than no pipeline, because it manufactures false confidence in dates that no longer hold. Three properties keep it trustworthy, and they mirror the resilience contract the rest of the site enforces.
- Dead-letter for unparseable exports. A truncated
XER, a Project file saved in an unexpected schema version, or a corrupt upload is never allowed to resolve to an empty activity list — an empty list would read downstream as “nothing changed,” the most dangerous possible lie. Any parse fault raisesUnparseableExport, and the file is routed to a dead-letter queue that retains the original bytes, the detected source, and the exception context, so a reviewer sees not just that the export failed but why, and the file is replayable once the parser is fixed. A schedule bug causes a delay in visibility, never a permanent gap in the record. - Alert thresholds tuned to what moves the finish date. Not every delta deserves a page. A non-critical activity gaining a day of float is logged and forgotten; a change to the project finish date, a re-route of the critical path through a new chain of activities, or an activity crossing from positive float into negative escalates immediately to the scheduler and the project manager. Rate-limiting and criticality-awareness are the line between an actionable signal and the alert fatigue that trains a team to ignore the pipeline entirely.
- An immutable audit trail of every update. Every schedule import is recorded — when the export arrived, which source produced it, how many activities it carried, which deltas were detected, what confidence each WBS binding scored, and which change order or RFI was attached as a delta’s basis. Structured single-line logs make that history queryable, so months later the system can answer the question a delay claim turns on: which version of the schedule was in force on this date, what moved it, and when did we know. That record, not a reconstructed narrative, is what supports or defeats a request for a time extension.
By treating the schedule export as a typed artifact a pipeline parses, binds, diffs, and logs deterministically — and by treating every failure as a first-class, replayable path rather than a dropped file — construction technology teams turn the schedule from a monthly rumor into a continuously trustworthy input to cost, earned value, and claims.
Frequently Asked Questions
How does the pipeline know a change order actually moved the finish date?
It computes the critical path and total float for each update as it lands and diffs the result against the prior update. When the project finish date moves or an activity crosses into negative float, that delta is detected immediately; the diff engine then looks for an approved change order or resolved RFI bound to the same WBS node within the update window and attaches it as the delta’s documented basis. The finish date moving inside a Primavera P6 or MS Project file is caught by the parse-and-diff step, not by a human reading the update.
Why parse the P6 XER file as tab-delimited tables instead of calling an API?
The XER export is Primavera’s portable interchange format and is available to anyone the schedule is sent to, with no P6 installation, license, or database connection required. It is structured as a set of tab-delimited tables — TASK, PROJWBS, TASKPRED — flagged by %T table and %R row markers, so it can be walked deterministically with the standard library. Parsing the file the general contractor already emails means the pipeline works for every project, including those where you have no access to the scheduler’s server.
How are the confidence bands used when binding activities to the WBS?
Binding a free-text activity name to a canonical WBS node is a fuzzy match, so it is routed by the site’s three canonical bands. A match of 0.92 or above auto-logs the delta against the resolved node, a 0.75 to 0.92 match logs it but flags the binding for a scheduler to confirm, and below 0.75 the delta is quarantined rather than attributed to a guessed scope. Attaching a slipped date to the wrong WBS element would corrupt the wrong budget line, so an uncertain binding is held for a person instead of committed.
What makes an activity critical in the model — is it strictly zero float?
An activity is critical when its total float is zero or negative, and the activity contract enforces that definition: is_critical and total_float_days are cross-checked in a model validator, so a record claiming to be non-critical with zero float is rejected at construction time. The classifier additionally tags near-critical activities within a configurable threshold — five work days by default — because an activity a week from critical is the one most likely to become the next controlling constraint and deserves a scheduler’s attention before it does.
How does this layer help justify a time extension for a late RFI?
Every material schedule change is written to an immutable audit trail with its cause attached. When an RFI response is delayed and the affected activity slips, the impact record captures which activity moved, how many work days it lost, the float it consumed, the date the slip began, and the specific RFI — correlated by WBS node from the document pipeline — that caused it. That is exactly the documented basis a time-extension request requires, produced automatically rather than reconstructed from memory after the contractual notice window has closed.
Related
- Primavera P6 Export Parsing
- MS Project Schedule Parsing
- Critical Path Delta Detection
- Schedule Impact Logging
- Schedule Baseline Variance
← Back to all construction automation topics