Skip to content

CPM Scheduler

The scheduling engine lives in packages/scheduler and ships independently as trueppm-scheduler on PyPI. It has no Django dependency.

Terminal window
pip install trueppm-scheduler
NotebookContents
01-cpm-quickstart.ipynbProject definition, CPM run, per-task float table, custom calendar, SS dependency, cycle detection, JSON round-trip
02-monte-carlo.ipynbPERT three-point estimates, Monte Carlo run, P50/P80/P95 output, matplotlib histogram, scenario comparison
03-calendar-aware.ipynbMon–Sat weeks, public holiday exceptions, multi-week shutdown blocks, calendar-aware lag, JSON round-trip
04-incremental-scheduling.ipynbIncremental CPM, equivalence verification, fallback behavior, local bench
Terminal window
# Run locally (from repo root)
pip install -e "packages/scheduler[dev]" matplotlib
jupyter notebook packages/scheduler/notebooks/

schedule() performs a forward pass, backward pass, float calculation, and critical-path identification on a directed acyclic graph of tasks and dependencies.

CodeNameConstraint
FSFinish-to-StartSuccessor starts after predecessor finishes (+ lag)
SSStart-to-StartSuccessor starts after predecessor starts (+ lag)
FFFinish-to-FinishSuccessor finishes after predecessor finishes (+ lag)
SFStart-to-FinishSuccessor finishes after predecessor starts (+ lag)

Lag is in calendar working days. Negative lag (lead) is supported.

FieldTypeDescription
early_startdateEarliest date the task can start
early_finishdateEarliest date the task can finish
late_startdateLatest start without delaying the project
late_finishdateLatest finish without delaying the project
total_floattimedeltaWorking days of slack before the task delays the project end
free_floattimedeltaWorking days a task can slip without delaying any immediate successor’s early start (see note)
is_criticalboolTrue when total_float == timedelta(0)

Working-day arithmetic skips weekends and any dates listed in Calendar.exceptions (DateRange entries). Applied to all lag calculations and task duration expansions.

schedule() raises CyclicDependencyError if the graph contains a cycle. The .cycle attribute contains the list of task IDs forming the cycle.

from datetime import date, timedelta
from trueppm_scheduler import (
Calendar, DateRange, Dependency, DependencyType,
Project, Task, schedule, CyclicDependencyError,
)
# Calendar: Mon–Fri, Good Friday excluded
cal = Calendar(
exceptions=[
DateRange(start=date(2026, 4, 3), end=date(2026, 4, 3)),
]
)
tasks = [
Task(id="design", name="Design", duration=timedelta(days=5)),
Task(id="build", name="Build", duration=timedelta(days=10)),
Task(id="test", name="Test", duration=timedelta(days=7)),
Task(id="deploy", name="Deploy", duration=timedelta(days=2)),
]
dependencies = [
Dependency(predecessor_id="design", successor_id="build"),
Dependency(predecessor_id="design", successor_id="test"),
Dependency(predecessor_id="build", successor_id="deploy"),
Dependency(predecessor_id="test", successor_id="deploy"),
]
project = Project(
id="release-v1",
name="Release v1.0",
start_date=date(2026, 4, 1),
tasks=tasks,
dependencies=dependencies,
calendar=cal,
)
try:
result = schedule(project)
except CyclicDependencyError as e:
print("Cycle:", e.cycle)
print(f"Finish: {result.project_finish}")
print(f"Critical path: {''.join(result.critical_path)}")
for t in result.tasks:
print(t.name, t.early_finish, "float:", t.total_float.days, "critical:", t.is_critical)

Non-FS dependencies use the dep_type and optional lag arguments:

Dependency(
predecessor_id="code",
successor_id="test",
dep_type=DependencyType.SS,
lag=timedelta(days=2),
)
json_str = project.to_json(indent=2)
project_rt = Project.from_json(json_str)
result_rt = schedule(project_rt)
Terminal window
trueppm-scheduler schedule project.json
trueppm-scheduler schedule project.json --json

monte_carlo() runs probabilistic simulation using PERT-Beta distributions (method-of-moments parameterization). Vectorized with numpy; 10,000 runs on a 200-task chain completes in under 5 seconds.

Add optimistic_duration, most_likely_duration, and pessimistic_duration to any task you want sampled stochastically. Tasks without these fields use their deterministic duration on every run.

FieldMeaning
optimistic_durationBest-case (timedelta)
most_likely_durationExpected case — should match duration (timedelta)
pessimistic_durationWorst-case (timedelta)
FieldDescription
runsNumber of simulations executed
p50Completion date in 50% of simulations
p80Completion date in 80% of simulations (recommended stakeholder commitment date)
p95Completion date in 95% of simulations (contractual deadline buffer)
distributionFull sorted list of completion dates (one per run)
sensitivityDuration-sensitivity tornado: [{task_id, index}], the tasks whose duration moves the finish most, sorted by index (absolute rank correlation, 0–1) descending

sensitivity ranks tasks by how strongly their sampled duration drives the project finish: the absolute Spearman rank correlation between each task’s per-run duration and the project completion date. This is the standard duration-sensitivity tornado — it answers “which tasks do I protect to hold the date?” It accounts for network position, so a high-variance task with lots of float ranks low (its slack absorbs the variance) while a task on the binding path ranks high. Tasks whose duration cannot vary the finish (deterministic durations, completed tasks, zero-duration milestones) are omitted; a fully deterministic project returns an empty list. The list is capped to the top entries.

from datetime import date, timedelta
from trueppm_scheduler import Calendar, Dependency, Project, Task, monte_carlo, schedule
def days(n: int) -> timedelta:
return timedelta(days=n)
tasks = [
Task(
id="design", name="Design",
duration=days(5),
optimistic_duration=days(3),
most_likely_duration=days(5),
pessimistic_duration=days(10),
),
Task(
id="build", name="Build",
duration=days(15),
optimistic_duration=days(10),
most_likely_duration=days(15),
pessimistic_duration=days(25),
),
# No PERT estimates — deterministic every run
Task(id="deploy", name="Deploy", duration=days(2)),
]
project = Project(
id="release-mc",
name="Release v1.0 (Monte Carlo)",
start_date=date(2026, 4, 1),
tasks=tasks,
dependencies=[
Dependency(predecessor_id="design", successor_id="build"),
Dependency(predecessor_id="build", successor_id="deploy"),
],
calendar=Calendar(),
)
# CPM deterministic baseline
cpm = schedule(project)
print(f"CPM finish (P50 proxy): {cpm.project_finish}")
# Monte Carlo
mc = monte_carlo(project, runs=10_000, seed=42)
print(f"P50: {mc.p50}")
print(f"P80: {mc.p80} ← recommended commitment date")
print(f"P95: {mc.p95}")
slip = (mc.p80 - cpm.project_finish).days
print(f"P80 vs CPM: +{slip} calendar days ({slip/7:.1f} weeks of schedule risk)")

:::tip P80 is the commitment date

The CPM deterministic finish is typically close to P50 — meaning there is only a 50% chance the project finishes on the date shown in a traditional Gantt chart. Commit to the P80 date to reflect realistic schedule risk.

:::

Terminal window
# Summary output
trueppm-scheduler monte-carlo project.json
# JSON output with full weekly distribution (for frontend histograms)
trueppm-scheduler monte-carlo project.json --json --distribution

Every exception the engine raises subclasses ValueError, so a single except ValueError covers them — but each is individually catchable.

ExceptionRaised when
CyclicDependencyErrorThe dependency graph contains a cycle. .cycle holds the offending task IDs.
SimulationCapExceededmonte_carlo(runs=…) exceeds max_runs, or the project has more tasks than max_tasks.
InvalidScheduleInputThe input is structurally valid but out of range (see below).

Because the engine walks the working calendar one day at a time, it validates input up front rather than spinning on a degenerate project (a calendar with no working day, or a century-long duration, would otherwise drive the day-by-day walk to the date ceiling and raise an opaque OverflowError):

InputLimit
Calendar.working_daysMust set at least one weekday bit (Mon–Sun). A calendar whose exceptions blanket the whole search window is also rejected.
Task duration (and each PERT estimate)0 to MAX_DURATION_DAYS (36_525, ~100 years); negatives rejected.
Dependency.lagWithin ±MAX_LAG_DAYS (36_525).
Cumulative project spanSum of every task’s worst-case duration + every lag must stay under MAX_PROJECT_SPAN_DAYS (366_000, ~1000 years), regardless of task count.
monte_carlo(runs=…)Must be >= 1.

Project.from_json() rejects the non-standard JSON literals NaN, Infinity, and -Infinity.

from trueppm_scheduler import schedule, InvalidScheduleInput
try:
result = schedule(project)
except InvalidScheduleInput as e:
print("Bad input:", e)

The recalculate_schedule Celery task fires automatically via transaction.on_commit() after every Task or Dependency write:

  1. Acquires a per-project Valkey lock (SET NX) — prevents redundant concurrent recalculations
  2. Fetches all live (non-deleted) tasks and dependencies for the project
  3. Calls trueppm-scheduler’s schedule() function
  4. Writes CPM output fields back to Task rows
  5. Broadcasts a cpm_complete WebSocket event (plus per-task task_dates_updated deltas) to all connected clients

If the lock is already held, the task re-queues itself with a 10-second countdown.