Comparing Baseline and Current Schedules in Python
Two exports of the same construction schedule — the approved baseline and this month’s update — look like they should line up row for row, but they never do. Activities get added as the design develops, dropped when scopes merge, and occasionally renumbered wholesale. This page solves one precise problem: how to align two activity sets by a stable key and compute start, finish, and duration variance without being fooled by activities that were added or removed between the two snapshots, so a variance number is only ever produced for a pair that genuinely represents the same work. Getting the comparison right is the whole basis of schedule baseline variance reporting inside Schedule Sync & Critical Path Integration: a variance computed against the wrong pair, or a fabricated variance for an activity that has no baseline, poisons every rollup and every time-impact claim downstream. It targets Python automation builders comparing typed activity records already parsed from Primavera P6 export parsing or MS Project schedule parsing.
Key Rules and Specification
A comparison is only trustworthy if it refuses to invent data. These rules govern every run:
- Join on the stable key. The
activity_idis minted at baseline and preserved across updates; it is the only field safe to align on. Names, WBS paths, and row order all drift between exports. - Compute the symmetric difference explicitly. Intersection gives matched pairs;
current − baselinegives added activities;baseline − currentgives deleted ones. All three sets are outputs, not internal details — the added and deleted sets are the reconciliation report. - Never null-fill an unmatched activity. An added activity has no baseline dates, so it has no variance; a deleted one has no current dates. Substituting a zero fabricates an on-time result that hides real scope change.
- Measure in working days. Variance is an integer count of working days on the project calendar, not calendar days, so a slip across a weekend is not over-reported.
- Fix the sign convention. A positive variance means the current date is later than baseline — a slip. Keep the convention identical for start, finish, and duration so a report never mixes signs.
- Reject duplicate keys. If either schedule contains a repeated
activity_id, the index is ambiguous; raise before comparing rather than letting one row silently overwrite another.
| Set operation | Definition | Yields | What to do |
|---|---|---|---|
| Intersection | id in both schedules |
Matched pairs | Compute variance rows |
| Current − baseline | id only in current |
Added activities | Flag; no baseline to vary against |
| Baseline − current | id only in baseline |
Deleted activities | Flag; work removed or merged |
| Duplicate key | id repeated in one set |
Ambiguous index | Raise before comparing |
Production Code Example
The comparator indexes each schedule into a dictionary keyed on activity_id, derives the three sets with plain set algebra, and builds a typed VarianceRow only for matched pairs. Working-day arithmetic uses the project calendar so a weekend never inflates a slip; pattern and precision details follow the standard Python decimal documentation.
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s | %(message)s")
logger = logging.getLogger("schedule.compare")
Discipline = Literal["ARCH", "STR", "MEP", "CIV", "ELEC", "PLMB"]
@dataclass(frozen=True)
class ActivitySnapshot:
"""A typed activity from a parsed P6 (XER/XML) or MS Project (XML) export."""
activity_id: str
wbs_node: str
discipline: Discipline
start: datetime
finish: datetime
class VarianceRow(BaseModel):
"""Start, finish, and duration variance for one matched activity pair."""
model_config = ConfigDict(frozen=True)
activity_id: str = Field(min_length=1, max_length=40)
wbs_node: str
start_variance_days: int # + = current start later than baseline (a slip)
finish_variance_days: int # + = current finish later than baseline
duration_variance_days: int # + = activity is longer than at baseline
class ScheduleComparison(BaseModel):
"""The full result of comparing two schedules: variance plus scope changes."""
model_config = ConfigDict(frozen=True)
rows: list[VarianceRow]
added_ids: list[str] # in current only — no baseline to vary against
deleted_ids: list[str] # in baseline only — work removed or merged
def net_working_days(start: date, end: date, holidays: frozenset[date]) -> int:
"""Signed working-day count from start to end (Mon–Fri minus holidays)."""
if start == end:
return 0
step = 1 if end > start else -1
lo, hi = (start, end) if end > start else (end, start)
days, cursor = 0, lo
while cursor < hi:
cursor += timedelta(days=1)
if cursor.weekday() < 5 and cursor not in holidays:
days += 1
return days * step
def _index(activities: list[ActivitySnapshot], label: str) -> dict[str, ActivitySnapshot]:
by_id = {a.activity_id: a for a in activities}
if len(by_id) != len(activities):
raise ValueError(f"duplicate activity_id in {label} schedule")
return by_id
def compare_schedules(
baseline: list[ActivitySnapshot],
current: list[ActivitySnapshot],
holidays: frozenset[date] = frozenset(),
) -> ScheduleComparison:
"""Align two schedules by activity_id and compute variance for matched pairs only."""
base_by_id = _index(baseline, "baseline")
curr_by_id = _index(current, "current")
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()
if added:
logger.info("added activities (no baseline): %s", sorted(added))
if deleted:
logger.info("deleted activities (no current): %s", sorted(deleted))
rows: list[VarianceRow] = []
for activity_id in sorted(matched):
base, curr = base_by_id[activity_id], curr_by_id[activity_id]
base_dur = net_working_days(base.start.date(), base.finish.date(), holidays)
curr_dur = net_working_days(curr.start.date(), curr.finish.date(), holidays)
rows.append(
VarianceRow(
activity_id=activity_id,
wbs_node=curr.wbs_node,
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,
)
)
return ScheduleComparison(
rows=rows, added_ids=sorted(added), deleted_ids=sorted(deleted)
)
if __name__ == "__main__":
def _dt(y: int, m: int, d: int) -> datetime:
return datetime(y, m, d, 12, 0, tzinfo=timezone.utc)
baseline = [
ActivitySnapshot("A1000", "PROJ-014-STR-02", "STR", _dt(2026, 3, 2), _dt(2026, 3, 6)),
ActivitySnapshot("A1010", "PROJ-014-STR-02", "STR", _dt(2026, 3, 9), _dt(2026, 3, 13)),
]
current = [
# A1000 finish slips from Fri to the next Tue: two working days, not four.
ActivitySnapshot("A1000", "PROJ-014-STR-02", "STR", _dt(2026, 3, 2), _dt(2026, 3, 10)),
# A1010 is gone (merged); A2000 is new (no baseline to vary against).
ActivitySnapshot("A2000", "PROJ-014-MEP-01", "MEP", _dt(2026, 3, 16), _dt(2026, 3, 20)),
]
result = compare_schedules(baseline, current)
for row in result.rows:
print(f"{row.activity_id}: finish {row.finish_variance_days:+d}d "
f"duration {row.duration_variance_days:+d}d")
print("added:", result.added_ids, "deleted:", result.deleted_ids)The result object is deliberately three-part: the variance rows for reporting, and the added and deleted id lists so a planner can reconcile scope change before the numbers are trusted. Serializing it with model_dump_json() writes the whole comparison — variance and scope delta together — straight into the audit store.
Common Mistakes and Gotchas
Aligning by name or row order. Zipping the two schedules together by position, or joining on activity name, is the classic failure. A single inserted activity shifts every subsequent row, so zip pairs each activity against its neighbour and reports a wall of fictional variance; a renamed activity joins against nothing. Index both sides into a dictionary keyed on activity_id and join on the key — the only field that survives a re-sequence or a rename.
Ignoring added and removed activities. Treating the comparison as a pure subtraction and iterating only the baseline (or only the current) list silently drops one side of the symmetric difference. Worse is null-filling: substituting a zero baseline for an added activity manufactures a spurious slip, while zeroing a deleted one hides removed scope. Surface both sets explicitly and hand them to reconciliation, exactly as the parent schedule baseline variance pipeline routes its added-and-deleted branch.
Mixing calendars between the two snapshots. If the baseline was built on a five-day week and the current update switched to six days, identical dates yield different working-day durations and a duration variance appears where no work changed. Confirm both schedules reference the same calendar and holiday set before comparing, and if the calendar genuinely changed, report that delta separately rather than letting it contaminate the duration numbers.
Where This Fits in the Pipeline
This comparator is the alignment stage of schedule baseline variance under Schedule Sync & Critical Path Integration. Upstream, both schedules arrive as typed records from Primavera P6 export parsing or MS Project schedule parsing. Downstream, the matched variance rows roll up the WBS and route by severity, while the added and deleted sets feed reconciliation. A clean date comparison is also the precondition for detecting out-of-sequence progress: you cannot judge whether an activity progressed ahead of its predecessors until you have reliably matched it to the same activity in the prior snapshot.
Frequently Asked Questions
Why not just zip the two schedules together?
Because zip pairs by position, and the two exports are almost never in the same order or the same length. One activity added near the top shifts every row below it, so zip compares each activity against a different one and reports variance that is entirely an artefact of ordering. Indexing both into dictionaries keyed on activity_id and joining on the key is the only alignment that survives insertion, deletion, and re-sorting.
What should happen to an activity that only exists in the current update?
It goes into the added set and gets no variance row. An activity with no baseline has nothing to be compared against, so any number you compute for it is fabricated. The right move is to surface it for reconciliation — a planner confirms it is genuinely new scope rather than a renumbered old activity — before it influences any rollup.
Why measure duration in working days rather than calendar days?
Because a construction activity’s duration is the number of days crews actually work, and slip is judged the same way. A finish that moves from Friday to Monday is one working day late, not three. Computing every start, finish, and duration difference with a working-day count against the project calendar keeps the comparison aligned with how the schedule tool itself calculates dates.
How do I tell a renumbered activity apart from a genuine add and delete?
A wholesale ID renumber shows up as a large added set and an equally large deleted set at once — the same work appears on both sides under different ids. When you see that pattern, recover the tool’s old-to-new ID crosswalk if it exists, or fuzzy-match the added and deleted sets on WBS node plus name and dates, stamping the resulting confidence so the re-paired rows land in review rather than auto-publishing.
Should the comparison output the scope changes or just the variance?
Both, in one object. The variance rows answer “how far did matched work move,” and the added and deleted lists answer “what work changed shape.” Emitting them together means a reviewer sees, in a single record, that a WBS node’s apparent improvement was really an activity being deleted rather than accelerated — a distinction that a variance-only report hides.
Related
- Schedule Baseline Variance
- Detecting Out-of-Sequence Progress in Schedule Updates
- Primavera P6 Export Parsing
- MS Project Schedule Parsing
- Critical Path Delta Detection
← Back to Schedule Baseline Variance