From fc7c4d2d3b79e37cdfa459208d68564d812d016f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 18 Jun 2026 14:15:34 +0000 Subject: [PATCH] fix(climate): compute EPC CO2/PE on the postcode demand cascade (SAP 10.2 Appendix U p.124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SAP/EI rating is computed on UK-average weather (Appendix U Tables U1-U3 region 0) so ratings are nationally comparable, but Appendix U paragraph 1 (PDF p.124) requires that "other calculations (such as for energy use and costs on EPCs) are done using local weather. Weather data for each postcode district are taken from the PCDB". `Sap10Calculator. calculate` ran ONE cascade (UK-average) and fed it to SAP, CO2 AND primary energy, so every cert's EPC-displayed CO2/PE were computed on the wrong climate. Because most of England is warmer than the UK-average, this systematically OVER-counted heating demand on the emissions/PE outputs. The two cascades (`cert_to_inputs` rating, `cert_to_demand_inputs` postcode) already existed; this wires the demand cascade into the production entry point and grafts its CO2/PE onto the rating result (SAP unchanged). The corpus gauge's longstanding +5% CO2/PE over-estimate was mostly this climate bug, NOT (as previously diagnosed) per-cert mapper fidelity: CO2 MAE 0.26 -> 0.12 t/yr (bias +0.18 -> +0.04) PE MAE 13.6 -> 3.8 kWh/m2 (bias +9.0 -> +0.24) SAP within-0.5 = 69.7% (rating cascade, unchanged) Worksheet-validated to 1e-4 on simulated case 45 (heat-pump ground-floor flat, postcode W6): the P960 prints the current dwelling twice — Block 1 on UK-average weather (SAP 60.5318, CO2 692.13) and Block 2 on postcode weather (CO2 626.78, PE 6581.59). Both reproduce exactly. Added a tracked case-45 Summary fixture + two-cascade cascade pin as a permanent guard, and ratcheted the corpus CO2/PE ceilings to 0.13 / 4.2. The e2e Elmhurst suite (Block-1 line refs) now pins the rating cascade directly; the two Vaillant overlay snapshots refreshed to demand-cascade CO2/PE. pyright not installed in this codespace (strict gate not run locally); change is type-trivial (dataclasses.replace over SapResult). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/fixtures/Summary_001431_case45.pdf | Bin 0 -> 80737 bytes domain/sap10_calculator/calculator.py | 25 +++- .../modelling/test_elmhurst_cascade_pins.py | 13 ++- .../_elmhurst_worksheet_001431_case45.py | 107 ++++++++++++++++++ .../worksheet/test_e2e_elmhurst_sap_score.py | 14 ++- .../worksheet/test_section_cascade_pins.py | 62 ++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 33 ++++-- 7 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf create mode 100644 tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf new file mode 100644 index 0000000000000000000000000000000000000000..48a5273e27cf1e15948bb78faf95e9efa4463d31 GIT binary patch literal 80737 zcmeF)1ymeez9{+#gy0T=;0YewgS)$Qu*TgTf&~Z=+}(o*X&|@;cMopC-8JZIzB4m- z&YZixcW2(Zv)($VI;*Jau3fu!cmHeGu3h={CRY#CP8?7_IFADNWvg&<7Dkf!p0zNX6T^F{Kp0) z%&d>wkubCW$Bgrj84Kg%O#5%Su|S{xU2Y%b_05e892u0I^c^1yB54IJ0Rza)(9x8H znURq}!pO|T)RBaVg%!F>OB;J-TRj6K1`#7?GXoodOl%37xjP6KT%Zr?NU|Em@ug~$M5eR?aN(qJoj$v z=O)T5HVtGbEd-%~2|}CniuE24wkV0leS9tg>rX~Ma?l$&j&E8%sl zB1wJ~zdQTB@#aHB=e>dog~M|Ptul=2YDUyPa>wwn&74w{K$sH0c4lHex>5%-dKKmIWqsi6VhpIb;t>B8b)h0$Fps;@4-)n{(&Wc5ZFKMrVZ5k zaok6W`}q35@g*AJJqnDJ^68xoV?&nua_o@9gXVqwCQR4ojR@juuRq>!cXui0r3V$> zZ`U0kjkQS`T9{CVoHOa!I-|7QSegeY-E6(`TG;43|FGA38r7n*fMm9eA(x=G=sLzy zcV^Dv7fC?!ZOrV7%UcIRktv-KD*J`~I=^{)26s6(!NLNs3)Ag+H`$NfjNeRO@8+G=qyzCGs@-z&~PpCPWe0>5DsKdB9QxC9% zB@m9WvjK+R)c8^m7##IwY;uq5O3h8wqaBW=Z;ie$Puy~w^BKY@STEsD(Fqq8*|DAY zGr6gcc0k)F!DCCi_|7XnlrF7BV}bjLAIV;)&^DVfRn$A!KC$jz4T)moLhZwwLmi9d zf>5ms*}|@?f>)a=cOQD=#t1E1C%Cz+-sP9Ej@&-37k$l)(X2-obS_PMf8~5`POjz>7G6vQ}$MTo;<6}D-tkq3;ATrRy}Q8!N3l?PA$W)(x5 z;_}ApBM+~+tq%`p@AgEZ>M1F>yD7eL*Wd5!XKm-tiGin`OWCZQ&XtxK+Cm_clj|An z57&)7Cw)5Da85JMgbg1OgN{poM1vgqX)TQ|eBJS-0<^Ff$i=>r-#hLL2o%MD`dbRr zKrF_^s?XvvKOj=TcWGy+xz@yn{n_Oy`t@cVzDTyK2$4U@)ml$$x;hVC$lS*9tsn zyI+6Zo*l~|B-yo=+u`%|3>tBht$N=Y*&wC6>jKg>md(7G7ql(U8a{ngPcVR1#nZ7# z@Q{^b?WSdg@`~5Y;3I@@qM7#N8*vyK$x3#+1`jo{j7oVT_5wAYj$lFlDIHY&!+{s| zd5YJWVfQ)8LrTWRZM46x){CwWja{k&HPWW03C=nz-8xJ07d*xc`XcylE4rLFQMPZY zhaPC|z-|SneIhE-*!>t~-^0z?FfnfO^T=Z&EBkbA@=TO*ZVpZnd~qpk@fq?4X`LB< zU`?VC`p?XytB~5@i3jNop3vwoDw|e;_ho{(YgNt2LxA{VXKmvOG!pdG}ENfyLgm(*GFsxC1#8v%x(}j$=$qDd~ zJRSPomY0ZRerm>;+s9JG>7+KaK7%}UXMKS*3g_+=c`uAN--~rW)RdAUgRQz(so`g? zD*3J6pxa9_D9oV33iCCq%%dz*ujki|wtEqhd zeLQ~s0)f?88+5#>I>v395UnAhGr=ZU2DATl)V~ZI3wF>h@BVqHV?W!tlaX%p`U@#R zr4!e){-VBWVkS@BT19hWJU>P=yjLePIqduHLf*!wH+#eS(Y&hmbZC(1Me{^d(sjp* z0LHe5v24rV#l}w67uDV+ zlQq@8C&9K5n~{n(Mu)F+g(hMns@^UhQCuj4a{l*}cStuwu)}%AUAQ_ZLqj2~JS~ij zEO4*Y`Gskf4h`SKWjTBMUYYEUXCqFPwIf51 zX*lVN@{7;(P9t^oG?c&@?wvjxND&EWye|GaD^48VmH4J1U&{3yENIA*EP3doUDJ>; z%eo9biDuZ}mnLEGx!dk(ln;~ikwF#*WO3XL+i1tll$+Eucg;(R#U~%YSxdT1 zKG?Ckrf<@yi^d$T4?D_Di1w_Om8{s9h3VQh``Tm5;dxu^gBkWK%P{nIW8fi2%Q8hH zhtKw=AYGR>$5du>jikA>O*RRjA6;sDU-W*_a_KD_`>T+)*Ncq(C_+6s(GztJJy>vx zQ{Me`)SeXuJr6=pf`|8=`vSZBdvWpw-FaKrJ8ER>9&S(ch`NN^;-<1 zn0;vW=rsdwh7OsZ9S1LXAt%Xme>bT-;Co$G_^LrHiq0arNgtwh?z@mo2ll@>AUu)= zhlBWjBPMIk3>5RmozV06G^wT1fy?u&4U2*o#Q78zPSi!ux5uPz{baAklTK*jBa6dU zUCA@GF3_n53iI|fd?GwzT4(hsz=FGI`FoZ}cr`}U3{T4~bS_-v9yYsL&ZeA1e;!K( zl*+yFRCfp&9FPi#R_T_?+98-g(MfW}j_9U7)qyNsFpy zhHmGp_%$G+p5tdn5%pqCXq~LKjY>ZbP6Cl!0%b3=g%_(WZ+c#>J>SSXK2lP3&!?vB zFIsmx|5}h9?&W}|BgH@Vj`H`*OB9#pOa734KQD{wiN2kp)i{(35AHJpPfgOO$u_pD z%nW!%*Ut1>={LP%aPIYNVGfCVDg~)$4vk~w2Ap^O>74qg>{t2Ur)+Rpi!|c-Q0Z;+ zx1Z&PkJ{A5(oHqLnitsx%*MkuIlUhW%?9;qKG*y(hftx z8=dO~fefe7xG-5YzQnQoPV8%De8Yrvmi_*hdn!~JIL3D&_sp))whS^i%4*u!)Va%0 ztm;h0rcc0|7CG|FU5`>u|2IY64eG;Q7Ppm_39XZU&0zzvayW4bgpJy{&tJfX@?F?B zN#G_Y%fcaEdnXPBN^?py_JmPjz94JN}IrmvWac+^ZVi$iwkR-oRrH1oK8) z0=PQ^SFhzA)Cg#mf;t?C?PPd=j*yO9&C>zd$`8i|?^9r`F6ZXu-wodRu|VRT247U2 zABUsR3UC>;lw)J!h_@Amu24I&;alOo5<)&aN8Z_ZUg*8{jJ9zB6 z7TV^ul4xW3($OSsY8NBTaLD&sfUPS5d`;y9f7tg>QVMpwT8+Xj-qqlVb>D_wO|6a5 z=8Bvdc5iWh3tYtU^j$<_C>5=_h4B1>6lXiJN?j&h2!fmK4gA-!{bMBT!9n5L@8*vQ z>@uC|Pd|{GzB>+Xj1US_WG3cvfgemb%_v!D@HDt?F5uC|!Mv)h6)Ma>I`?_-()R9l zWPtac$R_u4Z6?XZkEq{1h!xf-`-IKlh-GH&sYL=mc{lXZg|bGg0VFg<8nZ}=bLnAp zNcAg6sVC?b14;74Sip^D?t%a@QO4=oww?jkj+&@l%5?w@J#`(@d$J?rHX)u6?DdKO z!A-C@bLnflqJurRpIzWX?pGW&GWbIlcz&;npI>(b#YT1diK?>sSC2V^eQ%M>S_OJ> zBT*qEVJ9)lFr=Gjlk zaxuNotKdGP$6i}AjOG>@~o0?zHQ!V6|c5S+%gWvBs5#G@oWoMe@g$SKxoj3TbMXKqcr-tbdRe4NII~6Nl@O2gRc_S0QU!$IGe6&#ru#{`*(b=Yx@48c$^<_0h^3$*_HqjfD7A zzt!okbevj5XX~!SZHuuCl2lk!oVj++rsPOpHLM|Y&_=($HYE#Xpzm!DbS0-Cgk2+1 zEKZ3U)3+}}5-5s&Ew_U5T&q{i+JR(nTIfp#u}(cr`X>)k`E@mWq5IXk1KZHIM6YvB zzHBn;5Q)EoY}qs2c=lQ?^a$Ri3MhRQTE#Brug-mYYvh6;=q7r~c2mL>-&-ueAQ-1V zaV*gIwY=+s#*?rP6a<<=Qi-a%Ui0Et*p(sA#$Itkz|M*IXdW*4HCn3JkpxfqMAfc< zwlC+609)byUcgX9UN1b^ne(Oc) zaLMhWX(v{zufysc$MP!oTUz86N4?UGlKOyfuK>uqn!Z(4Psopj(r@g+9cD+ zTvD^!c%5If(jgW|I$RkXrQb{YZ17hFG`CKf@ycgo1DgWh7$=bA!5wsMGJ1Wrk;G8*DTc|oQgaGrLMxR zs4B&9y-sh`FL5Unv6LQW^nqRgf}V7+?x12qqMO&e_uSZT7UB-i_**mik`p52Nb?2w zLfTd2*#~gM0*eWwNV5~$Z9+hXmMDXCR9u=DO~%xIqaCzoNVB<@D0LUtGoUyV#J#~r zoYF?0#xCQSXMWqNlgQBa_vaT#^lESd9DTE~sF042wG}X=le{0mQQ5ydY}5z5aft{; z=|e~_vP2c+zfxea!J7wcXZj+C?+)nGp-~XVMF;ukH+*J5>mg^3BK3I;MCjRMTm!IIr@O3k` z={)a@FVH5eP=VpMh*!`&<$YrtPwpwg52_Jq6t`t9Blp$}kR$^*bR{6%5>tcCNC(=r zpwHh=$=mu;ID3HG{_&E9)laxL@R-ckS~zDIPw0#OuZX||*OEbNl*E+*BPDb-_coTw18+EOaDMsWsRT;H69i}%_g}h!|I*q0*oFNsbWcNjs{gs}X{JBvp5|a> zWBgCu({PTjr{X5kt!83nE8H8of?TGWxaCp}BVwkN_BgGh=gIry>D2w3E)mqqTokby z5DJCLIm&#RFyDUo@d5*eTd?d)&U8ar!YlZvPmx}Z;e@bW-ueln+z&jkI6Syt-o1$W z@(dOMi%KC=z~{d%wD|=k2*Ro9}W@ctyvXwKO`K_yZ ziC?il_>(3q*qcYH#?QZ&1(uhUF>-QpzGvoRW=-#48QL1}->s3}ACp?kSdrh@cigAi zQDAJv*zMhA-7Kbv1Po>M%WhX>n&UlL^;K`_}edt~76n z=H{eFx7<`h+v1Pu>BJ83f`h;RCa298?W{1?s<==Z}YgY1WnanyUx#n4AmVHPPo zL`=MkLL9e^OWfe9JNU(IF;9!4Vw(p1ORFqXjPf7D6E`Zqj^V z0DnAa)8$%?YL9GSaIp7vQG;UEH1_pW^Xk%-H?|P58_0Fa{CrfY{--p|Vv&pUwK3Hm zJ`w_qXH(Pa>gq|OFKr_WX`lIJRq?gk=%??EA6rZ+OmqE+*yAGJo(#HNTT|`f*lKFd zu(93U-DOU0Z51F@<=K>+A-J()KRBda#?>BbPa3YDse@iNt^VGj$mJiWo}FTjuez&w z0%|TRv)vr>)EiHZ7kl7TzzP=v6v7y1<&|Dfmyt!2Ggjl|OU0~~Ir-k*w z{R{X8>*|d>&2+i%Q}L-}opG8BM!K8A=evb7>sD#)NJ{=M0$uCfL&HNC4=ZZ5N~>~J zg|u$(=uII8&c-i7&DU%rcg!s`H8nD;bZf=L#B#LtNcr2!_A*%92Mn(lqi6y>+EY-I z5*~cPcVyGK>a~XmmmSWF&s<@Z)YU(H-MDOc$3u?Hc21i>zjWx^EDntu0U?|3VX*$c zU{dg32f>8f;p2bov`4HC89BA}_iRHstq^b1|624@3a0l)9oBJ`#3*}etdEm3YUA+{^qiwIDP{MQ+PG;HOGOA;L@8Tjloda@60SU z-$zKZ%JS+p+KTe>D2@;ULZ%i`O4ulHPCwHkS#TPwo@e+!m;3w?t|1uK(N!QJIU%!I zxA-M^)B?;*dsFvjx2|qIXul3N7}TfOU&`ygbWpYB(hS=2D_J zbe`OZ``ZWc7k*>#eCUM@MTk;f)+=%?kv6LL1v>2(Mr~D0X?>(EyZbttu@7y9*IZ^w zLE|BnecifMFN`_oY!R^_u?FI<{;$wlGljsU==#lJ{r)UivLW;wUPU_B5ekQF?VIBb z$W}BDQ>8nSL2%Fm20TeUi@R{K?^5_0hLR>SH5Lnx2;H9a>UzS$AJ!iev*I8w6jb)m z!_VU2vcfw5(OGSBJtV=lYC16`4m*xn2?sPZ+#k`5fsKyiwK{vQWlWDBE2DnWxU;j< zr)#Jc7Tk>G#K~pz#QGGEGDzluaV4veRAU#2!+&a@rnk;I|VPMzMuvb{0E5*MoS$?^B@ z!z$;KG%wn2ZmtV66j_*9PmV8MIP6(k4yV4iGLbHV6eSEvcG0?kIAooWw}fxQIP?o_ zG7IwrT^#M5ZGTRTjgC)-g?>rQXyLy2?B3DN?|vQnd~hgEPTZJ~&WfX+cG35YE!VB8 zu@Q-E+tfS@E4d0;?z8%jIPGov+i<5-v+?mU{`ZcY+J&70MZeFoBl`RM$i)Tj<@s(4 z8jCGU=Hqcp;3&hsn-degf499ZVEkH|wu;5Xs-&1#YhlmKSz`yk;D`SYk{)A2i7)b3 zd)e}Y`vfDgN33T~XQ9ZU&3D&1kc8n4a2G{+ef{k0*fW#-R1lg5^7*+HvNn%bk@Dav zkrO1T%ZxBx>5okF_ER!1x08i(cuX0VH)zTv5$|n zUShYN8#qJinH*nuj`&2%-TmV-RxcYoI5#inmyM0QP1U%`alD>+mvxwIsQi&D4_2_b zyPNA*tX_4-lmZsMxOw}J8p+M!*4kESYcB_Idm=7igNjPB%&g4(6Hz7$iyW*jUN!_+ zWflcCyg1hFQ!w%PN>P^DgG5Az`c6~On&@hK?U(EN*m`iq$x7STy@7YtKgS!0Kd6H# zII~khmmc~CChfL{`|$03-W4StT3m&ZsUH~XS?`qsk7e8;y2@3)L5wWyKH58bDvWn| z+1c$~eY34YL|$Q>LS3|I&%2k0MkJOI4NIS+eEORCG_d`t_b(AcW*J$z7qIEwb-HP5 zB(6q!<|rsybsjT4Kgy&_1C*7OafjfRiz>#Ldt2#F`vy9&#OYyRvwV79ej53OX7Icn zpDW-;-Q~rFkdQ>U%J*-29j~^L$aB40e!PCFm$|F5tg8Gbzr`(Ck=V5BI?Cbvv`@0i z7-F7rY38=U5kLH!61=2R@&oRQL`@Z2JzyXdDeU_W7Z+D_gF+AOOQLCHS_i~7s@~_a zuhWNy0s?UJ8K1KR!t>?i=1GNr0-4@0>Q_&)GY`r6@)*-EW;+W>+8YU z__;pS#HRLSrNEfAPu*eZ=(sA_z(zhGXA}ibqugBPBvRd>48+gt zB3jvN$uQL!Aea5PzD9F1%XJQ9m~wt5lRL*Z2Ir|Jy=s^C`j?UpOip(>O2b~c z)wyZlC(o$Qkd-rnqx~crgFXkQa9g5d`AbkkWWqw@&RE(-$=W-6Br1@!=%;>8!vt$U zOXKS&w*8q=?d2C%2j%ay2@r{J%Ons9qe_Oy%Xb)aO4?S0JTMhvq^$>=*e58g!{n27 zuRwPpZ3nPHTUg^&ss&K z?noAGq*=Dl+>a32UO^(>33G?$2NfbgY-Pz+-VO<+7s#FjN2Svb)4uU+PvOw-k&~2@ z85^Hu8W|517Jo}tQkYYIb4Q{C8keA^oCr9V?%v7obzN6`D0XjWA$IpViC;hVJA2c@ z!ez@YA>T>U+1=^G82Q0f?WNutW-%IgrfSO+=4MD^7>_TrV zkX}LtLXF(~U5~n)jDqbf8l_|!l<$+=A9d#GB@K*X2S$zVl;F|#md`tG3862kkV==K>_&S%hL_kJ~A}A~@933BB*!Yb^#TwIEP+0l`@`L`!Ll|uL z+N~sPpN|c$Sp9*#oi?H?U$EpdE&O)yLfThFDzWcN(QtG!~9 zP-E8f=6~{@oSb6A&?H;772S`Wrp%(m@65cU*;1wFV{63abkbH$P8fESm&pn4AesSo z(P#5lJKN)Fsc{n4U~ZdRFlZSca{HXX)Y;Q>m#Wg;)n9k8ZGd2Z{bGOaP+%@;Xu)8MS8E@M^wtn9>yE#c%s(M%TM(@M@6BfYQ4m&M9hmF?7qK#*%^^CBN3KHBD(*y`k# zsiAAfcFfX>*=l)zx1V97FHL|*;M&rn?;*Ok4 zhinv#DcfddzunwK`iz)cOB?6sgHP;7QbM)&!?eS~kPp~eYK-or^dYyd5#H|G`*-$T z2-{9YmwcBzOf{S}cK!!hwFT2-^FnvU$tfWgdxI%lGhBF~NJrgc1GjsH<4>EtOcSFA z3ce+k!&^REOzZG@@d9Q=&)LN(2D??lz{6pWb6Pb1>ANw`Y$7}yQtimuLY4%wI5SZm zCOWpT5k%54xZRx{0d@&-8`R;?wO4C7Yh@#h&$T_Rkm-E6s3oZ>BfOQhJEK#hQxk8` z%U5$d5uCXLOTz2zVZ)xa!S6{)%C&00?V=ROGAk$)PHPL0-qg|77Tx_ZF0kvje{y;L zCeLl2p9I4vS14LRx{cl=J}zEJ!a-9rYErofeuuZ>B8Pf+2@U&wIrzxhEn4ja!4P{d z4yiD5vCQkipazn+k;ksek!4Fo$2QnK-rqOhCb9IRW8@_cl3GE$HZ5cay>LY~DJ3y( z>Ge?)p2PTfyD|w0;W<4Wb?(gA2PKHo3+*&*dPm$wwqA6ih|RsVPz~xr_91%*yBLtN zYe3|6Rk0Ixa_5p z#RCCoERzxN2J3SdR~*ZS2OPYlHpP(_a~`(S>mlW4Gw?RwbnWy6IjQ!x4}8QMN=!wB zpG{xX)p1Qc@7?d->6=x1#hBH^+oZfASa2Agw7uJUyG0e-hBTQS zpWn!dV@I3Q5wUvTtZ@x;lbLHq1|lLOBNwhLzECY7*8{0~DT9rC#pf6tqaTiNLm<5p z{%7BMpQe>rXYo%?hT_mv0J`V*uVC9ZcK@FvcsGIcw1A9%f(?;3u9nl;LOp*<>-CD=e7w|RrkGw zgm{?e&(l1-ymgj*5k_ky=4Za#O03!=KI#3WOQ_f2F~G;hHZi_1J~6|_!{Z@7O4WXH zQV|?=|Lqmcq>0Pd;z}%^tQ{J}!nX;X0|r;sJO+t(D>@}DN>mG%(4dI)aIC=Aow7;i*S0mAuth`vvYEm!Y?W- zhs0Sn3{n&EJsXoRiePW=n7ZjU=EvB|N@q{c%53~XZ0!(CWJ6STDEnGkYVGKVMJX35ucE59y}bo)F3>%KO+ZHC zG|@MWO^f5DdX=h^sB=@!)2gdwSsY*gg2>--dJ<=?(7XeA3p~1ny zK0Xbe8xzImR>&n^j$ZO*pyJ5N$|0km;NW7X;HPwGwUE9-`()Gct5$*P=lEnHWJ!0{ zTjAGlhx?}~o{kM@sR02w9UX&-#ae1|?kY)z+Wjp=i0`lRaxGO}O2gVY}H(7Ue zZ0&AQYjwcj8W`&bPrda#GFM@JLr2KaF92%!Fv`i?W0qM&+48Y7?!dRQF()&mxv(7l zC)o#)C$WA$D=v&RIRL;1h-4vDh7n z&kc+-8W|hY&#{@FJH1qAwJW!qDg}#d(U?5st>L@b%>Mt8-Vixuzb>B@DBf5ET`hq9|a<-i5uIlM*XvE7D`R8LBRUM^C< z-Ia&W$;(Mks6cHOZa41ALte|SCp#V{pVm-!z(3I7dR6eH%+>*u<@fd_^b(e{)I&IB zEyoag83PY_Lda(RS=}j`WAXliY@`rUSjnxFv4hB9f^l&Mk^P<4 z$CC-tYg~DY7JN|Yo>PD3_wShdAKOPSkX}5O0-xBJ+Xwq6)tz{fgdl`GyS*iU*f8Au zQE2sn7<#62O!zUfe`J(-m-=%N5oX1@YlU__L4T^OgzjCgO-W-hK`OzmA^1Ka&Uy&LVY<8FT{uEuU5Ur z8!&du`f=sW@7N>J>kEYtPZ0O4%+jq7tQ~P(a6$g?G@YHjczOwjQb>;OvGZMo%ugUW z2DBo{j+7=^vXX*VeT$Ryw5JC{%b8E*sA=BCwzz)Mc_LGAf*?@dKG{0}hZ#b}kd)f7 zNHONF$E#b*%hWp1>$9w}t3+Y~(gWMsSsU4od+~0pdRM9McD~>8ID@up7Av@UnCt4A zAc{CDOpCvuHS1e=>k$6jG(|_1*fUE#|Hx%*OHf{|a!7HkyZ=JGjJdBh9FzGs?7c02 zTAMHs=Su{q;w7QCJ16UiKEX9)Zo1I3kIJAvImKHl)f0;*bsuM&_s!g8{!!pjIh7;eH zklJs6*l2EU$;!@(Pjbi?9f$#Dv**m~qVSWt+?SD;u3^=j8YX!vge|$mC!$U9TkS0> zO{NF5w+sOhK`ACiSy@T9`2cxqErEqADz*KiTSRl_dAxOEXMD()h>-8qWlDBsi9c{$ z$uVpMXqxCCq-&-X*6YLrn7X|-5sAHG;7fg3|&3T}%GwQ*7 zonN)O4k#fZ1<^V8Eu{(LSU-y2=As6Ib{FAH?m9DreK7FNcMRj%-@U~OXC`~+p)h()f(Gwn;;rMY zWx1Ez6Vhk1ruTSGi&W@Yx2Nsv@2_~R|J}EEhX4KELw-H`SZ?39C&PG39j!y)3M47~ zIxZPHr@{K!mWX*#?V#vIVtG<2>B(~)^k1yDwuMI{Wixm)r9(3_>LWkeeri<|ziRXy z)SIH(z*bOI&n?WeV`8=!krFvSKX1ipWo+BL{(Ti)!W|M;2fqS)bzN1%|MI!-(i=4# z(eYJ8+*uu7+XFbf^`&!{9o!9EH1Z_8lwaC!DJj<1OX=pnG-Irrn%KyHk0C;NF$DMG z1)R2mm!L5UrbrxrIPU)bHtf0|XjsBzZFAj(ky#g9O!rQ)@@)F@_O`JwD(Ebr@?>j! z`vtFMNwM7^v;%yRDE-Jnq=3_TH$H?#n=vyHBrGi4^&;T)VyAFOec>+-q03GzYyE6e za)I{1dH;o%2s{xcDBj(rg;5#RFn)E*wGWxPWm)Nte$T+%3>;+OOsi6}R68^$g z$3-am*4Nu=XYGusiOgduacG}u1m~%)%wsoJH0m1LS1jnNm51* z4t8*e@^<1!QCZ^Ea1ua7rNmQ2MEK4XE{*uu3$3fNa&kjM1JEueME^Euzm{dx>jfhv z^X!PRXeAsAE8^E3i9Yw6Xcv9*0=WW$Z!a8;jEp*LW&|>XrRv7CNjjm;qc!g|=+$Na z*Z_gCxs?sC&XS^N+7T9&gn_ejbtb#rk*HB9=eto(60YV2c1-1lS_L z76G;hutk6^0&EdrivU{$*do9d0k#ORMSv{=Y!P6K09ypuBES{_wg|9AfGq-S5nzh| zTLjpm|M9kn<6paZ`%l{<)<3y<3)mvS76G;h7`F%*x5xn)w+I-w2pG2r7`F%*w+I-w z2pG2r7`Nyje&PS|DKKskFm4erZV@nU5io8MFm4erZV@nU(SPf4i#Y$a?&*Je+#7E8`5nzh|TLjo5z!m|v2(U$fEdp#2V2c1-1lS_L76G;hutk6^0&EdrivU{$*do9d z0k#ORMSv{=Y!P6K09ypuqW|%>i0fawdHYY>BKAMIc?;Mgz!m|v2(U$fEdp%OSHKnl zwg|9AfGq-S5nzh|TLjo5_`|-3l2Wka)oK)O@va6>tot@#ivU{$*do9d0k#ORMSv~( zZ*7a182`2Y>3bP=G7SpLuN z-ZHT z8RYb=j2I-D82{wvEgL%*AD@Gxy^)?3!UuoR=wW$GL9Ffv@@tC*t8-lvCUf4%^z(va zLmr8k4u%bkhVlICSXvUtsj=r=7iZF* zbw$SI2KUn8xW?RqztSPczC3KxN5Ty7WRaqKktMOES?t0?{r4@a@6X-ely0<FW--HTOi%9Z@RSH-gEFQ|V_)<;yxC9*4n|HAQOMC6GzH4G-)@ za%C#?;r@Mli&AA#@v1`#4L%({%6LXgp@CTBm=&*IpPt>GK&_&79OHX}E*Xim;5)cN z7PV*k^~ByDS*yQh81ZPw_-MEFC56}8Rhw{V^BcIgemuZTOcMf-c$Un{(C~32?%WT{!E*;L%d z>B9Zp)jh@8^>OW7nnAvVSQM8;)O*zwK2R#ZQWCF36c==hur~9})5ZIT`}+s#>)WgE zlj*uS5~2|tswwHped~(QuY-xcIDD;m70Dv)=G)|ni(QhlUaB7cX@-6D#`p((VU<(0lgd%%Th_# z8hMX;CC_@bp|!T1(~a;ZFS$5w?<&LX)3uM^_2uHZWn$mEmKg;%I@my@Kh`P+wt?&C zvu|#p_2|vW%AjHbDX&SM-0)hbZnn5gEVp)=?DW>p*`0B(8n8>1Lg85W+4a%=1MbTH zvQLGJLISr=rbOd(xkiSNLIQty-Ix12#GAXDmbr?QZr`JeJ?Q%xT^)8PHiUkF^6}i@ zEV1hGBBLBd=zErl609Asy1c$T`+c%;ynKCoJ+(1mTcoR=4$X>3A(2NfTe@|&-lxJ5 znz?Wohf2I8G(GftgTC#ag$|2+EwxmB#Y7&dC@!^B0plFy>CFjf0__Yb`LIx_ZibRV+~fO}h~$)v=F-U)39R2IU?#QGTjTEk6$Cj)W(!vy0(6<_=j{sXnEcTvTz5o@CCCeCi3d% ziOI(cln$2uzWM!__6@X{b6ay(xn>_yC3LdIbaF(svqkjs#Wm7J^)oaJdkdfmZ=eYu z6XWlk>_P@0o~7;@$*QV}^5!|l$v=`O*C(NwTs{16P8jU}(oywKw}n{#dQ^#7>Ny(y z=OYSe3juAR{~K)~j(<;RvjU+Fw1xi7Z6VJ89NJ)#(=&{&2ZpzMr)%pVW1$9Vex_U|#8|I-oj*CPVDm&Yi7T*tw}LBhiE zmm>$d*T25T#>Py-%El97Xrlf8kF0}sz12hHOQTIW92 zvPukM&}!iEP=FSI;e))sxsib*biE|>L;GX3BooQw=LTK-`1nf&{YSpgE}zGY%q$&^ zptb&gZCNvMvN8VcV)cW6ntH$UTo3xfDecE?E)$q@M2Mw*aYBj67!?L3*s#JhN*06Z zWe@TQzL&h(?|v0b5`|cauQ>)828aimHQ$PMomFqTsa6Ia#&{xS>~Qk+BHx;|QS^^n z4AJ+E@{93{^*W=E+hO%^hC1U08|!`V8x)r+=L}BqMpX@oJd$^2wE*#Y;K5%ouLxpM zCLfddupI@;)Xjf*0@81uYMa@zL=x*NUFB|CkuhaY56i*uiDjzA?e>W#;FII$Esd96 zo!qNWjI%b|DSqdL_h914wP-5x-cXkqKe+OTIvQir=~K7z1E*GJSl@O>c~+)2geW^tQHRiYAbw1J)49Tj!h2X zjb%4+DO!&qTr??(sc|LhwoR^5lM$yjYF{qfGfw24PI9VFMB^aF`$S;DmbzhGgno9x z+juubis-GA$fKG5v-+Fntz6Xa4@P6f;P5t6&9*t4-&@C5VnMap?{&CrD~%&E4wL*e zXf`TKged4gu+}On6|&2~G%{FZ4$0gTIg?*}cptc*ouLTRsAa4Q`%I~vpKl`H!eXAd z1*snG;5$qorGZ+4s2**f-uh??ffl%h7PhAiPnwGt_T_Z`C%HwFCwSKbV=jhIoqwzJ5VcdVi;TL@_|TIY~NsmDA)e3~H0jDLx2JSfm}pM`*2qA&mc z^F!rO2Ah)eQf8;FFR#gbh(x}i3i{#*+4BV>7a=U+PvW-*%(cs6ziJR%W7YBrPx4ZS z_Qw*&{qD;9Qq#tIqJkQt+X^u%Cr%NzHrCe2_KcW@WtnmrrE)YcH}|@*PUDg=IdJ`a zn2T_*q((urhnJ`>tTZrRZI94;6;U3zDk^{wv2(7c)r=+|)>f|~Bk3na z5N1_w-w_jZ)Y61CnHC$XdJXMXXpMnyNtauYiqHim*hy%fK480m5nIM&*>FbIvZDaF8?#|GI-J80BOkye% zOZ`+$;fVgexSANn_fB*-WYxAKn-<335TZ9zkY;f*znE&0*xKmPaHcaLX3|N0St##9 z3?^K}H9KUXElyKo{8mQUC-$upQl-iSo5i?9C;MPaFh}IM^nCPaHOf~x2VEYEBB0tnk^73d1 znHfbA#?)%M$d}Z;I!o<^x2xiIE|i5t1}*Jm1Z^34<1u7fuFL7uw2AQ=Y!!GdZ4Hm# zDcK}=v&pMlrQ7!t_1Q}r?k@YCLQF8f8GP?r(jX?=FF}gFj z87YVSc19YTpFr&;TLD!JGXl(16x_YVww`TCC(~S1kllWe)o36CKg#7E?u>F- z9D3GJH|B#^H_Xh_f^@oa4~6n2P5Hn5f+AR)w6bof z4%!#XMT~lnquZH21wKoQvBuwx^l>%KSR|M>9HuZk6H@KDsKBniSv!jvhv z-bpyIeOP+6HaKVjb15RZoO=}_W%<1;M(){SzmHD<@r^;knSHT+x^+Uo<5^X3v8P&b zvSs2K<}jZ^3n8-4l@+vCSNo>E%bepwV@YSyRiP#xewl-k@+mSR_R(%*NwN}#Z^Fnp z;ItX8gZKjhFmAp-J2dgyeM0EQXHVgS-h0N#W^S; zgxufBkCX&a1>8BOp@{k9(w6n$OvatTV`d(0xHT-q>?j^Yl~eXN@iQiR(= za{8P~N&~Qj*6Q(86P>X`*HF{Z(?l>bG^C^3x27#4&d(!~ld+wd!wZ~82+*#}ly!R) zf%y4FVk7AdxQc9-J&;%sqfs{DQrDmJ;Z=KS!KEz>@RJ|ermv1`()$a6XJMJ{YfQ>Ycv+x7!Rd&hahSYg;T zwIiw;B>5wnZ|$Ic0J*&H1u*81 zT!u7U`mXHZY4a1Qnqo_4E{1(w(ky-)|F%Z)L{)K~y?|I6GS^d3Sgc&_X6B9t8JmROK#q)E5!ac%ek8C&V_UjsnWtL?L8XF2W!6>&!ZFR4{~AIj!PmkaH~%*Awbd(T zxGO9$9UvcOFJNyKW-mVCt8&MLzk(h6MozTTGhvXKXN8`UHO*|10t*%7wHjBsp8b9p zW%i@NVYe_^r=)42Vp%A%8_ff46MS{Exo{OfgU%Dnhjy;+kdI3XrU%81^xux&%Fk%8 z($T?{c^T1s%V`6!+i|?X9~{!Ee;W$dZD`hJ9F&J+F4zN|Sr{rDgHK#YRb>hEaGn5U zrlfr9=;Pj|?@oXO=_Sd2jZm7|s~bYq$KfG%)akh~}QZV?k+XWutm(E7=>eKE>VtF7bx zFh=&Vfw;-~MN%BF`Wg4yvg?4Q1Q&f8Ar0$EmRbBot&zRvR9ejt zqa3@|uvgXBh@Ek*_}6e)+Ei~hWfr_S&~(1&$g)z)uepQibKbFD(NWr#3IU4%W};HWU8c7Nd; zxyoTC#Gu&a`gf?*M%)7JzW5pBq?)VK!DRb+D^{N~UxNk=QPWq;&K=Wtwxg zm<%a#Ys<(Y048-oz{qCXVfxcOxCuvTp=+-S*5tMZ5%JEPQ$cSEc`}+S9>+>`L^XNG zQR9P^%i0!mq#aaO9tC;eGHUJTKcNuM3|x1<4Ckfy-myC5+b@wyLv%;-=9Xy>#(4a4 z&g-Bz$$Ks`p>U@#R7p^|ZJE{$V^qhs?EPahfWgEII(%H^e#4$F8Rf?KSaF{lQ6q&W zEY39kP1p=({6hX3Nh9voQQgY%*_S{t&rxF1GV1xG70$uYhvD!v)3Xfo3hs@aK>3>4 zy!ZtIlp|-Ra_hc2LzUVw5SJ{V%Kdqi+`IH*kQJ9?+!HIXZ&QPk!?cbgJ$ofueJuXU zAPE#jrq-t1a1a)E8(x{KtSyNC5{f84Ge1K;L()<5HP(gqt$2!b0T)wVOjj^O(cVzh z#ltJe^_g%&tHypmTv~I9Em};7VHSH7Ppy0+ zaq!BlA@ycHXF8^PD}0VAPuHAafqK;;ra#n`_2mWTnxA4N46(CF>Sg&x>BLe80X8XR z={cHROcgT*?NbvrG8spY$Yp{C5ZK?+@#Lp@{!Oi`iIM|E9&+YJY0+j-<&9 zPz9+e-*{%Tt%QUdm-|!p*r=#_F!cm=hOZpnder%Z?2Z88gEAq9*hq5A=W`CugdW*^ zM*>L4ohDweWC5nwKfbtPw?g>p50*8Lu6Mk)+Al(yqFNe4{fo|Yi@aOor`{>#f>2EQn!K0XJ23Qrty(|@6K$$FN{o7{79#Q zWmYXFs|&fbyx_vwo zW#Xvk5(=P$SB|J;mF*>|K7mRJA$?-=s+%~K6M|^sJE9aBg+r-ybYyuNH(nA!^*Gw@ z66vL8Mf2E!5xP9t;g~+S z!2McFzl-=#DaDm$dCR*yYSkMC(_$Qr=!i1W*G4eXxRi9<)fs(Cjr!5bgc`~#kCc0+1p z_<7R{gbVdi(1t39piROjHzpL9>{_l~_b-#Z)*$`Lodc0+d^|z7A76|n5i<}e0a2oG z9b_V{R4>P2DQ%GgmN;@>Qas58w1BdTZvyOAAC-y9V;=^9@&>rxgF;oo;lWP^RS*gG z>SJZttu{oG%131JY6FasOTBZBAE+FQ;Ft8TQt&|Bd?u_)$g5}pRd1#qaZ3io|F|?L z#Iy&%1nV1)2?5``lsMCcsMmqg53_2;g`}$+X}ZN2UlDP3Ob=Ol1!XJ0c{oUoNK*B^ z!}3gxx2s;dXLrp>_sK$n-zwHh0RL?;@ZC|{GoA1xp1a=)$_I%B@}_9?u!f>(2x$nH z?Hz@e(E|u%cA5;;kqw)8(5#j!hrCCNHd{zAlaVKhN#1m96Nb)use@mGZ#JW_#OZZiW#S~Q;MUn_2PLwMbZq2 zUqxWF!ERVBiD)&9kx4^Sdu?n=D2Nap>-vT3^z%-Ynz}miK-u_EJfDt|wHL5Xn*r0m zl4~1Xn`y;*53Ta^?)zwc3)S@|m%&g*6pK=5C}!b&nv=%%sc^c|kCZ|I2&MY$t98<9^(fEsmg*VPQD+G%Ql)+8oUPKasnWE4;ulhf=i#3u zsu~;Xh+FM7l5LqP^YOeDtANStd}7|VFgAGaS~^!g%57)%Q|)vefwztTk0Z_qe{`wo z6MQw^BxV{*VxG$Y1jfF>HF}M1t$YSwgZKCtgRI?>Ft8*b?8A2xri~qQP2G^~Pie&Z z$R8ZIiy2!*h}@fXPtFbUMvXcz8mzdmk}^LZe@lsUr4+2_&D#|AYKXP)iSHu)N&-7a z;T>N~3ELx0;aGEkooR*o{4;r=rPBmyNrCm+lqpctkRH`};c5VVEucnAAtXGVZbJR@ zsrQkSRN(-Z^w$BRUS!j)Zvwz0H6z+)k7{W&@k9n!MJCoon58mG!shBp6>&im!t)Zv zs!*v|ql4(~Zv/*P!T$Yhw9I*3Xp^JK@{l5Hq$ucBv8wCJy*NBVwjEn7S)uehLb zdgNlnw-u6A8mRhgd52e7sZ_~n(T}1>f%IT_xuhg5@QmCI`Y{e6ESp@6i z4d2vtC*t@ip@>?8>gGt(hiLZ(*~JG7xo+Y3ScmSZD`6dce0|27iuyWI7#&@xkr;If z5t)IdN{KUP_*5EvDNPsgS81(81~z`=)IF-c_A%zDyP`aBHwTs$S2v;0-OT=joZVx4 z|3=RKXfOYh-ZPZq4`1+f9p1-&zZlP++Yq*hP z%bT?2-JLtdlE9%n-C))G99=RgZJWcQ7hF$Prto}sX|~m?GO65t@h1EW;1yvmj|h-^ z^&L8`J7EZp`s=Tq#pDLOAFsuljzq$9L0s$9K6VeZ40~0FyJSHgv(f{|pyI+S^zog5 z3bKlhq^Z@y1>#7KId5x?QPV17DVS6pe*C4;DGV}bEPKE)Sp(?A7Iv0Sx8-m{AO!g( zC(aW07l@@KBW3*p!~6}LO?Gq8?+Xun_c{MT6n?_pf3fhuKWWB4f*u?cKmYvU1(P@@ z1A5g)I#=C%Il>KqC<#;u_uha+6EgGju#gEPC{g!)-*UM}-+i(4S*Nr`xq z#CWyZS*dD#jK@X5j;-=0L+nQ-CucF4R^`3iaxJo(!wHMUo|E^7o_>weY82pqQtW}P zw<5KM!m5(eD4V9WC-9buly6U@ft;$IgAB1Df}OCjuZJhs98lT*BJUj$V?@N6iwPIs z&J}|3+d_(c4}ERs@Hr070mkB0xGenhadE?=rWc9p1*(dHjU!VN=1xSQY!(()*6?c^XL8Wl2cr)QH z+%q`^fetU}`bvwL85N`G7vE|p@UyeM_FV041bf~*EHjdz@!`st*sM1#T(5BKBRlT{&s29S3VqpX2V z!aFv#YrG|@6;Ym%9QjdGf4qP1`hcr{fmz*7(nv23kI2@vIT$DgTu6s2>EO31*E>r* zv*UJ?t?K(Yu}59ql#YbTOytti&|_MxPpU>`#Sc(6^(fDDKPhpjW=MpoU#9KP6Co3z z+;`88E3h{bU$T_>3Q(wEoj5ru5zDt0wgKWt!lNTkvlJA3mPZ{nkBDhz< zpmCAobk31{UGy#Xki1L8#J)Ao+k@Xxu_Xl+CzYROhqkSpfkj3Y^&G3!EKDFK*&tOX z2)(A;Rzvn-pvF@A=sKJCYV4JNA=qp+0x$RgZ=y_B0Lv2bb29cKvn6x3`wAj4%9^vo z@->sHEkglt=KV6qRI__##}p$FDc+Imrebnqe#jlUV7B!&=J5*(OTTm`bW9tG&j%4T zl+0K1uwiKnQ}N$N-K9BP_E7{+HXdesQ45-=Z@4_TNy(LH8SlT^?1E328)`@7!>otZ zu-@o68aD4QWb=^4r#(;FRut2Ea;!YnMIqKf+D8~>ZIM+j%*9mUn|_Xpiqu9Bo%PH- z*|E4t%S{4=V~$0t7~ZXVgfv2u5VAG@mW5v!N-Q`Z{O$1gQ~9j?)AnWoJr`<4jh0UY zv*_QhXG^3syHY|SdSBvgjgdd%O3nkAz2vVZ0UvJw3Na?xBS<+%PuTLcS&6>84p`Zd zO@)6U4dQELOyDuvGik_PYe}sK}GpWyDr60l$eR zOI5NvOCWnJ*V`MIg*e38-_xe6y^%56EvjWjWB#9m0?Nh)W}`5s_{(;yn0E(-^QuTW_PB80jHvHu}RwyU?~~eeWC0ecl7@kUnmFkE`$GSo7-(Dkl)5*f&MmMc2+R+{kgHTLT>jE+FcW-tpH4UMp@$bSJUT0njP literal 0 HcmV?d00001 diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index f7099f18..11cecf73 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -42,7 +42,7 @@ Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors. from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import Final, Optional, TYPE_CHECKING from domain.sap10_calculator.climate.appendix_u import external_temperature_c @@ -863,6 +863,25 @@ class Sap10Calculator(SapCalculator): """ def calculate(self, epc: "EpcPropertyData") -> SapResult: - from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs + # SAP 10.2 Appendix U paragraph 1 (p.124): the SAP and EI ratings are + # computed on UK-average climate (so ratings are nationally + # comparable), but "other calculations (such as for energy use and + # costs on EPCs) are done using local weather" — the EPC-displayed + # CO2 emissions and primary energy use postcode-district weather from + # the PCDB. So we run two climate cascades and graft the demand + # cascade's CO2/PE onto the rating cascade's SAP result. (Worked + # example: simulated case 45 — rating SAP 60.53/CO2 692.13 on + # UK-average; demand CO2 626.78/PE 6581.59 on the W6 postcode.) + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + cert_to_demand_inputs, + cert_to_inputs, + ) - return calculate_sap_from_inputs(cert_to_inputs(epc)) + rating = calculate_sap_from_inputs(cert_to_inputs(epc)) + demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) + return replace( + rating, + co2_kg_per_yr=demand.co2_kg_per_yr, + primary_energy_kwh_per_yr=demand.primary_energy_kwh_per_yr, + primary_energy_kwh_per_m2=demand.primary_energy_kwh_per_m2, + ) diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 0e6832b7..19186eae 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -686,12 +686,15 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None: # dwelling's baseline fabric and so the ASHP end-state SAP. Still a snapshot # of the Vaillant overlay's own output, validated transitively by the # system-boiler pin below (which reproduces a real Vaillant cert at delta 0). + # CO2/PE are the postcode DEMAND cascade now that `Sap10Calculator. + # calculate` computes EPC emissions/PE on local weather (SAP 10.2 + # Appendix U p.124); SAP is unchanged (UK-average rating cascade). _assert_overlay_scores( before, option.overlay, sap=51.99820176096402, - co2=1268.4645083243888, - pe=13080.20756425629, + co2=1065.7593506066496, + pe=10995.781557709413, ) @@ -715,12 +718,14 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw # boiler-1 pin above); the same merge also resolved this cert's main-fuel # mapper gap (§14.2 mains-gas derivation), so its raw before now baselines — # see `test_gas_boiler_instant_hw_before_baselines`. + # CO2/PE are the postcode DEMAND cascade now (see the boiler-1 pin above); + # SAP is unchanged (UK-average rating cascade). _assert_overlay_scores( before, option.overlay, sap=39.00740809309464, - co2=2248.6089062232704, - pe=23094.10189037302, + co2=1845.8588018295509, + pe=18944.42568846759, ) diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py new file mode 100644 index 00000000..7d9ab95f --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py @@ -0,0 +1,107 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 45" worksheet — a ~47 m² GROUND-FLOOR FLAT heated by an +air-source HEAT PUMP (PCDB 100053 ECODAN, radiators, MCS=No) with a +WHC-903 electric-immersion DHW and a 110 L cylinder, postcode W6 9BF +(SAP Region "Thames Valley"). + +Case 45 is the 1e-4 oracle for the SAP 10.2 Appendix U (PDF p.124) TWO- +CLIMATE-CASCADE split. The P960 prints the current dwelling TWICE: + + * Block 1 — "11a. SAP rating / 12a. CO2" — computed on UK-AVERAGE + weather (Appendix U Tables U1-U3 region 0). Drives the SAP/EI rating. + Space-heat demand (98c) = 7333.79; SAP value (258) = 60.5318 (-> 61); + total CO2 (272) = 692.13. + * Block 2 — "CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY ENERGY" — + computed on POSTCODE-DISTRICT weather (PCDB Table 172, W6). Drives the + EPC-displayed figures. Space-heat demand (98c) = 5921.05; total CO2 + (272) = 626.78; total primary energy (286) = 6581.59. + +Per Appendix U paragraph 1: "Other calculations (such as for energy use +and costs on EPCs) are done using local weather." `Sap10Calculator. +calculate` therefore runs both cascades and grafts the demand cascade's +CO2/PE onto the rating cascade's SAP — this fixture pins BOTH. + +Like the other `_elmhurst_worksheet_001431_case*` fixtures it does NOT +hand-build the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises +the WHOLE extractor + mapper + calculator pipeline. + +Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/ +simulated case 45/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf` so the +test runs without depending on the unstaged workspace. + +Per [[feedback-zero-error-strict]]: pins are abs <= 1e-4 against the PDF. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_case45.pdf" +) + +# Block 1 — UK-average RATING cascade (`cert_to_inputs`). +RATING_SPACE_HEATING_KWH: Final[float] = 7333.7892 # (98c) +RATING_SAP_CONTINUOUS: Final[float] = 60.5318 # (258) un-rounded +RATING_SAP_INTEGER: Final[int] = 61 # (258) +RATING_CO2_KG_PER_YR: Final[float] = 692.1287 # (272) + +# Block 2 — POSTCODE-district DEMAND cascade (`cert_to_demand_inputs`). +DEMAND_SPACE_HEATING_KWH: Final[float] = 5921.0486 # (98c) +DEMAND_CO2_KG_PER_YR: Final[float] = 626.7797 # (272) +DEMAND_PRIMARY_ENERGY_KWH: Final[float] = 6581.5936 # (286) + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label/value token sequences). + Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-45 Summary through extractor + mapper. + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. This module is a pin PROVIDER (build_epc + constants); + the collected assertions live in `test_section_cascade_pins`.""" + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 1637f281..1625bcbc 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -24,7 +24,10 @@ from typing import Final import pytest -from domain.sap10_calculator.calculator import Sap10Calculator +from domain.sap10_calculator.calculator import ( + Sap10Calculator, + calculate_sap_from_inputs, +) from domain.sap10_calculator.rdsap.cert_to_inputs import ( cert_to_inputs, water_heating_section_from_cert, @@ -338,8 +341,13 @@ def test_sap_result_pin(fixture_name: str, field_name: str) -> None: epc = _FIXTURE_MODULES[fixture_name].build_epc() expected = getattr(pin, field_name) - # Act - result = Sap10Calculator().calculate(epc) + # Act — these pins are the worksheet's Block-1 (energy-rating) line refs, + # i.e. the UK-average RATING cascade. `Sap10Calculator.calculate` now + # grafts the postcode DEMAND cascade's CO2/PE onto the result (SAP 10.2 + # Appendix U p.124), so the rating-cascade fields are pinned via + # `cert_to_inputs` directly; the demand cascade is pinned separately + # (corpus gauge + simulated case 45 Block-2 pins). + result = calculate_sap_from_inputs(cert_to_inputs(epc)) actual = getattr(result, field_name) # Assert diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 6547a585..ce93e21c 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -46,6 +46,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431_case21 as _w001431_case21, _elmhurst_worksheet_001431_case43 as _w001431_case43, _elmhurst_worksheet_001431_case44 as _w001431_case44, + _elmhurst_worksheet_001431_case45 as _w001431_case45, ) @@ -491,6 +492,67 @@ def test_case44_blower_door_pressure_test_matches_pdf() -> None: _pin(vent.effective_monthly_ach[0], 0.5812, "§2 (25) Jan case44") +def test_case45_heat_pump_two_climate_cascade_matches_pdf() -> None: + """Simulated case 45 (heat-pump ground-floor flat, postcode W6) is the + 1e-4 oracle for the SAP 10.2 Appendix U (p.124) two-climate-cascade + split. The P960 prints the current dwelling twice: + + * Block 1 ("11a SAP rating / 12a CO2") on UK-AVERAGE weather (region + 0): space heat (98c) 7333.79, SAP (258) 60.5318, CO2 (272) 692.13. + * Block 2 ("EPC COSTS, EMISSIONS AND PRIMARY ENERGY") on POSTCODE + weather (PCDB Table 172, W6): space heat (98c) 5921.05, CO2 (272) + 626.78, primary energy (286) 6581.59. + + The SAP/EI rating reads the rating cascade; the EPC-displayed CO2/PE + read the demand cascade. Pins both ends at abs=1e-4.""" + # Arrange + from domain.sap10_calculator.calculator import calculate_sap_from_inputs + from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_demand_inputs + + epc = _w001431_case45.build_epc() + # The split only exists because the postcode resolves to local weather. + assert local_climate_for_cert(epc) is not None + + # Act — both climate cascades from the one cert. + rating = calculate_sap_from_inputs(cert_to_inputs(epc)) + demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) + + # Assert — Block 1 (UK-average rating cascade). + _pin( + rating.space_heating_kwh_per_yr, + _w001431_case45.RATING_SPACE_HEATING_KWH, + "(98c) rating case45", + ) + _pin( + rating.sap_score_continuous, + _w001431_case45.RATING_SAP_CONTINUOUS, + "(258) rating case45", + ) + assert rating.sap_score == _w001431_case45.RATING_SAP_INTEGER + _pin( + rating.co2_kg_per_yr, + _w001431_case45.RATING_CO2_KG_PER_YR, + "(272) rating case45", + ) + + # Assert — Block 2 (postcode demand cascade). + _pin( + demand.space_heating_kwh_per_yr, + _w001431_case45.DEMAND_SPACE_HEATING_KWH, + "(98c) demand case45", + ) + _pin( + demand.co2_kg_per_yr, + _w001431_case45.DEMAND_CO2_KG_PER_YR, + "(272) demand case45", + ) + _pin( + demand.primary_energy_kwh_per_yr, + _w001431_case45.DEMAND_PRIMARY_ENERGY_KWH, + "(286) demand case45", + ) + + def test_case6_main_2_emitter_and_control_extracted() -> None: """Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter ("Underfloor Heating") and control ("SAP code 2110, ...") — the two diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 7b510ab3..ca4681e4 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -30,11 +30,7 @@ from typing import Any import pytest from datatypes.epc.domain.mapper import EpcPropertyDataMapper -from domain.sap10_calculator.calculator import calculate_sap_from_inputs -from domain.sap10_calculator.rdsap.cert_to_inputs import ( - SAP_10_2_SPEC_PRICES, - cert_to_inputs, -) +from domain.sap10_calculator.calculator import Sap10Calculator _CORPUS = Path( "backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl" @@ -127,10 +123,25 @@ _CORPUS = Path( # "ground floor" floor_type as exposed (worksheet-validated to 1e-4 on simulated # case 45: floor (28a) 0 -> 25.38 W/K, fabric (33) 75.6 -> 101.01) -> 69.5% -> # 69.7% (MAE 0.859 -> 0.854). Pinned in test_heat_transmission. +# POSTCODE DEMAND CASCADE (SAP 10.2 Appendix U paragraph 1, p.124): the +# CO2/PE over-estimate diagnosed above as "per-cert mapper/demand fidelity" +# was largely a CLIMATE-cascade bug. The SAP/EI rating is computed on +# UK-average weather (Tables U1-U3 region 0), but EPC-displayed energy use, +# CO2 emissions and primary energy use POSTCODE-DISTRICT weather from PCDB +# Table 172 — "other calculations (such as for energy use and costs on EPCs) +# are done using local weather". We were feeding the UK-average demand to all +# three outputs, so warm-region certs (most of England, warmer than the +# UK-average) over-counted heating demand → CO2/PE high. `Sap10Calculator. +# calculate` now grafts the demand cascade's CO2/PE onto the rating cascade's +# SAP. Across the corpus this moved CO2 MAE 0.26 -> 0.12 t/yr (bias +0.18 -> +# +0.04) and PE MAE 13.6 -> 3.8 kWh/m2/yr (bias +9.0 -> +0.24); SAP unchanged +# (rating cascade). Worksheet-validated to 1e-4 on simulated case 45 (rating +# CO2 692.13; demand CO2 626.78, PE 6581.59). The residual PE/CO2 spread is +# now the genuine per-cert mapper-fidelity tail. _MIN_WITHIN_HALF_SAP = 0.695 _MAX_SAP_MAE = 0.86 -_MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current +_MAX_CO2_MAE_TONNES = 0.13 # t CO2 / yr vs co2_emissions_current +_MAX_PE_PER_M2_MAE = 4.2 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: @@ -155,8 +166,12 @@ def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus( co2_signed_errs_t: list[float] = [] # our − lodged, tonnes/yr pe_signed_errs: list[float] = [] # our − lodged, kWh/m²/yr skipped = 0 + _calculator = Sap10Calculator() # Act — run the API → EpcPropertyData → calculator pipeline per cert. + # `Sap10Calculator.calculate` runs both climate cascades (SAP 10.2 + # Appendix U p.124): the SAP rating on UK-average weather, CO2/PE on + # postcode-district weather — exactly the two figures the EPC lodges. for doc in corpus: lodged_sap = doc.get("energy_rating_current") if lodged_sap is None: @@ -164,9 +179,7 @@ def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus( continue try: epc = EpcPropertyDataMapper.from_api_response(doc) - result = calculate_sap_from_inputs( - cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) - ) + result = _calculator.calculate(epc) except Exception: # A mapper / calculator raise is a coverage gap tracked elsewhere # (eval_api_sap_accuracy.py); here we gauge the certs that compute.