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 +27 -0
- pmcontrols/_result.py +173 -0
- pmcontrols/_version.py +1 -0
- pmcontrols/crash.py +128 -0
- pmcontrols/evm.py +136 -0
- pmcontrols/network.py +224 -0
- pmcontrols/py.typed +0 -0
- pmcontrols-0.1.0.dist-info/METADATA +145 -0
- pmcontrols-0.1.0.dist-info/RECORD +12 -0
- pmcontrols-0.1.0.dist-info/WHEEL +5 -0
- pmcontrols-0.1.0.dist-info/licenses/LICENSE +21 -0
- pmcontrols-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://github.com/arikanatakan/pmcontrols/actions/workflows/ci.yml)
|
|
40
|
+
[](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,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
|