pmcontrols 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- pmcontrols-0.1.0/PKG-INFO +145 -0
- pmcontrols-0.1.0/README.md +109 -0
- pmcontrols-0.1.0/pmcontrols/__init__.py +27 -0
- pmcontrols-0.1.0/pmcontrols/_result.py +173 -0
- pmcontrols-0.1.0/pmcontrols/_version.py +1 -0
- pmcontrols-0.1.0/pmcontrols/crash.py +128 -0
- pmcontrols-0.1.0/pmcontrols/evm.py +136 -0
- pmcontrols-0.1.0/pmcontrols/network.py +224 -0
- pmcontrols-0.1.0/pmcontrols/py.typed +0 -0
- pmcontrols-0.1.0/pmcontrols.egg-info/PKG-INFO +145 -0
- pmcontrols-0.1.0/pmcontrols.egg-info/SOURCES.txt +20 -0
- pmcontrols-0.1.0/pmcontrols.egg-info/dependency_links.txt +1 -0
- pmcontrols-0.1.0/pmcontrols.egg-info/requires.txt +12 -0
- pmcontrols-0.1.0/pmcontrols.egg-info/top_level.txt +1 -0
- pmcontrols-0.1.0/pyproject.toml +49 -0
- pmcontrols-0.1.0/setup.cfg +4 -0
- pmcontrols-0.1.0/tests/test_cpm.py +92 -0
- pmcontrols-0.1.0/tests/test_crash.py +77 -0
- pmcontrols-0.1.0/tests/test_evm.py +118 -0
- pmcontrols-0.1.0/tests/test_pert.py +66 -0
- pmcontrols-0.1.0/tests/test_validation_suite.py +64 -0
pmcontrols-0.1.0/LICENSE
ADDED
|
@@ -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,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,109 @@
|
|
|
1
|
+
# pmcontrols
|
|
2
|
+
|
|
3
|
+
[](https://github.com/arikanatakan/pmcontrols/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Project scheduling and earned value control for Python.
|
|
7
|
+
|
|
8
|
+
CPM, PERT with Monte Carlo schedule risk, minimum-cost crashing, and
|
|
9
|
+
EVM/earned-schedule control — computed from the defining formulations,
|
|
10
|
+
never from spreadsheet conventions, and checked against published
|
|
11
|
+
reference values in the test suite.
|
|
12
|
+
|
|
13
|
+
Companion to [lotsampling](https://github.com/arikanatakan/lotsampling) (acceptance
|
|
14
|
+
sampling): lotsampling judges the lot, pmcontrols keeps the project honest.
|
|
15
|
+
|
|
16
|
+
## Motivation
|
|
17
|
+
|
|
18
|
+
Every project office computes CPI and SPI; almost all of it happens in
|
|
19
|
+
Excel. R and commercial tools (Primavera, Deltek, @RISK) cover schedule
|
|
20
|
+
risk and earned value; in Python the landscape is a 4 KB CPM toy and an
|
|
21
|
+
abandoned ERP add-on. There is no maintained library a cost engineer or
|
|
22
|
+
a project-controls researcher can `pip install` to get:
|
|
23
|
+
|
|
24
|
+
- the **critical path** with full ES/EF/LS/LF/slack accounting
|
|
25
|
+
- **PERT** three-point analysis plus what the textbook procedure cannot
|
|
26
|
+
give you: a Monte Carlo completion distribution and per-activity
|
|
27
|
+
**criticality indices**
|
|
28
|
+
- **crashing as optimization** — the cheapest set of compressions
|
|
29
|
+
meeting a deadline, solved as the classical time/cost trade-off LP
|
|
30
|
+
rather than by manual marginal-cost inspection
|
|
31
|
+
- **EVM with earned schedule** — the full indicator set (CV, SV, CPI,
|
|
32
|
+
SPI, the EAC family, TCPI, VAC) plus Lipke's time-based ES, SPI(t)
|
|
33
|
+
and duration forecast IEAC(t), which plain EVM gets wrong late in
|
|
34
|
+
projects
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import pmcontrols as pm
|
|
40
|
+
|
|
41
|
+
activities = [
|
|
42
|
+
{"id": "A", "predecessors": [], "duration": 2},
|
|
43
|
+
{"id": "B", "predecessors": [], "duration": 3},
|
|
44
|
+
{"id": "C", "predecessors": ["A"], "duration": 2},
|
|
45
|
+
{"id": "D", "predecessors": ["B"], "duration": 4},
|
|
46
|
+
{"id": "E", "predecessors": ["C"], "duration": 4},
|
|
47
|
+
{"id": "F", "predecessors": ["C"], "duration": 3},
|
|
48
|
+
{"id": "G", "predecessors": ["D", "E"], "duration": 5},
|
|
49
|
+
{"id": "H", "predecessors": ["F", "G"], "duration": 2},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
r = pm.cpm(activities)
|
|
53
|
+
r.stats["project_duration"] # 15.0
|
|
54
|
+
r.meta["critical_activities"] # ['A', 'C', 'E', 'G', 'H']
|
|
55
|
+
r.table # tidy ES/EF/LS/LF/slack DataFrame
|
|
56
|
+
|
|
57
|
+
# Cheapest way to finish in 13 periods (linear program):
|
|
58
|
+
r = pm.crash(crash_activities, target=13)
|
|
59
|
+
r.stats["total_crash_cost"] # optimal, not greedy
|
|
60
|
+
|
|
61
|
+
# Plan once, freeze, control forever:
|
|
62
|
+
pm.plan(periods, pv_curve).save("pmb.json") # commit this to git
|
|
63
|
+
r = pm.evm("pmb.json", ev=30_000, ac=35_000, at=4)
|
|
64
|
+
r.ok # False — CPI and SPI(t) below threshold
|
|
65
|
+
print(r.summary()) # audit text with plain-language verdict
|
|
66
|
+
r.stats["ieac_t"] # earned-schedule duration forecast
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`r.ok` is the automation primitive: a weekly cron job can evaluate the
|
|
70
|
+
latest actuals against the frozen PMB and exit nonzero the moment cost
|
|
71
|
+
or schedule efficiency breaches a threshold.
|
|
72
|
+
|
|
73
|
+
## Validation philosophy
|
|
74
|
+
|
|
75
|
+
Every release must reproduce, in `tests/validation_cases.json` (each
|
|
76
|
+
case ships with its full derivation):
|
|
77
|
+
|
|
78
|
+
1. the General Foundry reference network — complete ES/EF/LS/LF/slack
|
|
79
|
+
table, 15-period critical path, and optimal crash costs to 14 and 13
|
|
80
|
+
periods,
|
|
81
|
+
2. hand-derived EVM/earned-schedule cases covering the full indicator
|
|
82
|
+
set and the ES interpolation formula,
|
|
83
|
+
3. identity and property checks (slack = LS−ES = LF−EF; SPI(t) = ES/AT;
|
|
84
|
+
crash cost monotone in target; Monte Carlo means converging to
|
|
85
|
+
analytic values),
|
|
86
|
+
4. (before 0.1) published PMI/Lipke earned-schedule case studies,
|
|
87
|
+
verified privately where copyright prevents redistribution.
|
|
88
|
+
|
|
89
|
+
## Status
|
|
90
|
+
|
|
91
|
+
0.1.0 — first public release. The API surface is small on purpose; the
|
|
92
|
+
`Result`/`PMB` contract is frozen and append-only from this version on.
|
|
93
|
+
|
|
94
|
+
## Roadmap
|
|
95
|
+
|
|
96
|
+
| Version | Scope |
|
|
97
|
+
| ------- | ----- |
|
|
98
|
+
| 0.2 | Published PMI/Lipke earned-schedule case-study validation; Gantt and OC plotting (separate from the statistics) |
|
|
99
|
+
| 0.3 | Resource leveling and constrained scheduling; schedule risk drivers (correlation, risk events); EVM from time-phased ledgers (DataFrame in, indicators out) |
|
|
100
|
+
| 0.4 | Critical chain buffers; probabilistic crashing (crash under uncertainty); ES forecasting variants (performance factors); JOSS paper |
|
|
101
|
+
|
|
102
|
+
Out of scope: Gantt-chart project *editors* (use dedicated PM tools),
|
|
103
|
+
process control, acceptance sampling (see lotsampling), generic LP modeling
|
|
104
|
+
(use scipy/pyomo directly).
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT. Written and maintained by [Atakan Arikan](https://github.com/arikanatakan),
|
|
109
|
+
MSc Student at Tsinghua University and Politecnico di Milano.
|
|
@@ -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
|
+
]
|
|
@@ -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"))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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)
|