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.
- package/CHANGELOG.md +56 -0
- package/bin/_cctally_alerts.py +65 -4
- package/bin/_cctally_config.py +62 -2
- package/bin/_cctally_core.py +62 -1
- package/bin/_cctally_dashboard.py +11 -0
- package/bin/_cctally_dashboard_envelope.py +319 -14
- package/bin/_cctally_dashboard_share.py +56 -25
- package/bin/_cctally_doctor.py +63 -0
- package/bin/_cctally_forecast.py +917 -44
- package/bin/_cctally_journal.py +153 -5
- package/bin/_cctally_parser.py +66 -0
- package/bin/_cctally_project.py +535 -10
- package/bin/_cctally_quota.py +13 -0
- package/bin/_cctally_quota_calibration.py +146 -0
- package/bin/_cctally_quota_model.py +1616 -0
- package/bin/_cctally_record.py +114 -6
- package/bin/_cctally_share.py +16 -8
- package/bin/_cctally_statusline.py +34 -0
- package/bin/_cctally_tui.py +214 -45
- package/bin/_lib_dashboard_settings_contract.py +2 -0
- package/bin/_lib_doctor.py +159 -1
- package/bin/_lib_forecast.py +337 -43
- package/bin/_lib_meter_rate_change.py +294 -0
- package/bin/_lib_quota_calibration.py +311 -0
- package/bin/_lib_quota_copy.py +131 -0
- package/bin/_lib_quota_model.py +2333 -0
- package/bin/_lib_rederive.py +10 -0
- package/bin/_lib_render.py +6 -0
- package/bin/_lib_share_templates.py +37 -5
- package/bin/_lib_statusline.py +226 -2
- package/bin/_lib_view_models.py +30 -12
- package/bin/cctally +32 -0
- package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
- package/dashboard/static/assets/index-klO46NcU.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +7 -1
- package/dashboard/static/assets/index-Di2hljvB.css +0 -1
- package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
package/bin/_lib_forecast.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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,
|
|
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
|
-
|
|
421
|
+
p_now, inputs.remaining_hours, r_avg, r_avg
|
|
143
422
|
)
|
|
144
423
|
else:
|
|
145
424
|
a, b = project_linear(
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
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
|
|
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 -
|
|
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=
|
|
460
|
+
already_capped=False,
|
|
200
461
|
cap_at=cap_at,
|
|
201
|
-
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
|