Project Controls in the AI EraRisk management, Monte Carlo and change control · Lesson 15 of 22

Lab: an EVM and Monte Carlo notebook in Python

Article · 25 min · 9 min lecture

Video lecture

Lab: an EVM and Monte Carlo notebook you can reuse

10 chapters · about 9 min · full transcript

Coming soon

Chapter 1 of 10

Build it once, check every tool forever

  • Earned value by account, with trends
  • EAC range, TCPI and a simple anomaly flag
  • Monte Carlo with a risk event and correlation

The narrated lecture is in production

Every chapter is scripted and ready. Browse the chapters and read the full transcript now — the video will appear here when it’s published.

Chapters

What you will build

In this lab you build a small, reusable Jupyter notebook that does the analytical core of a monthly controls cycle: earned value metrics and trends by control account, an EAC range with TCPI, a simple anomaly flag, and a Monte Carlo schedule risk analysis with a discrete risk, correlation, P50/P80, criticality and a tornado ranking. It ends by writing a structured status table that a controls lead reviews and that an approved AI assistant can use to draft a narrative. Every number is illustrative; the method is what you reuse.

The notebook deliberately stays small so you can read every line. Enterprise tools (Primavera P6, Microsoft Project, Power BI, Safran Risk, Deltek Acumen Risk, @RISK) do all of this at scale; building it once by hand is how you learn to check them.

Setup

python -m venv .venv && source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install pandas numpy jupyter matplotlib
jupyter notebook

Create a notebook called controls_lab.ipynb and paste each cell below into its own cell.

Cell 0 and 1: imports and data

In real use, cell 1 is a pd.read_csv() of your monthly export by control account and period (cumulative PV, EV and AC, plus BAC). Keep the shape: one row per account per period.

import numpy as np
import pandas as pd

# Cell 1: cumulative EVM data by control account and period (USD 000, illustrative)
data = pd.DataFrame({
    "account": ["Civil"] * 4 + ["Pumps"] * 4,
    "period":  [1, 2, 3, 4] * 2,
    "BAC": [2400] * 4 + [5000] * 4,
    "PV":  [300, 800, 1300, 1900, 400, 1100, 1900, 2800],
    "EV":  [290, 760, 1250, 1800, 380, 950, 1550, 2200],
    "AC":  [295, 780, 1280, 1850, 400, 1080, 1850, 2700],
})

Cell 2: indices, variances and the EAC range

Notice that every metric is computed from cumulative values, and that indices are never averaged across accounts.

# Cell 2: indices, variances and EAC range per account and period
d = data.copy()
d["CPI"], d["SPI"] = d.EV / d.AC, d.EV / d.PV
d["CV"], d["SV"] = d.EV - d.AC, d.EV - d.PV
d["EAC_cpi"] = d.BAC / d.CPI
d["EAC_comp"] = d.AC + (d.BAC - d.EV) / (d.CPI * d.SPI)
d["TCPI_bac"] = (d.BAC - d.EV) / (d.BAC - d.AC)
latest = d[d.period == d.period.max()]
print(latest[["account", "CPI", "SPI", "EAC_cpi", "EAC_comp", "TCPI_bac"]].round(2))

Reading the output: Pumps has CPI 0.81 and SPI 0.79. Its CPI-method EAC is about 6.14M against a 5.0M budget, the composite method gives about 7.07M, and the TCPI to BAC is 1.22. A budget-level forecast for Pumps is not credible without a specific change. Civil is close to plan.

Cell 3: a simple anomaly flag

Anomaly detection does not have to start with machine learning. A rule such as "CPI has fallen for three consecutive periods" catches a large share of what matters and is easy to explain.

# Cell 3: simple anomaly flag - CPI falling for 3 consecutive periods
d["dCPI"] = d.groupby("account")["CPI"].diff()
falling = d.groupby("account")["dCPI"].apply(lambda s: (s.tail(3) < 0).all())
print("CPI falling 3 periods:", list(falling[falling].index))

Later you can add statistical flags (for example a z-score on period CPI or on cost postings) or a model; keep the simple rules as a baseline the model must beat.

Cell 4: Monte Carlo schedule risk

Five activities, PERT distributions from three-point estimates, a shared productivity factor that correlates the two site activities, and one discrete risk from the register.

# Cell 4: schedule Monte Carlo on a small network (days)
rng = np.random.default_rng(7)
N = 20_000

def pert(a, m, b, size, lam=4):
    """PERT (beta) sample from min a, most likely m, max b."""
    alpha = 1 + lam * (m - a) / (b - a)
    beta_ = 1 + lam * (b - m) / (b - a)
    return a + rng.beta(alpha, beta_, size) * (b - a)

acts = {  # id: (min, most likely, max, predecessors)
    "Design":      (18, 20, 30, []),
    "Procure":     (40, 45, 70, ["Design"]),
    "Civil":       (30, 35, 50, ["Design"]),
    "Install":     (25, 28, 40, ["Procure", "Civil"]),
    "Commission":  (8, 10, 16, ["Install"]),
}
# correlation via a shared productivity factor on site work (Civil, Install)
site_factor = rng.normal(1.0, 0.08, N).clip(0.8, 1.3)
dur = {a: pert(*acts[a][:3], N) for a in acts}
dur["Civil"] *= site_factor
dur["Install"] *= site_factor
# discrete risk from the register: R-07 grid approval, 30% chance, +10 to +25 days on Commission
r07 = rng.random(N) < 0.30
dur["Commission"] += np.where(r07, rng.uniform(10, 25, N), 0)

ef = {}
for a, (_, _, _, preds) in acts.items():  # dict order is topological here
    es = np.maximum.reduce([ef[p] for p in preds]) if preds else np.zeros(N)
    ef[a] = es + dur[a]
finish = ef["Commission"]
plan = 20 + 45 + 28 + 10  # deterministic most-likely critical path
print(f"Plan {plan} d | confidence {np.mean(finish <= plan):.0%} | "
      f"P50 {np.percentile(finish, 50):.0f} | P80 {np.percentile(finish, 80):.0f}")

Typical output: a deterministic plan of 103 days with only about 11% confidence, P50 around 113 days and P80 around 125 days. Two effects drive the gap: right-skewed ranges (maximums further from the most likely than minimums) and the 30% risk event on commissioning.

Cell 5: criticality and a tornado ranking

# Cell 5: criticality of the Procure vs Civil branch, and a simple tornado (rank correlation)
crit_procure = np.mean(ef["Procure"] >= ef["Civil"])
print(f"Procure drives Install in {crit_procure:.0%} of runs; Civil in {1 - crit_procure:.0%}")
rank = lambda x: pd.Series(x).rank().to_numpy()
tornado = {a: np.corrcoef(rank(dur[a]), rank(finish))[0, 1] for a in acts}
for a, r in sorted(tornado.items(), key=lambda kv: -abs(kv[1])):
    print(f"{a:11s} {r:+.2f}")

Procurement drives installation in almost every iteration, so it is effectively critical; commissioning ranks highest in the tornado because it carries the discrete risk. That tells you where mitigation buys the most confidence: the grid-approval risk and procurement lead time.

Cell 6: a structured status table

# Cell 6: a structured status table for review (and for an approved AI tool to draft from)
status = latest[["account", "CPI", "SPI", "CV", "EAC_cpi", "TCPI_bac"]].round(2)
status["schedule_P50_days"] = round(float(np.percentile(finish, 50)))
status["schedule_P80_days"] = round(float(np.percentile(finish, 80)))
status.to_csv("status_for_review.csv", index=False)
print(status.to_string(index=False))

This CSV is the single, reviewed source for the monthly narrative. If you use an approved enterprise AI assistant to draft the narrative, give it this table (not raw emails or commercial documents) with a template prompt such as:

Draft a 150-word project status narrative from the table below.
Structure: headline; cost (CPI, EAC range, TCPI); schedule (P50/P80 vs plan); top driver; decisions needed.
Cite the column for every number. Do not state causes that are not in the table; write "cause to be confirmed".

The controls lead checks every figure against the notebook output, confirms causes with control account managers and records the edits in the audit trail.

From lab to real projects

Lab stepReal-world sourceWatch out for
EVM dataERP and schedule exports by control account and periodAccruals, consistent data dates, codes that match the WBS
NetworkPrimavera P6 or Microsoft Project export (activities, links, durations)Only finish-to-start handled here; lags and SS/FF links need care
RangesStructured interviews plus historical actual-vs-planBlanket ±10% ranges; anchoring on the plan
RisksRegister entries mapped to activitiesDouble counting with activity ranges
CorrelationShared drivers (productivity, weather, a common supplier)Ignoring it narrows the range unrealistically

Common mistakes

  • Averaging CPIs across accounts instead of computing from summed EV and AC.
  • Running Monte Carlo on a network with missing logic.
  • Reporting P80 to one decimal place of a day, implying false precision.
  • Letting an AI-drafted narrative go out without checking every number against this output.

How to measure success

  • The notebook reproduces your scheduling tool's deterministic finish for the same network (a basic validation).
  • Results are stable when you change the random seed (use at least 10,000 iterations).
  • Each month's status narrative can be traced, number by number, to the status table.

Key takeaways

  • Keep EVM data as one row per control account per period, with cumulative PV, EV, AC and BAC.
  • Compute project indices from summed EV, PV and AC; never average account CPIs.
  • PERT ranges, a shared productivity factor and discrete register risks make a Monte Carlo model realistic.
  • Read plan confidence, P50, P80, criticality and a tornado ranking together to target mitigation.
  • A structured, reviewed status table is the only input an approved AI tool should draft narratives from.

Check your understanding

Quick questions to lock in the lesson. They don’t count towards your certificate.

  1. Two control accounts have CPIs of 0.97 (BAC 2.4M) and 0.81 (BAC 5.0M). How should the project CPI be calculated?
  2. Why does the lab multiply the Civil and Install durations by the same random site factor?
  3. The notebook shows a deterministic plan of 103 days with 11% confidence and P80 of 125 days. What is the best summary for a sponsor?

Put it into practice

Build the notebook from the lesson, run it, then widen the procurement maximum from 70 to 90 days. Record how P80 and plan confidence change and write a two-sentence explanation.

Enrol for free to save your progress

Reading is always free. Enrol to keep your place, take the final assessment and earn a verifiable certificate.