Skip to content

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.

Aligning baseline and current schedules by a stable key A top-down diagram. A baseline set of approved dates and a current update set both feed an index-by-activity_id stage that builds a dictionary keyed on the id. The index fans out into three set-operation chips: matched equals baseline intersect current in the middle, added equals current minus baseline on the right, and deleted equals baseline minus current on the left. Only the matched chip flows down into typed variance rows carrying start, finish, and duration variance in integer working days. The added and deleted chips route down and inward into a single flag box labelled no variance to compute, reconcile with the planner, because an activity present in only one schedule has nothing to subtract against. Baseline set approved dates Current update set this month's dates Index by activity_id dict keyed on stable id deleted base − current matched base ∩ current added current − base Typed variance rows start / finish / duration integer working days No variance to compute reconcile with planner
Indexing both schedules by a stable id splits them into three sets; only the intersection yields variance, while activities present in just one schedule are surfaced for reconciliation rather than silently zeroed.

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_id is 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 − baseline gives added activities; baseline − current gives 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.

← Back to Schedule Baseline Variance