fix/gdmth-cap-q
needs attentionviewing older commit4a519dd · fullPR #312reviewed 2026-07-15 00:11 UTC1H · 5M · 5L · 3I- Purpose
- Fix systematic under-billing of GDMTH capacity charges in no-punta winter seasons (Tijuana). CFE mandates the Q-method (demanda facturable) when no punta demand exists; the calculator was billing $0 instead.
- Goal
- Port the legacy method_q_demand_mandatory branch into the new pure billing calculator, data-gated on the chargeFactor rate field so existing tariffs without it are unaffected.
- Sub-goals
- SG-1: Add qFallbackFactor to Charge interface and chargeFactor to RateValues/RateComponents
- SG-2: Wire chargeFactor → qFallbackFactor on TOU capacity charges in tariff adapter
- SG-3: Apply Q formula in demandQuantity when measured=0/null and qFallbackFactor is set
- SG-4: Validate against legacy KK figure (170.8107 kW) and A/B vs mgmt bills across 33 sites × 3 periods
- What
- Added qFallbackFactor optional field to Charge; chargeFactor optional field to RateValues and RateComponents; demandQuantity fallback logic in decisions.ts; adapter wiring; 6 new test cases for Q-fallback and 3 for adapter wiring.
- Why
- GDMTH winter (Tijuana, no punta band) billed capacity=0 instead of the CFE mandatory Q-method demanda facturable. Fix closes 12/12 winter mismatches, zero summer regressions.
- Areas
- domains/utility/src/bill-calculator/__tests__/bill-calculator.decisions.test.ts+106−0domains/utility/src/bill-calculator/__tests__/tariff.adapter.test.ts+15−0domains/utility/src/bill-calculator/bill-calculator.decisions.ts+14−1domains/utility/src/bill-calculator/bill-calculator.types.ts+15−0domains/utility/src/bill-calculator/tariff.adapter.ts+11−1domains/utility/src/tariff-rate/tariff-rate.type.ts+9−0
- Blast
- 6 files, +170/-2 lines. Pure domain layer (domains/utility/src/bill-calculator + tariff-rate). No API, shell, or DB schema changes. Data-gated on chargeFactor presence.
Findings · 14
correctness2
Q fires on genuine shutdown (bands absent, peak_15min=0) — no test pins this as intentional
domains/utility/src/bill-calculator/bill-calculator.decisions.ts:106
When by_tou_band is absent AND peak_15min=0 (genuine shutdown, no punta), measuredDemandForCharge returns undefined??0=0, triggering Q. On derived_from_energy the same scenario returns min(0,Q)=0. This mirrors legacy !tip_kw behavior and is intentional — but there is no test documenting this specific input (bands absent + peak_15min=0) to prevent an accidental regression normalizing both strategies to 0 in a refactor.
Q formula always uses total_consumption regardless of charge.schedule — correct today, implicit assumption
domains/utility/src/bill-calculator/bill-calculator.decisions.ts:108
derived_from_energy selects kWh by schedule type (tou_band vs always). The new fallback always uses total_consumption. This is correct because the capacity charge has schedule: {type: 'always'}, but if qFallbackFactor were set on a tou_band charge in future, it would silently use the wrong basis. No guard or comment captures this assumption.
security1
num() already guards NaN/Infinity — parseFloat safety concern is addressed
domains/utility/src/bill-calculator/tariff.adapter.ts
num() returns Number.isFinite(n) ? n : undefined, correctly rejecting Infinity, -Infinity, and NaN. The security surface for qFallbackFactor corruption from JSONB is appropriately mitigated at the parse layer.
conventions1
chargeFactor naming breaks the {type}Cost / {type}Adjustment convention in RateComponents
domains/utility/src/tariff-rate/tariff-rate.type.ts:46
All other RateComponents fields follow {concept}Cost (supplyCost, capacityCost, etc.) or {concept}Adjustment (energyAdjustment). chargeFactor has no suffix. More consistent: capacityChargeFactor or qDemandFactor. Low impact since this is an internal adapter field.
tests6
Missing test: derived_from_energy vs measured_15min produce different results on identical winterInput(0)
domains/utility/src/bill-calculator/__tests__/bill-calculator.decisions.test.ts:195
No test runs both demand strategies on the same zero-punta input and asserts they diverge. This asymmetry is the behavioral core of the fix — a future refactor that accidentally normalizes both paths to 0 would pass all existing tests.
Missing test: Q-fallback + FAC (applyEnergyAdjustment) interaction on measured path
domains/utility/src/bill-calculator/__tests__/bill-calculator.decisions.test.ts
THOR GDMTH sites (Krispy Kreme, BC = Tijuana) can combine FAC scaling with no-punta winter. The Q formula reads total_consumption after applyEnergyAdjustment — so Q scales with FAC. No test covers Q + FAC. Given KK is a live THOR GDMTH site in Tijuana, this is a real production scenario.
Missing test: bimonthly period (60+ days) with Q fallback
domains/utility/src/bill-calculator/__tests__/bill-calculator.decisions.test.ts
GDMTH account prefixes 01-40 are bimonthly (~62 days). Q = totalKwh / (24 × daysInPeriod × CF) — a 62-day period roughly halves the result vs 31 days. No test covers this. Bimonthly billing regressions have historical precedent in this codebase.
winterInput helper casts `peakDemand as number` — hides undefined in typed field
domains/utility/src/bill-calculator/__tests__/bill-calculator.decisions.test.ts:184
by_tou_band.peak: peakDemand as number casts undefined into a number-typed field. The test behavior is correct (undefined?? falls back to peak_15min=150), but the type cast bypasses TS safety. Prefer omitting the peak key entirely when undefined: ...(peakDemand !== undefined && { peak: peakDemand }).
KK regression test precision at 3dp when fix was validated to the cent (4dp)
domains/utility/src/bill-calculator/__tests__/bill-calculator.decisions.test.ts:223
toBeCloseTo(170.8107, 3) has ±0.0005 tolerance. PR claims cent-level match; consider tightening to 4dp to actually pin that claim.
Adapter negative test vacuously passes if distribution charge is absent
domains/utility/src/bill-calculator/__tests__/tariff.adapter.test.ts:22
expect(t.charges.find(c => c.sourceConcept === 'distribution')?.qFallbackFactor).toBeUndefined() trivially passes when find() returns undefined. Add an assertion that the distribution charge exists before asserting its field.
improvement4
Asymmetric zero-treatment between demand strategies is undocumented — fragile for future maintainers
domains/utility/src/bill-calculator/bill-calculator.decisions.ts:106
derived_from_energy treats measured=0 as a solar-credit cap (min(0,Q)=0). The new qFallbackFactor branch treats measured=0 as 'no punta season' and TRIGGERS Q — the opposite. This is intentional (different CFE rules) but the comment only explains the new branch; it doesn't note the contrast with derived_from_energy. A developer reading both branches in sequence will flag the inconsistency as a bug. Add a cross-reference: 'note: derived_from_energy caps Q at measured (min(0,Q)=0 for solar offset); this path treats measured=0 as absent punta — different CFE rules, intentional.'
qFallbackFactor is charge-level but semantically tariff-level — misconfiguration risk at adapter
domains/utility/src/bill-calculator/tariff.adapter.ts:91
chargeFactor (0.57) is a GDMTH tariff property, not a per-charge property. Attaching it at charge-level means any future author adding a second TOU demand charge in buildTariff must know to omit qFallbackFactor — the type system won't enforce this. A comment on the capacity-charge block noting 'only capacity charge for TOU tariffs gets this factor' reduces future extension blast radius.
Double guard `charge.qFallbackFactor && charge.qFallbackFactor > 0` is redundant
domains/utility/src/bill-calculator/bill-calculator.decisions.ts:106
The truthiness check is redundant with > 0. Simplify to (charge.qFallbackFactor ?? 0) > 0. Minor style nit.
No DB migration needed — worth noting in PR so reviewers don't hunt for one
domains/utility/src/bill-calculator/bill-calculator.types.ts
qFallbackFactor lives on in-memory Charge objects assembled by buildTariff at runtime. chargeFactor on RateComponents is an additive JSONB field (no schema change). No migration needed.