cctally 1.103.0 → 1.104.0

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +65 -4
  3. package/bin/_cctally_config.py +62 -2
  4. package/bin/_cctally_core.py +62 -1
  5. package/bin/_cctally_dashboard.py +11 -0
  6. package/bin/_cctally_dashboard_envelope.py +319 -14
  7. package/bin/_cctally_dashboard_share.py +56 -25
  8. package/bin/_cctally_doctor.py +63 -0
  9. package/bin/_cctally_forecast.py +917 -44
  10. package/bin/_cctally_journal.py +153 -5
  11. package/bin/_cctally_parser.py +66 -0
  12. package/bin/_cctally_project.py +535 -10
  13. package/bin/_cctally_quota.py +13 -0
  14. package/bin/_cctally_quota_calibration.py +146 -0
  15. package/bin/_cctally_quota_model.py +1616 -0
  16. package/bin/_cctally_record.py +114 -6
  17. package/bin/_cctally_share.py +16 -8
  18. package/bin/_cctally_statusline.py +34 -0
  19. package/bin/_cctally_tui.py +214 -45
  20. package/bin/_lib_dashboard_settings_contract.py +2 -0
  21. package/bin/_lib_doctor.py +159 -1
  22. package/bin/_lib_forecast.py +337 -43
  23. package/bin/_lib_meter_rate_change.py +294 -0
  24. package/bin/_lib_quota_calibration.py +311 -0
  25. package/bin/_lib_quota_copy.py +131 -0
  26. package/bin/_lib_quota_model.py +2333 -0
  27. package/bin/_lib_rederive.py +10 -0
  28. package/bin/_lib_render.py +6 -0
  29. package/bin/_lib_share_templates.py +37 -5
  30. package/bin/_lib_statusline.py +226 -2
  31. package/bin/_lib_view_models.py +30 -12
  32. package/bin/cctally +32 -0
  33. package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
  34. package/dashboard/static/assets/index-klO46NcU.css +1 -0
  35. package/dashboard/static/dashboard.html +2 -2
  36. package/package.json +7 -1
  37. package/dashboard/static/assets/index-Di2hljvB.css +0 -1
  38. package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
@@ -19,12 +19,142 @@ Spec: docs/superpowers/specs/2026-07-09-279-s4-record-kernelization-design.md
19
19
  from __future__ import annotations
20
20
 
21
21
  import datetime as dt
22
+ import math
22
23
  from dataclasses import dataclass
23
24
  from enum import Enum
24
25
 
26
+ import _lib_quota_model as _qm
25
27
  from _lib_budget import project_linear
26
28
 
27
29
 
30
+ # --- The ceiling correction (#661 S2, spec section 3.1) --------------------
31
+ #
32
+ # Both meters DISPLAY A CEILING, so a shown reading of `k` means true
33
+ # consumption fell in `[k-1, k)`. The rule itself has one home — the quota
34
+ # kernel's `true_percent_point` / `true_percent_interval` — and these two
35
+ # helpers only adapt a possibly-fractional stored reading to it through the
36
+ # canonical integer-percent floor. A second spelling of the rule here would
37
+ # be a second place to get `[k-1, k)` wrong.
38
+ def corrected_percent_point(shown) -> "float | None":
39
+ """Unbiased point estimate of the consumption behind a shown reading.
40
+
41
+ `None` when the reading is right-censored — a displayed 100 denotes
42
+ `[99, +inf)` and has no finite point estimate, so returning 99.5 would
43
+ invent a value the observation does not supply.
44
+ """
45
+ if shown is None:
46
+ return None
47
+ return _qm.true_percent_point(_qm.integer_percent(float(shown)))
48
+
49
+
50
+ def corrected_percent_interval(shown) -> "tuple | None":
51
+ """The half-open interval a shown reading denotes; `hi` is None at 100."""
52
+ if shown is None:
53
+ return None
54
+ return _qm.true_percent_interval(_qm.integer_percent(float(shown)))
55
+
56
+
57
+ def projection_base(inputs) -> "float | None":
58
+ """The reading every projection is measured FROM.
59
+
60
+ The corrected point where one exists; the raw displayed reading
61
+ otherwise. `otherwise` covers two cases: a right-censored reading, which
62
+ has no corrected point at all, and a duck-typed inputs object from an
63
+ older fixture that carries no corrected field. Several renderers
64
+ re-derive `p_now + rate * remaining` for their own labels rather than
65
+ reading `final_percent_*` (which are min/max aggregates and swap labels),
66
+ and each of those must project from the SAME operand `_compute_forecast`
67
+ used, or the rendered projection mixes a corrected rate with a raw base.
68
+ """
69
+ corrected = getattr(inputs, "p_now_corrected", None)
70
+ if corrected is not None:
71
+ return corrected
72
+ return getattr(inputs, "p_now", None)
73
+
74
+
75
+ class ProjectionBasis(str, Enum):
76
+ """Which measurement a published end-of-week projection came from.
77
+
78
+ The three values are a wire contract every S2 surface renders, so they
79
+ are the exact strings the selector emits.
80
+ """
81
+
82
+ CALIBRATED = "calibrated"
83
+ CORRECTED_METER = "corrected-meter"
84
+ WITHHELD = "withheld"
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class SelectedProjection:
89
+ basis: ProjectionBasis
90
+ value: "float | None"
91
+ #: A member of the quota kernel's closed `EVIDENCE_CODES` union when the
92
+ #: basis is WITHHELD; None otherwise.
93
+ code: "str | None" = None
94
+
95
+
96
+ #: The cause a withheld projection states when the meter is right-censored.
97
+ #: A member of `_lib_quota_model.EVIDENCE_CODES`, pinned by a test rather
98
+ #: than by this comment.
99
+ _RIGHT_CENSORED_CODE = "right-censored"
100
+ _UNAVAILABLE_CODE = "unavailable"
101
+
102
+
103
+ def select_projection_basis(inputs) -> SelectedProjection:
104
+ """The one projection every S2 consumer publishes, and where it came from.
105
+
106
+ The calibrated model when the section 1.1 adapters said it was
107
+ trustworthy for THIS week's population, the corrected meter otherwise,
108
+ and a typed withholding when neither is available. Pure: the caller
109
+ supplies the calibrated value on the inputs, because reaching it needs a
110
+ store read and this function must stay callable from the kernel.
111
+
112
+ Selection lives here and in `_compute_forecast` rather than in
113
+ `build_forecast_view`, because the CLI calls `_compute_forecast` directly
114
+ and the view is only one of its consumers.
115
+ """
116
+ calibrated = getattr(inputs, "calibrated_projection_pct", None)
117
+ if calibrated is not None:
118
+ try:
119
+ usable = math.isfinite(calibrated)
120
+ except (TypeError, OverflowError):
121
+ usable = False
122
+ if usable:
123
+ return SelectedProjection(ProjectionBasis.CALIBRATED,
124
+ float(calibrated), None)
125
+ # A non-finite calibrated value is not a projection, so the meter is
126
+ # selected below. This is a BACKSTOP, not the reporting site: the
127
+ # producer `_calibrated_projection` refuses a non-finite result and
128
+ # states `unavailable` on the wire's `calibration_code`, so a value
129
+ # reaching here unusable means a caller built the inputs by hand.
130
+
131
+ # The meter's own projection. A right-censored reading has no point
132
+ # estimate, so there is nothing to project from and none is invented.
133
+ # Right-censoring applies at 100 and ONLY at 100, because that reading
134
+ # alone denotes an interval unbounded above (spec §3.6). Every other
135
+ # reading, zero included, denotes a bounded interval whose midpoint is
136
+ # the corrected point, so a displayed 0 projects from 0.25 exactly as a
137
+ # displayed 40 projects from 39.5. An earlier revision withheld the
138
+ # zero-reading week and gave it the cause `no-local-history`; both halves
139
+ # were wrong. It singled out the one reading where the correction is most
140
+ # visible and refused to apply it, and the cause was false independently,
141
+ # because S1 defines `no-local-history` as the ingest tail failing to
142
+ # cover the interval or the interval holding no entries, while the
143
+ # fixture that produced it carries $9.12 of spend across seven snapshots.
144
+ if getattr(inputs, "right_censored", False):
145
+ return SelectedProjection(ProjectionBasis.WITHHELD, None,
146
+ _RIGHT_CENSORED_CODE)
147
+ base = projection_base(inputs)
148
+ remaining = getattr(inputs, "remaining_hours", None)
149
+ elapsed = getattr(inputs, "elapsed_hours", None)
150
+ if base is None or remaining is None or elapsed is None:
151
+ return SelectedProjection(ProjectionBasis.WITHHELD, None,
152
+ _UNAVAILABLE_CODE)
153
+ r_avg = base / elapsed if elapsed > 0 else 0.0
154
+ return SelectedProjection(ProjectionBasis.CORRECTED_METER,
155
+ base + r_avg * remaining, None)
156
+
157
+
28
158
  class ForecastConfidenceCause(str, Enum):
29
159
  """The closed set of reasons a forecast is low-confidence.
30
160
 
@@ -74,6 +204,38 @@ def assess_forecast_confidence(
74
204
  )
75
205
 
76
206
 
207
+ #: Every value `_select_dollars_per_percent` can return, with its human
208
+ #: copy. The union used to live in a trailing comment on the field and had
209
+ #: already fallen behind the code by one member.
210
+ #:
211
+ #: The three trailing-median members share a denominator and differ only in
212
+ #: what the §4.2 comparability test was able to say about the candidate
213
+ #: population: it passed, it rejected at least one week for model-mix drift,
214
+ #: or it could not run at all because the local store was unreadable. A bare
215
+ #: `replace("_", " ")` rendered the qualification as raw text with no
216
+ #: explanation, which is what this table replaces.
217
+ DOLLARS_PER_PERCENT_SOURCES: dict = {
218
+ "this_week": "this week",
219
+ "this_week_sparse": "this week sparse",
220
+ "no_usage_observed": "no usage observed",
221
+ "trailing_4wk_median": "trailing 4wk median",
222
+ "trailing_4wk_median_drifted":
223
+ "trailing 4wk median (drift-reduced confidence)",
224
+ "trailing_4wk_median_unverified":
225
+ "trailing 4wk median (comparability unverified)",
226
+ }
227
+
228
+
229
+ def dollars_per_percent_source_label(source: str) -> str:
230
+ """Human copy for a `dollars_per_percent_source` code.
231
+
232
+ An unknown code degrades to the old `replace("_", " ")` rendering rather
233
+ than raising or printing an empty cell, so a code added by a later
234
+ session is readable before its copy lands.
235
+ """
236
+ return DOLLARS_PER_PERCENT_SOURCES.get(source, str(source).replace("_", " "))
237
+
238
+
77
239
  @dataclass
78
240
  class ForecastInputs:
79
241
  now_utc: dt.datetime
@@ -96,10 +258,53 @@ class ForecastInputs:
96
258
  # every dollar-derived output is then unavailable rather than zero, and
97
259
  # `dollars_per_percent_source` carries the cause.
98
260
  dollars_per_percent: "float | None"
99
- dollars_per_percent_source: str # "this_week" | "trailing_4wk_median" | "this_week_sparse" | "no_usage_observed"
261
+ #: One of `DOLLARS_PER_PERCENT_SOURCES`. It is a MACHINE code — every
262
+ #: `--json` surface emits it verbatim — and human copy comes from
263
+ #: `dollars_per_percent_source_label`, never from a `replace("_", " ")`
264
+ #: at a render site.
265
+ dollars_per_percent_source: str
100
266
  # Confidence
101
267
  confidence: str # "high" | "low"
102
268
  low_confidence_reasons: list[str]
269
+ # --- The ceiling-corrected pair (#661 S2, spec section 3.1) ------------
270
+ #
271
+ # `p_now` and `p_24h_ago` above stay the RAW displayed readings, which is
272
+ # what every surface prints. These four carry the corrected reading the
273
+ # arithmetic runs on. They are derived in `__post_init__` rather than
274
+ # required of the caller, so every construction site — the loader, the
275
+ # TUI's demo builder, a test — gets the correction; a field the loader
276
+ # alone filled in would leave every other site silently uncorrected.
277
+ p_now_corrected: "float | None" = None
278
+ p_now_interval: "tuple | None" = None
279
+ p_24h_ago_corrected: "float | None" = None
280
+ right_censored: "bool | None" = None
281
+ # --- The calibrated basis (#661 S2, spec section 3.3) -----------------
282
+ #
283
+ # The model-backed projected end-of-week percent, present only when the
284
+ # section 1.1 adapters said the persisted regime was trustworthy for THIS
285
+ # week's population. `calibrated_withheld_code` states why it is absent,
286
+ # as a member of the quota kernel's closed `EVIDENCE_CODES` union, so a
287
+ # renderer states a cause rather than a blank.
288
+ calibrated_projection_pct: "float | None" = None
289
+ calibrated_withheld_code: "str | None" = None
290
+ # The rest of what the same week-scan produced (#661 S2 spec §10). The
291
+ # dashboard publishes consumption and headroom beside the projection,
292
+ # and reading them from a second call would run a second unbounded week
293
+ # scan per refresh for numbers the first scan already computed. All
294
+ # three are None whenever `calibrated_projection_pct` is.
295
+ calibrated_consumption_pct: "float | None" = None
296
+ calibrated_consumption_interval: "tuple | None" = None
297
+ calibrated_headroom_pct: "float | None" = None
298
+
299
+ def __post_init__(self) -> None:
300
+ if self.p_now_corrected is None:
301
+ self.p_now_corrected = corrected_percent_point(self.p_now)
302
+ if self.p_now_interval is None:
303
+ self.p_now_interval = corrected_percent_interval(self.p_now)
304
+ if self.p_24h_ago_corrected is None:
305
+ self.p_24h_ago_corrected = corrected_percent_point(self.p_24h_ago)
306
+ if self.right_censored is None:
307
+ self.right_censored = self.p_now_corrected is None
103
308
 
104
309
 
105
310
  @dataclass
@@ -113,37 +318,111 @@ class BudgetRow:
113
318
  @dataclass
114
319
  class ForecastOutput:
115
320
  inputs: ForecastInputs
116
- r_avg: float # pct per hour, week-avg
321
+ # pct per hour, week-avg. `None` at a right-censored reading: a displayed
322
+ # 100 denotes `[99, +inf)`, so the observation supplies no rate either.
323
+ r_avg: float | None
117
324
  r_recent: float | None # pct per hour, 24h recent; None if no prior sample
118
- final_percent_low: float
119
- final_percent_high: float
120
- week_avg_projection_pct: float # p_now + r_avg*remaining (smooth estimator)
325
+ # The three projections below are `None` in the right-censored state
326
+ # (#661 S2, spec section 3.2): a displayed 100 denotes `[99, +inf)`, so
327
+ # there is no point estimate to project from and none is fabricated.
328
+ final_percent_low: float | None
329
+ final_percent_high: float | None
330
+ week_avg_projection_pct: float | None
121
331
  projected_cap: bool
122
332
  already_capped: bool
123
333
  cap_at: dt.datetime | None
124
334
  budgets: list[BudgetRow]
335
+ #: True when the current reading is right-censored. `already_capped`
336
+ #: says the meter SHOWS its cap; this says the observation carries no
337
+ #: upper bound, which is why the projections above are withheld.
338
+ right_censored: bool = False
339
+ #: `ProjectionBasis` value naming where `week_avg_projection_pct` came
340
+ #: from, and the typed cause when it is withheld.
341
+ projection_basis: str = ProjectionBasis.CORRECTED_METER.value
342
+ projection_code: "str | None" = None
125
343
 
126
344
 
127
345
  def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOutput:
128
- """Implements spec §2. targets are sorted desc for stable output (100, 90, …)."""
129
- # Rate methods
130
- r_avg = inputs.p_now / inputs.elapsed_hours if inputs.elapsed_hours > 0 else 0.0
131
- if inputs.p_24h_ago is not None and inputs.t_24h_actual_hours:
346
+ """Implements spec §2. targets are sorted desc for stable output (100, 90, …).
347
+
348
+ Every quantity below is computed from the CEILING-CORRECTED reading
349
+ (#661 S2, spec section 3.1). `inputs.p_now` remains the raw displayed
350
+ value and is what the renderers print; `inputs.p_now_corrected` is the
351
+ unbiased estimate of what was actually consumed, and the two differ by
352
+ half a point at every reading above 1.
353
+ """
354
+ censored = bool(inputs.right_censored)
355
+ # In the censored state there is no corrected point, so there is no
356
+ # week-average rate either and none is invented. A displayed 103 denotes
357
+ # `[99, +inf)`; 103/120 is arithmetic over a reading the model declares
358
+ # censored, not a lower bound on the rate and not an estimate of it. An
359
+ # earlier revision fell back to the displayed value here and claimed
360
+ # nothing derived from it was published, which was false — it reached the
361
+ # wire as `rates.week_average_pct_per_hour`.
362
+ r_avg: float | None = None
363
+ if not censored:
364
+ r_avg = (inputs.p_now_corrected / inputs.elapsed_hours
365
+ if inputs.elapsed_hours > 0 else 0.0)
366
+ if (not censored and inputs.p_24h_ago_corrected is not None
367
+ and inputs.t_24h_actual_hours):
132
368
  r_recent: float | None = max(
133
- 0.0, (inputs.p_now - inputs.p_24h_ago) / inputs.t_24h_actual_hours
369
+ 0.0,
370
+ (inputs.p_now_corrected - inputs.p_24h_ago_corrected)
371
+ / inputs.t_24h_actual_hours
134
372
  )
135
373
  else:
136
374
  r_recent = None
137
375
 
376
+ # The ONE selector both this kernel and the projected-alert twin call
377
+ # (#661 S2 spec section 3.3), so the two cannot drift apart.
378
+ selection = select_projection_basis(inputs)
379
+
380
+ if censored:
381
+ # No point estimate, no ETA, no headroom, no budget amount is
382
+ # fabricated from a censored METER. The cap itself is a FACT here,
383
+ # not a projection, so both cap flags stay true. A trustworthy
384
+ # calibration still supplies `week_avg_projection_pct`, because it
385
+ # reads tokens rather than the censored meter; the band, the ETA and
386
+ # the budgets stay withheld either way, because those are derived
387
+ # from the meter and this session does not model them.
388
+ return ForecastOutput(
389
+ inputs=inputs, r_avg=r_avg, r_recent=None,
390
+ final_percent_low=None, final_percent_high=None,
391
+ week_avg_projection_pct=selection.value, projected_cap=True,
392
+ already_capped=True, cap_at=None, budgets=[],
393
+ right_censored=True,
394
+ projection_basis=selection.basis.value,
395
+ projection_code=selection.code,
396
+ )
397
+ p_now = inputs.p_now_corrected
398
+
399
+ if selection.basis is ProjectionBasis.WITHHELD:
400
+ # An uncensored week reaches this branch on exactly one cause,
401
+ # `unavailable`: the corrected point, the elapsed span or the
402
+ # remaining span is missing, so there is no pace to project along and
403
+ # no ceiling distance to price. The percent budget below does not
404
+ # depend on a rate and is still published, which is the split
405
+ # #620 S1 D5 already decided for the dollar budget.
406
+ return ForecastOutput(
407
+ inputs=inputs, r_avg=r_avg, r_recent=r_recent,
408
+ final_percent_low=None, final_percent_high=None,
409
+ week_avg_projection_pct=None, projected_cap=False,
410
+ already_capped=False, cap_at=None,
411
+ budgets=_budget_rows(inputs, targets, p_now),
412
+ right_censored=False,
413
+ projection_basis=selection.basis.value,
414
+ projection_code=selection.code,
415
+ )
416
+
138
417
  # Projected final % — routed through the shared project_linear primitive
139
418
  # (spec F1). r_recent is None ⇒ collapse to the average projection.
140
419
  if r_recent is None:
141
420
  final_low, final_high = project_linear(
142
- inputs.p_now, inputs.remaining_hours, r_avg, r_avg
421
+ p_now, inputs.remaining_hours, r_avg, r_avg
143
422
  )
144
423
  else:
145
424
  a, b = project_linear(
146
- inputs.p_now, inputs.remaining_hours, r_avg, r_recent
425
+ p_now, inputs.remaining_hours, r_avg, r_recent
147
426
  )
148
427
  final_low, final_high = min(a, b), max(a, b)
149
428
 
@@ -151,43 +430,25 @@ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOut
151
430
  # the displayed band (which keys off final_high): this is the conservative
152
431
  # week-average value the projected-pace alert axis fires on.
153
432
  # p_now + r_avg*remaining (== project_linear collapsed to the single rate).
154
- week_avg_projection_pct = inputs.p_now + r_avg * inputs.remaining_hours
433
+ # The selector's value, not a second derivation of it. When no
434
+ # calibration is available this is exactly `p_now + r_avg * remaining`,
435
+ # which is what keeps the meter-basis behaviour unchanged.
436
+ week_avg_projection_pct = selection.value
155
437
 
156
- already_capped = inputs.p_now >= 100.0
157
- projected_cap = already_capped or final_high >= 100.0
438
+ # `already_capped` is UNREACHABLE here: `corrected_percent_point` returns
439
+ # None at every displayed reading of 100 or more, which is the censored
440
+ # branch above, so the corrected point tops out at 98.5. The flag is
441
+ # therefore False on this path by construction and is not re-tested.
442
+ projected_cap = final_high >= 100.0
158
443
 
159
444
  cap_at: dt.datetime | None = None
160
- if not already_capped and projected_cap:
445
+ if projected_cap:
161
446
  r_pessimistic = max(r_avg, r_recent or 0.0)
162
447
  if r_pessimistic > 0:
163
- hours_to_cap = (100.0 - inputs.p_now) / r_pessimistic
448
+ hours_to_cap = (100.0 - p_now) / r_pessimistic
164
449
  if hours_to_cap < inputs.remaining_hours:
165
450
  cap_at = inputs.now_utc + dt.timedelta(hours=hours_to_cap)
166
451
 
167
- # Budgets
168
- budgets: list[BudgetRow] = []
169
- if not already_capped:
170
- for t in sorted(targets, reverse=True):
171
- headroom = t - inputs.p_now
172
- if headroom <= 0 or inputs.remaining_days <= 0:
173
- budgets.append(BudgetRow(target_percent=t, pct_headroom=None,
174
- dollars_per_day=None, percent_per_day=None))
175
- continue
176
- # #620 S1 D5: with no observed usage there is no rate, so the
177
- # dollar budget is unavailable. The percent budget does not
178
- # depend on the rate and is still published.
179
- dollars_day = (
180
- None if inputs.dollars_per_percent is None
181
- else (headroom * inputs.dollars_per_percent) / inputs.remaining_days
182
- )
183
- pct_day = headroom / inputs.remaining_days
184
- budgets.append(BudgetRow(
185
- target_percent=t,
186
- pct_headroom=headroom,
187
- dollars_per_day=dollars_day,
188
- percent_per_day=pct_day,
189
- ))
190
-
191
452
  return ForecastOutput(
192
453
  inputs=inputs,
193
454
  r_avg=r_avg,
@@ -196,7 +457,40 @@ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOut
196
457
  final_percent_high=final_high,
197
458
  week_avg_projection_pct=week_avg_projection_pct,
198
459
  projected_cap=projected_cap,
199
- already_capped=already_capped,
460
+ already_capped=False,
200
461
  cap_at=cap_at,
201
- budgets=budgets,
462
+ budgets=_budget_rows(inputs, targets, p_now),
463
+ right_censored=False,
464
+ projection_basis=selection.basis.value,
465
+ projection_code=selection.code,
202
466
  )
467
+
468
+
469
+ def _budget_rows(inputs, targets, p_now) -> "list[BudgetRow]":
470
+ """One `BudgetRow` per target, measured from the corrected reading.
471
+
472
+ Shared by the ordinary path and the no-observed-usage path, so the two
473
+ cannot drift: the percent budget never depended on a projection, and #620
474
+ S1 D5 already decided that it is published where the dollar budget is not.
475
+ """
476
+ rows: list[BudgetRow] = []
477
+ for t in sorted(targets, reverse=True):
478
+ headroom = t - p_now
479
+ if headroom <= 0 or inputs.remaining_days <= 0:
480
+ rows.append(BudgetRow(target_percent=t, pct_headroom=None,
481
+ dollars_per_day=None, percent_per_day=None))
482
+ continue
483
+ # #620 S1 D5: with no observed usage there is no rate, so the dollar
484
+ # budget is unavailable. The percent budget does not depend on the
485
+ # rate and is still published.
486
+ dollars_day = (
487
+ None if inputs.dollars_per_percent is None
488
+ else (headroom * inputs.dollars_per_percent) / inputs.remaining_days
489
+ )
490
+ rows.append(BudgetRow(
491
+ target_percent=t,
492
+ pct_headroom=headroom,
493
+ dollars_per_day=dollars_day,
494
+ percent_per_day=headroom / inputs.remaining_days,
495
+ ))
496
+ return rows