pmcontrols 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pmcontrols/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """pmcontrols — project scheduling and earned value control for Python.
2
+
3
+ Validated, pandas-native, automation-first project controls.
4
+
5
+ import pmcontrols as pm
6
+
7
+ r = pm.cpm(activities) # critical path, slack, float
8
+ r = pm.pert(activities) # three-point + Monte Carlo risk
9
+ r = pm.crash(activities, target=14) # cheapest compression (LP)
10
+
11
+ # plan once, freeze, control forever:
12
+ pm.plan(periods, pv).save("project_pmb.json")
13
+ r = pm.evm("project_pmb.json", ev=30_000, ac=35_000, at=4)
14
+ r.ok # True iff CPI and SPI(t) clear thresholds
15
+ r.summary() # plain-language verdict
16
+ """
17
+
18
+ from ._result import PMB, Alert, Result
19
+ from ._version import __version__
20
+ from .crash import crash
21
+ from .evm import earned_schedule, evm, plan
22
+ from .network import cpm, pert
23
+
24
+ __all__ = [
25
+ "cpm", "pert", "crash", "evm", "plan", "earned_schedule",
26
+ "Result", "Alert", "PMB", "__version__",
27
+ ]
pmcontrols/_result.py ADDED
@@ -0,0 +1,173 @@
1
+ """The Result protocol: the one object every pmcontrols analysis returns.
2
+
3
+ Design contract (append-only from v0.1 on):
4
+
5
+ Result.method stable analysis alias ("cpm", "pert", "evm", "crash")
6
+ Result.params echo of the user's inputs
7
+ Result.stats named scalars (durations, indices, forecasts, costs)
8
+ Result.alerts tuple of structured threshold events; empty == on track
9
+ Result.meta provenance: n, version, input hash, timestamp
10
+ Result.table tidy per-activity / per-period DataFrame
11
+
12
+ r.ok True iff no alerts -> sys.exit(0 if r.ok else 1)
13
+ r.summary() fixed-width audit text with a plain-language verdict
14
+ r.to_dict() JSON-safe, integer-versioned schema
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ import datetime
21
+ import hashlib
22
+ import json
23
+ import pathlib
24
+ from typing import Any, Mapping
25
+
26
+ import numpy as np
27
+ import pandas as pd
28
+
29
+ _SCHEMA = 1
30
+
31
+
32
+ def utcnow() -> str:
33
+ return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
34
+
35
+
36
+ def data_hash(values: Any) -> str:
37
+ payload = json.dumps(values, sort_keys=True, default=str).encode()
38
+ return "sha256:" + hashlib.sha256(payload).hexdigest()[:16]
39
+
40
+
41
+ def _jsonable(obj: Any) -> Any:
42
+ if isinstance(obj, (str, int, float, bool)) or obj is None:
43
+ return obj
44
+ if isinstance(obj, Mapping):
45
+ return {str(k): _jsonable(v) for k, v in obj.items()}
46
+ if isinstance(obj, (list, tuple)):
47
+ return [_jsonable(v) for v in obj]
48
+ if isinstance(obj, (np.integer, np.floating)):
49
+ return obj.item()
50
+ return str(obj)
51
+
52
+
53
+ @dataclasses.dataclass(frozen=True)
54
+ class Alert:
55
+ """One threshold event: which indicator, where, how bad."""
56
+
57
+ indicator: str
58
+ value: float
59
+ threshold: float
60
+ note: str = ""
61
+
62
+ def to_dict(self) -> dict:
63
+ return {
64
+ "indicator": self.indicator,
65
+ "value": self.value,
66
+ "threshold": self.threshold,
67
+ "note": self.note,
68
+ }
69
+
70
+ def __str__(self) -> str:
71
+ base = f"{self.indicator}={self.value:.3f} breaches {self.threshold:g}"
72
+ return base + (f" - {self.note}" if self.note else "")
73
+
74
+
75
+ @dataclasses.dataclass(frozen=True)
76
+ class Result:
77
+ method: str
78
+ params: Mapping[str, Any]
79
+ stats: Mapping[str, float]
80
+ table: pd.DataFrame
81
+ alerts: tuple[Alert, ...] = ()
82
+ meta: Mapping[str, Any] = dataclasses.field(default_factory=dict)
83
+
84
+ @property
85
+ def ok(self) -> bool:
86
+ return len(self.alerts) == 0
87
+
88
+ def to_dict(self) -> dict:
89
+ return {
90
+ "schema": _SCHEMA,
91
+ "method": self.method,
92
+ "params": _jsonable(self.params),
93
+ "stats": _jsonable(self.stats),
94
+ "alerts": [a.to_dict() for a in self.alerts],
95
+ "meta": _jsonable(self.meta),
96
+ "table": _jsonable(self.table.to_dict(orient="list")),
97
+ }
98
+
99
+ def summary(self) -> str:
100
+ lines = [f"pmcontrols {self.method} — {self.meta.get('computed_at', '')}"]
101
+ for k, v in self.stats.items():
102
+ lines.append(f" {k:<24} {v:>12.4f}" if isinstance(v, float) else f" {k:<24} {v}")
103
+ if self.alerts:
104
+ lines.append("Alerts:")
105
+ lines += [f" ! {a}" for a in self.alerts]
106
+ lines.append("Verdict: ATTENTION — indicators breach thresholds.")
107
+ else:
108
+ lines.append("Verdict: on track — no indicator breaches thresholds.")
109
+ return "\n".join(lines)
110
+
111
+
112
+ @dataclasses.dataclass(frozen=True)
113
+ class PMB:
114
+ """Performance Measurement Baseline: plan once, freeze, control forever.
115
+
116
+ The EVM sibling of a control-chart baseline: the time-phased planned
117
+ value curve and budget at completion are fixed at planning time,
118
+ committed to version control, and every reporting period is evaluated
119
+ against them.
120
+ """
121
+
122
+ periods: tuple[float, ...]
123
+ pv: tuple[float, ...]
124
+ bac: float
125
+ created_at: str
126
+ version: str
127
+
128
+ def __post_init__(self) -> None:
129
+ if len(self.periods) != len(self.pv):
130
+ raise ValueError("periods and pv must have the same length")
131
+ if len(self.periods) < 2:
132
+ raise ValueError("a PMB needs at least two points")
133
+ if any(b > a for a, b in zip(self.pv[1:], self.pv[:-1])):
134
+ raise ValueError("planned value must be non-decreasing")
135
+ if abs(self.pv[-1] - self.bac) > 1e-9 * max(1.0, self.bac):
136
+ raise ValueError("planned value must end at BAC")
137
+
138
+ @property
139
+ def planned_duration(self) -> float:
140
+ return float(self.periods[-1] - self.periods[0])
141
+
142
+ def to_json(self) -> str:
143
+ return json.dumps(
144
+ {
145
+ "schema": _SCHEMA,
146
+ "periods": list(self.periods),
147
+ "pv": list(self.pv),
148
+ "bac": self.bac,
149
+ "created_at": self.created_at,
150
+ "pmcontrols_version": self.version,
151
+ },
152
+ indent=2,
153
+ )
154
+
155
+ def save(self, path: str | pathlib.Path) -> pathlib.Path:
156
+ path = pathlib.Path(path)
157
+ path.write_text(self.to_json() + "\n", encoding="utf-8")
158
+ return path
159
+
160
+ @classmethod
161
+ def from_json(cls, text: str) -> "PMB":
162
+ raw = json.loads(text)
163
+ return cls(
164
+ periods=tuple(float(x) for x in raw["periods"]),
165
+ pv=tuple(float(x) for x in raw["pv"]),
166
+ bac=float(raw["bac"]),
167
+ created_at=raw["created_at"],
168
+ version=raw["pmcontrols_version"],
169
+ )
170
+
171
+ @classmethod
172
+ def load(cls, path: str | pathlib.Path) -> "PMB":
173
+ return cls.from_json(pathlib.Path(path).read_text(encoding="utf-8"))
pmcontrols/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
pmcontrols/crash.py ADDED
@@ -0,0 +1,128 @@
1
+ """Minimum-cost schedule compression (crashing) as a linear program.
2
+
3
+ Decision variables are activity start times s_i, crash amounts y_i and
4
+ the project finish time F:
5
+
6
+ minimize sum_i c_i * y_i
7
+ subject to s_j >= s_i + (d_i - y_i) for every precedence i -> j
8
+ F >= s_i + (d_i - y_i) for every terminal activity i
9
+ F <= T_target
10
+ 0 <= y_i <= d_i - crash_i, s_i >= 0
11
+
12
+ This is the classical CPM time/cost trade-off LP (Kelley 1961), solved
13
+ with scipy.optimize.linprog (HiGHS).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Iterable, Mapping
19
+
20
+ import numpy as np
21
+ from scipy.optimize import linprog
22
+
23
+ from ._result import Result, data_hash, utcnow
24
+ from ._version import __version__
25
+ from .network import _normalize, _passes
26
+
27
+ __all__ = ["crash"]
28
+
29
+
30
+ def crash(
31
+ activities: Iterable[Mapping[str, object]],
32
+ target: float,
33
+ duration: str = "duration",
34
+ crash_duration: str = "crash_duration",
35
+ cost_per_period: str = "crash_cost",
36
+ ) -> Result:
37
+ """Cheapest set of crash decisions meeting a target completion time.
38
+
39
+ Each activity mapping must carry: ``id``, ``predecessors``, a normal
40
+ duration, a fully-crashed duration, and a (linear) crash cost per
41
+ period. Returns the optimal crash amount per activity, the resulting
42
+ schedule, and the total crash cost.
43
+ """
44
+ acts = _normalize(activities)
45
+ names = list(acts)
46
+ idx = {a: i for i, a in enumerate(names)}
47
+ n = len(names)
48
+ d = np.array([float(acts[a][duration]) for a in names])
49
+ dc = np.array([float(acts[a][crash_duration]) for a in names])
50
+ cost = np.array([float(acts[a][cost_per_period]) for a in names])
51
+ if np.any(dc > d):
52
+ raise ValueError("crash duration cannot exceed normal duration")
53
+ if np.any(cost < 0):
54
+ raise ValueError("crash costs must be non-negative")
55
+
56
+ normal = _passes(acts, {a: float(acts[a][duration]) for a in names})
57
+ normal_finish = float(normal["ef"].max())
58
+ if target > normal_finish:
59
+ raise ValueError(
60
+ f"target {target} exceeds the uncrashed duration {normal_finish}; "
61
+ "nothing to crash"
62
+ )
63
+
64
+ # Variables: [s_0..s_{n-1}, F, y_0..y_{n-1}]
65
+ nv = 2 * n + 1
66
+ c = np.zeros(nv)
67
+ c[n + 1:] = cost
68
+
69
+ A_ub, b_ub = [], []
70
+ succs: dict[str, list[str]] = {a: [] for a in acts}
71
+ for a in acts:
72
+ for p in acts[a]["preds"]:
73
+ succs[p].append(a)
74
+ # Precedence: s_i + d_i - y_i - s_j <= 0
75
+ for a in names:
76
+ for s in succs[a]:
77
+ row = np.zeros(nv)
78
+ row[idx[a]] = 1.0
79
+ row[idx[s]] = -1.0
80
+ row[n + 1 + idx[a]] = -1.0
81
+ A_ub.append(row)
82
+ b_ub.append(-d[idx[a]])
83
+ # Terminal: s_i + d_i - y_i - F <= 0
84
+ for a in names:
85
+ if not succs[a]:
86
+ row = np.zeros(nv)
87
+ row[idx[a]] = 1.0
88
+ row[n] = -1.0
89
+ row[n + 1 + idx[a]] = -1.0
90
+ A_ub.append(row)
91
+ b_ub.append(-d[idx[a]])
92
+
93
+ bounds = [(0, None)] * n + [(0, target)] + [
94
+ (0, float(d[i] - dc[i])) for i in range(n)
95
+ ]
96
+ res = linprog(c, A_ub=np.array(A_ub), b_ub=np.array(b_ub), bounds=bounds,
97
+ method="highs")
98
+ if not res.success:
99
+ raise RuntimeError(f"crashing LP infeasible or failed: {res.message}")
100
+
101
+ y = res.x[n + 1:]
102
+ new_dur = {a: float(d[idx[a]] - y[idx[a]]) for a in names}
103
+ sched = _passes(acts, new_dur)
104
+ sched["normal_duration"] = sched["activity"].map(
105
+ {a: float(d[idx[a]]) for a in names}
106
+ )
107
+ sched["crash_amount"] = sched["activity"].map(
108
+ {a: float(y[idx[a]]) for a in names}
109
+ )
110
+ sched["crash_cost"] = sched["activity"].map(
111
+ {a: float(y[idx[a]] * cost[idx[a]]) for a in names}
112
+ )
113
+
114
+ stats = {
115
+ "normal_duration_total": normal_finish,
116
+ "target": float(target),
117
+ "achieved_duration": float(sched["ef"].max()),
118
+ "total_crash_cost": float(res.fun),
119
+ }
120
+ meta = {
121
+ "computed_at": utcnow(),
122
+ "version": __version__,
123
+ "input_hash": data_hash({a: [float(d[idx[a]]), float(dc[idx[a]]),
124
+ float(cost[idx[a]])] for a in names}),
125
+ "solver": "scipy.linprog/highs",
126
+ }
127
+ return Result(method="crash", params={"target": target}, stats=stats,
128
+ table=sched, meta=meta)
pmcontrols/evm.py ADDED
@@ -0,0 +1,136 @@
1
+ """Earned value management and earned schedule.
2
+
3
+ Cost indicators follow the standard EVM formulation (PMBOK; EIA-748
4
+ practice): CV = EV - AC, SV = EV - PV, CPI = EV/AC, SPI = EV/PV, with
5
+ the classical estimate-at-completion family and TCPI.
6
+
7
+ Time indicators follow Lipke's earned schedule: ES(t) is the time at
8
+ which the planned value curve equals current EV (linear interpolation
9
+ between baseline periods), SV(t) = ES - AT, SPI(t) = ES / AT, and the
10
+ duration forecast IEAC(t) = PD / SPI(t).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Mapping, Sequence
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from ._result import PMB, Alert, Result, data_hash, utcnow
21
+ from ._version import __version__
22
+
23
+ __all__ = ["evm", "plan", "earned_schedule"]
24
+
25
+
26
+ def plan(periods: Sequence[float], pv: Sequence[float]) -> PMB:
27
+ """Freeze a time-phased planned value curve into a PMB."""
28
+ return PMB(
29
+ periods=tuple(float(x) for x in periods),
30
+ pv=tuple(float(x) for x in pv),
31
+ bac=float(pv[-1]),
32
+ created_at=utcnow(),
33
+ version=__version__,
34
+ )
35
+
36
+
37
+ def earned_schedule(pmb: PMB, ev: float) -> float:
38
+ """Earned schedule ES(t): when the plan earned what we have earned.
39
+
40
+ Linear interpolation on the cumulative PV curve (Lipke 2003):
41
+ ES = t_N + (EV - PV_N) / (PV_{N+1} - PV_N) * (t_{N+1} - t_N),
42
+ where N is the last baseline period with PV_N <= EV.
43
+ """
44
+ t = np.asarray(pmb.periods, dtype=float)
45
+ pv = np.asarray(pmb.pv, dtype=float)
46
+ if ev <= pv[0]:
47
+ return float(t[0])
48
+ if ev >= pv[-1]:
49
+ return float(t[-1])
50
+ n = int(np.searchsorted(pv, ev, side="right") - 1)
51
+ if pv[n + 1] == pv[n]:
52
+ return float(t[n + 1])
53
+ return float(t[n] + (ev - pv[n]) / (pv[n + 1] - pv[n]) * (t[n + 1] - t[n]))
54
+
55
+
56
+ def evm(
57
+ pmb: PMB | str,
58
+ ev: float,
59
+ ac: float,
60
+ at: float,
61
+ thresholds: Mapping[str, float] | None = None,
62
+ ) -> Result:
63
+ """Evaluate project status at actual time ``at`` against a frozen PMB.
64
+
65
+ Parameters
66
+ ----------
67
+ pmb : a PMB object or a path to a saved PMB JSON.
68
+ ev : cumulative earned value at ``at``.
69
+ ac : cumulative actual cost at ``at``.
70
+ at : actual time, in the PMB's time units.
71
+ thresholds : alert thresholds; defaults to CPI and SPI(t) below 0.90.
72
+
73
+ Returns a Result whose stats include the full indicator set:
74
+ PV, CV, SV, CPI, SPI, the EAC family, ETC, TCPI, VAC, and the
75
+ earned-schedule block ES, SV(t), SPI(t), IEAC(t).
76
+ """
77
+ if isinstance(pmb, str):
78
+ pmb = PMB.load(pmb)
79
+ if ev < 0 or ac < 0:
80
+ raise ValueError("EV and AC must be non-negative")
81
+ if ev > pmb.bac * (1 + 1e-9):
82
+ raise ValueError("EV cannot exceed BAC")
83
+ t = np.asarray(pmb.periods, dtype=float)
84
+ pv_curve = np.asarray(pmb.pv, dtype=float)
85
+ pv = float(np.interp(at, t, pv_curve))
86
+ bac, pd_ = pmb.bac, pmb.planned_duration
87
+
88
+ cv, sv = ev - ac, ev - pv
89
+ cpi = ev / ac if ac > 0 else np.nan
90
+ spi = ev / pv if pv > 0 else np.nan
91
+ eac_cpi = bac / cpi if cpi and not np.isnan(cpi) and cpi > 0 else np.nan
92
+ eac_typical = ac + (bac - ev) # remaining work at planned efficiency
93
+ spi_for_blend = spi if spi and not np.isnan(spi) and spi > 0 else np.nan
94
+ eac_blend = (
95
+ ac + (bac - ev) / (cpi * spi_for_blend)
96
+ if cpi and spi_for_blend and cpi > 0
97
+ else np.nan
98
+ )
99
+ etc = eac_cpi - ac if not np.isnan(eac_cpi) else np.nan
100
+ tcpi_bac = (bac - ev) / (bac - ac) if bac > ac else np.inf
101
+ vac = bac - eac_cpi if not np.isnan(eac_cpi) else np.nan
102
+
103
+ es = earned_schedule(pmb, ev)
104
+ sv_t = es - at
105
+ spi_t = es / at if at > 0 else np.nan
106
+ ieac_t = pd_ / spi_t if spi_t and not np.isnan(spi_t) and spi_t > 0 else np.nan
107
+
108
+ stats = {
109
+ "bac": bac, "planned_duration": pd_, "at": at,
110
+ "pv": pv, "ev": ev, "ac": ac,
111
+ "cv": cv, "sv": sv, "cpi": cpi, "spi": spi,
112
+ "eac_cpi": eac_cpi, "eac_typical": eac_typical, "eac_cpi_spi": eac_blend,
113
+ "etc": etc, "tcpi_bac": tcpi_bac, "vac": vac,
114
+ "es": es, "sv_t": sv_t, "spi_t": spi_t, "ieac_t": ieac_t,
115
+ "pct_complete": ev / bac, "pct_spent": ac / bac,
116
+ }
117
+
118
+ thr = {"cpi": 0.90, "spi_t": 0.90, **(thresholds or {})}
119
+ alerts = tuple(
120
+ Alert(indicator=k, value=float(stats[k]), threshold=float(v),
121
+ note="below threshold")
122
+ for k, v in thr.items()
123
+ if k in stats and not np.isnan(stats[k]) and stats[k] < v
124
+ )
125
+
126
+ table = pd.DataFrame(
127
+ {"indicator": list(stats), "value": [stats[k] for k in stats]}
128
+ )
129
+ meta = {
130
+ "computed_at": utcnow(),
131
+ "version": __version__,
132
+ "input_hash": data_hash({"ev": ev, "ac": ac, "at": at, "pv": list(pmb.pv)}),
133
+ "pmb_created_at": pmb.created_at,
134
+ }
135
+ return Result(method="evm", params={"ev": ev, "ac": ac, "at": at},
136
+ stats=stats, table=table, alerts=alerts, meta=meta)
pmcontrols/network.py ADDED
@@ -0,0 +1,224 @@
1
+ """Project networks: CPM and PERT.
2
+
3
+ Activity-on-node networks built from plain records
4
+ (id, duration, predecessors). The forward/backward pass follows the
5
+ standard formulation (e.g. Render, Stair & Hanna; PMBOK):
6
+
7
+ EF = ES + t ES = max EF of immediate predecessors
8
+ LS = LF - t LF = min LS of immediate successors
9
+ slack = LS - ES = LF - EF
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from graphlib import CycleError, TopologicalSorter
15
+ from typing import Iterable, Mapping
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from ._result import Result, utcnow, data_hash
21
+ from ._version import __version__
22
+
23
+ __all__ = ["cpm", "pert"]
24
+
25
+ Activity = Mapping[str, object]
26
+
27
+
28
+ def _normalize(activities: Iterable[Activity]) -> dict[str, dict]:
29
+ acts: dict[str, dict] = {}
30
+ for a in activities:
31
+ aid = str(a["id"])
32
+ if aid in acts:
33
+ raise ValueError(f"duplicate activity id: {aid}")
34
+ preds_raw = a.get("predecessors", a.get("pred", ())) or ()
35
+ if isinstance(preds_raw, str):
36
+ preds: tuple[str, ...] = tuple(
37
+ p.strip() for p in preds_raw.split(",") if p.strip()
38
+ )
39
+ else:
40
+ preds = tuple(str(p) for p in preds_raw) # type: ignore[attr-defined]
41
+ acts[aid] = {"preds": preds, **{
42
+ k: v for k, v in a.items() if k not in ("id", "predecessors", "pred")
43
+ }}
44
+ for aid, a in acts.items():
45
+ for p in a["preds"]:
46
+ if p not in acts:
47
+ raise ValueError(f"activity {aid} references unknown predecessor {p!r}")
48
+ return acts
49
+
50
+
51
+ def _topo_order(acts: dict[str, dict]) -> list[str]:
52
+ """Topological order, raising a clear error on precedence cycles."""
53
+ try:
54
+ return list(
55
+ TopologicalSorter({k: v["preds"] for k, v in acts.items()}).static_order()
56
+ )
57
+ except CycleError as exc: # graphlib's message is opaque to end users
58
+ cycle = " -> ".join(map(str, exc.args[1])) if len(exc.args) > 1 else "?"
59
+ raise ValueError(f"precedence cycle in the activity network: {cycle}") from None
60
+
61
+
62
+ def _passes(acts: dict[str, dict], dur: Mapping[str, float]) -> pd.DataFrame:
63
+ order = _topo_order(acts)
64
+ es: dict[str, float] = {}
65
+ ef: dict[str, float] = {}
66
+ for a in order:
67
+ es[a] = max((ef[p] for p in acts[a]["preds"]), default=0.0)
68
+ ef[a] = es[a] + dur[a]
69
+ finish = max(ef.values())
70
+ succs: dict[str, list[str]] = {a: [] for a in acts}
71
+ for a in acts:
72
+ for p in acts[a]["preds"]:
73
+ succs[p].append(a)
74
+ lf: dict[str, float] = {}
75
+ ls: dict[str, float] = {}
76
+ for a in reversed(order):
77
+ lf[a] = min((ls[s] for s in succs[a]), default=finish)
78
+ ls[a] = lf[a] - dur[a]
79
+ rows = []
80
+ for a in order:
81
+ slack = ls[a] - es[a]
82
+ rows.append(
83
+ {
84
+ "activity": a,
85
+ "duration": dur[a],
86
+ "es": es[a],
87
+ "ef": ef[a],
88
+ "ls": ls[a],
89
+ "lf": lf[a],
90
+ "slack": slack,
91
+ "critical": bool(abs(slack) < 1e-9),
92
+ }
93
+ )
94
+ return pd.DataFrame(rows)
95
+
96
+
97
+ def cpm(activities: Iterable[Activity], duration: str = "duration") -> Result:
98
+ """Critical path analysis of a deterministic activity network.
99
+
100
+ Parameters
101
+ ----------
102
+ activities : iterable of mappings with keys ``id``, ``predecessors``
103
+ (list or comma-separated string; empty for start activities) and a
104
+ duration field.
105
+ duration : name of the duration field (default ``"duration"``).
106
+ """
107
+ acts = _normalize(activities)
108
+ dur = {a: float(v[duration]) for a, v in acts.items()}
109
+ if any(d < 0 for d in dur.values()):
110
+ raise ValueError("durations must be non-negative")
111
+ table = _passes(acts, dur)
112
+ crit = table.loc[table["critical"], "activity"].tolist()
113
+ stats = {
114
+ "project_duration": float(table["ef"].max()),
115
+ "n_activities": float(len(table)),
116
+ "n_critical": float(len(crit)),
117
+ }
118
+ meta = {
119
+ "computed_at": utcnow(),
120
+ "version": __version__,
121
+ "input_hash": data_hash({a: dur[a] for a in sorted(dur)}),
122
+ "critical_activities": crit,
123
+ }
124
+ return Result(method="cpm", params={"duration": duration},
125
+ stats=stats, table=table, meta=meta)
126
+
127
+
128
+ def pert(
129
+ activities: Iterable[Activity],
130
+ optimistic: str = "a",
131
+ most_likely: str = "m",
132
+ pessimistic: str = "b",
133
+ n_sim: int = 20_000,
134
+ seed: int | None = 0,
135
+ ) -> Result:
136
+ """PERT three-point analysis with Monte Carlo schedule risk.
137
+
138
+ Expected time and variance per activity follow the classical beta
139
+ approximation te = (a + 4m + b) / 6, var = ((b - a) / 6)^2. The
140
+ analytic project estimate sums te and var along the te-critical path
141
+ (the textbook procedure); the Monte Carlo simulation samples each
142
+ activity from the PERT-beta distribution and reports the empirical
143
+ completion distribution and per-activity criticality indices, which
144
+ the analytic procedure cannot provide.
145
+ """
146
+ acts = _normalize(activities)
147
+ te, var, lo, hi = {}, {}, {}, {}
148
+ for k, v in acts.items():
149
+ a, m, b = float(v[optimistic]), float(v[most_likely]), float(v[pessimistic])
150
+ if not a <= m <= b:
151
+ raise ValueError(f"activity {k}: require a <= m <= b")
152
+ te[k] = (a + 4 * m + b) / 6.0
153
+ var[k] = ((b - a) / 6.0) ** 2
154
+ lo[k], hi[k] = a, b
155
+ base = _passes(acts, te)
156
+ base["te"] = base["activity"].map(te)
157
+ base["var"] = base["activity"].map(var)
158
+ crit = base.loc[base["critical"], "activity"].tolist()
159
+ mean = float(base["ef"].max())
160
+ sigma = float(np.sqrt(sum(var[c] for c in crit)))
161
+
162
+ rng = np.random.default_rng(seed)
163
+ names = list(acts)
164
+ samples = {}
165
+ for k in names:
166
+ a, b = lo[k], hi[k]
167
+ if b - a < 1e-12:
168
+ samples[k] = np.full(n_sim, a)
169
+ continue
170
+ # PERT-beta with alpha + beta = 6 (the classic four-sigma range).
171
+ alpha = 1.0 + 4.0 * (float(acts[k][most_likely]) - a) / (b - a)
172
+ beta = 6.0 - alpha
173
+ samples[k] = a + (b - a) * rng.beta(alpha, beta, n_sim)
174
+
175
+ order = _topo_order(acts)
176
+ ef_sim = {k: np.zeros(n_sim) for k in names}
177
+ for k in order:
178
+ preds = acts[k]["preds"]
179
+ es_k = np.maximum.reduce([ef_sim[p] for p in preds]) if preds else np.zeros(n_sim)
180
+ ef_sim[k] = es_k + samples[k]
181
+ finish = np.maximum.reduce([ef_sim[k] for k in names])
182
+
183
+ # Per-activity criticality index: share of simulation runs in which the
184
+ # activity lies on the critical path (zero slack) under sampled durations.
185
+ succs: dict[str, list[str]] = {name: [] for name in acts}
186
+ for name in acts:
187
+ for pred in acts[name]["preds"]:
188
+ succs[pred].append(name)
189
+ ls_sim: dict[str, np.ndarray] = {}
190
+ lf_sim: dict[str, np.ndarray] = {}
191
+ crit_idx: dict[str, float] = {}
192
+ for k in reversed(order):
193
+ lf_sim[k] = (
194
+ np.minimum.reduce([ls_sim[s] for s in succs[k]]) if succs[k] else finish
195
+ )
196
+ es_k = (
197
+ np.maximum.reduce([ef_sim[p] for p in acts[k]["preds"]])
198
+ if acts[k]["preds"]
199
+ else np.zeros(n_sim)
200
+ )
201
+ ls_sim[k] = lf_sim[k] - samples[k]
202
+ crit_idx[k] = float(np.mean(np.abs(ls_sim[k] - es_k) < 1e-9))
203
+ base["criticality_index"] = base["activity"].map(crit_idx)
204
+
205
+ stats = {
206
+ "expected_duration": mean,
207
+ "sigma_critical_path": sigma,
208
+ "mc_mean": float(np.mean(finish)),
209
+ "mc_p50": float(np.percentile(finish, 50)),
210
+ "mc_p80": float(np.percentile(finish, 80)),
211
+ "mc_p95": float(np.percentile(finish, 95)),
212
+ }
213
+ meta = {
214
+ "computed_at": utcnow(),
215
+ "version": __version__,
216
+ "n_sim": n_sim,
217
+ "seed": seed,
218
+ "critical_activities": crit,
219
+ "mc_finish_quantiles": {
220
+ str(q): float(np.percentile(finish, q)) for q in (5, 25, 50, 75, 95)
221
+ },
222
+ }
223
+ return Result(method="pert", params={"n_sim": n_sim}, stats=stats,
224
+ table=base, meta=meta)
pmcontrols/py.typed ADDED
File without changes
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: pmcontrols
3
+ Version: 0.1.0
4
+ Summary: Project scheduling and earned value control for Python: CPM, PERT with Monte Carlo schedule risk, minimum-cost crashing, EVM and earned schedule, validated against published reference values.
5
+ Author: Atakan Arikan
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arikanatakan/pmcontrols
8
+ Project-URL: Documentation, https://arikanatakan.github.io/pmcontrols/
9
+ Project-URL: Repository, https://github.com/arikanatakan/pmcontrols
10
+ Project-URL: Issues, https://github.com/arikanatakan/pmcontrols/issues
11
+ Keywords: project-management,critical-path,cpm,pert,earned-value,evm,earned-schedule,schedule-risk,crashing,project-controls,eia-748
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Manufacturing
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Classifier: Topic :: Office/Business :: Scheduling
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy>=1.24
26
+ Requires-Dist: pandas>=2.0
27
+ Requires-Dist: scipy>=1.10
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7; extra == "dev"
30
+ Requires-Dist: ruff; extra == "dev"
31
+ Requires-Dist: mypy; extra == "dev"
32
+ Requires-Dist: build; extra == "dev"
33
+ Provides-Extra: docs
34
+ Requires-Dist: mkdocs-material; extra == "docs"
35
+ Dynamic: license-file
36
+
37
+ # pmcontrols
38
+
39
+ [![CI](https://github.com/arikanatakan/pmcontrols/actions/workflows/ci.yml/badge.svg)](https://github.com/arikanatakan/pmcontrols/actions/workflows/ci.yml)
40
+ [![License: MIT](https://img.shields.io/github/license/arikanatakan/pmcontrols)](LICENSE)
41
+
42
+ Project scheduling and earned value control for Python.
43
+
44
+ CPM, PERT with Monte Carlo schedule risk, minimum-cost crashing, and
45
+ EVM/earned-schedule control — computed from the defining formulations,
46
+ never from spreadsheet conventions, and checked against published
47
+ reference values in the test suite.
48
+
49
+ Companion to [lotsampling](https://github.com/arikanatakan/lotsampling) (acceptance
50
+ sampling): lotsampling judges the lot, pmcontrols keeps the project honest.
51
+
52
+ ## Motivation
53
+
54
+ Every project office computes CPI and SPI; almost all of it happens in
55
+ Excel. R and commercial tools (Primavera, Deltek, @RISK) cover schedule
56
+ risk and earned value; in Python the landscape is a 4 KB CPM toy and an
57
+ abandoned ERP add-on. There is no maintained library a cost engineer or
58
+ a project-controls researcher can `pip install` to get:
59
+
60
+ - the **critical path** with full ES/EF/LS/LF/slack accounting
61
+ - **PERT** three-point analysis plus what the textbook procedure cannot
62
+ give you: a Monte Carlo completion distribution and per-activity
63
+ **criticality indices**
64
+ - **crashing as optimization** — the cheapest set of compressions
65
+ meeting a deadline, solved as the classical time/cost trade-off LP
66
+ rather than by manual marginal-cost inspection
67
+ - **EVM with earned schedule** — the full indicator set (CV, SV, CPI,
68
+ SPI, the EAC family, TCPI, VAC) plus Lipke's time-based ES, SPI(t)
69
+ and duration forecast IEAC(t), which plain EVM gets wrong late in
70
+ projects
71
+
72
+ ## Quickstart
73
+
74
+ ```python
75
+ import pmcontrols as pm
76
+
77
+ activities = [
78
+ {"id": "A", "predecessors": [], "duration": 2},
79
+ {"id": "B", "predecessors": [], "duration": 3},
80
+ {"id": "C", "predecessors": ["A"], "duration": 2},
81
+ {"id": "D", "predecessors": ["B"], "duration": 4},
82
+ {"id": "E", "predecessors": ["C"], "duration": 4},
83
+ {"id": "F", "predecessors": ["C"], "duration": 3},
84
+ {"id": "G", "predecessors": ["D", "E"], "duration": 5},
85
+ {"id": "H", "predecessors": ["F", "G"], "duration": 2},
86
+ ]
87
+
88
+ r = pm.cpm(activities)
89
+ r.stats["project_duration"] # 15.0
90
+ r.meta["critical_activities"] # ['A', 'C', 'E', 'G', 'H']
91
+ r.table # tidy ES/EF/LS/LF/slack DataFrame
92
+
93
+ # Cheapest way to finish in 13 periods (linear program):
94
+ r = pm.crash(crash_activities, target=13)
95
+ r.stats["total_crash_cost"] # optimal, not greedy
96
+
97
+ # Plan once, freeze, control forever:
98
+ pm.plan(periods, pv_curve).save("pmb.json") # commit this to git
99
+ r = pm.evm("pmb.json", ev=30_000, ac=35_000, at=4)
100
+ r.ok # False — CPI and SPI(t) below threshold
101
+ print(r.summary()) # audit text with plain-language verdict
102
+ r.stats["ieac_t"] # earned-schedule duration forecast
103
+ ```
104
+
105
+ `r.ok` is the automation primitive: a weekly cron job can evaluate the
106
+ latest actuals against the frozen PMB and exit nonzero the moment cost
107
+ or schedule efficiency breaches a threshold.
108
+
109
+ ## Validation philosophy
110
+
111
+ Every release must reproduce, in `tests/validation_cases.json` (each
112
+ case ships with its full derivation):
113
+
114
+ 1. the General Foundry reference network — complete ES/EF/LS/LF/slack
115
+ table, 15-period critical path, and optimal crash costs to 14 and 13
116
+ periods,
117
+ 2. hand-derived EVM/earned-schedule cases covering the full indicator
118
+ set and the ES interpolation formula,
119
+ 3. identity and property checks (slack = LS−ES = LF−EF; SPI(t) = ES/AT;
120
+ crash cost monotone in target; Monte Carlo means converging to
121
+ analytic values),
122
+ 4. (before 0.1) published PMI/Lipke earned-schedule case studies,
123
+ verified privately where copyright prevents redistribution.
124
+
125
+ ## Status
126
+
127
+ 0.1.0 — first public release. The API surface is small on purpose; the
128
+ `Result`/`PMB` contract is frozen and append-only from this version on.
129
+
130
+ ## Roadmap
131
+
132
+ | Version | Scope |
133
+ | ------- | ----- |
134
+ | 0.2 | Published PMI/Lipke earned-schedule case-study validation; Gantt and OC plotting (separate from the statistics) |
135
+ | 0.3 | Resource leveling and constrained scheduling; schedule risk drivers (correlation, risk events); EVM from time-phased ledgers (DataFrame in, indicators out) |
136
+ | 0.4 | Critical chain buffers; probabilistic crashing (crash under uncertainty); ES forecasting variants (performance factors); JOSS paper |
137
+
138
+ Out of scope: Gantt-chart project *editors* (use dedicated PM tools),
139
+ process control, acceptance sampling (see lotsampling), generic LP modeling
140
+ (use scipy/pyomo directly).
141
+
142
+ ## License
143
+
144
+ MIT. Written and maintained by [Atakan Arikan](https://github.com/arikanatakan),
145
+ MSc Student at Tsinghua University and Politecnico di Milano.
@@ -0,0 +1,12 @@
1
+ pmcontrols/__init__.py,sha256=YXhB1tbUoJl6wCy5GptZTbc7S0rNWm3dJSe3F3g2x34,928
2
+ pmcontrols/_result.py,sha256=qPe0auuviSxq4VQ_haJVLFPFdG6qr0oQxL6om_C7I6c,5640
3
+ pmcontrols/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
4
+ pmcontrols/crash.py,sha256=ZmuX-r3CxyKQcpokl6MTKHvRvNtZ3cK68a_ouMKVHEA,4372
5
+ pmcontrols/evm.py,sha256=ZDZ6g30yqINQkME92umLVsLZXbFV9W0Nl-MSfdc8OL8,4895
6
+ pmcontrols/network.py,sha256=n13VN0PpKutGWLi3cQLGWvtvMD7_I7DYrNm0Zp4UQjw,8092
7
+ pmcontrols/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pmcontrols-0.1.0.dist-info/licenses/LICENSE,sha256=a3yEF0vGHVodoVsu7IKY7Otc_8-fuwfYXMMm9h3VoJg,1070
9
+ pmcontrols-0.1.0.dist-info/METADATA,sha256=_l2Q535rGvcZ2QImri9w4qzUCi1PmSQxtcyH_BJ-yn8,6435
10
+ pmcontrols-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ pmcontrols-0.1.0.dist-info/top_level.txt,sha256=gK8Yoyxrm2EFREu4Is1E2IWET3GoNnvhTMUaFgib-0c,11
12
+ pmcontrols-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atakan Arikan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pmcontrols