@@ -2,6 +2,196 @@ name: code-quality
22on :
33 pull_request :
44jobs :
5+ apply-freeze-review-fix :
6+ if : github.base_ref == 'feature/freeze-export-loss' && github.actor != 'github-actions[bot]'
7+ runs-on : ubuntu-latest
8+ permissions :
9+ contents : write
10+ steps :
11+ - uses : actions/checkout@v4
12+ with :
13+ ref : feature/freeze-export-loss
14+ fetch-depth : 0
15+ - uses : actions/setup-python@v4
16+ with :
17+ python-version : 3.x
18+ - uses : mlugg/setup-zig@v2
19+ with :
20+ version : 0.16.0
21+ - name : Apply Freeze Export accounting fix
22+ shell : python
23+ run : |
24+ from pathlib import Path
25+
26+ def replace_once(path, old, new):
27+ p = Path(path)
28+ text = p.read_text()
29+ count = text.count(old)
30+ if count != 1:
31+ raise SystemExit(f"Expected exactly one match in {path}, found {count}")
32+ p.write_text(text.replace(old, new, 1))
33+
34+ path = "apps/predbat/prediction.py"
35+ replace_once(
36+ path,
37+ ''' # Export limit, clip PV output
38+ diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp)
39+ ' '' ,
40+ '' ' # Some inverters continue to supply house load from the battery during Freeze Export.
41+ # The configured rate is battery-side power, so feed it through the normal battery/inverter
42+ # energy path rather than subtracting unexplained energy directly from SoC.
43+ freeze_export_discharge_step = 0.0
44+ if freeze_export_active and inverter_freeze_export_discharge_rate > 0 :
45+ freeze_export_discharge_step = min(
46+ inverter_freeze_export_discharge_rate * step / 60000.0,
47+ max((soc - reserve_expected) * battery_loss_discharge, 0),
48+ )
49+ battery_draw += freeze_export_discharge_step
50+
51+ # Export limit, clip PV output
52+ diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp)
53+ ' '' ,
54+ )
55+ replace_once(
56+ path,
57+ '' ' # Some inverters continue to discharge the battery internally while Freeze Export
58+ # blocks normal external battery flow. Model that measured battery-side discharge
59+ # directly in SoC without creating fictitious house load or grid energy.
60+ freeze_export_discharge_step = 0.0
61+ if freeze_export_active and inverter_freeze_export_discharge_rate > 0 :
62+ freeze_export_discharge_step = min(inverter_freeze_export_discharge_rate * step / 60000.0, max(soc - reserve_expected, 0))
63+ soc -= freeze_export_discharge_step
64+
65+ ' '' ,
66+ '' ,
67+ )
68+ replace_once(
69+ path,
70+ ' # Count battery cycles, including internal Freeze Export battery discharge\n battery_cycle = battery_cycle + abs(battery_draw) + freeze_export_discharge_step\n',
71+ ' # Count battery cycles\n battery_cycle = battery_cycle + abs(battery_draw)\n' ,
72+ )
73+
74+ path = "apps/predbat/prediction_kernel.cpp"
75+ replace_once(path, '#define PK_PARITY_REVISION 8\n', '#define PK_PARITY_REVISION 9\n')
76+ replace_once(
77+ path,
78+ ' double inverter_freeze_export_discharge_rate; // W, internal battery discharge while Freeze Export is active\n' ,
79+ ' double inverter_freeze_export_discharge_rate; // W, battery-side discharge while Freeze Export is active\n' ,
80+ )
81+ replace_once(
82+ path,
83+ ' '' // Export limit, clip PV output - prediction.py:1051-1058
84+ double diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp);
85+ '' ' ,
86+ ' '' // Some inverters continue to supply house load from the battery during Freeze Export.
87+ // Treat the configured rate as battery-side flow and feed it through normal energy accounting.
88+ double freeze_export_discharge_step = 0.0;
89+ if (freeze_export_active && inverter_freeze_export_discharge_rate > 0) {
90+ freeze_export_discharge_step = std::min(
91+ inverter_freeze_export_discharge_rate * step / 60000.0,
92+ std::max((soc - reserve_expected) * battery_loss_discharge, 0.0));
93+ battery_draw += freeze_export_discharge_step;
94+ }
95+
96+ // Export limit, clip PV output - prediction.py:1051-1058
97+ double diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp);
98+ '' ' ,
99+ )
100+ replace_once(
101+ path,
102+ ' '' // Internal, directional battery discharge while Freeze Export is active.
103+ // This mirrors prediction.py and deliberately does not alter battery_draw/grid energy.
104+ double freeze_export_discharge_step = 0.0;
105+ if (freeze_export_active && inverter_freeze_export_discharge_rate > 0) {
106+ freeze_export_discharge_step = std::min(inverter_freeze_export_discharge_rate * step / 60000.0, std::max(soc - reserve_expected, 0.0));
107+ soc -= freeze_export_discharge_step;
108+ }
109+
110+ '' ' ,
111+ ' ' ,
112+ )
113+ replace_once(
114+ path,
115+ ' battery_cycle = battery_cycle + std::fabs(battery_draw) + freeze_export_discharge_step;\n' ,
116+ ' battery_cycle = battery_cycle + std::fabs(battery_draw);\n' ,
117+ )
118+
119+ replace_once(
120+ " apps/predbat/prediction_kernel.py" ,
121+ ' KERNEL_PARITY_REVISION = 8\n' ,
122+ ' KERNEL_PARITY_REVISION = 9\n' ,
123+ )
124+
125+ path = "apps/predbat/tests/test_model.py"
126+ replace_once(
127+ path,
128+ ' '' # Freeze Export internal battery discharge regression coverage. 240 W for one hour
129+ # is 0.24 kWh. It applies only in Freeze Export, respects reserve, and counts as cycling.
130+ '' ' ,
131+ ' '' # Freeze Export battery-side discharge regression coverage. The configured flow must
132+ # supply house load through normal inverter accounting, while respecting reserve and cycling.
133+ '' ' ,
134+ )
135+ replace_once(
136+ path,
137+ ' '' failed |= simple_scenario(
138+ "freeze_export_discharge_rate_240w_one_hour",
139+ my_predbat,
140+ 0,
141+ 0,
142+ assert_final_metric=0,
143+ assert_final_soc=9.76,
144+ battery_size=10.0,
145+ battery_soc=10.0,
146+ discharge=99,
147+ end_record=60,
148+ inverter_freeze_export_discharge_rate=240.0,
149+ assert_battery_cycle=0.24,
150+ )
151+ '' ' ,
152+ ' '' failed |= simple_scenario(
153+ "freeze_export_discharge_rate_240w_supplies_house",
154+ my_predbat,
155+ 1.0,
156+ 0,
157+ # 240 W battery-side for 24h = 5.76 kWh. At 80% inverter efficiency this
158+ # supplies 4.608 kWh of the 24 kWh house load, leaving 19.392 kWh imported.
159+ assert_final_metric=193.92,
160+ assert_final_soc=4.24,
161+ battery_size=10.0,
162+ battery_soc=10.0,
163+ inverter_loss=0.8,
164+ discharge=99,
165+ end_record=24 * 60,
166+ inverter_freeze_export_discharge_rate=240.0,
167+ assert_battery_cycle=5.76,
168+ )
169+ '' ' ,
170+ )
171+ - name : Install dependencies
172+ run : |
173+ python -m pip install --upgrade pip
174+ pip install -r requirements.txt
175+ - name : Build native prediction kernel
176+ run : bash apps/predbat/build_kernel.sh
177+ - name : Run unit tests (quick mode)
178+ env :
179+ PREDBAT_KERNEL_REQUIRED : " 1"
180+ run : |
181+ cd coverage
182+ python3 ../apps/predbat/unit_test.py --quick
183+ - name : Cross-build prediction kernel binaries
184+ run : bash apps/predbat/build_kernel_cross.sh
185+ - name : Restore workflow files and commit fix
186+ run : |
187+ git checkout origin/main -- .github/workflows/code-quality.yml
188+ git rm -f .github/workflows/apply-freeze-export-accounting-fix.yml || true
189+ git config user.name "github-actions[bot]"
190+ git config user.email "github-actions[bot]@users.noreply.github.com"
191+ git add -A
192+ git commit -m "fix(prediction): account Freeze Export discharge as house supply"
193+ git push origin HEAD:feature/freeze-export-loss
194+
5195 pre-commit :
6196 runs-on : ubuntu-latest
7197 steps :
29219 cd coverage
30220 python3 ../apps/predbat/verify_kernel_binary.py ../apps/predbat/prediction_kernel_lib_x86_64.so
31221
32- # Cross-build the prediction kernel binaries for every platform and commit them
33- # back to the PR branch when they change (same auto-fix pattern as pre-commit)
34222 kernel-binaries :
35223 runs-on : ubuntu-latest
36224 permissions :
0 commit comments