Skip to content

Extracting Activity Relationships from P6 XML Exports

A list of activities is not a schedule — the logic that connects them is. To rebuild a dependency graph and reason about the critical path, you need every relationship: which activity drives which, whether the link is finish-to-start or one of the three other types, and how much lag or lead sits on it. This page solves one precise problem: how to extract predecessor and successor relationships, with their FS/SS/FF/SF type and their lag, from a Primavera P6 XML export into typed records, then assemble them into an adjacency map a critical-path routine can traverse. The relationships live as <Relationship> elements under a namespaced root, they reference activities by internal object id rather than the display code, and their lag is stored in hours — three details that quietly break a naive read. This is a companion stage to Primavera P6 export parsing within schedule sync integration: the activity nodes come from parsing the TASK table, and the edges come from here. It targets Python automation builders who need a correct, typed dependency graph as the input to critical-path analysis.

P6 XML relationship extraction to adjacency map A top-down diagram. A P6 XML export is parsed with xml.etree.ElementTree using namespace-qualified lookups. Each Relationship element yields four fields fanned out as chips: a predecessor object id, a successor object id, a link type mapped to one of FS, SS, FF, or SF, and a lag value converted from hours to whole days. The four fields converge into a typed ActivityRelationship record carrying a Literal rel_type and an integer lag_days. That record populates an adjacency map holding successors keyed by predecessor and predecessors keyed by successor, which becomes the dependency graph for critical-path work. P6 XML export <Relationship> elements ElementTree parse namespace-qualified findall Predecessor ObjectId Successor ObjectId Type FS · SS · FF · SF Lag hours → days ActivityRelationship rel_type Literal · lag_days int Adjacency map successors[pred] · predecessors[succ]
Each namespaced <Relationship> element becomes a typed edge; the edges populate a two-way adjacency map that is the dependency graph a critical-path routine walks.

Key Rules and Specification

Relationships are the edges of the schedule graph, and each one must be typed exactly at the boundary:

  • Namespaces are mandatory. P6 XML declares a default namespace on its root, so xml.etree.ElementTree returns nothing from an unqualified .//Relationship. Every iterfind must pass the namespace map, and the URI carries the P6 version, so recover it from the root tag rather than hard-coding a release.
  • Relationships reference activities by object id. A <Relationship> names its endpoints with PredecessorActivityObjectId and SuccessorActivityObjectId — the internal integer key, not the human A1010 code. Rebuild the graph on object ids and translate to display codes only for reporting.
  • The link type is a closed set of four. Every relationship is FS, SS, FF, or SF (finish-to-start, start-to-start, finish-to-finish, start-to-finish). Model it as a Literal, mapping P6’s verbose Finish to Start text onto the two-letter code so a typo can never create a fifth type.
  • Lag is a signed duration in hours. P6 stores lag as an hour count against a calendar; convert it to whole days and preserve the sign, because a negative lag is a lead and dropping the sign silently compresses the schedule.
  • Dangling endpoints are expected, not errors. External relationships point at activities in another project not present in this export. Detect a reference that resolves to no known activity and record it as external rather than letting it inject a phantom node into the graph.
  • Build the map both ways. A usable dependency graph needs successors[predecessor] for a forward pass and predecessors[successor] for a backward pass; populate both from the same typed edge.
P6 Type text Code Meaning
Finish to Start FS Successor starts after predecessor finishes
Start to Start SS Successor starts after predecessor starts
Finish to Finish FF Successor finishes after predecessor finishes
Start to Finish SF Successor finishes after predecessor starts

Production Code Example

The extractor parses the export once, qualifies every lookup with the recovered namespace, maps each relationship onto a typed ActivityRelationship, and accumulates the two-way adjacency map. A relationship whose endpoints are not both in the known-activity set is logged and set aside as external rather than added to the graph.

from __future__ import annotations

import logging
import xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, Field, field_validator

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(message)s")
logger = logging.getLogger("p6_relationships")

RelType = Literal["FS", "SS", "FF", "SF"]

# P6 XML spells the type out; collapse it onto the canonical two-letter code.
_TYPE_MAP: dict[str, RelType] = {
    "Finish to Start": "FS", "FS": "FS",
    "Start to Start": "SS", "SS": "SS",
    "Finish to Finish": "FF", "FF": "FF",
    "Start to Finish": "SF", "SF": "SF",
}


class ActivityRelationship(BaseModel):
    """A typed schedule-logic edge between two activities (by object id)."""

    predecessor_id: str = Field(min_length=1)
    successor_id: str = Field(min_length=1)
    rel_type: RelType
    lag_days: int                      # signed: negative lag is a lead

    @field_validator("successor_id")
    @classmethod
    def _no_self_loop(cls, v: str, info) -> str:
        if v == info.data.get("predecessor_id"):
            raise ValueError("relationship cannot link an activity to itself")
        return v


def _namespace(root: ET.Element) -> dict[str, str]:
    """Recover the P6 namespace from the root tag; the URI carries the version."""
    if root.tag.startswith("{"):
        return {"p6": root.tag[1:].split("}", 1)[0]}
    return {"p6": ""}


def _text(elem: ET.Element, tag: str, ns: dict[str, str]) -> str:
    child = elem.find(f"p6:{tag}", ns)
    return (child.text or "").strip() if child is not None else ""


def _lag_to_days(raw: str, day_hours: float = 8.0) -> int:
    hours = float(raw or 0.0)          # P6 lag is an hour count, and may be negative
    return int(round(hours / day_hours)) if day_hours else 0


def extract_relationships(
    path: Path,
    known_activity_ids: set[str],
) -> list[ActivityRelationship]:
    """Parse <Relationship> elements into typed edges, skipping external links."""
    root = ET.parse(path).getroot()    # raises ParseError on malformed XML
    ns = _namespace(root)
    edges: list[ActivityRelationship] = []

    for rel in root.iterfind(".//p6:Relationship", ns):
        pred = _text(rel, "PredecessorActivityObjectId", ns)
        succ = _text(rel, "SuccessorActivityObjectId", ns)
        raw_type = _text(rel, "Type", ns)

        if pred not in known_activity_ids or succ not in known_activity_ids:
            # External relationship into another project's schedule; not a graph node.
            logger.info("skipping external relationship %s -> %s", pred, succ)
            continue
        if raw_type not in _TYPE_MAP:
            logger.warning("unknown relationship type %r; quarantining edge", raw_type)
            continue

        edges.append(
            ActivityRelationship(
                predecessor_id=pred,
                successor_id=succ,
                rel_type=_TYPE_MAP[raw_type],
                lag_days=_lag_to_days(_text(rel, "Lag", ns)),
            )
        )
    return edges

With the typed edges in hand, the adjacency map is a small, deterministic fold. Keeping both directions in one structure means a forward critical-path pass and a backward float pass read from the same source of truth.

def build_adjacency(
    edges: list[ActivityRelationship],
) -> dict[str, dict[str, list[ActivityRelationship]]]:
    """Fold typed edges into a two-way adjacency map for graph traversal."""
    successors: dict[str, list[ActivityRelationship]] = defaultdict(list)
    predecessors: dict[str, list[ActivityRelationship]] = defaultdict(list)
    for edge in edges:
        successors[edge.predecessor_id].append(edge)
        predecessors[edge.successor_id].append(edge)
    return {"successors": dict(successors), "predecessors": dict(predecessors)}


if __name__ == "__main__":
    known = {"1001", "1002", "1003"}          # object ids of activities in this export
    rels = extract_relationships(Path("schedule.xml"), known)
    graph = build_adjacency(rels)
    logger.info("extracted %d edges across %d driving activities",
                len(rels), len(graph["successors"]))
    for pred, out in graph["successors"].items():
        for e in out:
            print(f"{pred} --{e.rel_type} lag={e.lag_days}d--> {e.successor_id}")

Common Mistakes and Gotchas

Forgetting the namespace. The single most common failure is root.iterfind(".//Relationship") returning an empty list on a file that plainly contains relationships. P6 XML declares a default namespace on the root, so the tag is really {http://…}Relationship. Qualify the search with the namespace map, and recover the URI from the root tag instead of pasting a version string that will not match the next client’s export.

Treating lag as days or dropping its sign. P6 XML stores lag as an hour count, so a Lag of 40 on an eight-hour calendar is five days, not forty. Just as important, lag can be negative — a lead that pulls the successor earlier. Coercing lag with abs() or reading it as a raw day figure silently distorts the schedule, and the distortion only surfaces when the recomputed critical path disagrees with P6’s.

Adding dangling edges to the graph. An export of one project still contains relationships to activities in linked projects. If the predecessor or successor object id is not among the activities you parsed, adding the edge injects a node with no dates and no float, which corrupts a forward pass. Check both endpoints against the known-activity set and record unmatched links as external rather than folding them into the adjacency map.

Where This Fits in the Pipeline

This extractor supplies the edges for Primavera P6 export parsing: the activity nodes come from the parsed TASK table, and the typed relationships here connect them into a directed graph. That graph is the direct input to critical path delta detection, where a forward and backward pass over the adjacency map recomputes total float and the longest path, then compares two schedule updates to flag activities that gained or lost criticality. Because every edge is typed with a Literal relationship type and a signed integer lag, and because external links are set aside rather than silently dropped, the graph handed downstream is complete and traversable — a slipped dependency shows up as a real change, not as an artefact of a mis-parsed lag or a phantom node.

Frequently Asked Questions

Why does findall return nothing on a P6 XML file that clearly has relationships?

Because the file declares a default XML namespace on its root element, so every tag is namespace-qualified under the hood. An unqualified .//Relationship search matches nothing. Pass a namespace map to iterfind with the P6 URI, and recover that URI from the root tag rather than hard-coding it, since the namespace string encodes the P6 version and differs between client releases.

Why reference activities by object id instead of the activity code?

P6 relationships name their endpoints with PredecessorActivityObjectId and SuccessorActivityObjectId, the internal integer keys, not the display code like A1010. Object ids are unique within the export and stable across the relationships, so building the graph on them avoids the ambiguity of activity codes that repeat across projects. Translate object ids back to display codes only when producing human-readable output.

What units is the lag in, and can it be negative?

Lag is stored as an hour count against a calendar, so it must be divided by the calendar’s hours-per-day to express it in working days. It is signed: a negative lag is a lead that lets the successor start before the predecessor completes. Preserving the sign is essential — coercing it to an absolute value silently extends the schedule and makes a recomputed critical path disagree with P6’s.

How should relationships to activities outside the export be handled?

Treat them as external, not as errors and not as graph nodes. A single-project export legitimately contains relationships whose other endpoint lives in a linked project. Check both the predecessor and successor object ids against the set of activities you actually parsed; if either is missing, log the edge as external and exclude it, so it never injects a dateless phantom node that would corrupt a forward pass.

Why model the relationship type as a Literal?

The four schedule-logic types — FS, SS, FF, SF — are a fixed vocabulary, and each one drives a different date constraint in critical-path math. Mapping P6’s verbose Finish to Start text onto a two-letter Literal means an unexpected value raises at construction time instead of silently creating a fifth type that a traversal would mishandle. It also keeps the edge compact for the adjacency map.

← Back to Primavera P6 Export Parsing