Schedule Baseline Variance
Every contract schedule is signed off once as a baseline, and every month after that the project drifts away from it. The specific sub-problem this page solves is how a pipeline compares a current schedule update against the approved baseline to compute start, finish, and duration variance for each activity, rolls that variance up the Work Breakdown Structure, and flags material slippage deterministically — so that a slipped critical activity generates a defensible time-impact record instead of a subjective argument in a monthly progress meeting. Baseline variance is the arithmetic foundation of schedule-performance reporting and the numeric backbone of any change-order time-impact analysis: without it, delay claims rest on memory rather than on a signed baseline and a dated update. This layer sits inside Schedule Sync & Critical Path Integration and consumes the typed activity records produced by Primavera P6 export parsing and MS Project schedule parsing. It answers a narrower question than critical path delta detection, which tracks how the longest path re-routes between updates; here we measure how far each activity has moved from the commitment recorded in the baseline snapshot. This page targets Python automation builders, project controls engineers, and schedulers who need repeatable variance numbers under real-world schedule churn.
Prerequisites
Baseline variance sits downstream of schedule parsing and upstream of schedule-performance reporting and time-impact analysis. Before implementing the patterns below, you need:
- Python 3.11+ with
pydanticv2 for typed validation, plus the standard-librarydecimal,datetime,collections,dataclasses, andloggingmodules. Durations and variances are integer working-day counts; percent-complete is aDecimal, never a float, so weighted rollups stay exact. - Typed activity records from both schedules. Each activity must already be parsed into a record carrying an
activity_id, timezone-aware start and finish datetimes, anis_criticalflag, and a percent-complete value — the output contract of Primavera P6 export parsing (XER/XML) and MS Project schedule parsing (XML). - A baseline snapshot — the approved schedule, stored immutably and keyed by activity so it can be diffed against any later update. The baseline is a fixed reference; a rebaseline is a new snapshot, never an edit of the old one.
- A WBS binding so leaf-activity variance can aggregate to a scope node. Activities bind to the same Work Breakdown Structure that drives WBS mapping strategies; the activity carries when work happens, the WBS node carries where that work sits in scope and budget.
- An escalation path for slipped scope. Material and critical slippage hands off to fallback alert routing, and the resulting variance rows are persisted by schedule impact logging so the time-impact history is auditable.
The pipeline assumes both schedules have already been parsed into typed records against a shared project calendar; the work here is alignment, variance arithmetic, rollup, and routing — not XER or XML parsing.
Architecture: alignment, variance, rollup, and routing
Baseline variance is not a subtraction; it is an alignment problem wearing a subtraction’s clothes. Two schedules of the same project rarely contain the same activity set: the current update has added activities that did not exist at baseline, dropped activities that were deleted or merged, and — on a badly managed file — reassigned activity IDs entirely. If you zip the two lists together by row order or match them by activity name, you compute variance against the wrong pair and every downstream number is quietly wrong. So the first stage aligns the two snapshots by a stable activity key, computes variance only for genuinely matched pairs, and reports the symmetric difference (added and deleted activities) as facts rather than null-filling them to zero. The second stage rolls the per-activity variance up the WBS by the worst member, not the average, because a single critical activity fifteen days late must not be diluted by ten on-track neighbours. The routing decision then uses the site-canonical confidence bands: an alignment match score of 0.92 or above auto-publishes the variance report, 0.75–0.92 flags the rollup for a planner’s review, and below 0.75 the record is quarantined rather than reported against a guessed pairing.
| Stage | Input | Output | Error branch |
|---|---|---|---|
| Align by activity_id | Baseline + current activity lists | Matched pairs + added/deleted sets | Duplicate activity_id → raise before diffing |
| Per-activity variance | One matched baseline/current pair | Typed BaselineVarianceRow, int-day variances |
Finish precedes start → validation error |
| WBS rollup | Leaf variance rows | Per-node worst variance + weighted % complete | Empty node → skipped, logged |
| Severity classify | Worst finish variance (int days) | on_track / minor / material / critical |
— |
| Route | Rollup + match confidence | Publish / planner review / escalate / quarantine | < 0.75 confidence → quarantine |
Step-by-step implementation
Step 1 — Define the variance row contract
The unit of the whole layer is one activity’s variance, so model it as a frozen, typed contract. Variances are integer working-day counts — a finish that slides across a weekend by two calendar days is zero working-day slip — while percent-complete is a Decimal bounded to 0–100, because a duration-weighted rollup of percent-complete must stay exact to the cent of progress. The WBS element is regex-constrained to PROJ-NNN-DIV-NN and the discipline is a closed Literal, so a malformed binding is rejected at construction time rather than corrupting a rollup bucket. All four dates are timezone-aware; a naive timestamp is rejected because variance math across project sites in different zones would otherwise drift by a day.
from __future__ import annotations
import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
logger = logging.getLogger("schedule.variance")
Discipline = Literal["ARCH", "STR", "MEP", "CIV", "ELEC", "PLMB"]
Severity = Literal["on_track", "minor_slip", "material_slip", "critical_slip"]
WBS_PATTERN = r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$"
def _severity_for(slip_days: int) -> Severity:
"""Map the worst finish/start slip (working days) onto a severity band."""
if slip_days <= 0:
return "on_track"
if slip_days <= 5:
return "minor_slip"
if slip_days <= 15:
return "material_slip"
return "critical_slip"
class BaselineVarianceRow(BaseModel):
"""One activity's variance between the approved baseline and the current update."""
model_config = ConfigDict(frozen=True)
activity_id: str = Field(min_length=1, max_length=40)
wbs_node: str = Field(pattern=WBS_PATTERN)
discipline: Discipline
is_critical: bool
baseline_start: datetime
baseline_finish: datetime
current_start: datetime
current_finish: datetime
start_variance_days: int # current_start - baseline_start, working days
finish_variance_days: int # current_finish - baseline_finish, working days
duration_variance_days: int # current duration - baseline duration, working days
percent_complete: Decimal = Field(ge=0, le=100, decimal_places=2)
match_confidence: Decimal = Field(ge=0, le=1, decimal_places=2)
@field_validator(
"baseline_start", "baseline_finish", "current_start", "current_finish"
)
@classmethod
def require_tz_aware(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("schedule dates must be timezone-aware (ISO 8601)")
return v.astimezone(timezone.utc)
@model_validator(mode="after")
def finish_after_start(self) -> "BaselineVarianceRow":
if self.current_finish < self.current_start:
raise ValueError("current_finish precedes current_start")
if self.baseline_finish < self.baseline_start:
raise ValueError("baseline_finish precedes baseline_start")
return self
@property
def severity(self) -> Severity:
# Positive variance is a slip (later than baseline); the worse of the two
# boundary movements sets the band so an early start can't mask a late finish.
return _severity_for(max(self.start_variance_days, self.finish_variance_days))Step 2 — Align the two snapshots by a stable key
Alignment is where most variance engines quietly go wrong. Two schedules of the same project are two sets of activities, not two parallel lists, so the only safe join is on a stable identity — the activity_id minted at baseline and preserved through every update. Index each side into a dictionary, compute the intersection to get matched pairs, and compute the symmetric difference to get activities added in the current update and deleted since the baseline. Neither difference set is ever null-filled to a zero-variance row: an added activity has no baseline to vary against, and a deleted one has no current status, so both are reported as facts for a planner to reconcile. Variance itself is measured in working days on the project calendar, because construction schedules are worked five (or six) days a week and a calendar-day subtraction would over-report every slip that spans a weekend.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
@dataclass(frozen=True)
class ActivitySnapshot:
"""A typed activity as parsed from a P6 (XER/XML) or MS Project (XML) export."""
activity_id: str
wbs_node: str
discipline: Discipline
is_critical: bool
start: datetime
finish: datetime
percent_complete: Decimal
def net_working_days(start: date, end: date, holidays: frozenset[date]) -> int:
"""Signed working-day count from start to end (Mon–Fri minus holidays).
Construction slip is measured in working days, not calendar days: a finish
that moves from Friday to the following Monday is one working day late, not
three. A later `end` yields a positive count (a slip); an earlier one negative.
"""
if start == end:
return 0
step = 1 if end > start else -1
lo, hi = (start, end) if end > start else (end, start)
days = 0
cursor = lo
while cursor < hi:
cursor += timedelta(days=1)
if cursor.weekday() < 5 and cursor not in holidays:
days += 1
return days * step
def align_snapshots(
baseline: list[ActivitySnapshot],
current: list[ActivitySnapshot],
) -> tuple[list[tuple[ActivitySnapshot, ActivitySnapshot]], set[str], set[str]]:
"""Align two activity sets by the stable activity_id key.
Returns matched (baseline, current) pairs plus the symmetric difference:
ids added in the current update and ids deleted since the baseline. An
unmatched activity is a reportable fact, never a silently zeroed row.
"""
base_by_id = {a.activity_id: a for a in baseline}
curr_by_id = {a.activity_id: a for a in current}
if len(base_by_id) != len(baseline):
raise ValueError("duplicate activity_id in baseline snapshot")
if len(curr_by_id) != len(current):
raise ValueError("duplicate activity_id in current update")
matched = base_by_id.keys() & curr_by_id.keys()
added = curr_by_id.keys() - base_by_id.keys()
deleted = base_by_id.keys() - curr_by_id.keys()
pairs = [(base_by_id[i], curr_by_id[i]) for i in sorted(matched)]
return pairs, set(added), set(deleted)
def build_variance_row(
base: ActivitySnapshot,
curr: ActivitySnapshot,
holidays: frozenset[date],
match_confidence: Decimal = Decimal("1.00"),
) -> BaselineVarianceRow:
"""Compute the typed variance row for one matched activity pair."""
base_dur = net_working_days(base.start.date(), base.finish.date(), holidays)
curr_dur = net_working_days(curr.start.date(), curr.finish.date(), holidays)
return BaselineVarianceRow(
activity_id=curr.activity_id,
wbs_node=curr.wbs_node,
discipline=curr.discipline,
is_critical=curr.is_critical,
baseline_start=base.start,
baseline_finish=base.finish,
current_start=curr.start,
current_finish=curr.finish,
start_variance_days=net_working_days(base.start.date(), curr.start.date(), holidays),
finish_variance_days=net_working_days(base.finish.date(), curr.finish.date(), holidays),
duration_variance_days=curr_dur - base_dur,
percent_complete=curr.percent_complete,
match_confidence=match_confidence,
)Step 3 — Roll variance up the WBS bottom-up
A per-activity table is too granular for a progress report; the audience reads variance by scope node. Rolling up correctly means two different aggregations at once. Slip rolls up by the worst member — the largest finish variance among the node’s activities — because a WBS element is late if any of its activities is late, and averaging would hide a critical slip behind on-track siblings. Percent-complete, by contrast, rolls up as a duration-weighted mean, so a thirty-day pour is not outvoted by a one-day inspection that happens to be finished. The rollup also carries the worst variance among only the critical activities, because that number, not the overall worst, is what feeds time-impact analysis.
from collections import defaultdict
class WBSRollup(BaseModel):
"""Aggregated baseline variance for a single WBS node."""
model_config = ConfigDict(frozen=True)
wbs_node: str = Field(pattern=WBS_PATTERN)
activity_count: int = Field(gt=0)
worst_finish_variance_days: int
critical_finish_variance_days: int # worst among is_critical members, else 0
weighted_percent_complete: Decimal = Field(ge=0, le=100, decimal_places=2)
severity: Severity
def roll_up_by_wbs(
rows: list[BaselineVarianceRow],
holidays: frozenset[date],
) -> dict[str, WBSRollup]:
"""Aggregate leaf-activity variance up to each WBS node.
Slip rolls up by the worst member so one critical activity 20 days late is
not diluted by ten on-track neighbours. Percent complete rolls up as a
duration-weighted mean so a long activity is not outvoted by a short one.
"""
grouped: dict[str, list[BaselineVarianceRow]] = defaultdict(list)
for row in rows:
grouped[row.wbs_node].append(row)
rollups: dict[str, WBSRollup] = {}
for node, members in grouped.items():
worst = max(r.finish_variance_days for r in members)
crit = [r.finish_variance_days for r in members if r.is_critical]
crit_worst = max(crit) if crit else 0
# Weight percent complete by baseline working-day duration (floor 1 day).
weights = [
net_working_days(r.baseline_start.date(), r.baseline_finish.date(), holidays) or 1
for r in members
]
weighted = sum(r.percent_complete * w for r, w in zip(members, weights))
wpc = (weighted / sum(weights)).quantize(Decimal("0.01"))
rollups[node] = WBSRollup(
wbs_node=node,
activity_count=len(members),
worst_finish_variance_days=worst,
critical_finish_variance_days=crit_worst,
weighted_percent_complete=wpc,
severity=_severity_for(max(worst, crit_worst)),
)
return rollupsStep 4 — Route each rollup by confidence, then by severity
The final stage decides where a rolled-up node goes, and it gates on two things in a fixed order. Alignment confidence comes first: if the activity pairing that produced the variance is itself untrustworthy — because IDs churned or a fuzzy re-match was needed — the number is quarantined below 0.75 and sent to a planner between 0.75 and 0.92, exactly the site-canonical bands used everywhere a match decision is made. Only once alignment is trusted does slip severity decide the destination: a material_slip or critical_slip escalates to the fallback alert router and seeds a time-impact record, while an on-track or minor-slip node publishes straight to the variance report. Routing returns an action rather than performing side effects, so it stays pure and a broker retry re-derives the same verdict.
AUTO_ROUTE = Decimal("0.92") # >= 0.92 alignment -> trust the pairing
REVIEW_FLOOR = Decimal("0.75") # 0.75–0.92 -> planner review; < 0.75 -> quarantine
RouteAction = Literal["publish_report", "planner_review", "escalate_alert", "quarantine"]
def route_rollup(rollup: WBSRollup, match_confidence: Decimal) -> RouteAction:
"""Route a rolled-up WBS variance by alignment confidence, then slip severity."""
if match_confidence < REVIEW_FLOOR:
logger.warning(
"variance.quarantine wbs=%s conf=%s", rollup.wbs_node, match_confidence
)
return "quarantine"
if match_confidence < AUTO_ROUTE:
return "planner_review"
# Alignment is trusted; the slip magnitude now decides the destination.
if rollup.severity in ("material_slip", "critical_slip"):
logger.info(
"variance.escalate wbs=%s worst=%d critical=%d",
rollup.wbs_node,
rollup.worst_finish_variance_days,
rollup.critical_finish_variance_days,
)
return "escalate_alert"
return "publish_report"The escalate branch is the hand-off point to change-order time-impact analysis: a material or critical slip on a critical activity is precisely the signal that a delay may be compensable, so the escalated rows are persisted by schedule impact logging with the baseline and current dates intact for the record.
Schema and configuration reference
| Field | Type / pattern | Rule | Why it matters |
|---|---|---|---|
activity_id |
str 1–40 chars |
Stable across updates | The only safe alignment key |
wbs_node |
PROJ-NNN-DIV-NN |
Must resolve in the master WBS | Binds variance to a budgeted scope element |
start_variance_days |
int |
Working days, signed | Positive = later than baseline (a slip) |
finish_variance_days |
int |
Working days, signed | Drives severity and time-impact analysis |
duration_variance_days |
int |
Current minus baseline duration | Separates a delay from a stretch |
percent_complete |
Decimal, 2 dp, 0–100 |
No floats | Keeps the duration-weighted rollup exact |
is_critical |
bool |
From the parsed schedule | Selects the critical-only rollup number |
match_confidence |
Decimal, 2 dp, 0–1 |
From alignment | Gates the routing decision |
| Severity band | Worst finish slip (working days) | Meaning | Route when confident |
|---|---|---|---|
on_track |
<= 0 |
At or ahead of baseline | Publish report |
minor_slip |
1–5 |
Within a normal update swing | Publish report |
material_slip |
6–15 |
Reportable; time-impact candidate | Escalate alert |
critical_slip |
> 15 |
Serious; likely change-order driver | Escalate alert |
Routing constants are site-canonical and appear identically wherever a match decision runs: AUTO_ROUTE = 0.92 and REVIEW_FLOOR = 0.75. The severity thresholds (5 and 15 working days) are per-project configurable and should be pinned in project controls configuration, not hard-coded per run.
Verification and testing
Alignment, variance arithmetic, and rollup are pure functions, so assert them against known inputs — including the weekend that the working-day count must skip and the added activity the alignment must surface. The dates below were chosen so the expected working-day counts are checkable by hand.
from datetime import datetime, timezone
from decimal import Decimal
NO_HOLIDAYS: frozenset = frozenset()
def _dt(y: int, m: int, d: int) -> datetime:
return datetime(y, m, d, 12, 0, tzinfo=timezone.utc)
def _snap(activity_id, start, finish, *, wbs="PROJ-014-STR-02", crit=True, pc="0.00"):
return ActivitySnapshot(
activity_id=activity_id, wbs_node=wbs, discipline="STR",
is_critical=crit, start=start, finish=finish, percent_complete=Decimal(pc),
)
def test_alignment_reports_added_and_deleted():
base = [_snap("A1000", _dt(2026, 3, 2), _dt(2026, 3, 6))]
curr = [
_snap("A1000", _dt(2026, 3, 2), _dt(2026, 3, 6)),
_snap("A1010", _dt(2026, 3, 9), _dt(2026, 3, 13)),
]
pairs, added, deleted = align_snapshots(base, curr)
assert {p[0].activity_id for p in pairs} == {"A1000"}
assert added == {"A1010"}
assert deleted == set()
def test_finish_slip_counts_working_days_only():
base = _snap("A1000", _dt(2026, 3, 2), _dt(2026, 3, 6)) # Mon–Fri baseline
curr = _snap("A1000", _dt(2026, 3, 2), _dt(2026, 3, 10)) # finish slips to next Tue
row = build_variance_row(base, curr, NO_HOLIDAYS)
assert row.finish_variance_days == 2 # Mon + Tue; the weekend is excluded
assert row.duration_variance_days == 2
assert row.severity == "minor_slip"
def test_rollup_takes_worst_member_not_average():
rows = [
build_variance_row(
_snap("A1", _dt(2026, 3, 2), _dt(2026, 3, 6)),
_snap("A1", _dt(2026, 3, 2), _dt(2026, 3, 6)), NO_HOLIDAYS,
),
build_variance_row(
_snap("A2", _dt(2026, 3, 2), _dt(2026, 3, 6)),
_snap("A2", _dt(2026, 3, 2), _dt(2026, 3, 27)), NO_HOLIDAYS,
),
]
rollup = roll_up_by_wbs(rows, NO_HOLIDAYS)["PROJ-014-STR-02"]
assert rollup.worst_finish_variance_days == 15
assert rollup.severity == "material_slip"
def test_low_confidence_quarantines():
row = build_variance_row(
_snap("A1", _dt(2026, 3, 2), _dt(2026, 3, 6)),
_snap("A1", _dt(2026, 3, 2), _dt(2026, 3, 6)),
NO_HOLIDAYS, match_confidence=Decimal("0.60"),
)
rollup = roll_up_by_wbs([row], NO_HOLIDAYS)["PROJ-014-STR-02"]
assert route_rollup(rollup, Decimal("0.60")) == "quarantine"Run the suite with pytest -q. For a fast manual check, serialize a row with model_dump_json() and confirm the three variance integers and the Decimal percent-complete round-trip through BaselineVarianceRow.model_validate_json() unchanged.
Troubleshooting
- Variance explodes on activities added between baseline and current. The current update introduced activities that never existed at baseline, and a name- or order-based join paired them against unrelated baseline rows. Align by
activity_idand route the added set to review instead: an activity with no baseline has no variance to compute, and forcing one produces a fictional slip. The symmetric-difference set fromalign_snapshotsis the audit record of exactly which activities changed shape. - Every activity reads as “on-time” after a rebaseline. Someone recomputed variance against the new baseline, which is by definition equal to the current schedule, so all variance collapses to zero. A rebaseline is a fresh, separately stored snapshot; keep the original approved baseline immutable and compute against the contractually approved snapshot for claim math, comparing to the rebaseline only for forward-looking management.
- Slip is over-reported by exactly the number of weekends in the span. The variance was computed in calendar days rather than working days, so a finish that slid from Friday to Monday shows as three days late instead of one. Run every boundary difference through
net_working_daysagainst the project calendar, and load the same holiday set that the schedule tool uses so a bank holiday does not count as a working slip. - A duration change appears where only the calendar changed. The current update runs a six-day work week while the baseline ran five, so identical start and finish dates yield different working-day durations. Detect a calendar-definition change between snapshots and record it explicitly; report the calendar delta separately from a true logic or progress delay rather than letting it masquerade as duration variance.
- Whole WBS branches quarantine after an ID renumber. A scheduler renumbered activities (a P6 “renumber activity IDs” pass), so the current IDs no longer match the baseline and alignment confidence collapses. Recover the mapping from the tool’s old-to-new ID crosswalk if one exists, or fuzzy-match on
wbs_nodeplus name and stamp the resultingmatch_confidenceso the re-paired rows land in the review band rather than auto-publishing against a guessed pairing.
Frequently Asked Questions
Why align by activity_id instead of activity name?
Activity names are edited constantly — a planner reworks wording, fixes a typo, or renames a phase — while the activity_id is minted once and preserved across updates. Aligning by name pairs a renamed activity against nothing (reported as a false delete-plus-add) or, worse, against a different activity that happens to share a name. The stable id is the only join that survives normal schedule maintenance, which is why the symmetric difference is computed on ids, not names.
Why measure variance in working days rather than calendar days?
Construction crews work a five- or six-day week, so slip is a working-day quantity. A finish that moves from Friday to the following Monday is one working day late, but a calendar-day subtraction reports three, over-stating every delay that spans a weekend and corrupting any time-impact number built on it. Running each boundary difference through a working-day count against the project calendar — the same calendar and holiday set the schedule tool uses — keeps the variance honest.
Why roll slip up by the worst member but percent complete by a weighted mean?
They answer different questions. A WBS node is late if any of its activities is late, so slip rolls up by the maximum finish variance — averaging would hide one critical activity fifteen days behind behind ten on-track siblings. Percent complete, by contrast, is a measure of how much of the node’s work is done, so it rolls up as a duration-weighted mean, preventing a one-day inspection from outvoting a thirty-day pour.
How do the confidence bands apply to baseline variance?
They gate on the trustworthiness of the activity pairing before any number is published. When alignment is clean the match confidence is 1.0 and the rollup auto-publishes. When ids churned and a fuzzy re-match was needed, a combined score of 0.75–0.92 sends the rollup to a planner, and below 0.75 it is quarantined — because a variance computed against a guessed pairing is worse than no variance at all.
How is a rebaseline handled without losing the claim baseline?
A rebaseline is stored as a new, separate snapshot; the original approved baseline is never edited. Claim and time-impact math run against the contractually approved baseline so a delay stays visible, while day-to-day management can compare the current update to the latest rebaseline for a forward-looking view. Keeping both immutable means an auditor can reproduce either comparison from the stored snapshots on any date.