elecevm 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.
- elecevm/__init__.py +30 -0
- elecevm/actuals.py +230 -0
- elecevm/allocation.py +381 -0
- elecevm/calendar.py +128 -0
- elecevm/cli.py +380 -0
- elecevm/config.py +329 -0
- elecevm/crew.py +29 -0
- elecevm/curves.py +167 -0
- elecevm/evm.py +598 -0
- elecevm/example.py +260 -0
- elecevm/ingest/__init__.py +10 -0
- elecevm/ingest/p6.py +167 -0
- elecevm/ingest/quantities.py +77 -0
- elecevm/ingest/vista.py +155 -0
- elecevm/models.py +271 -0
- elecevm/report.py +308 -0
- elecevm/rules_of_credit.py +160 -0
- elecevm/status_log.py +104 -0
- elecevm-0.1.0.dist-info/METADATA +228 -0
- elecevm-0.1.0.dist-info/RECORD +24 -0
- elecevm-0.1.0.dist-info/WHEEL +5 -0
- elecevm-0.1.0.dist-info/entry_points.txt +2 -0
- elecevm-0.1.0.dist-info/licenses/LICENSE +190 -0
- elecevm-0.1.0.dist-info/top_level.txt +1 -0
elecevm/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""elecevm — an earned value management engine for electrical construction.
|
|
2
|
+
|
|
3
|
+
Reference implementation of *Earned Value Management for Electrical
|
|
4
|
+
Construction — A Recommended Practice for Specialty Contractors*
|
|
5
|
+
(``docs/standard.md``). Pure Python; charts and .xlsx ingest are optional extras.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from .calendar import WeekCalendar, WorkCalendar # noqa: E402
|
|
11
|
+
from .models import (Activity, Budget, ChangeOrder, CostTransaction, CostType, Discipline, # noqa: E402
|
|
12
|
+
EVMethod, QuantityRecord)
|
|
13
|
+
from .config import ACBasisRule, Project, load_project, project_from_dict # noqa: E402
|
|
14
|
+
from .allocation import Baseline, allocate_hours, build_baseline, forecast_remaining # noqa: E402
|
|
15
|
+
from .actuals import WeeklyActuals, apply_basis, bucket_transactions, reconcile, restatements # noqa: E402
|
|
16
|
+
from .rules_of_credit import DEFAULT_RULES, RuleOfCredit, earned_fraction, get_rule # noqa: E402
|
|
17
|
+
from .evm import StatusSnapshot, compute_status, health_checks, measure_activities # noqa: E402
|
|
18
|
+
from .status_log import LockedWeekError, StatusLog # noqa: E402
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"__version__",
|
|
22
|
+
"WeekCalendar", "WorkCalendar",
|
|
23
|
+
"Activity", "Budget", "ChangeOrder", "CostTransaction", "CostType", "Discipline", "EVMethod", "QuantityRecord",
|
|
24
|
+
"ACBasisRule", "Project", "load_project", "project_from_dict",
|
|
25
|
+
"Baseline", "allocate_hours", "build_baseline", "forecast_remaining",
|
|
26
|
+
"WeeklyActuals", "apply_basis", "bucket_transactions", "reconcile", "restatements",
|
|
27
|
+
"DEFAULT_RULES", "RuleOfCredit", "earned_fraction", "get_rule",
|
|
28
|
+
"StatusSnapshot", "compute_status", "health_checks", "measure_activities",
|
|
29
|
+
"LockedWeekError", "StatusLog",
|
|
30
|
+
]
|
elecevm/actuals.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Actual cost: transaction-date bucketing, basis rules, reconciliation (RP §7)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from .calendar import WeekCalendar
|
|
8
|
+
from .config import ACBasisRule
|
|
9
|
+
from .models import CostTransaction, CostType
|
|
10
|
+
|
|
11
|
+
Key = Tuple[str, str] # (discipline, CostType value)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ACBucket:
|
|
16
|
+
hours: float = 0.0
|
|
17
|
+
amount: float = 0.0
|
|
18
|
+
|
|
19
|
+
def add(self, hours: float, amount: float) -> None:
|
|
20
|
+
self.hours += hours
|
|
21
|
+
self.amount += amount
|
|
22
|
+
|
|
23
|
+
def copy(self) -> "ACBucket":
|
|
24
|
+
return ACBucket(self.hours, self.amount)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class WeeklyActuals:
|
|
29
|
+
"""Actual cost by payroll week, discipline and cost type through the status week."""
|
|
30
|
+
|
|
31
|
+
status_week: int
|
|
32
|
+
buckets: Dict[int, Dict[Key, ACBucket]] = field(default_factory=dict)
|
|
33
|
+
post_status: List[CostTransaction] = field(default_factory=list)
|
|
34
|
+
source_totals: Dict[str, ACBucket] = field(default_factory=dict) # by cost type value
|
|
35
|
+
notes: List[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def _iter(self, week: int, discipline: Optional[str], cost_type: Optional[CostType]):
|
|
38
|
+
for (disc, ct), b in self.buckets.get(week, {}).items():
|
|
39
|
+
if discipline is not None and disc != discipline:
|
|
40
|
+
continue
|
|
41
|
+
if cost_type is not None and ct != cost_type.value:
|
|
42
|
+
continue
|
|
43
|
+
yield b
|
|
44
|
+
|
|
45
|
+
def weekly(self, week: int, discipline: Optional[str] = None,
|
|
46
|
+
cost_type: Optional[CostType] = None) -> ACBucket:
|
|
47
|
+
out = ACBucket()
|
|
48
|
+
for b in self._iter(week, discipline, cost_type):
|
|
49
|
+
out.add(b.hours, b.amount)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
def cumulative(self, through: int, discipline: Optional[str] = None,
|
|
53
|
+
cost_type: Optional[CostType] = None) -> ACBucket:
|
|
54
|
+
out = ACBucket()
|
|
55
|
+
for w in range(1, through + 1):
|
|
56
|
+
b = self.weekly(w, discipline, cost_type)
|
|
57
|
+
out.add(b.hours, b.amount)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
def totals_by_cost_type(self) -> Dict[str, ACBucket]:
|
|
61
|
+
out: Dict[str, ACBucket] = {}
|
|
62
|
+
for week in self.buckets.values():
|
|
63
|
+
for (_, ct), b in week.items():
|
|
64
|
+
out.setdefault(ct, ACBucket()).add(b.hours, b.amount)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
def disciplines(self) -> List[str]:
|
|
68
|
+
seen: List[str] = []
|
|
69
|
+
for week in self.buckets.values():
|
|
70
|
+
for (disc, _) in week:
|
|
71
|
+
if disc not in seen:
|
|
72
|
+
seen.append(disc)
|
|
73
|
+
return seen
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict:
|
|
76
|
+
return {
|
|
77
|
+
"status_week": self.status_week,
|
|
78
|
+
"buckets": {str(w): {f"{d}:{ct}": [b.hours, b.amount] for (d, ct), b in row.items()} for w, row in self.buckets.items()},
|
|
79
|
+
"source_totals": {ct: [b.hours, b.amount] for ct, b in self.source_totals.items()},
|
|
80
|
+
"post_status_count": len(self.post_status),
|
|
81
|
+
"notes": list(self.notes),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, data: dict) -> "WeeklyActuals":
|
|
86
|
+
out = cls(int(data["status_week"]))
|
|
87
|
+
for w, row in data.get("buckets", {}).items():
|
|
88
|
+
for key, (h, amt) in row.items():
|
|
89
|
+
d, ct = key.rsplit(":", 1)
|
|
90
|
+
out.buckets.setdefault(int(w), {})[(d, ct)] = ACBucket(float(h), float(amt))
|
|
91
|
+
out.source_totals = {ct: ACBucket(float(h), float(amt)) for ct, (h, amt) in data.get("source_totals", {}).items()}
|
|
92
|
+
out.notes = list(data.get("notes", []))
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
def copy(self) -> "WeeklyActuals":
|
|
96
|
+
return WeeklyActuals(
|
|
97
|
+
self.status_week,
|
|
98
|
+
{w: {k: b.copy() for k, b in row.items()} for w, row in self.buckets.items()},
|
|
99
|
+
list(self.post_status),
|
|
100
|
+
{k: b.copy() for k, b in self.source_totals.items()},
|
|
101
|
+
list(self.notes),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def bucket_transactions(transactions: Iterable[CostTransaction], week_cal: WeekCalendar,
|
|
106
|
+
status_week: int) -> WeeklyActuals:
|
|
107
|
+
"""Bucket ledger lines by *transaction date* into payroll weeks (RP §7.1).
|
|
108
|
+
|
|
109
|
+
Lines dated after the status week-ending date are kept aside in
|
|
110
|
+
``post_status`` — they are real cost, but they belong to the next status
|
|
111
|
+
period and must not leak into this one. Lines dated before the project
|
|
112
|
+
start land in week 1.
|
|
113
|
+
"""
|
|
114
|
+
actuals = WeeklyActuals(status_week=status_week)
|
|
115
|
+
for t in transactions:
|
|
116
|
+
week = week_cal.week_of(t.trans_date)
|
|
117
|
+
key = (t.discipline, CostType.parse(t.cost_type).value)
|
|
118
|
+
# raw totals of the whole pull, post-status lines included — what the source report prints
|
|
119
|
+
actuals.source_totals.setdefault(key[1], ACBucket()).add(float(t.hours), float(t.amount))
|
|
120
|
+
if week > status_week:
|
|
121
|
+
actuals.post_status.append(t)
|
|
122
|
+
continue
|
|
123
|
+
actuals.buckets.setdefault(week, {}).setdefault(key, ACBucket()).add(float(t.hours), float(t.amount))
|
|
124
|
+
return actuals
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def reconcile(actuals: WeeklyActuals, expected: Dict[str, Tuple[float, float]],
|
|
128
|
+
tolerance: float = 0.01) -> List[str]:
|
|
129
|
+
"""Compare the raw totals of the pull with the source report's totals by cost type.
|
|
130
|
+
|
|
131
|
+
``expected`` maps a cost type value (``"L"``, ``"M"`` …) to ``(hours, amount)``
|
|
132
|
+
as printed on the job-cost report. The comparison uses the raw ledger
|
|
133
|
+
amounts captured at bucketing time (before any AC basis rule is applied)
|
|
134
|
+
and includes lines dated after the status week, because the report
|
|
135
|
+
includes them too. Returns a list of mismatch messages; empty means the
|
|
136
|
+
pull reconciles exactly (RP §7.1).
|
|
137
|
+
"""
|
|
138
|
+
issues: List[str] = []
|
|
139
|
+
totals = actuals.source_totals
|
|
140
|
+
for ct, (hours, amount) in expected.items():
|
|
141
|
+
got = totals.get(ct, ACBucket())
|
|
142
|
+
if abs(got.hours - hours) > tolerance:
|
|
143
|
+
issues.append(f"{ct}: hours {got.hours:,.2f} vs source {hours:,.2f}")
|
|
144
|
+
if abs(got.amount - amount) > tolerance:
|
|
145
|
+
issues.append(f"{ct}: amount {got.amount:,.2f} vs source {amount:,.2f}")
|
|
146
|
+
for ct in totals:
|
|
147
|
+
if ct not in expected:
|
|
148
|
+
issues.append(f"{ct}: in the pull but absent from source totals")
|
|
149
|
+
return issues
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def apply_basis(actuals: WeeklyActuals, rules: Dict[CostType, ACBasisRule],
|
|
153
|
+
billed_to_date: Optional[Dict[CostType, float]] = None) -> WeeklyActuals:
|
|
154
|
+
"""Apply the per-cost-type AC basis (RP §7.3) and return a new table.
|
|
155
|
+
|
|
156
|
+
* ``raw``: unchanged.
|
|
157
|
+
* ``markup``: every week's amount × markup.
|
|
158
|
+
* ``billed``: weeks before status at ledger × markup; the status week is
|
|
159
|
+
replaced so the cumulative equals the supplied billed-to-date value.
|
|
160
|
+
The residual is shared across the status week's disciplines in
|
|
161
|
+
proportion to their status-week ledger amounts (or their cumulative
|
|
162
|
+
amounts if the status week is empty). Hours are never adjusted.
|
|
163
|
+
"""
|
|
164
|
+
out = actuals.copy()
|
|
165
|
+
billed_to_date = billed_to_date or {}
|
|
166
|
+
status = out.status_week
|
|
167
|
+
for ct, rule in rules.items():
|
|
168
|
+
if rule.method == "raw":
|
|
169
|
+
continue
|
|
170
|
+
factor = rule.markup
|
|
171
|
+
for week, row in out.buckets.items():
|
|
172
|
+
for key, b in row.items():
|
|
173
|
+
if key[1] == ct.value:
|
|
174
|
+
b.amount *= factor
|
|
175
|
+
if rule.method == "billed" and ct not in billed_to_date:
|
|
176
|
+
out.notes.append(f"{ct.label}: billed basis declared but no billed-to-date value supplied; "
|
|
177
|
+
f"carried at ledger × {factor:g}")
|
|
178
|
+
if rule.method == "billed" and ct in billed_to_date and status >= 1:
|
|
179
|
+
billed = float(billed_to_date[ct])
|
|
180
|
+
prior = sum(out.weekly(w, None, ct).amount for w in range(1, status))
|
|
181
|
+
residual = billed - prior
|
|
182
|
+
status_row = out.buckets.setdefault(status, {})
|
|
183
|
+
shares = {k: b.amount for k, b in status_row.items() if k[1] == ct.value}
|
|
184
|
+
if sum(shares.values()) <= 0:
|
|
185
|
+
shares = {}
|
|
186
|
+
for w in range(1, status + 1):
|
|
187
|
+
for k, b in out.buckets.get(w, {}).items():
|
|
188
|
+
if k[1] == ct.value:
|
|
189
|
+
shares[k] = shares.get(k, 0.0) + b.amount
|
|
190
|
+
total_share = sum(shares.values())
|
|
191
|
+
if total_share <= 0:
|
|
192
|
+
disc = out.disciplines()[0] if out.disciplines() else "ALL"
|
|
193
|
+
shares = {(disc, ct.value): 1.0}
|
|
194
|
+
total_share = 1.0
|
|
195
|
+
for k, share in shares.items():
|
|
196
|
+
b = status_row.setdefault(k, ACBucket())
|
|
197
|
+
b.amount = residual * share / total_share
|
|
198
|
+
out.notes.append(
|
|
199
|
+
f"{ct.label}: status week {status} carries the billed-basis residual "
|
|
200
|
+
f"{residual:,.2f} (billed-to-date {billed:,.2f} − prior weeks {prior:,.2f})")
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class Restatement:
|
|
206
|
+
week: int
|
|
207
|
+
discipline: str
|
|
208
|
+
cost_type: str
|
|
209
|
+
delta_hours: float
|
|
210
|
+
delta_amount: float
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def restatements(previous: WeeklyActuals, current: WeeklyActuals, locked_through: int,
|
|
214
|
+
tolerance: float = 0.005) -> List[Restatement]:
|
|
215
|
+
"""Late postings: differences in weeks already statused (RP §7.2).
|
|
216
|
+
|
|
217
|
+
These are logged, and the affected weeks' AC buckets are restated to the
|
|
218
|
+
transaction week; the locked status-log rows for those weeks are *not*
|
|
219
|
+
changed (RP §9.1).
|
|
220
|
+
"""
|
|
221
|
+
out: List[Restatement] = []
|
|
222
|
+
for week in range(1, locked_through + 1):
|
|
223
|
+
keys = set(previous.buckets.get(week, {})) | set(current.buckets.get(week, {}))
|
|
224
|
+
for key in sorted(keys):
|
|
225
|
+
before = previous.buckets.get(week, {}).get(key, ACBucket())
|
|
226
|
+
after = current.buckets.get(week, {}).get(key, ACBucket())
|
|
227
|
+
dh, da = after.hours - before.hours, after.amount - before.amount
|
|
228
|
+
if abs(dh) > tolerance or abs(da) > tolerance:
|
|
229
|
+
out.append(Restatement(week, key[0], key[1], dh, da))
|
|
230
|
+
return out
|
elecevm/allocation.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Cost-loaded schedule, allocation and the time-phased baseline (RP §5).
|
|
2
|
+
|
|
3
|
+
The baseline is built in four steps:
|
|
4
|
+
|
|
5
|
+
1. **Allocate** each discipline's budget labor hours to its schedule activities,
|
|
6
|
+
by resource-loaded budget hours when the schedule carries them, otherwise by
|
|
7
|
+
a *score* (original duration × hours/day × area weight × activity factor).
|
|
8
|
+
The allocation ties to the discipline budget exactly (RP §5.2).
|
|
9
|
+
2. **Spread** each activity's hours evenly over its working days and sum them
|
|
10
|
+
into payroll weeks (RP §5.3).
|
|
11
|
+
3. **Level** (optional) the total labor curve under a crew cap, delay-only (RP §5.4).
|
|
12
|
+
4. **Price** the weekly hours at the effective discipline rate and phase
|
|
13
|
+
material, subcontract, equipment and other budgets on the configured
|
|
14
|
+
profile so that terminal PV equals BAC (RP §5.5).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import date, timedelta
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from . import curves
|
|
25
|
+
from .calendar import WeekCalendar, WorkCalendar
|
|
26
|
+
from .config import Project
|
|
27
|
+
from .models import Activity, BudgetKey, CostType
|
|
28
|
+
|
|
29
|
+
ALLOCATION_TOLERANCE = 1e-6
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def activity_score(a: Activity, work_cal: WorkCalendar) -> Tuple[float, str]:
|
|
33
|
+
"""Return (score, basis) for an activity: budget hours if loaded, else duration-based."""
|
|
34
|
+
if a.budget_hours is not None and a.budget_hours > 0:
|
|
35
|
+
return float(a.budget_hours), "budget_hours"
|
|
36
|
+
od = a.original_duration if a.original_duration else work_cal.workdays_between(a.start, a.finish)
|
|
37
|
+
od = max(od, 1)
|
|
38
|
+
return od * a.hours_per_day * a.area_weight * a.activity_factor, "score"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def allocate_hours(activities: Iterable[Activity], bac_hours: Dict[str, float],
|
|
42
|
+
work_cal: WorkCalendar) -> Dict[str, float]:
|
|
43
|
+
"""Allocate discipline budget hours to activities; ties exactly per discipline."""
|
|
44
|
+
acts = list(activities)
|
|
45
|
+
by_disc: Dict[str, List[Activity]] = {}
|
|
46
|
+
for a in acts:
|
|
47
|
+
by_disc.setdefault(a.discipline, []).append(a)
|
|
48
|
+
|
|
49
|
+
allocation: Dict[str, float] = {}
|
|
50
|
+
for disc, group in by_disc.items():
|
|
51
|
+
target = float(bac_hours.get(disc, 0.0))
|
|
52
|
+
scored = {a.id: activity_score(a, work_cal) for a in group}
|
|
53
|
+
bases = {basis for _, basis in scored.values()}
|
|
54
|
+
if len(bases) > 1:
|
|
55
|
+
unloaded = sorted(aid for aid, (_, basis) in scored.items() if basis == "score")
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"discipline {disc} mixes resource-loaded and unloaded activities; "
|
|
58
|
+
f"activities with no budget hours: {unloaded} (RP §5.1: a data error, not a zero allocation)")
|
|
59
|
+
scores = {aid: s for aid, (s, _) in scored.items()}
|
|
60
|
+
total_score = sum(scores.values())
|
|
61
|
+
if total_score <= 0:
|
|
62
|
+
for a in group:
|
|
63
|
+
allocation[a.id] = 0.0
|
|
64
|
+
continue
|
|
65
|
+
running = 0.0
|
|
66
|
+
for a in group:
|
|
67
|
+
allocation[a.id] = target * scores[a.id] / total_score
|
|
68
|
+
running += allocation[a.id]
|
|
69
|
+
# absorb floating-point residual on the largest activity so the tie is exact
|
|
70
|
+
residual = target - running
|
|
71
|
+
if group and abs(residual) > 0:
|
|
72
|
+
biggest = max(group, key=lambda x: scores[x.id])
|
|
73
|
+
allocation[biggest.id] += residual
|
|
74
|
+
return allocation
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def spread_hours(hours: float, start: date, finish: date, work_cal: WorkCalendar,
|
|
78
|
+
week_cal: WeekCalendar) -> Dict[int, float]:
|
|
79
|
+
"""Spread ``hours`` evenly over the working days in [start, finish], by payroll week."""
|
|
80
|
+
days = work_cal.workday_dates(start, finish)
|
|
81
|
+
out: Dict[int, float] = {}
|
|
82
|
+
if hours == 0:
|
|
83
|
+
return out
|
|
84
|
+
if not days:
|
|
85
|
+
out[week_cal.week_of(start)] = hours
|
|
86
|
+
return out
|
|
87
|
+
per_day = hours / len(days)
|
|
88
|
+
for d in days:
|
|
89
|
+
w = week_cal.week_of(d)
|
|
90
|
+
out[w] = out.get(w, 0.0) + per_day
|
|
91
|
+
# exact conservation
|
|
92
|
+
residual = hours - sum(out.values())
|
|
93
|
+
if residual:
|
|
94
|
+
last = max(out)
|
|
95
|
+
out[last] += residual
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Baseline:
|
|
101
|
+
"""A versioned time-phased plan: allocation, weekly hours and dollars, BAC."""
|
|
102
|
+
|
|
103
|
+
version: int
|
|
104
|
+
created: date
|
|
105
|
+
note: str
|
|
106
|
+
basis: str
|
|
107
|
+
allocation: Dict[str, float]
|
|
108
|
+
allocation_basis: Dict[str, str]
|
|
109
|
+
activity_weeks: Dict[str, Dict[int, float]]
|
|
110
|
+
hours: Dict[int, Dict[str, float]] # week -> discipline -> labor hours
|
|
111
|
+
dollars: Dict[int, Dict[BudgetKey, float]] # week -> (disc, ct) -> $
|
|
112
|
+
bac: Dict[BudgetKey, float]
|
|
113
|
+
bac_hours: Dict[str, float]
|
|
114
|
+
rates: Dict[str, float]
|
|
115
|
+
leveled: bool = False
|
|
116
|
+
bcr_log: List[dict] = field(default_factory=list)
|
|
117
|
+
unleveled_hours: Dict[int, Dict[str, float]] = field(default_factory=dict) # schedule demand before leveling
|
|
118
|
+
|
|
119
|
+
# -- shape -----------------------------------------------------------
|
|
120
|
+
@property
|
|
121
|
+
def weeks(self) -> int:
|
|
122
|
+
candidates = [w for w, v in self.hours.items() if sum(v.values()) > 0]
|
|
123
|
+
candidates += [w for w, v in self.dollars.items() if sum(v.values()) > 0]
|
|
124
|
+
return max(candidates) if candidates else 0
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def disciplines(self) -> List[str]:
|
|
128
|
+
seen: List[str] = []
|
|
129
|
+
for d in list(self.bac_hours) + [k[0] for k in self.bac]:
|
|
130
|
+
if d not in seen:
|
|
131
|
+
seen.append(d)
|
|
132
|
+
return seen
|
|
133
|
+
|
|
134
|
+
# -- weekly values ---------------------------------------------------
|
|
135
|
+
def weekly_hours(self, week: int, discipline: Optional[str] = None) -> float:
|
|
136
|
+
row = self.hours.get(week, {})
|
|
137
|
+
if discipline is None:
|
|
138
|
+
return sum(row.values())
|
|
139
|
+
return row.get(discipline, 0.0)
|
|
140
|
+
|
|
141
|
+
def weekly_demand(self, week: int, discipline: Optional[str] = None) -> float:
|
|
142
|
+
"""Unleveled schedule demand for the week (equals weekly_hours when not leveled)."""
|
|
143
|
+
table = self.unleveled_hours if self.leveled and self.unleveled_hours else self.hours
|
|
144
|
+
row = table.get(week, {})
|
|
145
|
+
return sum(row.values()) if discipline is None else row.get(discipline, 0.0)
|
|
146
|
+
|
|
147
|
+
def weekly_dollars(self, week: int, discipline: Optional[str] = None,
|
|
148
|
+
cost_type: Optional[CostType] = None) -> float:
|
|
149
|
+
total = 0.0
|
|
150
|
+
for (disc, ct), amount in self.dollars.get(week, {}).items():
|
|
151
|
+
if discipline is not None and disc != discipline:
|
|
152
|
+
continue
|
|
153
|
+
if cost_type is not None and ct != cost_type.value:
|
|
154
|
+
continue
|
|
155
|
+
total += amount
|
|
156
|
+
return total
|
|
157
|
+
|
|
158
|
+
# -- cumulative values -----------------------------------------------
|
|
159
|
+
def pv(self, through: int, discipline: Optional[str] = None,
|
|
160
|
+
cost_type: Optional[CostType] = None) -> float:
|
|
161
|
+
return sum(self.weekly_dollars(w, discipline, cost_type) for w in range(1, through + 1))
|
|
162
|
+
|
|
163
|
+
def pv_hours(self, through: int, discipline: Optional[str] = None) -> float:
|
|
164
|
+
return sum(self.weekly_hours(w, discipline) for w in range(1, through + 1))
|
|
165
|
+
|
|
166
|
+
def activity_pv_fraction(self, activity_id: str, through: int) -> float:
|
|
167
|
+
total = self.allocation.get(activity_id, 0.0)
|
|
168
|
+
if total <= 0:
|
|
169
|
+
return 0.0
|
|
170
|
+
done = sum(h for w, h in self.activity_weeks.get(activity_id, {}).items() if w <= through)
|
|
171
|
+
return min(max(done / total, 0.0), 1.0)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def terminal_pv(self) -> float:
|
|
175
|
+
return self.pv(self.weeks)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def total_bac(self) -> float:
|
|
179
|
+
return sum(self.bac.values())
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def total_bac_hours(self) -> float:
|
|
183
|
+
return sum(self.bac_hours.values())
|
|
184
|
+
|
|
185
|
+
# -- persistence -----------------------------------------------------
|
|
186
|
+
def to_dict(self) -> dict:
|
|
187
|
+
return {
|
|
188
|
+
"version": self.version,
|
|
189
|
+
"created": self.created.isoformat(),
|
|
190
|
+
"note": self.note,
|
|
191
|
+
"basis": self.basis,
|
|
192
|
+
"leveled": self.leveled,
|
|
193
|
+
"allocation": self.allocation,
|
|
194
|
+
"allocation_basis": self.allocation_basis,
|
|
195
|
+
"activity_weeks": {k: {str(w): h for w, h in v.items()} for k, v in self.activity_weeks.items()},
|
|
196
|
+
"hours": {str(w): v for w, v in self.hours.items()},
|
|
197
|
+
"dollars": {str(w): {f"{d}:{ct}": amt for (d, ct), amt in v.items()} for w, v in self.dollars.items()},
|
|
198
|
+
"bac": {f"{d}:{ct}": amt for (d, ct), amt in self.bac.items()},
|
|
199
|
+
"bac_hours": self.bac_hours,
|
|
200
|
+
"rates": self.rates,
|
|
201
|
+
"bcr_log": self.bcr_log,
|
|
202
|
+
"unleveled_hours": {str(w): v for w, v in self.unleveled_hours.items()},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def from_dict(cls, data: dict) -> "Baseline":
|
|
207
|
+
def split(key: str) -> BudgetKey:
|
|
208
|
+
d, ct = key.rsplit(":", 1)
|
|
209
|
+
return (d, ct)
|
|
210
|
+
|
|
211
|
+
return cls(
|
|
212
|
+
version=int(data["version"]),
|
|
213
|
+
created=date.fromisoformat(data["created"]),
|
|
214
|
+
note=str(data.get("note", "")),
|
|
215
|
+
basis=str(data.get("basis", "estimated")),
|
|
216
|
+
allocation={k: float(v) for k, v in data["allocation"].items()},
|
|
217
|
+
allocation_basis={k: str(v) for k, v in data.get("allocation_basis", {}).items()},
|
|
218
|
+
activity_weeks={k: {int(w): float(h) for w, h in v.items()} for k, v in data["activity_weeks"].items()},
|
|
219
|
+
hours={int(w): {d: float(h) for d, h in v.items()} for w, v in data["hours"].items()},
|
|
220
|
+
dollars={int(w): {split(k): float(a) for k, a in v.items()} for w, v in data["dollars"].items()},
|
|
221
|
+
bac={split(k): float(v) for k, v in data["bac"].items()},
|
|
222
|
+
bac_hours={k: float(v) for k, v in data["bac_hours"].items()},
|
|
223
|
+
rates={k: float(v) for k, v in data.get("rates", {}).items()},
|
|
224
|
+
leveled=bool(data.get("leveled", False)),
|
|
225
|
+
bcr_log=list(data.get("bcr_log", [])),
|
|
226
|
+
unleveled_hours={int(w): {d: float(h) for d, h in v.items()} for w, v in data.get("unleveled_hours", {}).items()},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def save(self, path) -> None:
|
|
230
|
+
Path(path).write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def load(cls, path) -> "Baseline":
|
|
234
|
+
return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _phase_dollars(total: float, weights_by_week: Dict[int, float]) -> Dict[int, float]:
|
|
238
|
+
if total == 0 or not weights_by_week:
|
|
239
|
+
return {}
|
|
240
|
+
weeks = sorted(weights_by_week)
|
|
241
|
+
values = curves.scale_to_total([weights_by_week[w] for w in weeks], total)
|
|
242
|
+
return {w: v for w, v in zip(weeks, values) if v != 0}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def build_baseline(project: Project, activities: Iterable[Activity], version: int = 1,
|
|
246
|
+
note: str = "Initial baseline", created: Optional[date] = None,
|
|
247
|
+
level: Optional[bool] = None) -> Baseline:
|
|
248
|
+
"""Build the time-phased baseline for ``project`` from its schedule activities."""
|
|
249
|
+
acts = list(activities)
|
|
250
|
+
if not acts:
|
|
251
|
+
raise ValueError("no activities to build a baseline from")
|
|
252
|
+
budget = project.budget
|
|
253
|
+
work_cal, week_cal = project.work_cal, project.week_cal
|
|
254
|
+
|
|
255
|
+
discs_with_acts = sorted({a.discipline for a in acts})
|
|
256
|
+
bac_hours = {d: budget.bac_hours(d) for d in discs_with_acts}
|
|
257
|
+
missing = [d for d in budget.disciplines() if d not in discs_with_acts and (budget.bac(d) > 0 or budget.bac_hours(d) > 0)]
|
|
258
|
+
if missing:
|
|
259
|
+
raise ValueError(f"budget carries disciplines with no schedule activities to phase against: {missing}")
|
|
260
|
+
|
|
261
|
+
allocation = allocate_hours(acts, bac_hours, work_cal)
|
|
262
|
+
allocation_basis = {a.id: activity_score(a, work_cal)[1] for a in acts}
|
|
263
|
+
|
|
264
|
+
activity_weeks: Dict[str, Dict[int, float]] = {}
|
|
265
|
+
hours: Dict[int, Dict[str, float]] = {}
|
|
266
|
+
for a in acts:
|
|
267
|
+
weekly = spread_hours(allocation[a.id], a.start, a.finish, work_cal, week_cal)
|
|
268
|
+
activity_weeks[a.id] = weekly
|
|
269
|
+
for w, h in weekly.items():
|
|
270
|
+
hours.setdefault(w, {})
|
|
271
|
+
hours[w][a.discipline] = hours[w].get(a.discipline, 0.0) + h
|
|
272
|
+
|
|
273
|
+
leveled = False
|
|
274
|
+
unleveled: Dict[int, Dict[str, float]] = {}
|
|
275
|
+
do_level = project.crew_cap_hours is not None if level is None else level
|
|
276
|
+
if do_level and project.crew_cap_hours:
|
|
277
|
+
unleveled = {w: dict(v) for w, v in hours.items()}
|
|
278
|
+
# level every activity's own curve under the shared cap, then rebuild the discipline table
|
|
279
|
+
by_activity: Dict[int, Dict[str, float]] = {}
|
|
280
|
+
for aid, weekly in activity_weeks.items():
|
|
281
|
+
for w, h in weekly.items():
|
|
282
|
+
by_activity.setdefault(w, {})[aid] = h
|
|
283
|
+
leveled_acts = curves.level_by_group(by_activity, project.crew_cap_hours)
|
|
284
|
+
disc_of = {a.id: a.discipline for a in acts}
|
|
285
|
+
activity_weeks = {aid: {} for aid in activity_weeks}
|
|
286
|
+
hours = {}
|
|
287
|
+
for w, row in leveled_acts.items():
|
|
288
|
+
for aid, h in row.items():
|
|
289
|
+
activity_weeks[aid][w] = h
|
|
290
|
+
hours.setdefault(w, {})
|
|
291
|
+
hours[w][disc_of[aid]] = hours[w].get(disc_of[aid], 0.0) + h
|
|
292
|
+
leveled = True
|
|
293
|
+
|
|
294
|
+
rates = {d: project.rate(d) for d in discs_with_acts}
|
|
295
|
+
dollars: Dict[int, Dict[BudgetKey, float]] = {}
|
|
296
|
+
bac: Dict[BudgetKey, float] = {}
|
|
297
|
+
|
|
298
|
+
for d in discs_with_acts:
|
|
299
|
+
# labor $ = hours × effective rate (ties to budget labor $ when both are budgeted)
|
|
300
|
+
labor_bac = budget.bac(d, CostType.LABOR)
|
|
301
|
+
if labor_bac <= 0 and bac_hours[d] > 0:
|
|
302
|
+
labor_bac = bac_hours[d] * rates[d]
|
|
303
|
+
if labor_bac > 0:
|
|
304
|
+
weights = {w: hours[w].get(d, 0.0) for w in hours if hours[w].get(d, 0.0) > 0}
|
|
305
|
+
for w, amt in _phase_dollars(labor_bac, weights).items():
|
|
306
|
+
dollars.setdefault(w, {})[(d, CostType.LABOR.value)] = amt
|
|
307
|
+
bac[(d, CostType.LABOR.value)] = labor_bac
|
|
308
|
+
|
|
309
|
+
for ct in (CostType.MATERIAL, CostType.SUBCONTRACT, CostType.EQUIPMENT, CostType.OTHER):
|
|
310
|
+
total = budget.bac(d, ct)
|
|
311
|
+
if total <= 0:
|
|
312
|
+
continue
|
|
313
|
+
bac[(d, ct.value)] = total
|
|
314
|
+
span = [w for w in sorted(hours) if hours[w].get(d, 0.0) > 0]
|
|
315
|
+
if not span:
|
|
316
|
+
continue
|
|
317
|
+
if project.material_phasing == "follow_labor":
|
|
318
|
+
weights = {w: hours[w][d] for w in span}
|
|
319
|
+
else:
|
|
320
|
+
first, last = span[0], span[-1]
|
|
321
|
+
n = last - first + 1
|
|
322
|
+
shape = curves.bell(n) if project.material_phasing == "bell" else curves.linear(n)
|
|
323
|
+
weights = {first + i: shape[i] for i in range(n)}
|
|
324
|
+
for w, amt in _phase_dollars(total, weights).items():
|
|
325
|
+
dollars.setdefault(w, {})[(d, ct.value)] = amt
|
|
326
|
+
|
|
327
|
+
return Baseline(
|
|
328
|
+
version=version,
|
|
329
|
+
created=created or date.today(),
|
|
330
|
+
note=note,
|
|
331
|
+
basis=budget.basis,
|
|
332
|
+
allocation=allocation,
|
|
333
|
+
allocation_basis=allocation_basis,
|
|
334
|
+
activity_weeks=activity_weeks,
|
|
335
|
+
hours=hours,
|
|
336
|
+
dollars=dollars,
|
|
337
|
+
bac=bac,
|
|
338
|
+
bac_hours=bac_hours,
|
|
339
|
+
rates=rates,
|
|
340
|
+
leveled=leveled,
|
|
341
|
+
bcr_log=[{"version": version, "date": (created or date.today()).isoformat(), "note": note}],
|
|
342
|
+
unleveled_hours=unleveled,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def forecast_remaining(project: Project, baseline: Baseline, activities: Iterable[Activity],
|
|
347
|
+
earned_hours: Dict[str, float], status_week: int,
|
|
348
|
+
etc_hours: Optional[Dict[str, float]] = None) -> Dict[int, Dict[str, float]]:
|
|
349
|
+
"""Phase the estimate-to-complete hours after the status week (RP §8.4).
|
|
350
|
+
|
|
351
|
+
Each activity's remaining hours (allocation − earned, or a supplied ETC by
|
|
352
|
+
discipline shared by remaining allocation) are spread over the working days
|
|
353
|
+
from the week after status through the activity's current finish. Finished
|
|
354
|
+
or exhausted activities contribute nothing; activities whose finish is
|
|
355
|
+
already past are pushed into the week after status.
|
|
356
|
+
"""
|
|
357
|
+
week_cal, work_cal = project.week_cal, project.work_cal
|
|
358
|
+
cutoff = week_cal.week_end(status_week)
|
|
359
|
+
acts = list(activities)
|
|
360
|
+
remaining_by_act = {a.id: max(baseline.allocation.get(a.id, 0.0) - earned_hours.get(a.id, 0.0), 0.0) for a in acts}
|
|
361
|
+
if etc_hours:
|
|
362
|
+
by_disc: Dict[str, float] = {}
|
|
363
|
+
for a in acts:
|
|
364
|
+
by_disc[a.discipline] = by_disc.get(a.discipline, 0.0) + remaining_by_act[a.id]
|
|
365
|
+
for a in acts:
|
|
366
|
+
share = by_disc.get(a.discipline, 0.0)
|
|
367
|
+
if share > 0 and a.discipline in etc_hours:
|
|
368
|
+
remaining_by_act[a.id] = etc_hours[a.discipline] * remaining_by_act[a.id] / share
|
|
369
|
+
out: Dict[int, Dict[str, float]] = {}
|
|
370
|
+
for a in acts:
|
|
371
|
+
rem = remaining_by_act[a.id]
|
|
372
|
+
if rem <= 0:
|
|
373
|
+
continue
|
|
374
|
+
# a schedule-complete activity with unearned hours still owes them: next week
|
|
375
|
+
start = max(a.current_start, cutoff + timedelta(days=1))
|
|
376
|
+
finish = max(a.current_finish, start)
|
|
377
|
+
weekly = spread_hours(rem, start, finish, work_cal, week_cal)
|
|
378
|
+
for w, h in weekly.items():
|
|
379
|
+
out.setdefault(w, {})
|
|
380
|
+
out[w][a.discipline] = out[w].get(a.discipline, 0.0) + h
|
|
381
|
+
return out
|