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 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