pF$nGPk*J9Y4KBw%?^gL4%U2MUB@LfX__ zWJ5G_lSz3nAn#vK3ztq0TDKRRe~1;YnpKgo37>W8>k40fm&|+m_-giCGWoTXw!h*ZX`8{@!KG^@a>4hz}f zpPe|)Y$e~r8b;^1C(m*6o$B&e)Ra;`W%{F5<#%qPFa)$SXxW>aQT05 tMx4b`N(d8{Qz6%=I+BStlBn8AsKMbH}?`N+ldy2z^@3~9;wupDGMw@=1{0yOn zQVuC6oa7gj_p b4&fwjf eek{9Uwie}ac;Y!dM-~iMH}jxt+f8_dHafq z$gRJ4MIF>u=6)`#FH=Dc Z(qu9Q~OwdduU>*rhU|7#4Db|Ni^3%L2)P4X+%sD~Xp| zMriEEhYHEZ4ch0PwPpz)iGciWmfSEQzV>Ff(CVb*z*3C6gcpQuBgqn5hxiw}rDxcy z9K>q@Q8~u&{m^RHm#^=XJaX+mb~##2XtyTOZs}5q>+@(@e^U03O|#}1mQa-}9k^P- zK;O(Ymu4!EAo5V^1{RDM4n&~YOP4YwOI2;hxssR*xf)acH6rrZd*r))20CDUVzB~? zmA7tE;Bw3oTOAqU!px0vmElPpUWyp5P3ce-U-C6JhAXQrI5>7FMy6GiF>38z!=Pi* zxR%4RfGjOkGh{Z36+LaMy6Q*)1E?hThQ@~3+n2DQkrk`E-7{ z9DBP=9n<|fzhGCs68G$e0A_Ul#9J#q>UV!8F|F4CLfZ<+A~-IEI(YKuqSy960? zdJ>f;hVL7 -_09ri;+06yx#!TMt<_9n5nlo~xj0>|3 j= 4X`jr`5RXouRlc z`ucaE-yp*w(K89PCHh1DHVGUG?I*>&dgeUzIXL0C%;aIs7dg}y+f4I%uW@r-`J$7E zxf3YfsBmZ{EhL1b-VUd6JF`Eh6t6NqlwZa)R@&`z@2BqBZX5oke1?MqTn}}f#&LF! zwEH_>REq)372M+Tp`N6cgf>Ec=h4KUz|d)aBSEKnO<=3kCgHQo>fSPW%oW^}ZM0f- z%88&S=0La+rhp _Bm?LIM*~*6R(D{$ zk%iJI`gjWHY1Jm8PLuTblpoVtOA=2+$y&o4+5L(!q>0%uy|HIHdS!&}-a0ZZlh}7N zGOA$*IheOQJV0Ysv{yTj$6f$pL@kjithu<^7harbG|CNv4V^BSwH6fWttshbay{A` zg!&|l7&n?+o&*a8p~hhNJtn6j*1q2y=H>tR`lF3IbhU%>bsKX#L-BCB1YP?ui!?~F zYT!|2wxWMXz!e!!d3&MwQ_we{x5TYzs(A=)lK^CJU?)*Pv0t=yqKWUfI^cquu(|(m z4&;8um d?cBqk?U4M%TUd#CW^DylA3{?6bh >TB!`bMN{&8sf R&Y^E>VYR5FhFSBXx&AYLSyr z&ovaGKUd~rx3(w)7ZjXNGzk!eZzcFb2?$x!Q1izYI(wW}OmFMvQIcu}UV2vAzrXmg zY+VHdV{>56BGe?9hp^^%*6KEj{Lx@&!aYL80`P<=Zp-<;x?v~Xjir?Qs=CwHXr;tr z!zK_@uPE-3c(QFDT=+4~?tZN)$Xpns+Yb74gE%fR(DrhO4ACo*=+8+sGQ=XuJ}}2Z zGB-t69GNCrzs}pEPzIs3c_H8KLjLu_e56nM2BDq(OgWZ@ECbS;jiFM{?Q%}%@6Lf7 z6dR)$N%EMku~)8RJPP Ep($ps}c z#+QYK1NbZBL)C6rMWm^IDA{GL6_9@^)V{L-T{~-I0lU`b-#R{~Ul>0RNTjONBt* ^77-D^K+A?z-XfVAGu&BvkgmuV~007M5ferdOc+TDu0*nItd5usp6 zWw|e*#uvEOJ)vKA$9-;{O{kqp92g!(=i5E~jh4HJ#=v{^7YjB?dX!}i+f&gLpM(Yl zakvBU?kt>HR@OCPv!!=+n!wL{UId?|sxq3R1i9<5R~hFKe}>U7?Ja|5Fo_y&{?C z2h*G{*B#$>I_>$C?!;H`&dqypDeJU)7aAgS%oh_?D_3(R%v1q _`CPLs|N z#{u4b!+)#b&;N)H6ek^d{Ae&G13U5^16`H?a=QTxnMC><1aKjNl&Zeyupk5Dn0eh{ ziHxxX^s<8qsT+6eIy`*;Qngv|i+l0T>)(9CjD6ZhwMnaH9xjt57Mr8jAH*JbNrBnt znA0XzJ~D{kzK`9cKhlT640v2voRt5(PU#xab4i9AwPny=f+Lv6_+~tL*Y|h5smm); z0*ZDGLmUJp;m;zF?CY+EbS@Hkl=Uq7T5R)iLh)57Hw9sQ4QAvjg=9jd6BqW+72tCp zS=umF131w%CENz|#b_@_F 5fa zqDj?*XP&sm*F={ gb&SE^tU!kjS+14ghy7Bx$*>@ekB2s)73r oADA%q{(+wl=3D< z$|D@byIC({sF0D&_9zo&L%M9gQGLwN`lz^>$Q|86#q+h9(jPH%4tL3*LDwGL-vi28 z9FPcCx?sU2&--i3rz$sK(XoT=kE=u8@dAfoTlW_(cNpiR8qi98Jz}JlyF!p&7wS%r zbH+wUf|9{|+tj`}@grrV8A>Y8-jABU<{suy1i0t;YL}c{cF$AlH$!kS#l)@Co`3Mz zB781r5U&E1jX;s)=&*}7Ahmc;^z9s5nUn^LK+pB=;0hw7QFG`f1L___gKPk{cf>IZ z>q*&f)X-W<+b{qNLc!f-aU;XfFXMIvktF#^#YXmHQ%y{$p-CpY1f)QvJ@YCn_MPLH zd{Jo)lb$1uQ1gfq!_nog(=KKbdz#|I^Y9DtKJSfh3gK{ibN7*R?c`RkanTz<6->Og zqh9&1Ou@7FN7f=u1Ht6?IuCT*h!Ea8(^>35vG~}T?I7l*O-wkJtcTBjxq)@ %#Xu$!M5M;yyvU?sC7O1A*h-S1}vvXjxi5 A71 GyYc7`=TJ^pMt6rkh~7t^M?& zU*$wgdDp@BzPAePfuiW!t&5sbu;Kw=#!4yb$noG&fm7QF+-b}$z+55qcCeP;QtgFr zQhH1mf8!rS_EvE}^~i~By^dDr#CJij=1(e(yDOFZ>NnNJjisX*UzW*6b*Ahdn3W=G zlB#x6eI*)S#u}tK#3vTKv}6eD(u_n2Np@qD=dHEktPtEBi{PozdZT|oKQGRg0k=;K z$`fj7Xfk@ASrsW#lkycR=4wzaI^2Uiv!Z`r`8}J8j<}D5e|si+`+lc$>tN2gM7-QF znO!;bH@repezAdvRS^)-@GSn;1vIq71)?KoXIB5jDl^F4opC&I$1GHTJi}7%6}IAv zUuwaDto9zchOf|sQ VX~vd#CmeTDY2wI#7y=QbhD^O%9->qq3GiJ&10ZG`OP9z!;) zeK&$v5syx-&GB)J@yIbjf=SPBe(=ex)7Q}AGH1HXKsehf6-118)@3F{(E1uKd|s|b zY*XCxY+h^@y-U8s+2%LT-8DDFd^Od6-rVE|3Gd8k*uA8UO1|hiN>ru&82osUCYI~W zvk$E%iwF#iGo)~iZAYsOG7bg}a-~ou-O%nRXi;G4bNc$eHFf!v`g}Hp$RHO?*eh^l zuXpSV*9|B@fmLJg(R`0!`WE*kJ26Mq4g|3*;q75jAqCu0R8u6Tz6zwPSy+Xi8j?gg zRT%o!6kvX1q6HOX;J2>tNCEXD{N>D4+gI4#x0XJ47GwVI>2`fidc%u|2qF*lxT0HL zuMUN>)ub+F$7$CcJj>HoB=(C*mu~ZdK;FmV{kAv{x3&*8SbPj4>*^iF7Ax*-Oe_lE z4ENQ 2QekGwGIPYTX`e5-{Q4J~??680thT{-Dp(?O7PP=EmKUhxLHU$rpqX4jT< z4~B{}SNX}}4%#@zk0!IhKIWn8!=Z RrMj-!x-@;1APZa~y~>38TkpwRDBeK0Lvu-|Z*hsa6}rjMgcY$biZV0fJSoNUVV zZC<=+nfDs&$ZMrM@dI^Pprjo@IBs+#3C-wa-!l#gLQEtpa@{g7CB=?Xt5ZEsVO8R( zIiLo1AdlXZB)zZl{G~S(Dq;je$^_<^{Dn|ey0m8=Z}CLGH&Ne(Ij}dhdw0^cSVwiq ziJr{LqO?d$>ek`IgdR;+47Pr7%
(uxMJs %1-qM$ROe&*9TU@Ay{geeQ+q~RaCU!jTiJB zTp5w$`>NunW_h;O?|&vi=OHs0%3WJ^cBI{#x3%fptGB~?P|veUBHfeWD_YaVNE>_x zeCsRW&lcVbP~B|yb+{fqef?`H%cIZk^W>fWWZZed)Ag24tZR9)LIcVp;_3sgo81|F z*W#!tE8QU*{pfTuNzZ}oNt^u?oKO6Ia?SXs4Fl!Xe~#i){?%U_m)a?Pe{E*9SQ(my z-H$lI$x9$!eLx zSi*-kMa16WuIa1yiO7AKr*ehJ-;|Q!_TWxywiD+A{|PLFo9zp`Us)1JLFe(Hi|Bs8 zN3l9avCrR%R*-AJZ=D3h?@q6JNSmghPM<+pT{n+&U7lrRNk7?-U6z7eylS_VRc?8w zKdD6@**S){IS20y?16yytkWfzK{U_0H%n(_)GooyU1~k-?>}9(MPHJglA^1^DnFA@ zsweA~=IY{eBhAAna8SQgJ@(n37>wlpVd8chr+zuEiDl>*>H1r>6I685+0)HlgqL%P z5V2NrVTYP2{9b8P?Y;?_)HrPLo96}N^BLR5YU(QxY&OYFwA*lSriT;RFI?-l#8R{Z zzyEQ7(W|f0T$Lt0M%24xbT$6owEw}3)nsKAq)}|N09(`x_dABg7b`1 NNWo zzW$7RPYXtVopyWFH_4mFrhvAO7a$zhg#jCz1&S~2?h0s3{_eOPdQRSNY%1@pT6=9) z`2+TCR0Bm)d`PLh@Fmwy9j8+64xwDe`nw(Da-|nf?Ea=WUK%_lY|?3r9TNhC{JY=< z921^xHmHmG2C1AhM!z;dtMyGsvepmlr70Z*LkRmDrOAl%|DW=OIp`g)kX>PV#6 zOE>j$X0sp-*g_KLavun2cN*Yc%Jb-*`Z1KR+3VfM bd*!vedUvPY@l^hTC)}T!1qS=dQ+ipazBCUSd z1IDdLm~0 hb?nA~EU zA=mY;vi2M!cqu&e2UPFE-E(OLKx~B3Y?nSqm>ayT?kNt4@R~~G;aWP14n1M?wklbu z?g8DWdVw3j$kLoDL@(b?GC^z~1zD CUb`Q $(^v(=YXXIe_x2GX9QmDcjDt}5*?hQj074gFBbcC!v7|2wyVz~ z m_-h_cCH&B@nttiU(CTFybbxeQaS_t#>VP`24do%`+8>Bq!FY6+1s4cwAI=T zn{943`(bTz+=Nu=SLXwxel_RU0{hm#)vYK?JewxGklIEwW{!lT6R7PCeE9neex6yF zfnZGWYTR5XCS0xJL1jpIF|J0w^Q@M=W%08c@i*8pjF!5>Wh4JcnmC^yqb5X|xIz|? zzL$Sa;1bH&XI3kN_d-t#)v>|GQ3w+Lo)x}%8~)tZAfcqFPk0=%V8A$mA-rN|&gn`T z{v2Vcl7)TaeuRIuIpG*L>gZ!>KomYRZ&yz2zOoH0cIG&<4^-P}XRQc?j@a0tFpjkM z_bMn_7UMyCHyGqS)GjkbWoZHKXv5sKM)VyfPVKz1482&`3*^VzS-ORv^o;w91kx}F z?W)S=OFQ|Za$at!$N^Tq4y3$iN3$68`fPXZc0NlUe|m=Vd!}9I5S f4m=4x4n!-jtcOo&LC?6Lcm;D!|}uz3VS& zO}t7fU8&1$g+kTjru;Qq;5tRtBYH_Pi%KvhlufyW_2_-nXh+?4 z1_CwT$f0R!e%_~*@*Ja#HV~_<3tJp}8pt|9{;No_!#RWuk*1{m&`D6VgLs#2 +BJNozvN`&O@*CS2X_R#G3WvnrkDd>L6yRSg z7Tro=%>6;!CDIif;rkW~UaiHn30ooM>5Jk4=$Dg#bm>RJy&+zg(D@?zT`inm|E!+} zPtBprhMX*wMK6w|mFy1IPSipR9urC_|B|m;aJ-eh;BvNXFVZLZlDx8lhJsYUb#K1y zNL6@l3{o}jyYyq2FHqK}Ch^S 5bS)CpBiVg^$3yJaJi_O&@`oTb P#(zJlf0cBYpx(?p6j*YZ<`%8hXEUjoca1D zIu&t?6CQ?|`ir4=>{IcoMZxB1wlhCZ^fNfIR+yx>sY{g2uNfoD?DYqUIyzCMAa4N| zI_|}Fn>9XO=uDeliR>_Of5XkH-@V`p!KWkb?G4!njnpBpS{$JV9rL=C3Fh*r-puo3 z$n$SUDNPTqoz `RG!Q(7xt>b z-<9U|)vmbjAO=3Aoa>Ng-sL?tS}Fvd@6}@6Z4yB(g3qf{Y`DFAYxT?zXecxF2>A?Q z4q~{dO?FcL<62BSqoJI&gB3n)dY8G3CjPG%r2>Ysltrbqg#{q{8si`n1l%@tv$^Vj zPq%bEf_60McIS=&*)eINg;3Ld9VuTJjJtxnzMd_wmyH|JUDY(})_4N=*kBCk2LVx1 zm+=W=)A+3g--!Af3c;`=aa-~Z!*$2glfyy(qi(=pTkyED?@^XEAfZ8EVDAj>3D08F zno^hCdH RZt4r`wqoHop2Pev`=Eu>_jUIeCE8Aq2a47C|As}`kv~4Z#3tZGr3BZ zyOIq|l3h5~Il!!~pm?<`k?s<*Tfa#-ckh7wSEC#kR&Nn-W|F`4ko>NzjiJ1<=(pG3 z9Q*1^KUcTyO&H`r=wK&^^Tj%_Z <8f)|SJ!;)*^o`WgTT7rW7J#D>;89}Vu%$IH($7j zWJ*_T?%Ho k|6E5kR9m0GYX{j==HIsbJQ;-A6Sjjv8(u~%x0omBCL8w`M} zHi58n5_6r5dufG?X3Rrrg>8m=7kXB`o_cOnM{WEsgYIkoJf V<2qwt?DFXkG z#``~dZ}|U`djH?~*Vq4;|1Syu-(mj&>;GZq{|Q6>A7Oj{cK-)vSCWN>`Hu(0zwY$U KLNfnn^?v|i9< None: + metadata = json.loads(_METADATA_PATH.read_text()) + npz_path = _FIXTURE_DIR / metadata["artifacts"]["npz_filename"] + + assert hashlib.sha256(npz_path.read_bytes()).hexdigest() == ( + metadata["artifacts"]["npz_sha256"] + ) + + with np.load(npz_path) as fixture: + input_field = np.asarray( + fixture["input"], + dtype=float, + ) + expected_corrected = np.asarray( + fixture["corrected"], + dtype=float, + ) + + original_input = input_field.copy() + field = metadata["field"] + expected_flow = metadata["expected_control_flow"] + expected_result = metadata["expected_result"] + acceptance = metadata["acceptance"] + + result = _flatten_base._run_flatten_base( + input_field, + pixel_size_x=float(field["pixel_size_x"]), + pixel_size_y=float(field["pixel_size_y"]), + ) + + attempted_degrees = tuple(iteration.degree for iteration in result.polynomial_stage.iterations) + applied_degrees = tuple( + iteration.degree for iteration in result.polynomial_stage.iterations if iteration.applied + ) + expected_degrees = tuple(expected_flow["polynomial_degrees"]) + + assert len(result.facet_stage.iterations) == (expected_flow["facet_iterations"]) + assert attempted_degrees == expected_degrees + assert applied_degrees == expected_degrees + assert result.final_peak.success is (expected_flow["final_peak_success"]) + + assert result.final_peak.mean == pytest.approx( + expected_result["final_peak_mean"], + abs=acceptance["final_peak_mean_abs_error"], + rel=0.0, + ) + assert result.final_peak.rms == pytest.approx( + expected_result["final_peak_rms"], + abs=acceptance["final_peak_rms_abs_error"], + rel=0.0, + ) + + np.testing.assert_allclose( + result.corrected, + expected_corrected, + atol=acceptance["corrected_max_abs_error"], + rtol=0.0, + ) + + assert float(np.min(result.corrected)) == pytest.approx( + expected_result["corrected_minimum"], + abs=acceptance["corrected_max_abs_error"], + rel=0.0, + ) + assert float(np.max(result.corrected)) == pytest.approx( + expected_result["corrected_maximum"], + abs=acceptance["corrected_max_abs_error"], + rel=0.0, + ) + + np.testing.assert_array_equal( + input_field, + original_input, + ) + np.testing.assert_allclose( + result.corrected + result.background, + input_field, + atol=5e-14, + rtol=0.0, + ) + + assert not result.corrected.flags.writeable + assert not result.background.flags.writeable From 6b7ee83afb0bc7cf54caccfeb9b2996e7069ab9b Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:04:23 -0400 Subject: [PATCH 51/82] docs(science): record Flatten Base Gwyddion cross-validation --- docs/scientific-status.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/scientific-status.md b/docs/scientific-status.md index a25f010..2cf5e0e 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -35,6 +35,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Sa, Sq, Sz on public experimental GWY matrices | `core.analysis.roughness` | 12 cases, 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the shared-matrix algorithm track | Gwyddion 2.71 | Parser/end-to-end observations are separate: 10 equivalences and 2 preserved channel-count differences | | Limited Nanoscope III `.spm` images | `core.io.bruker_spm` | Six demonstrated files; 18/18 Sa/Sq/Sz comparisons within tolerance and zero reported pixel delta | NUMERICALLY_VERIFIED | Gwyddion 2.71 | `ACCIDENTAL_PRE_FREEZE_UNBLINDING`; partial variants only, no blind holdout or general Bruker support | | NanoSurf `.nid` mapping and orientation | `core.io.nid`, `core.verify` | Synthetic byte-budget/orientation tests and selected lab-context comparisons | SOFTWARE_VERIFIED; selected comparisons do not establish universal format coverage | Gwyddion exports for selected files | Private instrument corpus is not distributed; additional redistributable multi-instrument fixtures are needed | +| Gwyddion Flatten Base end-to-end trajectory | `core.analysis._flatten_base` | Focal LM and packed-Cholesky verification plus a frozen Gwyddion 2.71 end-to-end fixture; matching facet/polynomial control flow and corrected-field maximum absolute difference `1.465494e-14` | CROSS_VALIDATED | Gwyddion 2.71 executable | Internal Core path and one frozen end-to-end trajectory plus focused numerical cases; no universal equivalence claim across datasets, parameter regimes, platforms or Gwyddion versions | | Physical arc-revolution background | `core.analysis.background` | 55 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | | Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | @@ -54,6 +55,7 @@ and tolerance. It never transfers automatically to an adjacent feature. - [Public experimental GWY pilot summary](https://github.com/kegouro/spmkit-validation/blob/main/evidence/campaigns/real_data_roughness_pilot_v0.1_summary.json) - [Nanoscope `.spm` pilot summary](https://github.com/kegouro/spmkit-validation/blob/main/evidence/campaigns/nanoscope_spm_parser_pilot_v0.1_summary.json) - [Nanoscope incident and final audit](https://github.com/kegouro/spmkit-validation/blob/main/docs/campaigns/nanoscope_spm_parser_pilot_v0.1_audit.md) +- [Flatten Base Gwyddion 2.71 frozen end-to-end fixture](https://github.com/kegouro/spmkit/blob/flatten-base-gwyddion-parity-v1/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json) ## Test-count policy From 960bc7f3e681cafff6cbb21e20148457c9d71259 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:20:04 -0400 Subject: [PATCH 52/82] feat(analysis): add Gwyddion arc kernel primitives --- .../core/analysis/_gwyddion_arc_revolution.py | 133 +++++++++++ .../test_gwyddion_arc_revolution_kernel.py | 218 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 src/spmkit/core/analysis/_gwyddion_arc_revolution.py create mode 100644 tests/core/test_gwyddion_arc_revolution_kernel.py diff --git a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py new file mode 100644 index 0000000..dc4c552 --- /dev/null +++ b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py @@ -0,0 +1,133 @@ +"""Numerical kernels compatible with Gwyddion's Revolve Arc operation. + +This module implements the numerical semantics of the Gwyddion 2.71 +``arc-revolve`` process independently in NumPy. It intentionally remains +separate from SPMKit's physical arc-revolution estimator because the two +operations use different radius, scaling, and boundary conventions. +""" + +from __future__ import annotations + +import math + +import numpy as np +from numpy.typing import NDArray + +FloatArray = NDArray[np.float64] + + +def _gwyddion_round_positive(value: object) -> int: + """Round a finite non-negative scalar using ``floor(value + 0.5)``. + + Gwyddion's ``GWY_ROUND`` macro does not use bankers' rounding. In + particular, ``2.5`` becomes ``3`` and ``4.5`` becomes ``5``. + """ + value_data = np.asarray(value) + + if ( + value_data.ndim != 0 + or not np.issubdtype(value_data.dtype, np.number) + or np.iscomplexobj(value_data) + or isinstance(value, (bool, np.bool_)) + ): + raise TypeError("Gwyddion rounding requires a real scalar") + + numeric_value = float(value_data.item()) + + if not math.isfinite(numeric_value): + raise ValueError("Gwyddion rounding requires a finite scalar") + + if numeric_value < 0.0: + raise ValueError("Gwyddion rounding requires a non-negative scalar") + + return math.floor(numeric_value + 0.5) + + +def _make_gwyddion_arc( + radius: object, + maxres: object, +) -> FloatArray: + """Return the dimensionless arc generated by Gwyddion 2.71. + + Parameters + ---------- + radius: + Arc radius in samples, following Gwyddion's pixel-based convention. + maxres: + Maximum available profile resolution. The generated half-width is + ``GWY_ROUND(min(radius, maxres))``. + + Returns + ------- + numpy.ndarray + Read-only, symmetric ``float64`` arc with an odd number of samples. + + Notes + ----- + The very-flat-arc polynomial is reproduced with the same operation order + as the reference C implementation. Values whose normalized offset + exceeds one are clipped to one, matching Gwyddion's explicit branch. + """ + radius_data = np.asarray(radius) + + if ( + radius_data.ndim != 0 + or not np.issubdtype(radius_data.dtype, np.number) + or np.iscomplexobj(radius_data) + or isinstance(radius, (bool, np.bool_)) + ): + raise TypeError("Gwyddion arc radius must be a positive real scalar") + + radius_value = float(radius_data.item()) + + if not math.isfinite(radius_value): + raise ValueError("Gwyddion arc radius must be finite") + + if radius_value <= 0.0: + raise ValueError("Gwyddion arc radius must be positive") + + maxres_data = np.asarray(maxres) + + if ( + maxres_data.ndim != 0 + or not np.issubdtype(maxres_data.dtype, np.integer) + or isinstance(maxres, (bool, np.bool_)) + ): + raise TypeError("Gwyddion arc maxres must be a positive integer") + + maxres_value = int(maxres_data.item()) + + if maxres_value <= 0: + raise ValueError("Gwyddion arc maxres must be positive") + + size = _gwyddion_round_positive(min(radius_value, maxres_value)) + arc = np.empty(2 * size + 1, dtype=np.float64) + use_flat_arc_expansion = radius_value / 8.0 > maxres_value + + for offset in range(size + 1): + normalized_offset = offset / radius_value + + if use_flat_arc_expansion: + squared_offset = normalized_offset * normalized_offset + height = ( + squared_offset + / 2.0 + * ( + 1.0 + + squared_offset + / 4.0 + * (1.0 + squared_offset / 2.0) + ) + ) + elif normalized_offset > 1.0: + height = 1.0 + else: + height = 1.0 - math.sqrt( + 1.0 - normalized_offset * normalized_offset + ) + + arc[size + offset] = height + arc[size - offset] = height + + arc.setflags(write=False) + return arc diff --git a/tests/core/test_gwyddion_arc_revolution_kernel.py b/tests/core/test_gwyddion_arc_revolution_kernel.py new file mode 100644 index 0000000..258df24 --- /dev/null +++ b/tests/core/test_gwyddion_arc_revolution_kernel.py @@ -0,0 +1,218 @@ +"""Tests for the Gwyddion-compatible Revolve Arc numerical kernel.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_round_positive, + _make_gwyddion_arc, +) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (0.0, 0), + (1.49, 1), + (1.5, 2), + (2.49, 2), + (2.5, 3), + (3.5, 4), + (4.5, 5), + (7.999999999999, 8), + (8.0, 8), + ], +) +def test_gwyddion_round_uses_half_up_semantics( + value: float, + expected: int, +) -> None: + assert _gwyddion_round_positive(value) == expected + + +@pytest.mark.parametrize( + "value", + [True, "2.5", [2.5], 1.0 + 1.0j], +) +def test_gwyddion_round_rejects_non_real_scalars(value: object) -> None: + with pytest.raises(TypeError): + _gwyddion_round_positive(value) + + +@pytest.mark.parametrize( + "value", + [-1.0, np.nan, np.inf, -np.inf], +) +def test_gwyddion_round_rejects_invalid_values(value: float) -> None: + with pytest.raises(ValueError): + _gwyddion_round_positive(value) + + +@pytest.mark.parametrize( + ("radius", "maxres", "expected"), + [ + ( + 1.0, + 7, + np.array( + [ + 1.0, + 0.0, + 1.0, + ] + ), + ), + ( + 2.49, + 7, + np.array( + [ + 0.40430786866297319, + 0.084187639942432058, + 0.0, + 0.084187639942432058, + 0.40430786866297319, + ] + ), + ), + ( + 2.5, + 7, + np.array( + [ + 1.0, + 0.40000000000000013, + 0.083484861008832012, + 0.0, + 0.083484861008832012, + 0.40000000000000013, + 1.0, + ] + ), + ), + ( + 4.0, + 16, + np.array( + [ + 1.0, + 0.33856217223385232, + 0.1339745962155614, + 0.031754163448145745, + 0.0, + 0.031754163448145745, + 0.1339745962155614, + 0.33856217223385232, + 1.0, + ] + ), + ), + ( + 20.0, + 3, + np.array( + [ + 0.011314003335740508, + 0.0050125628933800348, + 0.0012507822280910519, + 0.0, + 0.0012507822280910519, + 0.0050125628933800348, + 0.011314003335740508, + ] + ), + ), + ( + 1000.0, + 7, + np.array( + [ + 2.4500300132353065e-05, + 1.8000162002915999e-05, + 1.2500078125976563e-05, + 8.000032000256001e-06, + 4.5000101250455631e-06, + 2.0000020000040001e-06, + 5.0000012500006248e-07, + 0.0, + 5.0000012500006248e-07, + 2.0000020000040001e-06, + 4.5000101250455631e-06, + 8.000032000256001e-06, + 1.2500078125976563e-05, + 1.8000162002915999e-05, + 2.4500300132353065e-05, + ] + ), + ), + ], +) +def test_make_arc_matches_gwyddion_2_71_reference( + radius: float, + maxres: int, + expected: np.ndarray, +) -> None: + result = _make_gwyddion_arc(radius, maxres) + + np.testing.assert_allclose( + result, + expected, + atol=5e-16, + rtol=0.0, + ) + + +def test_half_integer_radius_changes_arc_resolution() -> None: + below_half = _make_gwyddion_arc(2.49, 7) + half_up = _make_gwyddion_arc(2.5, 7) + + assert below_half.shape == (5,) + assert half_up.shape == (7,) + + +def test_arc_is_symmetric_centered_float64_and_read_only() -> None: + arc = _make_gwyddion_arc(4.0, 16) + + assert arc.dtype == np.float64 + assert arc.shape == (9,) + assert arc[arc.size // 2] == 0.0 + np.testing.assert_array_equal(arc, arc[::-1]) + assert not arc.flags.writeable + + +@pytest.mark.parametrize( + "radius", + [0.0, -1.0, np.nan, np.inf, -np.inf], +) +def test_make_arc_rejects_invalid_radius(radius: float) -> None: + with pytest.raises(ValueError): + _make_gwyddion_arc(radius, 7) + + +@pytest.mark.parametrize( + "radius", + [True, "2.5", [2.5], 1.0 + 1.0j], +) +def test_make_arc_rejects_non_real_radius(radius: object) -> None: + with pytest.raises(TypeError): + _make_gwyddion_arc(radius, 7) + + +@pytest.mark.parametrize( + "maxres", + [0, -1], +) +def test_make_arc_rejects_non_positive_resolution(maxres: int) -> None: + with pytest.raises(ValueError): + _make_gwyddion_arc(2.5, maxres) + + +@pytest.mark.parametrize( + "maxres", + [True, 3.5, "7", [7]], +) +def test_make_arc_rejects_non_integer_resolution(maxres: object) -> None: + with pytest.raises(TypeError): + _make_gwyddion_arc(2.5, maxres) From 84b22bb9aedd9f54836d0fed8b152358f47b32fa Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:52:19 -0400 Subject: [PATCH 53/82] feat(analysis): implement Gwyddion horizontal arc kernel --- .../core/analysis/_gwyddion_arc_revolution.py | 284 +++++++++++++++++ .../test_gwyddion_arc_revolution_kernel.py | 292 ++++++++++++++++++ 2 files changed, 576 insertions(+) diff --git a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py index dc4c552..2e1ec4d 100644 --- a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py +++ b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py @@ -131,3 +131,287 @@ def _make_gwyddion_arc( arc.setflags(write=False) return arc + + +def _gwyddion_population_rms(data: np.ndarray) -> float: + """Return Gwyddion's population RMS with respect to the global mean. + + The mean and squared deviations are accumulated sequentially in C order, + matching ``gwy_data_field_get_rms()``. The divisor is the complete sample + count, equivalent to ``ddof=0``. + """ + data_array = np.asarray(data) + + if data_array.size == 0: + raise ValueError("Gwyddion RMS requires non-empty data") + + if not np.issubdtype(data_array.dtype, np.number) or np.iscomplexobj(data_array): + raise TypeError("Gwyddion RMS requires real numeric data") + + if not np.all(np.isfinite(data_array)): + raise ValueError("Gwyddion RMS requires finite data") + + flattened = np.asarray( + data_array, + dtype=np.float64, + ).ravel(order="C") + + total = 0.0 + for value in flattened: + total += float(value) + + mean = total / flattened.size + + squared_deviation_sum = 0.0 + for value in flattened: + deviation = float(value) - mean + squared_deviation_sum += deviation * deviation + + return math.sqrt(squared_deviation_sum / flattened.size) + + +def _moving_sums( + row: np.ndarray, + size: object, +) -> tuple[FloatArray, FloatArray]: + """Return Gwyddion-compatible moving sums and squared sums. + + ``size`` is Gwyddion's historical moving-window parameter. For ordinary + sizes the operation is equivalent to an asymmetric truncated window. + When the window becomes comparable to the complete row, the reference + enters its distinct ``Moving a whale`` control-flow branch; this behaviour + is reproduced explicitly rather than replaced by a conventional window. + + Notes + ----- + Gwyddion 2.71 accesses memory before the output buffer for certain + one-sample and out-of-domain combinations. Such undefined combinations + are rejected here. The horizontal kernel handles a one-sample processing + axis explicitly as identity. + """ + row_array = np.asarray(row) + + if row_array.ndim != 1: + raise ValueError("Gwyddion moving sums require a one-dimensional row") + + if row_array.size == 0: + raise ValueError("Gwyddion moving sums require a non-empty row") + + if not np.issubdtype(row_array.dtype, np.number) or np.iscomplexobj(row_array): + raise TypeError("Gwyddion moving sums require real numeric data") + + if not np.all(np.isfinite(row_array)): + raise ValueError("Gwyddion moving sums require finite data") + + size_data = np.asarray(size) + + if ( + size_data.ndim != 0 + or not np.issubdtype(size_data.dtype, np.integer) + or isinstance(size, (bool, np.bool_)) + ): + raise TypeError("Gwyddion moving-sum size must be a non-negative integer") + + size_value = int(size_data.item()) + + if size_value < 0: + raise ValueError("Gwyddion moving-sum size must be non-negative") + + values = np.asarray( + row_array, + dtype=np.float64, + ) + resolution = values.size + + sums = np.zeros(resolution, dtype=np.float64) + squared_sums = np.zeros(resolution, dtype=np.float64) + + left_half = size_value // 2 + right_half = 0 if size_value == 0 else (size_value - 1) // 2 + + # Exact historical shortcut. It is unreachable from make_arc() because + # the generated half-width never exceeds the processed resolution. + if right_half >= resolution: + first_value = float(values[0]) + sums.fill(first_value) + squared_sums.fill(first_value * first_value) + return sums, squared_sums + + phase_3b_start = resolution - 1 - right_half + if phase_3b_start <= 0 and phase_3b_start <= left_half: + raise ValueError( + "Gwyddion 2.71 moving sums are undefined for this " + "resolution and window size" + ) + + # Phase 1: fill the first output element. + for index in range(right_half + 1): + value = float(values[index]) + sums[0] += value + squared_sums[0] += value * value + + # Phase 2: gather new values without dropping old ones. + phase_2_end = min( + left_half, + resolution - 1 - right_half, + ) + for index in range(1, phase_2_end + 1): + value = float(values[index + right_half]) + sums[index] = sums[index - 1] + value + squared_sums[index] = squared_sums[index - 1] + value * value + + # Phase 3a: move a complete window. + for index in range( + left_half + 1, + resolution - right_half, + ): + entering = float(values[index + right_half]) + leaving = float(values[index - left_half - 1]) + + sums[index] = sums[index - 1] + entering - leaving + squared_sums[index] = ( + squared_sums[index - 1] + + entering * entering + - leaving * leaving + ) + + # Phase 3b: a window larger than the available interior remains fixed. + for index in range( + phase_3b_start, + left_half + 1, + ): + sums[index] = sums[index - 1] + squared_sums[index] = squared_sums[index - 1] + + # Phase 4: lose values without gathering new ones. + for index in range( + max(left_half + 1, resolution - right_half), + resolution, + ): + leaving = float(values[index - left_half - 1]) + sums[index] = sums[index - 1] - leaving + squared_sums[index] = squared_sums[index - 1] - leaving * leaving + + return sums, squared_sums + + +def _gwyddion_arc_horizontal( + data: np.ndarray, + radius: object, +) -> FloatArray: + """Estimate a horizontal background using Gwyddion 2.71 semantics. + + The algorithm uses a global population RMS to scale the dimensionless arc, + clips downward protrusions against a local ``mean - 2.5*rms`` envelope, + and finds the minimum touching position using truncated edge support. + + A processing axis containing one sample is defined as identity. Gwyddion + 2.71 performs an out-of-bounds read for this degenerate geometry, so no + stable external numerical result exists to reproduce. + """ + data_array = np.asarray(data) + + if data_array.ndim != 2: + raise ValueError("Gwyddion horizontal arc requires two-dimensional data") + + if data_array.size == 0: + raise ValueError("Gwyddion horizontal arc requires non-empty data") + + if not np.issubdtype(data_array.dtype, np.number) or np.iscomplexobj(data_array): + raise TypeError("Gwyddion horizontal arc requires real numeric data") + + if not np.all(np.isfinite(data_array)): + raise ValueError("Gwyddion horizontal arc requires finite data") + + values = np.array( + data_array, + dtype=np.float64, + copy=True, + order="C", + ) + row_count, column_count = values.shape + + # Defined SPMKit behaviour for a reference implementation defect. + if column_count == 1: + values.setflags(write=False) + return values + + rms = _gwyddion_population_rms(values) + scale = rms / math.sqrt(2.0 / 3.0 - math.pi / 16.0) + + arc = _make_gwyddion_arc( + radius, + column_count, + ) + scaled_arc = np.asarray( + arc * -scale, + dtype=np.float64, + ) + half_width = scaled_arc.size // 2 + + weights, _ = _moving_sums( + np.ones(column_count, dtype=np.float64), + half_width, + ) + + background = np.empty_like(values) + clipped_row = np.empty(column_count, dtype=np.float64) + + for row_index in range(row_count): + source_row = values[row_index] + local_sums, local_squared_sums = _moving_sums( + source_row, + half_width, + ) + + for column_index in range(column_count): + local_mean = local_sums[column_index] / weights[column_index] + local_variance = ( + local_squared_sums[column_index] / weights[column_index] + - local_mean * local_mean + ) + + local_rms = ( + float("nan") + if local_variance < 0.0 + else math.sqrt(local_variance) + ) + + lower_envelope = local_mean - 2.5 * local_rms + source_value = float(source_row[column_index]) + + # Preserve the argument ordering of GLib's MAX(a, b) macro. + clipped_row[column_index] = ( + source_value + if source_value > lower_envelope + else lower_envelope + ) + + for column_index in range(column_count): + first_offset = max( + 0, + column_index - half_width, + ) - column_index + final_offset = min( + column_index + half_width, + column_count - 1, + ) - column_index + + minimum = math.inf + + for offset in range( + first_offset, + final_offset + 1, + ): + candidate = ( + -scaled_arc[half_width + offset] + + clipped_row[column_index + offset] + ) + + if candidate < minimum: + minimum = float(candidate) + + background[row_index, column_index] = minimum + + background.setflags(write=False) + return background diff --git a/tests/core/test_gwyddion_arc_revolution_kernel.py b/tests/core/test_gwyddion_arc_revolution_kernel.py index 258df24..c62027f 100644 --- a/tests/core/test_gwyddion_arc_revolution_kernel.py +++ b/tests/core/test_gwyddion_arc_revolution_kernel.py @@ -216,3 +216,295 @@ def test_make_arc_rejects_non_positive_resolution(maxres: int) -> None: def test_make_arc_rejects_non_integer_resolution(maxres: object) -> None: with pytest.raises(TypeError): _make_gwyddion_arc(2.5, maxres) + + +def _asymmetric_reference_field() -> np.ndarray: + data = np.empty((5, 7), dtype=float) + + for row in range(5): + for column in range(7): + value = ( + 2.0 + + 0.12 * column + - 0.07 * row + + 0.015 * column * row + + 0.03 * np.sin(0.9 * column + 0.4 * row) + ) + + if row == 1 and column == 4: + value += 3.5 + if row == 3 and column == 2: + value -= 4.0 + if row == 4 and column == 6: + value += 1.2 + + data[row, column] = value + + return data + + +def _independent_truncated_moving_sums( + row: np.ndarray, + size: int, +) -> tuple[np.ndarray, np.ndarray]: + left_half = size // 2 + right_half = 0 if size == 0 else (size - 1) // 2 + + sums = np.empty(row.size, dtype=float) + squared_sums = np.empty(row.size, dtype=float) + + for index in range(row.size): + start = max(0, index - left_half) + stop = min(row.size, index + right_half + 1) + window = row[start:stop] + + sums[index] = sum(float(value) for value in window) + squared_sums[index] = sum(float(value) ** 2 for value in window) + + return sums, squared_sums + + +def test_population_rms_matches_gwyddion_reference() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_population_rms, + ) + + data = _asymmetric_reference_field() + rms = _gwyddion_population_rms(data) + scale = rms / np.sqrt(2.0 / 3.0 - np.pi / 16.0) + + assert scale == pytest.approx( + 1.485841488666283, + abs=2e-15, + rel=0.0, + ) + + +@pytest.mark.parametrize("size", range(0, 6)) +def test_moving_sums_match_independent_truncated_oracle(size: int) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import _moving_sums + + row = np.array([1.0, -2.0, 4.0, 8.0, -1.0, 3.0]) + expected_sum, expected_sum2 = _independent_truncated_moving_sums( + row, + size, + ) + + result_sum, result_sum2 = _moving_sums( + row, + size, + ) + + np.testing.assert_array_equal(result_sum, expected_sum) + np.testing.assert_array_equal(result_sum2, expected_sum2) + + +@pytest.mark.parametrize( + ("size", "expected_sum", "expected_sum2"), + [ + ( + 6, + np.array([3.0, 11.0, 10.0, 10.0, 9.0, 11.0]), + np.array([21.0, 85.0, 86.0, 86.0, 85.0, 81.0]), + ), + ( + 7, + np.array([11.0, 10.0, 10.0, 10.0, 9.0, 11.0]), + np.array([85.0, 86.0, 86.0, 86.0, 85.0, 81.0]), + ), + ], +) +def test_moving_sums_match_gwyddion_whale_branch( + size: int, + expected_sum: np.ndarray, + expected_sum2: np.ndarray, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import _moving_sums + + row = np.array([1.0, -2.0, 4.0, 8.0, -1.0, 3.0]) + + result_sum, result_sum2 = _moving_sums( + row, + size, + ) + + np.testing.assert_array_equal(result_sum, expected_sum) + np.testing.assert_array_equal(result_sum2, expected_sum2) + + +def test_moving_sums_reproduce_reference_large_window_shortcut() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import _moving_sums + + row = np.array([1.0, -2.0, 4.0, 8.0, -1.0, 3.0]) + result_sum, result_sum2 = _moving_sums(row, 13) + + np.testing.assert_array_equal( + result_sum, + np.ones(6), + ) + np.testing.assert_array_equal( + result_sum2, + np.ones(6), + ) + + +def test_moving_sums_reject_reference_undefined_memory_case() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import _moving_sums + + with pytest.raises( + ValueError, + match="undefined", + ): + _moving_sums( + np.array([4.0]), + 1, + ) + + +def test_horizontal_kernel_matches_asymmetric_gwyddion_reference() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + expected = np.array( + [ + [2.0, 2.1240452701624606, 2.2675450774512851, + 2.3728213964070148, 2.4667243867011543, + 2.570674096470047, 2.6947193666325076], + [1.9416825502692594, 2.0657278204317202, + 2.2179520157249768, 2.3362474198729988, + 2.4602926900354594, 2.5755264216212703, + 2.6995716917837309], + [1.8815206827269855, 2.0055659528894463, + 2.1637952144760346, 2.299476503169311, + 2.4235217733317715, 2.5554972079457752, + 2.7090772468957436], + [-1.2814298042916905, -1.7517211295957433, + -1.8757663997582039, -1.7517211295957433, + -1.2814298042916905, -0.38992491109192096, + 2.7225247038845315], + [1.7499872080912451, 1.8740324782537057, + 2.0419994344855796, 2.1963790371016558, + 2.3565602920599771, 2.5375416304908565, + 2.738580395034298], + ] + ) + + result = _gwyddion_arc_horizontal( + _asymmetric_reference_field(), + 2.5, + ) + + np.testing.assert_allclose( + result, + expected, + atol=5e-15, + rtol=0.0, + ) + + +def test_horizontal_kernel_preserves_constant_field() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = np.full((2, 5), 3.25) + result = _gwyddion_arc_horizontal(data, 2.5) + + np.testing.assert_array_equal(result, data) + + +def test_horizontal_kernel_matches_single_row_reference() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = np.array([[0.0, 1.0, -2.0, 4.0, 1.0]]) + expected = np.array( + [[ + 0.0, + -1.2800008456684795, + -2.0, + -1.2800008456684795, + 0.82747338686665239, + ]] + ) + + result = _gwyddion_arc_horizontal(data, 1.5) + + np.testing.assert_allclose( + result, + expected, + atol=5e-15, + rtol=0.0, + ) + + +def test_horizontal_kernel_matches_large_radius_reference() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = np.array( + [ + [0.0, 1.0, 4.0, 2.0, -1.0, 3.0, 0.0], + [2.0, 2.5, 1.0, 0.0, -2.0, 1.0, 4.0], + ] + ) + expected = np.array( + [ + [ + -0.99997994598602924, + -0.9999887196368823, + -0.99999498651154795, + -0.99999874662882704, + -1.0, + -0.99999874662882704, + -0.99999498651154795, + ], + [ + -1.9999799459860292, + -1.9999887196368822, + -1.9999949865115478, + -1.9999987466288269, + -2.0, + -1.9999987466288269, + -1.9999949865115478, + ], + ] + ) + + result = _gwyddion_arc_horizontal(data, 1000.0) + + np.testing.assert_allclose( + result, + expected, + atol=5e-15, + rtol=0.0, + ) + + +def test_horizontal_kernel_defines_one_sample_axis_as_identity() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = np.array([[0.0], [-1.0], [2.0], [5.0]]) + result = _gwyddion_arc_horizontal(data, 2.5) + + np.testing.assert_array_equal(result, data) + + +def test_horizontal_kernel_does_not_mutate_input_and_returns_read_only() -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_horizontal, + ) + + data = _asymmetric_reference_field() + original = data.copy() + + result = _gwyddion_arc_horizontal(data, 2.5) + + np.testing.assert_array_equal(data, original) + assert result.dtype == np.float64 + assert not result.flags.writeable From c0e4fc1d3ed24d9970b2e4f6781fb2552d9527c8 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:06:30 -0400 Subject: [PATCH 54/82] feat(analysis): validate Gwyddion arc direction semantics --- .../core/analysis/_gwyddion_arc_revolution.py | 107 ++++++++++ .../test_gwyddion_arc_revolution_kernel.py | 191 +++++++++++++++++ .../gwyddion_2_71_directional.json | 102 +++++++++ .../gwyddion_2_71_directional.npz | Bin 0 -> 5634 bytes .../test_arc_revolution_vs_gwyddion.py | 200 ++++++++++++++++++ 5 files changed, 600 insertions(+) create mode 100644 tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json create mode 100644 tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.npz create mode 100644 tests/validation/test_arc_revolution_vs_gwyddion.py diff --git a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py index 2e1ec4d..713ab66 100644 --- a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py +++ b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py @@ -9,11 +9,13 @@ from __future__ import annotations import math +from typing import Literal import numpy as np from numpy.typing import NDArray FloatArray = NDArray[np.float64] +GwyddionArcDirection = Literal["horizontal", "vertical", "both"] def _gwyddion_round_positive(value: object) -> int: @@ -415,3 +417,108 @@ def _gwyddion_arc_horizontal( background.setflags(write=False) return background + + +def _readonly_float_array(values: np.ndarray) -> FloatArray: + """Return an independent C-contiguous read-only ``float64`` array.""" + result = np.array( + values, + dtype=np.float64, + copy=True, + order="C", + ) + result.setflags(write=False) + return result + + +def _gwyddion_arc_background( + data: np.ndarray, + radius: object, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> FloatArray: + """Compose a complete Gwyddion-compatible Revolve Arc background. + + ``"vertical"`` is implemented by transposing the field, applying the + horizontal kernel, and transposing the result back. ``"both"`` applies + horizontal first and vertical second, matching Gwyddion 2.71. + + Inversion follows the mathematically consistent dual ``-B(-data)``. + Gwyddion 2.71 computes this background correctly for all directions, + including its defective horizontal-inverted wrapper route. + """ + if not isinstance(direction, str): + raise TypeError("Gwyddion arc direction must be a string") + + if direction not in ("horizontal", "vertical", "both"): + raise ValueError( + "Gwyddion arc direction must be one of " + "'horizontal', 'vertical', or 'both'" + ) + + if not isinstance(inverted, (bool, np.bool_)): + raise TypeError("Gwyddion arc inverted must be a boolean") + + inverted_value = bool(inverted) + source = np.asarray(data) + + working = ( + -np.asarray(source, dtype=np.float64) + if inverted_value + else source + ) + + if direction == "horizontal": + background = _gwyddion_arc_horizontal( + working, + radius, + ) + elif direction == "vertical": + background = _gwyddion_arc_horizontal( + np.asarray(working).T, + radius, + ).T + else: + horizontal = _gwyddion_arc_horizontal( + working, + radius, + ) + background = _gwyddion_arc_horizontal( + horizontal.T, + radius, + ).T + + if inverted_value: + background = -background + + return _readonly_float_array(background) + + +def _gwyddion_arc_corrected( + data: np.ndarray, + radius: object, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> FloatArray: + """Subtract a Gwyddion-compatible arc background from an input field. + + Unlike the Gwyddion 2.71 module wrapper, this function also returns a + scientifically consistent result for ``direction="horizontal"`` with + ``inverted=True``. The reference computes the background correctly in + that route but returns before populating its corrected result field. + """ + background = _gwyddion_arc_background( + data, + radius, + direction=direction, + inverted=inverted, + ) + + corrected = ( + np.asarray(data, dtype=np.float64) + - background + ) + + return _readonly_float_array(corrected) diff --git a/tests/core/test_gwyddion_arc_revolution_kernel.py b/tests/core/test_gwyddion_arc_revolution_kernel.py index c62027f..0414c52 100644 --- a/tests/core/test_gwyddion_arc_revolution_kernel.py +++ b/tests/core/test_gwyddion_arc_revolution_kernel.py @@ -508,3 +508,194 @@ def test_horizontal_kernel_does_not_mutate_input_and_returns_read_only() -> None np.testing.assert_array_equal(data, original) assert result.dtype == np.float64 assert not result.flags.writeable + + +@pytest.mark.parametrize( + ("direction", "inverted"), + [ + ("horizontal", False), + ("horizontal", True), + ("vertical", False), + ("vertical", True), + ("both", False), + ("both", True), + ], +) +def test_directional_background_matches_explicit_composition( + direction: str, + inverted: bool, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + _gwyddion_arc_horizontal, + ) + + data = _asymmetric_reference_field() + working = -data if inverted else data + + if direction == "horizontal": + expected = _gwyddion_arc_horizontal( + working, + 2.5, + ) + elif direction == "vertical": + expected = _gwyddion_arc_horizontal( + working.T, + 2.5, + ).T + else: + horizontal = _gwyddion_arc_horizontal( + working, + 2.5, + ) + expected = _gwyddion_arc_horizontal( + horizontal.T, + 2.5, + ).T + + if inverted: + expected = -expected + + result = _gwyddion_arc_background( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + np.testing.assert_array_equal(result, expected) + + +@pytest.mark.parametrize( + ("direction", "inverted"), + [ + ("horizontal", False), + ("horizontal", True), + ("vertical", False), + ("vertical", True), + ("both", False), + ("both", True), + ], +) +def test_directional_corrected_reconstructs_input( + direction: str, + inverted: bool, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + _gwyddion_arc_corrected, + ) + + data = _asymmetric_reference_field() + original = data.copy() + + background = _gwyddion_arc_background( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + corrected = _gwyddion_arc_corrected( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + np.testing.assert_array_equal(data, original) + np.testing.assert_allclose( + corrected + background, + data, + atol=5e-15, + rtol=0.0, + ) + + assert background.dtype == np.float64 + assert corrected.dtype == np.float64 + assert background.flags.c_contiguous + assert corrected.flags.c_contiguous + assert not background.flags.writeable + assert not corrected.flags.writeable + + +@pytest.mark.parametrize( + "direction", + ["horizontal", "vertical", "both"], +) +@pytest.mark.parametrize( + "inverted", + [False, True], +) +def test_directional_one_by_one_field_is_identity( + direction: str, + inverted: bool, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + _gwyddion_arc_corrected, + ) + + data = np.array([[4.25]]) + + background = _gwyddion_arc_background( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + corrected = _gwyddion_arc_corrected( + data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + np.testing.assert_array_equal(background, data) + np.testing.assert_array_equal( + corrected, + np.zeros_like(data), + ) + + +@pytest.mark.parametrize( + "direction", + ["diagonal", "", 1], +) +def test_directional_background_rejects_invalid_direction( + direction: object, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + ) + + expected_exception = ( + TypeError + if not isinstance(direction, str) + else ValueError + ) + + with pytest.raises(expected_exception): + _gwyddion_arc_background( + np.ones((2, 3)), + 2.5, + direction=direction, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "inverted", + [0, 1, "yes", None], +) +def test_directional_background_rejects_non_boolean_inversion( + inverted: object, +) -> None: + from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + ) + + with pytest.raises(TypeError): + _gwyddion_arc_background( + np.ones((2, 3)), + 2.5, + inverted=inverted, # type: ignore[arg-type] + ) diff --git a/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json b/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json new file mode 100644 index 0000000..f32a35a --- /dev/null +++ b/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.json @@ -0,0 +1,102 @@ +{ + "acceptance": { + "background_max_abs_error": 5e-14, + "corrected_max_abs_error": 5e-14, + "reconstruction_max_abs_error": 5e-14 + }, + "artifacts": { + "array_canonical_sha256": { + "background_both_inverted": "3adb462bfcba10d75dd4c0416424db686959c392b3885dff8824ab0097d1f55b", + "background_both_normal": "931b156e5c8c58c24642e89573105531263d4534d795a8809edfccf8d2b6c163", + "background_horizontal_inverted": "9ffd9fc84037d3230be9a4bf3a353534833e57a186abfe2e759e8a9cb15b1b55", + "background_horizontal_normal": "a4e795a383f8e495edbca5b3b6a2fd903dd6739f9515169178a749ab86cab327", + "background_vertical_inverted": "df9f61f8ed98f63d8ec20905ecb56009a7f47945bda5d4805dd055a331fd9f56", + "background_vertical_normal": "6d4d08a08c1752aca685a452d1ec143d555471905c1bdcda48bbeba940b7cc30", + "corrected_both_inverted": "aff71f2fa45bb29b35c6c4cc0412263d0095732b222376f2291f3604b676a4cc", + "corrected_both_normal": "88d4553c6b95637decc3cc7841c56cd0bec556801c4712b09767b343f108953d", + "corrected_horizontal_inverted": "abb1ce32e2a6e68e5f7175306edc51de4cd23bccbc697df68b5fa5721853ab0b", + "corrected_horizontal_normal": "35cc88b2c8e2e5aa060d4446c459e8cda6c8e6eb9c1a00f4a7209422b24c6a60", + "corrected_vertical_inverted": "25249844f021891d31b2acea23d9ca256ed64a7c81c15999fd1fcf444075ee46", + "corrected_vertical_normal": "a3251acde29315ee05883a47ed53f0730f859239d6efc3a297303234d47baf83", + "input": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd" + }, + "npz_filename": "gwyddion_2_71_directional.npz", + "npz_sha256": "50b263b8add97950ba1ef882f96d5ee3bc35001908c47a1dbb20b9428bc3bc5e" + }, + "cases": { + "both_inverted": { + "corrected_reference_valid": true, + "direction": "both", + "inverted": true + }, + "both_normal": { + "corrected_reference_valid": true, + "direction": "both", + "inverted": false + }, + "horizontal_inverted": { + "corrected_reference_valid": false, + "direction": "horizontal", + "inverted": true, + "known_reference_defect": "Gwyddion 2.71 returns before populating the corrected result after computing and sign-restoring the background." + }, + "horizontal_normal": { + "corrected_reference_valid": true, + "direction": "horizontal", + "inverted": false + }, + "vertical_inverted": { + "corrected_reference_valid": true, + "direction": "vertical", + "inverted": true + }, + "vertical_normal": { + "corrected_reference_valid": true, + "direction": "vertical", + "inverted": false + } + }, + "field": { + "dtype": "float64", + "height_unit": "unspecified synthetic units", + "shape": [ + 5, + 7 + ], + "xreal": 5.6, + "yreal": 6.5 + }, + "fixture_id": "gwyddion-2.71-arc-revolution-directional", + "known_reference_defects": { + "horizontal_inverted_corrected_result": { + "classification": "KNOWN_REFERENCE_DEFECT", + "reference_result_untouched": true, + "sentinel": 123456789.0, + "spmkit_policy": "Preserve the validated background but return the scientifically consistent corrected field." + }, + "single_sample_processing_axis": { + "classification": "KNOWN_REFERENCE_DEFECT", + "reference_behavior": "The historical moving-sums control flow can read before its output buffer.", + "spmkit_policy": "Define a one-sample processing axis as identity." + } + }, + "parameters": { + "composition": { + "both": "horizontal followed by vertical", + "inverted": "negate input, process, negate background", + "vertical": "transpose, horizontal, transpose back" + }, + "radius_px": 2.5 + }, + "reference": { + "operation": "Revolve Arc", + "probe_output_sha256": "1f8ee0535ac3b0d93e3b330ec4f96b39436e4da1853f5d3ce9ba45e1f2d0eca3", + "probe_source": ".reference/gwyddion-2.71/arc-revolve-parity/arc_revolve_behavior_probe.c", + "probe_source_sha256": "27e92376d7955f134a6d76091775dc28fe2e1ba8246936b27e2e924d3ba765f4", + "software": "Gwyddion", + "source_file": ".reference/gwyddion-2.71/source/modules/process/arc-revolve.c", + "source_sha256": "afb19a2382b0abb46595fa3dabc126ade50ec31c91ec9c96ea2284f42d0a67ac", + "version": "2.71" + }, + "schema_version": 1 +} diff --git a/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.npz b/tests/validation/fixtures/gwyddion/arc_revolution/gwyddion_2_71_directional.npz new file mode 100644 index 0000000000000000000000000000000000000000..b7f5d817e21a464158e125e47651708482b8e453 GIT binary patch literal 5634 zcmcIoc{tR27sr$(H42Y)2CY)I5XPwK=2o`|X+g;t%OJ}z46+UCMiE_FEZJVlEoCiB zmeEDFkdR?8Mz(BO$1<3Aes7I?XG+~ayyKZ?9`nra^PKPbe7@&=&N0*lZBXFg;NWGv zw{wi`1@+OtzW6wVIqaQXaPErEE>}4@wsJ&sGJeB&p}&5{jfxISZmp>{d}H`LA@brw zvsC9tNzPH| Qfz%kh3zJLHnuEDt{wUxT_hm@O1fh=Ck6)n6$pjB5`(>s@)bM92i say!8rb zk9V0w`?Oc#l@znQMY@;2dhliX4&C00m5!>@Pp4p6lZ55)KYjS`#?Z%X^UliE{9%Lb zxuh^0BT+t@wR>WqWmeV{V%;?_V8CbL#@k`9Hr7lhQ{l+x
d9~3(Lw! zH*xRbsgg;ab7(sN7lV@3t;Xa *a$j%lZa=b=Y#9TnjbS1i5F?O*~G%OnqVHc!~l52-dE(jLWA zKZm@^KN7Pb{ZJqZ(L1b`zg%~FnO}MYJ9@Zbc Z`2ob;;=ke)Mk%)+;mVYJ6CQ9;d4x`q#0aUVX#;m zD|efJL6z9LQnlBPh@Wfv*cLS=z9YzqP ztC{*}tsPU5Tf z6{F6Z@7^pbQQ0nqrb7=mGtoQNycG7%9yVA;_Svt6**fMrW3lT*1k%{nrC?>5B7iu3 zJa}h3y8X *q~ zdS0m0-WT5#cryj{y25A=PnxZq?Jf;vxOVZ?C$hiZX *d7KE05ja^W#Q60`Gf0?_ACs2}lSD0I~6vI-xhi z j`it2+&8vHEft2RsO{Ur7Nm0Y1$SnUQmmo^bYyR}W}UTMM?tal z$Ff#?XW9cvTH0E57FE>}Qnw?@bhXYd=ZSzzl0cimo7*t% ka0VQ zyo|lpuF6`aPH#F)<-w|MkVSlg6^=-IDT7IGLC~Uj>T^`N#3AD`G2;{zJ(qr~`S#rF zCwkz|w&vSnFTEX2LS{GX AzsS0 z14ZeWoqThGdbSsQyehxm1Rh$6LWk?n8{M9PeF!3bPWKjV@l#2r9>-;_l^5n-960ld z+3$}L)4s}2$E+ aQ|AGFC$!ClU4+=@ z2PK$%u{Qmm()Gu>%8)Bb#m+7HgF*)AddnI9PI9ic`sJIF&WSqr8k}b5(`Zet?dVtf zes?Mbj6I)p1Z5V7K^qLblGReKOrO~7H*_Q{X7 cnMa^&eJS_-OCs<3;FJDkzNZ&7_P+~zPtuQ@jsd%^B zWfM1B`UlCnc7%6H7iNcHToYje#IS#J-o1TTY%kP@=`w4f#);Z_vmaOP{631WaP%K< zgTJB#W>jJ{rT1X2B0E)Fdp|9!nE6U>`H-chY))t@OrzAT_@E#AcoFHN +&FO@2V$J8aOFNuvjx)}-HpF6YZl%^O|?2D(& zOWa*AYc&_tmFyMSTGu 1oArjaf z9g2zOD7=zxPU)OlaCxyTMZw a+1 z9qq4GiTT!(E~dwv=pcAc@j3hwj1%>B`jEKI)nu{vX7IGufneB;CB1}Ky)K9K{NFdv zOm*L P;)sJaxfNr|}z+uqlmR70*$yE7jDE#Z73?3}+^Q@3wC~#{>_!x~j)D%UR zVn>iZ)2gx}ImiLrpGuhYw@(GJdq*BP(L@z`@A#B=CG_XZZix%y0L sSwG?cn8N|o#OYWTETEk8Z)hu-(G>WvTC)SS-r9BDSgQQ z&{e{X 1 zQ-NJyhN=SkjMVRkB(CW3EGmj>=7_mM_KwgB-VMRqI2)r9qzT#|wPc`jTOSsLe>{?C zf3lyTKL7$F$ODxnX2r;W(@RC<2V@# vgn@ z`3xTb1#{u6G`yC&2w$fyTC101IPa)e9=@S)A>;5cc}~dzVM*Ravn7;;mkCNok!k!A z^7jN(d`SXs8jNee$)x~dT@(R(AYdoN_qA^U9|8ZPWOZ%C{ur>n0M-z|V=^p{B{RPH z*)oG24H&S2Nf|(sW yda3swnZ4fyw(;$J`k_7t$0 z0F(?1R3t+d{~&Y~>qmzK%oE^(05n!YkMe&vupfZ|J_T?c0GI?zXuCGEgvQop*|P=o zXQ15zIN~fg?pyvD)K6f5)&aBu00zPWBO~x_I6pcVP%VMd2OvqZAbD>6CLmUzjST5= RaBN}xRWfdftRV2;e*vDRMK}Ne literal 0 HcmV?d00001 diff --git a/tests/validation/test_arc_revolution_vs_gwyddion.py b/tests/validation/test_arc_revolution_vs_gwyddion.py new file mode 100644 index 0000000..6d60b83 --- /dev/null +++ b/tests/validation/test_arc_revolution_vs_gwyddion.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np +import pytest + +from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_background, + _gwyddion_arc_corrected, +) + +_FIXTURE_DIR = ( + Path(__file__).resolve().parent + / "fixtures" + / "gwyddion" + / "arc_revolution" +) +_METADATA_PATH = _FIXTURE_DIR / "gwyddion_2_71_directional.json" +_METADATA = json.loads(_METADATA_PATH.read_text(encoding="utf-8")) +_CASE_NAMES = tuple(_METADATA["cases"]) + + +def _canonical_array_sha256(array: np.ndarray) -> str: + canonical = np.ascontiguousarray( + array, + dtype=np.float64, + ) + + digest = hashlib.sha256() + digest.update(str(canonical.dtype).encode("ascii")) + digest.update(b"\0") + digest.update( + ",".join(str(value) for value in canonical.shape).encode("ascii") + ) + digest.update(b"\0") + digest.update(canonical.tobytes(order="C")) + return digest.hexdigest() + + +def _load_fixture() -> dict[str, np.ndarray]: + npz_path = ( + _FIXTURE_DIR + / _METADATA["artifacts"]["npz_filename"] + ) + + assert hashlib.sha256(npz_path.read_bytes()).hexdigest() == ( + _METADATA["artifacts"]["npz_sha256"] + ) + + with np.load(npz_path) as fixture: + arrays = { + name: np.asarray( + fixture[name], + dtype=np.float64, + ) + for name in fixture.files + } + + expected_hashes = _METADATA["artifacts"][ + "array_canonical_sha256" + ] + + assert set(arrays) == set(expected_hashes) + + for name, array in arrays.items(): + assert _canonical_array_sha256(array) == expected_hashes[name] + + return arrays + + +@pytest.mark.parametrize("case_name", _CASE_NAMES) +def test_arc_background_matches_gwyddion_2_71( + case_name: str, +) -> None: + fixture = _load_fixture() + case = _METADATA["cases"][case_name] + acceptance = _METADATA["acceptance"] + + input_field = fixture["input"].copy() + original_input = input_field.copy() + + result = _gwyddion_arc_background( + input_field, + _METADATA["parameters"]["radius_px"], + direction=case["direction"], + inverted=case["inverted"], + ) + + np.testing.assert_allclose( + result, + fixture[f"background_{case_name}"], + atol=acceptance["background_max_abs_error"], + rtol=0.0, + ) + np.testing.assert_array_equal( + input_field, + original_input, + ) + + assert result.dtype == np.float64 + assert result.flags.c_contiguous + assert not result.flags.writeable + + +@pytest.mark.parametrize( + "case_name", + [ + name + for name, case in _METADATA["cases"].items() + if case["corrected_reference_valid"] + ], +) +def test_arc_corrected_matches_valid_gwyddion_2_71_results( + case_name: str, +) -> None: + fixture = _load_fixture() + case = _METADATA["cases"][case_name] + acceptance = _METADATA["acceptance"] + + input_field = fixture["input"].copy() + + background = _gwyddion_arc_background( + input_field, + _METADATA["parameters"]["radius_px"], + direction=case["direction"], + inverted=case["inverted"], + ) + corrected = _gwyddion_arc_corrected( + input_field, + _METADATA["parameters"]["radius_px"], + direction=case["direction"], + inverted=case["inverted"], + ) + + np.testing.assert_allclose( + corrected, + fixture[f"corrected_{case_name}"], + atol=acceptance["corrected_max_abs_error"], + rtol=0.0, + ) + np.testing.assert_allclose( + corrected + background, + input_field, + atol=acceptance["reconstruction_max_abs_error"], + rtol=0.0, + ) + + assert not corrected.flags.writeable + + +def test_horizontal_inverted_reference_defect_is_preserved_and_repaired() -> None: + fixture = _load_fixture() + defect = _METADATA["known_reference_defects"][ + "horizontal_inverted_corrected_result" + ] + acceptance = _METADATA["acceptance"] + + input_field = fixture["input"].copy() + reference_corrected = fixture[ + "corrected_horizontal_inverted" + ] + + assert defect["classification"] == "KNOWN_REFERENCE_DEFECT" + assert defect["reference_result_untouched"] is True + assert np.all( + reference_corrected == defect["sentinel"] + ) + + background = _gwyddion_arc_background( + input_field, + _METADATA["parameters"]["radius_px"], + direction="horizontal", + inverted=True, + ) + corrected = _gwyddion_arc_corrected( + input_field, + _METADATA["parameters"]["radius_px"], + direction="horizontal", + inverted=True, + ) + + np.testing.assert_allclose( + background, + fixture["background_horizontal_inverted"], + atol=acceptance["background_max_abs_error"], + rtol=0.0, + ) + np.testing.assert_allclose( + corrected + background, + input_field, + atol=acceptance["reconstruction_max_abs_error"], + rtol=0.0, + ) + + assert not np.any(corrected == defect["sentinel"]) + assert not background.flags.writeable + assert not corrected.flags.writeable From 38d0955ced027c010159ec0bc96e162ed43c8ff4 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:19:57 -0400 Subject: [PATCH 55/82] docs(science): specify Gwyddion arc compatibility --- .../GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 docs/design/GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md diff --git a/docs/design/GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md b/docs/design/GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md new file mode 100644 index 0000000..3e6c6b3 --- /dev/null +++ b/docs/design/GWYDDION_ARC_REVOLUTION_COMPATIBILITY.md @@ -0,0 +1,443 @@ +# Gwyddion 2.71 Revolve Arc Compatibility Specification + +**Specification ID:** `spmkit-gwyddion-arc-revolution-v1`
+**Status:** Normative pre-implementation contract
+**Reference:** Gwyddion 2.71
+**Base branch:** `feat/gwyddion-leveling-parity`
+**Base commit:** `c0e4fc1d3ed24d9970b2e4f6781fb2552d9527c8`
+**Frozen fixture:** `gwyddion-2.71-arc-revolution-directional`
+**Scoped maturity:** `LEVEL 3 — CROSS_VALIDATED` + +This claim applies only to the source, probes, fixtures, routes, parameter +cases and tolerances declared here. It is not a universal-equivalence claim. + +## 1. Purpose + +This specification binds together the mathematics, exact reference control +flow, external probes, numerical fixtures, public API, tests, provenance and +scientific claims for SPMKit's Gwyddion-compatible Revolve Arc operation. + +The implementation is judged against the evidence and this specification. +Neither the algorithm, tests nor specification may be silently altered merely +to obtain passing tests. + +Every discrepancy must first be classified as an implementation defect, test +defect, oracle defect, reference defect, specification defect, +unsupported-domain case, or floating-point/platform effect. + +## 2. Scientific identity and scope + +The operation is classified as a: + +> **Gwyddion 2.71-compatible, data-adaptive arc-envelope background +> estimator.** + +It is not represented as: + +- SPMKit's physical arc-revolution estimator; +- a classical morphological opening; +- an exact frequency cutoff; +- probe deconvolution; +- tip estimation; +- specimen-surface reconstruction; +- metrologically certified correction. + +SPMKit's existing physical Arc Revolution remains an independent algorithm. +It uses a physical radius in metres, lateral pixel spacing and explicit border +policies. The compatibility operation uses radius in samples and reproduces +the declared Gwyddion 2.71 semantics. + +## 3. Evidence hierarchy + +Conflicts are resolved in this order: + +1. Exact Gwyddion 2.71 source. +2. C probes compiled against Gwyddion 2.71. +3. Frozen JSON/NPZ numerical fixtures. +4. Official Gwyddion user documentation. +5. Mathematical and SPM-domain literature. +6. Bibliographic and general software context. + +Literature defines terminology and conceptual boundaries. It does not +override observed behaviour of the frozen executable reference. + +### 3.1 Executable evidence + +| Artifact | SHA-256 | +|---|---| +| Gwyddion 2.71 `arc-revolve.c` | `afb19a2382b0abb46595fa3dabc126ade50ec31c91ec9c96ea2284f42d0a67ac` | +| Behaviour-probe source | `27e92376d7955f134a6d76091775dc28fe2e1ba8246936b27e2e924d3ba765f4` | +| Behaviour-probe output | `1f8ee0535ac3b0d93e3b330ec4f96b39436e4da1853f5d3ce9ba45e1f2d0eca3` | +| Directional fixture metadata | `5e037b33e04d2c95420c3e71acbf7b4bc46b8723163bfc11363e6ac083005cd2` | +| Directional fixture NPZ | `50b263b8add97950ba1ef882f96d5ee3bc35001908c47a1dbb20b9428bc3bc5e` | + +### 3.2 Literature evidence + +| Source | Role | SHA-256 | +|---|---|---| +| Gwyddion levelling guide | Declared user semantics | `002b1af784a1f5c441c21bbf55b670c335d5ed53eb0f6e02ab894d4911178ade` | +| Heijmans, 1995 | Mathematical morphology context | `085cede6c5cce62e214d14eb9ef624db5902f8ac512ec9626761afeedafa41eb` | +| Villarrubia, 1997 | SPM geometry and reconstruction boundary | `d50c845edf53bb6713dc8c3d72fdded1db6ba44906bbac0ee9d830eaad0dbae9` | +| Nečas–Klapetek DOI CSL | Bibliographic identity | `5f6ec95fd7eb68ec66aa8eeeaeee4284d1232e8a85cfbccb48e5d11ca20f448e` | + +The Nečas–Klapetek full-text PDF is contextual and optional. Its absence does +not weaken executable numerical provenance. Dynamic publisher HTML is +explicitly non-normative. + +## 4. Mathematical boundary + +Mathematical morphology provides an algebraic and geometric framework for +non-linear image transformations. Villarrubia applies dilation and erosion to +SPM image simulation, surface reconstruction and tip estimation. + +These sources explain why geometric envelopes matter in SPM. They do not +prove that Gwyddion Revolve Arc is a classical morphological opening. + +The reference operator is data-adaptive because: + +- arc amplitude depends on the global RMS; +- local mean-minus-RMS clipping modifies the working profile; +- moving sums contain historical reference-specific control flow. + +Idempotence, anti-extensivity, increasingness and other morphology axioms +must not be claimed without separate proof for this exact adaptive operator. + +## 5. Public input contract + +The public operation accepts a real, finite, non-empty, two-dimensional +channel. + +- `radius_px` is a finite real scalar. +- Boolean, complex, NaN and infinite radii are rejected. +- `1.0 <= radius_px <= 1000.0`. +- Default `radius_px` is `20.0`. +- Direction is `horizontal`, `vertical` or `both`. +- `inverted` is strictly boolean. +- Masks and non-finite field values are unsupported. +- Z units are preserved and need not be geometric lengths. +- Physical lateral ranges do not affect the numerical output. + +## 6. Global scale + +For all N field samples in C-order, + + mu = (1/N) sum_i f_i + +and + + sigma = sqrt((1/N) sum_i (f_i - mu)^2). + +This is population RMS. The arc scale is + + q = sigma / sqrt(2/3 - pi/16). + +Accumulation order is part of numerical compatibility. + +## 7. Discrete arc + +For radius r and processing-axis resolution n, + + s = floor(min(r, n) + 1/2). + +This is Gwyddion positive half-up rounding, not bankers' rounding. + +For k from -s through s, with u = abs(k)/r, + + phi_r(k) = + u^2/2 * (1 + u^2/4 * (1 + u^2/2)) when r/8 > n + 1 when u > 1 + 1 - sqrt(1 - u^2) otherwise + +and + + a_r(k) = q * phi_r(k). + +Branch order and floating-point operation order are normative. + +## 8. Historical moving sums + +Local statistics reproduce Gwyddion 2.71 `moving_sums()` control flow. + +Ordinary regimes agree with asymmetric truncated windows. When window size +becomes comparable to the profile, the historical `Moving a whale` branch is +normative even where it differs from a conventional window oracle. + +The oversized shortcut is retained in the private primitive for source +fidelity, although it is unreachable through normal valid arc geometry. + +## 9. Local clipping and horizontal envelope + +At each position j, let m_j and s_j be the local mean and population RMS +produced by the historical moving-sum route. + + g_j = max(f_j, m_j - 2.5*s_j). + +No undocumented variance clamp is introduced. + +The horizontal background is + + H_r(f)_j = min_k (g_(j+k) + a_r(k)), + +using only offsets that remain within the profile. Edges use truncated +support. There is no padding mode and no public border parameter. + +## 10. Directional composition + +For a field F: + + B_horizontal(F) = H_r(F) + + B_vertical(F) = transpose(H_r(transpose(F))) + + B_both(F) = + transpose(H_r(transpose(H_r(F)))) + +`both` is horizontal followed by vertical. It is ordered and is not +represented as a commutative isotropic two-dimensional operator. + +## 11. Inversion and corrected field + +The inverted background is + + B_inverted(F) = -B(-F). + +The corrected field is always + + C(F) = F - B(F). + +This reconstruction identity applies to all six direction/inversion routes. + +## 12. Known reference defects + +### 12.1 Horizontal plus inverted corrected result + +Gwyddion 2.71 computes and restores the background correctly, then returns +before writing the corrected field. + +Classification: `KNOWN_REFERENCE_DEFECT`. + +SPMKit policy: + +- preserve the externally validated background; +- return `input - background`; +- test reconstruction explicitly; +- disclose the repaired divergence; +- never claim reproduction of the defective corrected output. + +### 12.2 One-sample processing axis + +The historical moving-sum branch can read before its output buffer. + +Classification: `KNOWN_REFERENCE_DEFECT`. + +SPMKit policy: + +- do not freeze process-memory-dependent output; +- define a one-sample processed axis as identity; +- test the safe definition explicitly. + +### 12.3 Dynamic publisher HTML + +Immediate downloads produced different generated byte streams. + +Classification: `NON_NORMATIVE_DYNAMIC_CAPTURE`. + +SPMKit policy: + +- use DOI CSL as canonical bibliographic identity; +- treat local full text as optional context; +- retain dynamic HTML only as provenance; +- never make its byte hash an implementation requirement. + +## 13. Public API + + estimate_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, + ) -> SPMChannel + + remove_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, + ) -> SPMChannel + + analyze_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, + ) -> BackgroundResult + +The existing physical functions remain unchanged. + +## 14. Single authoritative numerical route + +The adapter exposes one internal route: + + _gwyddion_arc_result( + data: np.ndarray, + radius: object, + *, + direction: GwyddionArcDirection, + inverted: bool, + ) -> tuple[FloatArray, FloatArray] + +It computes the background once and derives corrected data from that exact +background. + +- Estimate selects background. +- Remove selects corrected. +- Analyze wraps both. +- No public route recomputes the kernel. +- No alternative correction path can drift. + +## 15. Channel and structured-result contract + +Returned channels preserve: + +- name; +- unit; +- x and y ranges; +- acquisition direction; +- group; +- an independent metadata copy. + +Kernel arrays are: + +- `float64`; +- C-contiguous; +- independent of the input buffer; +- read-only; +- non-mutating. + +Structured results use: + + method = "gwyddion_arc_revolution" + +and record effective runtime parameters only: + + { + "radius_px": 20.0, + "direction": "horizontal", + "inverted": False, + } + +Reference versions, hashes, defects, tolerances and maturity remain in +validation provenance rather than runtime parameters. + +## 16. Verification model + +### 16.1 Source-semantic tests + +- half-up rounding; +- exact arc branch boundaries; +- population RMS; +- ordinary moving windows; +- `Moving a whale`; +- oversized shortcut; +- constant fields; +- single rows; +- large radii; +- one-sample safe definition; +- validation; +- non-mutation; +- immutability. + +### 16.2 External validation + +Frozen fixture: `gwyddion-2.71-arc-revolution-directional`. + +The campaign validates: + +- one asymmetric 5 by 7 field; +- radius 2.5; +- six background routes; +- five valid corrected routes; +- horizontal-inverted background; +- untouched defect sentinel; +- repaired SPMKit reconstruction; +- directional composition; +- artifact and array hashes. + +### 16.3 Metamorphic properties + +Where mathematically supported, test: + + B(F + c) = B(F) + c + C(F + c) = C(F) + B(alpha*F) = alpha*B(F), alpha > 0 + B_inverted(F) = -B(-F) + B_vertical(F) = transpose(B_horizontal(transpose(F))) + C(F) + B(F) = F + +Classical morphology axioms are not requirements without independent proof +for this adaptive operator. + +## 17. Scientific claim + +Supported claim: + +> SPMKit's declared Gwyddion-compatible Revolve Arc path is +> `LEVEL 3 — CROSS_VALIDATED` against Gwyddion 2.71 for the frozen kernels, +> fixture, six background routes, five valid corrected routes, focal cases +> and declared tolerances. + +This does not establish: + +- universal equivalence; +- equivalence with other Gwyddion versions; +- physical truth of the estimated background; +- specimen-surface recovery; +- tip deconvolution or reconstruction; +- metrological traceability; +- performance equivalence; +- support for masks or non-finite values. + +## 18. Change control + +Explicit specification review is required for changes to: + +- arithmetic or accumulation order; +- radius semantics; +- edge handling; +- local clipping; +- direction order; +- inversion; +- one-sample policy; +- public defaults or limits; +- runtime metadata; +- fixtures; +- tolerances; +- scientific claims. + +Tests may reveal a specification defect. They may not silently redefine the +specification. + +Source, probes, implementation, tests and specification must be reconciled +and classified before changing the algorithm. + +## 19. References + +1. Gwyddion developers, Data Levelling and Background Subtraction, frozen + official user documentation. +2. H. J. A. M. Heijmans, Mathematical Morphology: A Modern Approach in Image + Processing Based on Algebra and Geometry, SIAM Review 37(1), 1–36, 1995. + DOI: 10.1137/1037001. +3. J. S. Villarrubia, Algorithms for Scanned Probe Microscope Image + Simulation, Surface Reconstruction, and Tip Estimation, Journal of + Research of NIST 102(4), 425–454, 1997. + DOI: 10.6028/jres.102.030. +4. D. Nečas and P. Klapetek, Gwyddion: an open-source software for SPM data + analysis, Central European Journal of Physics 10(1), 181–188, 2012. + DOI: 10.2478/s11534-011-0096-2. +5. Masaryk University institutional publication record: + https://www.muni.cz/en/research/publications/966983 +6. Gwyddion project publication record: + https://gwyddion.net/publications/ From c693fc97e94a5829f57c8b2acf8f522f4f05fb4f Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:42:53 -0400 Subject: [PATCH 56/82] feat(analysis): expose Gwyddion arc background API --- docs/scientific-status.md | 1 + docs/validation/index.md | 1 + src/spmkit/core/analysis/__init__.py | 8 + .../core/analysis/_gwyddion_arc_revolution.py | 42 +- src/spmkit/core/analysis/background.py | 188 +++++- ...test_gwyddion_arc_revolution_background.py | 567 ++++++++++++++++++ .../test_arc_revolution_vs_gwyddion.py | 87 +++ 7 files changed, 882 insertions(+), 12 deletions(-) create mode 100644 tests/core/test_gwyddion_arc_revolution_background.py diff --git a/docs/scientific-status.md b/docs/scientific-status.md index 2cf5e0e..3d4c042 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -37,6 +37,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | NanoSurf `.nid` mapping and orientation | `core.io.nid`, `core.verify` | Synthetic byte-budget/orientation tests and selected lab-context comparisons | SOFTWARE_VERIFIED; selected comparisons do not establish universal format coverage | Gwyddion exports for selected files | Private instrument corpus is not distributed; additional redistributable multi-instrument fixtures are needed | | Gwyddion Flatten Base end-to-end trajectory | `core.analysis._flatten_base` | Focal LM and packed-Cholesky verification plus a frozen Gwyddion 2.71 end-to-end fixture; matching facet/polynomial control flow and corrected-field maximum absolute difference `1.465494e-14` | CROSS_VALIDATED | Gwyddion 2.71 executable | Internal Core path and one frozen end-to-end trajectory plus focused numerical cases; no universal equivalence claim across datasets, parameter regimes, platforms or Gwyddion versions | | Physical arc-revolution background | `core.analysis.background` | 55 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | +| Gwyddion-compatible Revolve Arc background | `core.analysis.background`, `core.analysis._gwyddion_arc_revolution` | Frozen Gwyddion 2.71 source semantics, focal kernel probes, one asymmetric 5×7 directional fixture, 6/6 background routes and 5/5 valid corrected routes within `5e-14`; repaired reconstruction for the defective horizontal-inverted wrapper | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes and frozen JSON/NPZ fixture | Radius is in samples; no masks or non-finite data; one-sample processing axes use a documented safe definition; no physical validation, tip reconstruction, performance equivalence or universal-equivalence claim | | Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | diff --git a/docs/validation/index.md b/docs/validation/index.md index 09f1190..39dd4ea 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -36,6 +36,7 @@ references, tolerances, outputs, hashes, and limitations. |---|---|---:|---|---| | Gwyddion roughness 48 v0.1 | Sa, Sq, Sz on 48 canonical synthetic matrices | 144/144 within tolerance | CROSS_VALIDATED | No preprocessing; shared matrices; not physical validation | | Real-data roughness pilot v0.1 | Sa, Sq, Sz on 12 public GWY matrices | 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the algorithm track | Parser/end-to-end observations are separate; real data are not ground truth | +| Gwyddion Revolve Arc 2.71 v1 | Data-adaptive arc-envelope background on a frozen asymmetric 5×7 field, six direction/inversion routes and focal kernel cases | 6/6 backgrounds and 5/5 valid corrected outputs within `5e-14`; horizontal-inverted reference defect preserved as evidence and repaired by reconstruction | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; known wrapper and one-sample reference defects documented; not physical validation or universal equivalence | | Nanoscope `.spm` pilot v0.1 | Six demonstrated files | 18/18 metric comparisons within tolerance | NUMERICALLY_VERIFIED limited parser claim | Partial support and `ACCIDENTAL_PRE_FREEZE_UNBLINDING` | See [Scientific status](../scientific-status.md) for the complete mapping and diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index a79358f..9b14aa8 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -17,19 +17,23 @@ ) from spmkit.core.analysis.background import ( BackgroundResult, + GwyddionArcDirection, analyze_arc_revolution_background, + analyze_gwyddion_arc_revolution_background, analyze_median_background, analyze_polynomial_background, analyze_rolling_ball_background, analyze_sphere_revolution_background, analyze_spline_background, estimate_arc_revolution_background, + estimate_gwyddion_arc_revolution_background, estimate_median_background, estimate_polynomial_background, estimate_rolling_ball_background, estimate_sphere_revolution_background, estimate_spline_background, remove_arc_revolution_background, + remove_gwyddion_arc_revolution_background, remove_median_background, remove_polynomial_background, remove_rolling_ball_background, @@ -60,19 +64,23 @@ __all__ = [ "background", "BackgroundResult", + "GwyddionArcDirection", "analyze_arc_revolution_background", + "analyze_gwyddion_arc_revolution_background", "analyze_median_background", "analyze_polynomial_background", "analyze_rolling_ball_background", "analyze_sphere_revolution_background", "analyze_spline_background", "estimate_arc_revolution_background", + "estimate_gwyddion_arc_revolution_background", "estimate_median_background", "estimate_polynomial_background", "estimate_rolling_ball_background", "estimate_sphere_revolution_background", "estimate_spline_background", "remove_arc_revolution_background", + "remove_gwyddion_arc_revolution_background", "remove_median_background", "remove_polynomial_background", "remove_rolling_ball_background", diff --git a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py index 713ab66..2f8206f 100644 --- a/src/spmkit/core/analysis/_gwyddion_arc_revolution.py +++ b/src/spmkit/core/analysis/_gwyddion_arc_revolution.py @@ -495,19 +495,18 @@ def _gwyddion_arc_background( return _readonly_float_array(background) -def _gwyddion_arc_corrected( +def _gwyddion_arc_result( data: np.ndarray, radius: object, *, direction: GwyddionArcDirection = "horizontal", inverted: bool = False, -) -> FloatArray: - """Subtract a Gwyddion-compatible arc background from an input field. +) -> tuple[FloatArray, FloatArray]: + """Return background and corrected fields through one numerical route. - Unlike the Gwyddion 2.71 module wrapper, this function also returns a - scientifically consistent result for ``direction="horizontal"`` with - ``inverted=True``. The reference computes the background correctly in - that route but returns before populating its corrected result field. + The background is computed exactly once. The corrected field is then + defined by the reconstruction identity ``corrected = input - background``. + Both arrays are independent, C-contiguous, ``float64`` and read-only. """ background = _gwyddion_arc_background( data, @@ -515,10 +514,31 @@ def _gwyddion_arc_corrected( direction=direction, inverted=inverted, ) + corrected = _readonly_float_array( + np.asarray(data, dtype=np.float64) - background + ) + + return background, corrected - corrected = ( - np.asarray(data, dtype=np.float64) - - background + +def _gwyddion_arc_corrected( + data: np.ndarray, + radius: object, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> FloatArray: + """Return the corrected field from the authoritative result route. + + Unlike the defective Gwyddion 2.71 horizontal-inverted wrapper, this + function always returns the scientifically consistent ``input-background`` + result while preserving the externally validated background. + """ + _, corrected = _gwyddion_arc_result( + data, + radius, + direction=direction, + inverted=inverted, ) - return _readonly_float_array(corrected) + return corrected diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 03a2f52..70e9376 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -8,11 +8,15 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal +from typing import Literal, cast import numpy as np from scipy.ndimage import generic_filter, grey_erosion, grey_opening +from spmkit.core.analysis._gwyddion_arc_revolution import ( + GwyddionArcDirection, + _gwyddion_arc_result, +) from spmkit.core.analysis._pspline import ( PSplineSurfaceFit, fit_pspline_surface, @@ -28,6 +32,7 @@ ArcBorder = Literal["nearest", "reflect"] BackgroundMethod = Literal[ "arc_revolution", + "gwyddion_arc_revolution", "sphere_revolution", "rolling_ball", "median", @@ -97,6 +102,40 @@ def _validated_channel_data( return data +def _validated_gwyddion_radius_px( + radius_px: object, + *, + operation: str, +) -> float: + """Validate the public Gwyddion radius measured in samples.""" + radius_data = np.asarray(radius_px) + + if ( + radius_data.ndim != 0 + or not np.issubdtype(radius_data.dtype, np.number) + or np.iscomplexobj(radius_data) + or isinstance(radius_px, (bool, np.bool_)) + ): + raise TypeError( + f"{operation} requires radius_px to be a real scalar" + ) + + value = float(radius_data.item()) + + if not np.isfinite(value): + raise ValueError( + f"{operation} requires radius_px to be finite" + ) + + if not 1.0 <= value <= 1000.0: + raise ValueError( + f"{operation} requires radius_px to be between " + "1.0 and 1000.0 inclusive" + ) + + return value + + def _positive_radius( radius: object, *, @@ -451,6 +490,119 @@ def remove_arc_revolution_background( return channel.with_data(corrected) +def _gwyddion_arc_channels( + channel: SPMChannel, + radius_px: object, + *, + direction: object, + inverted: object, + operation: str, +) -> tuple[ + SPMChannel, + SPMChannel, + float, + GwyddionArcDirection, + bool, +]: + """Validate one request and return background and corrected channels.""" + data = _validated_channel_data( + channel, + operation=operation, + ) + radius_value = _validated_gwyddion_radius_px( + radius_px, + operation=operation, + ) + direction_value = cast( + GwyddionArcDirection, + _validated_choice( + direction, + name="direction", + allowed=("horizontal", "vertical", "both"), + operation=operation, + ), + ) + + if not isinstance(inverted, (bool, np.bool_)): + raise TypeError( + f"{operation} requires inverted to be a boolean" + ) + + inverted_value = bool(inverted) + + background_data, corrected_data = _gwyddion_arc_result( + data, + radius_value, + direction=direction_value, + inverted=inverted_value, + ) + + return ( + channel.with_data(background_data), + channel.with_data(corrected_data), + radius_value, + direction_value, + inverted_value, + ) + + +def estimate_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> SPMChannel: + """Estimate a Gwyddion 2.71-compatible Revolve Arc background. + + Parameters + ---------- + channel: + Real, finite, non-empty two-dimensional channel. The Z unit is + preserved and need not represent geometric length. + radius_px: + Arc radius in samples. The public Gwyddion-compatible range is + inclusive from 1.0 through 1000.0. + direction: + ``"horizontal"`` processes rows, ``"vertical"`` processes columns, + and ``"both"`` applies horizontal followed by vertical. + inverted: + Apply the exact dual ``-B(-data)``. + + Notes + ----- + This is a data-adaptive compatibility estimator, not SPMKit's physical + arc-revolution model, tip deconvolution, or surface reconstruction. + Edges use the truncated support of Gwyddion 2.71; there is no border mode. + """ + background, _, _, _, _ = _gwyddion_arc_channels( + channel, + radius_px, + direction=direction, + inverted=inverted, + operation="estimate_gwyddion_arc_revolution_background", + ) + return background + + +def remove_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> SPMChannel: + """Subtract a Gwyddion 2.71-compatible Revolve Arc background.""" + _, corrected, _, _, _ = _gwyddion_arc_channels( + channel, + radius_px, + direction=direction, + inverted=inverted, + operation="remove_gwyddion_arc_revolution_background", + ) + return corrected + + def _sphere_structure( *, radius: float, @@ -1316,6 +1468,40 @@ def analyze_arc_revolution_background( ) +def analyze_gwyddion_arc_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + direction: GwyddionArcDirection = "horizontal", + inverted: bool = False, +) -> BackgroundResult: + """Estimate and subtract a compatible Revolve Arc background once.""" + ( + background, + corrected, + radius_value, + direction_value, + inverted_value, + ) = _gwyddion_arc_channels( + channel, + radius_px, + direction=direction, + inverted=inverted, + operation="analyze_gwyddion_arc_revolution_background", + ) + + return BackgroundResult( + background=background, + corrected=corrected, + method="gwyddion_arc_revolution", + parameters={ + "radius_px": radius_value, + "direction": direction_value, + "inverted": inverted_value, + }, + ) + + def analyze_sphere_revolution_background( channel: SPMChannel, radius: float, diff --git a/tests/core/test_gwyddion_arc_revolution_background.py b/tests/core/test_gwyddion_arc_revolution_background.py new file mode 100644 index 0000000..08ab76a --- /dev/null +++ b/tests/core/test_gwyddion_arc_revolution_background.py @@ -0,0 +1,567 @@ +"""Public-contract tests for Gwyddion-compatible Revolve Arc.""" + +from __future__ import annotations + +import inspect +from typing import get_args + +import numpy as np +import pytest + +import spmkit.core.analysis as analysis +import spmkit.core.analysis.background as background_module +from spmkit.core.analysis import ( + BackgroundResult, + GwyddionArcDirection, + analyze_gwyddion_arc_revolution_background, + estimate_gwyddion_arc_revolution_background, + remove_gwyddion_arc_revolution_background, +) +from spmkit.core.analysis._gwyddion_arc_revolution import ( + _gwyddion_arc_result, +) +from spmkit.core.models import SPMChannel + +_ROUTES = [ + ("horizontal", False), + ("horizontal", True), + ("vertical", False), + ("vertical", True), + ("both", False), + ("both", True), +] + + +def _field() -> np.ndarray: + return np.array( + [ + [2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5], + [1.5, 1.75, 2.0, 2.25, 6.0, 2.75, 3.0], + [1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5], + [0.5, 0.75, 1.0, -2.0, 1.5, 1.75, 2.0], + [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5], + ], + dtype=np.float64, + ) + + +def _channel( + data: np.ndarray | None = None, + *, + unit: str = "V", + x_range: float = 8.0e-6, + y_range: float = 5.0e-6, +) -> SPMChannel: + return SPMChannel( + name="Synthetic CPD", + data=_field() if data is None else data, + unit=unit, + x_range=x_range, + y_range=y_range, + direction="backward", + group="Validation group", + metadata={ + "source": "frozen synthetic contract", + "operator": "public-adapter-test", + }, + ) + + +def _assert_context_preserved( + source: SPMChannel, + result: SPMChannel, +) -> None: + assert result.name == source.name + assert result.unit == source.unit + assert result.x_range == source.x_range + assert result.y_range == source.y_range + assert result.direction == source.direction + assert result.group == source.group + assert result.metadata == source.metadata + assert result.metadata is not source.metadata + + +def _assert_array_contract(data: np.ndarray) -> None: + assert data.dtype == np.float64 + assert data.flags.c_contiguous + assert not data.flags.writeable + + +def _roundoff_bound(expected: np.ndarray) -> float: + scale = max( + 1.0, + float(np.max(np.abs(expected))), + ) + return 512.0 * np.finfo(np.float64).eps * scale + + +def test_public_exports_and_defaults_are_stable() -> None: + expected_names = { + "GwyddionArcDirection", + "estimate_gwyddion_arc_revolution_background", + "remove_gwyddion_arc_revolution_background", + "analyze_gwyddion_arc_revolution_background", + } + + assert expected_names <= set(analysis.__all__) + + for name in expected_names: + assert getattr(analysis, name) is not None + + assert set(get_args(GwyddionArcDirection)) == { + "horizontal", + "vertical", + "both", + } + + for function in ( + estimate_gwyddion_arc_revolution_background, + remove_gwyddion_arc_revolution_background, + analyze_gwyddion_arc_revolution_background, + ): + signature = inspect.signature(function) + assert signature.parameters["radius_px"].default == 20.0 + assert signature.parameters["direction"].default == "horizontal" + assert signature.parameters["inverted"].default is False + + +@pytest.mark.parametrize(("direction", "inverted"), _ROUTES) +def test_public_family_matches_authoritative_private_result( + direction: str, + inverted: bool, +) -> None: + channel = _channel() + original_data = channel.data.copy() + original_metadata = dict(channel.metadata) + + expected_background, expected_corrected = _gwyddion_arc_result( + channel.data, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + estimated = estimate_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + removed = remove_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + analyzed = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ) + + assert isinstance(analyzed, BackgroundResult) + + np.testing.assert_array_equal( + estimated.data, + expected_background, + ) + np.testing.assert_array_equal( + removed.data, + expected_corrected, + ) + np.testing.assert_array_equal( + analyzed.background.data, + expected_background, + ) + np.testing.assert_array_equal( + analyzed.corrected.data, + expected_corrected, + ) + np.testing.assert_array_equal( + analyzed.corrected.data + analyzed.background.data, + channel.data, + ) + + assert analyzed.method == "gwyddion_arc_revolution" + assert analyzed.parameters == { + "radius_px": 2.5, + "direction": direction, + "inverted": inverted, + } + + for result_channel in ( + estimated, + removed, + analyzed.background, + analyzed.corrected, + ): + _assert_context_preserved(channel, result_channel) + _assert_array_contract(result_channel.data) + + np.testing.assert_array_equal(channel.data, original_data) + assert channel.metadata == original_metadata + + payload = analyzed.to_dict() + assert payload["method"] == "gwyddion_arc_revolution" + assert payload["parameters"] == analyzed.parameters + assert payload["background"]["shape"] == [5, 7] # type: ignore[index] + assert payload["corrected"]["unit"] == "V" # type: ignore[index] + + +def test_analyze_executes_single_authoritative_result_route( + monkeypatch: pytest.MonkeyPatch, +) -> None: + call_count = 0 + original = background_module._gwyddion_arc_result + + def counted_result(*args: object, **kwargs: object) -> object: + nonlocal call_count + call_count += 1 + return original(*args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr( + background_module, + "_gwyddion_arc_result", + counted_result, + ) + + result = analyze_gwyddion_arc_revolution_background( + _channel(), + 2.5, + direction="both", + inverted=True, + ) + + assert isinstance(result, BackgroundResult) + assert call_count == 1 + + +@pytest.mark.parametrize(("direction", "inverted"), _ROUTES) +def test_metamorphic_translation_and_positive_scale( + direction: str, + inverted: bool, +) -> None: + data = _field() + shift = 16.0 + scale = 8.0 + + base_channel = _channel(data) + shifted_channel = _channel(data + shift) + scaled_channel = _channel(data * scale) + + base_background = estimate_gwyddion_arc_revolution_background( + base_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + shifted_background = estimate_gwyddion_arc_revolution_background( + shifted_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + scaled_background = estimate_gwyddion_arc_revolution_background( + scaled_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + + base_corrected = remove_gwyddion_arc_revolution_background( + base_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + shifted_corrected = remove_gwyddion_arc_revolution_background( + shifted_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + scaled_corrected = remove_gwyddion_arc_revolution_background( + scaled_channel, + 2.5, + direction=direction, # type: ignore[arg-type] + inverted=inverted, + ).data + + expected_shifted_background = base_background + shift + expected_scaled_background = base_background * scale + expected_scaled_corrected = base_corrected * scale + + np.testing.assert_allclose( + shifted_background, + expected_shifted_background, + atol=_roundoff_bound(expected_shifted_background), + rtol=0.0, + ) + np.testing.assert_allclose( + shifted_corrected, + base_corrected, + atol=_roundoff_bound(base_corrected), + rtol=0.0, + ) + np.testing.assert_allclose( + scaled_background, + expected_scaled_background, + atol=_roundoff_bound(expected_scaled_background), + rtol=0.0, + ) + np.testing.assert_allclose( + scaled_corrected, + expected_scaled_corrected, + atol=_roundoff_bound(expected_scaled_corrected), + rtol=0.0, + ) + + +def test_units_and_lateral_ranges_are_numerically_irrelevant() -> None: + voltage = _channel( + unit="V", + x_range=1.0e-9, + y_range=2.0e-9, + ) + phase = _channel( + unit="deg", + x_range=0.25, + y_range=12.0, + ) + + voltage_result = analyze_gwyddion_arc_revolution_background( + voltage, + 2.5, + direction="both", + ) + phase_result = analyze_gwyddion_arc_revolution_background( + phase, + 2.5, + direction="both", + ) + + np.testing.assert_array_equal( + voltage_result.background.data, + phase_result.background.data, + ) + np.testing.assert_array_equal( + voltage_result.corrected.data, + phase_result.corrected.data, + ) + + assert voltage_result.background.unit == "V" + assert voltage_result.corrected.unit == "V" + assert phase_result.background.unit == "deg" + assert phase_result.corrected.unit == "deg" + + +@pytest.mark.parametrize("radius_px", [1.0, 1000.0, np.float64(20.0)]) +def test_public_radius_boundaries_are_accepted( + radius_px: object, +) -> None: + result = analyze_gwyddion_arc_revolution_background( + _channel(), + radius_px, # type: ignore[arg-type] + ) + + assert result.parameters["radius_px"] == float(radius_px) + + +@pytest.mark.parametrize( + "radius_px", + [ + 0.0, + -1.0, + 0.999999, + 1000.000001, + np.nan, + np.inf, + -np.inf, + ], +) +def test_invalid_radius_values_are_rejected( + radius_px: float, +) -> None: + with pytest.raises(ValueError): + estimate_gwyddion_arc_revolution_background( + _channel(), + radius_px, + ) + + +@pytest.mark.parametrize( + "radius_px", + [ + True, + "20", + 20.0 + 0.0j, + [20.0], + np.array([20.0]), + ], +) +def test_invalid_radius_types_are_rejected( + radius_px: object, +) -> None: + with pytest.raises(TypeError): + estimate_gwyddion_arc_revolution_background( + _channel(), + radius_px, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "direction", + ["diagonal", "", 1, None], +) +def test_invalid_direction_is_rejected( + direction: object, +) -> None: + expected_exception = ( + TypeError + if not isinstance(direction, str) + else ValueError + ) + + with pytest.raises(expected_exception): + estimate_gwyddion_arc_revolution_background( + _channel(), + 2.5, + direction=direction, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "inverted", + [0, 1, "yes", None], +) +def test_non_boolean_inversion_is_rejected( + inverted: object, +) -> None: + with pytest.raises(TypeError): + estimate_gwyddion_arc_revolution_background( + _channel(), + 2.5, + inverted=inverted, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize( + "data", + [ + np.array([[1.0, np.nan]]), + np.array([[1.0, np.inf]]), + np.array([[1.0 + 0.0j, 2.0 + 0.0j]]), + np.array([1.0, 2.0]), + np.empty((0, 3)), + ], +) +def test_invalid_channel_data_is_rejected( + data: np.ndarray, +) -> None: + expected_exception = ( + TypeError + if np.iscomplexobj(data) + else ValueError + ) + + with pytest.raises(expected_exception): + estimate_gwyddion_arc_revolution_background( + _channel(data), + 2.5, + ) + + +@pytest.mark.parametrize( + ("shape", "direction"), + [ + ((1, 1), "horizontal"), + ((5, 1), "horizontal"), + ((1, 5), "vertical"), + ((1, 1), "both"), + ], +) +def test_single_sample_processing_axis_has_safe_identity_semantics( + shape: tuple[int, int], + direction: str, +) -> None: + data = np.arange( + shape[0] * shape[1], + dtype=np.float64, + ).reshape(shape) + channel = _channel(data) + + result = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + ) + + np.testing.assert_array_equal( + result.background.data, + data, + ) + np.testing.assert_array_equal( + result.corrected.data, + np.zeros_like(data), + ) + + +@pytest.mark.parametrize( + ("shape", "direction"), + [ + ((1, 5), "horizontal"), + ((5, 1), "vertical"), + ], +) +def test_singleton_orthogonal_axis_does_not_force_identity( + shape: tuple[int, int], + direction: str, +) -> None: + """A singleton orthogonal dimension is not a singleton processed axis.""" + data = np.arange( + shape[0] * shape[1], + dtype=np.float64, + ).reshape(shape) + channel = _channel(data) + + result = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction=direction, # type: ignore[arg-type] + ) + + assert not np.array_equal( + result.background.data, + data, + ) + np.testing.assert_array_equal( + result.corrected.data + result.background.data, + data, + ) + + +def test_public_result_is_deterministic() -> None: + channel = _channel() + + first = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction="both", + inverted=True, + ) + second = analyze_gwyddion_arc_revolution_background( + channel, + 2.5, + direction="both", + inverted=True, + ) + + np.testing.assert_array_equal( + first.background.data, + second.background.data, + ) + np.testing.assert_array_equal( + first.corrected.data, + second.corrected.data, + ) diff --git a/tests/validation/test_arc_revolution_vs_gwyddion.py b/tests/validation/test_arc_revolution_vs_gwyddion.py index 6d60b83..85cf51a 100644 --- a/tests/validation/test_arc_revolution_vs_gwyddion.py +++ b/tests/validation/test_arc_revolution_vs_gwyddion.py @@ -198,3 +198,90 @@ def test_horizontal_inverted_reference_defect_is_preserved_and_repaired() -> Non assert not np.any(corrected == defect["sentinel"]) assert not background.flags.writeable assert not corrected.flags.writeable + + +@pytest.mark.parametrize("case_name", _CASE_NAMES) +def test_public_arc_result_matches_gwyddion_2_71( + case_name: str, +) -> None: + from spmkit.core.analysis import ( + analyze_gwyddion_arc_revolution_background, + ) + from spmkit.core.models import SPMChannel + + fixture = _load_fixture() + case = _METADATA["cases"][case_name] + field = _METADATA["field"] + acceptance = _METADATA["acceptance"] + + input_field = fixture["input"].copy() + original_input = input_field.copy() + + channel = SPMChannel( + name="Gwyddion 2.71 frozen Revolve Arc field", + data=input_field, + unit="V", + x_range=float(field["xreal"]), + y_range=float(field["yreal"]), + direction="forward", + group="external-validation", + metadata={ + "fixture_id": _METADATA["fixture_id"], + "reference": "Gwyddion 2.71", + }, + ) + + result = analyze_gwyddion_arc_revolution_background( + channel, + _METADATA["parameters"]["radius_px"], + direction=case["direction"], + inverted=case["inverted"], + ) + + np.testing.assert_allclose( + result.background.data, + fixture[f"background_{case_name}"], + atol=acceptance["background_max_abs_error"], + rtol=0.0, + ) + + if case["corrected_reference_valid"]: + np.testing.assert_allclose( + result.corrected.data, + fixture[f"corrected_{case_name}"], + atol=acceptance["corrected_max_abs_error"], + rtol=0.0, + ) + else: + defect = _METADATA["known_reference_defects"][ + "horizontal_inverted_corrected_result" + ] + assert np.all( + fixture[f"corrected_{case_name}"] + == defect["sentinel"] + ) + assert not np.any( + result.corrected.data == defect["sentinel"] + ) + + np.testing.assert_allclose( + result.corrected.data + result.background.data, + input_field, + atol=acceptance["reconstruction_max_abs_error"], + rtol=0.0, + ) + np.testing.assert_array_equal( + input_field, + original_input, + ) + + assert result.method == "gwyddion_arc_revolution" + assert result.parameters == { + "radius_px": 2.5, + "direction": case["direction"], + "inverted": case["inverted"], + } + assert result.background.unit == "V" + assert result.corrected.unit == "V" + assert not result.background.data.flags.writeable + assert not result.corrected.data.flags.writeable From c466e7a8c15c2a9deb60453dad241ace29ab517e Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:07:04 -0400 Subject: [PATCH 57/82] feat(analysis): match Gwyddion Sphere Revolution semantics --- docs/api.md | 48 ++ ...WYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md | 486 ++++++++++++++++++ docs/scientific-status.md | 14 + docs/validation/index.md | 1 + src/spmkit/core/analysis/__init__.py | 6 + .../analysis/_gwyddion_sphere_revolution.py | 300 +++++++++++ src/spmkit/core/analysis/background.py | 126 +++++ ...t_gwyddion_sphere_revolution_background.py | 371 +++++++++++++ .../sphere_revolution_reference.json | 485 +++++++++++++++++ .../sphere_revolution_reference.npz | Bin 0 -> 39566 bytes .../test_sphere_revolution_vs_gwyddion.py | 306 +++++++++++ 11 files changed, 2143 insertions(+) create mode 100644 docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md create mode 100644 src/spmkit/core/analysis/_gwyddion_sphere_revolution.py create mode 100644 tests/core/test_gwyddion_sphere_revolution_background.py create mode 100644 tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json create mode 100644 tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.npz create mode 100644 tests/validation/test_sphere_revolution_vs_gwyddion.py diff --git a/docs/api.md b/docs/api.md index 8091b5d..c32afb1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -178,6 +178,54 @@ This implementation is LEVEL 1 — SOFTWARE_VERIFIED through synthetic tests and independent test-local two-dimensional oracles for both supported border policies. Numerical equivalence with Gwyddion has not been established. +## Gwyddion-compatible Sphere-revolution background + +SPM-Kit provides data-adaptive background estimation compatible with Gwyddion 2.71's +Revolve Sphere module: + +```python +from spmkit.core.analysis import ( + analyze_gwyddion_sphere_revolution_background, + estimate_gwyddion_sphere_revolution_background, + remove_gwyddion_sphere_revolution_background, +) + +background = estimate_gwyddion_sphere_revolution_background( + channel, + radius_px=20.0, + inverted=False, +) + +corrected = remove_gwyddion_sphere_revolution_background( + channel, + radius_px=20.0, + inverted=False, +) + +result = analyze_gwyddion_sphere_revolution_background( + channel, + radius_px=20.0, + inverted=False, +) +``` + +`radius_px` is expressed in samples (array-index units). The public Gwyddion-compatible +range is inclusive from 1.0 through 1000.0. `channel` must be a real, finite, non-empty +`SPMChannel`. + +`inverted=False` executes Gwyddion 2.71's normal Sphere Revolution route. `inverted=True` +applies the exact dual `-B(-data)` for background estimation. To avoid the internal crash +occurring in Gwyddion 2.71's C module when `inverted=True`, the corrected channel uses +the safe deliberate divergence `corrected = original - background`, guaranteeing exact +reconstruction of the original channel data. + +`analyze_gwyddion_sphere_revolution_background` returns a `BackgroundResult` with +`method="gwyddion_sphere_revolution"` and `parameters={"radius_px": float(radius_px), "inverted": bool(inverted)}`. + +This estimator is distinct from SPMKit's physical sphere-revolution model +(`estimate_sphere_revolution_background`), which operates with physical metric radii in metres, +circular footprints in physical coordinates, and explicit physical border policies. + ## KPFM statistics ```python diff --git a/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md b/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md new file mode 100644 index 0000000..60a7635 --- /dev/null +++ b/docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md @@ -0,0 +1,486 @@ +# Gwyddion 2.71 Sphere Revolution Compatibility Specification + +## 1. Status and scope + +This document establishes the normative design specification for SPMKit's compatibility with Gwyddion 2.71's 2D Revolve Sphere background leveling operation. + +Current Status: Normative Design Specification (Implementation Pending). + +The primary objective is to reproduce the exact, observable numerical semantics of Gwyddion 2.71's ``sphere-revolve`` module within SPMKit. + +Scope boundaries: +- Direct external reference scope (direct external reference): The normal route (`inverted=False`). +- Derived external reference scope (derived external reference): Inverted background evaluated via the exact mathematical dual `-B(-F)` using normal reference executions on negated inputs. +- Safe deliberate divergence scope (safe deliberate divergence): Inverted corrected field evaluated as `F - B_inv(F)` to guarantee complete reconstruction identity without inheriting upstream memory corruption bugs. +- Universal equivalence is explicitly excluded. Parity claims apply strictly to the frozen test suite and external validation fixtures. + +| Capability | Evidence class | Planned SPMKit behavior | +|---|---|---| +| Normal background | Direct external reference | Reproduce Gwyddion 2.71 numerical outputs | +| Normal corrected | Direct external reference | Reproduce Gwyddion 2.71 numerical outputs | +| Inverted reference wrapper | Frozen reference defect | Excluded due to upstream memory corruption crash | +| Inverted background | Derived external reference | Reproduce dual `-B_normal(-F)` using negated inputs | +| Inverted corrected | Safe deliberate divergence | Evaluate `F - B_inv(F)` to ensure input reconstruction | +| Physical Sphere Revolution | Independent physical model | Preserved completely intact without modification | + +## 2. Separation from physical Sphere Revolution + +SPMKit currently provides `estimate_sphere_revolution_background`, which models a physical 2D spherical contact tip over real surface topographies using physical SI units (metres), anisotropic lateral pixel dimensions (`dx`, `dy`), morphological opening operations, and physical border modes (`nearest`, `reflect`). + +The Gwyddion 2.71 compatibility operation defined herein uses dimensionless pixel-sample radii (`radius_px`), data-adaptive RMS scaling (`q`), and historical C-array index truncation. + +> The two operations remain separate because their parameter semantics, geometry, scaling, and evidence contracts are not equivalent. + +The existing physical Sphere Revolution implementation (`estimate_sphere_revolution_background`, `remove_sphere_revolution_background`, `analyze_sphere_revolution_background`) and its test suite shall remain completely intact and unmodified. + +## 3. Proposed public API + +The public API for Gwyddion Sphere Revolution compatibility shall provide three functions in `spmkit.core.analysis.background`: + +```python +estimate_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> SPMChannel +``` + +```python +remove_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> SPMChannel +``` + +```python +analyze_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> BackgroundResult +``` + +Method string string: + +`gwyddion_sphere_revolution` + +Parameters dictionary: + +```python +{ + "radius_px": float(radius_px), + "inverted": bool(inverted), +} +``` + +Public imports will be exported in `spmkit.core.analysis` and `spmkit` upon completion of Block D of the implementation sequence. + +## 4. Input and parameter contract + +The input channel and parameters must adhere to the following contract: +- Input channel data must be a two-dimensional, finite, real, non-empty `float64` array. +- All internal numerical calculations must use IEEE-754 `numpy.float64` double precision. +- `radius_px` must be a real, finite scalar in the inclusive range `1.0 <= radius_px <= 1000.0`. +- Boolean values passed as `radius_px` must be rejected with `TypeError`, matching SPMKit's `_validated_gwyddion_radius_px` contract. +- `inverted` must be a boolean. Non-boolean values (e.g. integers or strings) must be rejected with `TypeError`. +- `radius_px` represents a sample count along pixel grid axes and is completely independent of physical lateral metadata (`xreal`, `yreal`, `dx`, `dy`). +- Input channels and arrays must never be mutated in place. +- Private array outputs must be C-contiguous `numpy.float64` arrays with `flags.writeable = False`. +- Public SPMChannel metadata, Z units, and spatial context must be preserved using `channel.with_data(...)`. + +## 5. Global normalization + +Let $F$ be a 2D data field of dimensions $y_{\mathrm{res}} \times x_{\mathrm{res}}$ containing $N = y_{\mathrm{res}} \cdot x_{\mathrm{res}}$ samples. + +Global mean $\bar F$ is calculated using a serial C-order sum over double-precision values: + +$$ +\bar F = \frac{1}{N} \sum_{p=0}^{N-1} F_p +$$ + +Global population RMS is calculated in a second serial C-order pass: + +$$ +\operatorname{RMS}(F) = \sqrt{\frac{1}{N} \sum_{p=0}^{N-1} (F_p - \bar F)^2} +$$ + +The global scaling parameter $q$ is defined as: + +$$ +q = \frac{\operatorname{RMS}(F)}{\sqrt{5/6}} +$$ + +Key requirements: +- The divisor $N$ uses the full population count. +- Calculation requires two explicit serial passes to preserve exact accumulation order. +- A constant input field yields $\operatorname{RMS}(F) = 0.0$ and $q = 0.0$. +- `np.std()` must not be used as the normative definition due to variance in accumulation order. + +## 6. Discrete sphere construction + +The integer sphere radius $s$ in samples, kernel size $n$, and local filter half-width $k$ are derived via `GWY_ROUND` (`floor(val + 0.5)`): + +$$ +s = \left\lfloor \min(r, x_{\mathrm{res}}) + 0.5 \right\rfloor +$$ + +$$ +n = 2s + 1 +$$ + +$$ +k = \left\lfloor \frac{s}{2} \right\rfloor +$$ + +For indices $i, j \in [0, s]$, normalized coordinate offsets are defined as: + +$$ +u = \frac{i}{r}, \qquad v = \frac{j}{r}, \qquad \rho^2 = u^2 + v^2 +$$ + +The dimensionless sphere height $z$ is evaluated via the normal branch when $r / 8 \le x_{\mathrm{res}}$: + +$$ +z = \begin{cases} 1 - \sqrt{1 - \rho^2}, & \rho^2 \le 1 \\ 2, & \rho^2 > 1 \end{cases} +$$ + +When $r / 8 > x_{\mathrm{res}}$, the very-flat branch polynomial is evaluated: + +$$ +z = \frac{\rho^2}{2} \left[ 1 + \frac{\rho^2}{4} \left( 1 + \frac{\rho^2}{2} \right) \right] +$$ + +The scaled sphere kernel $S$ is obtained by quadrant-symmetric assignment and scaling: + +$$ +S = -q z +$$ + +Key requirements: +- Quadrant symmetry assigns identical $z$ to $(s-i, s-j)$, $(s-i, s+j)$, $(s+i, s-j)$, and $(s+i, s+j)$. +- The parameter $x_{\mathrm{res}}$ is the sole dimension passed to Gwyddion's `make_sphere`. +- On non-square rectangular grids ($x_{\mathrm{res}} \ne y_{\mathrm{res}}$), this creates an intentional asymmetry under matrix transposition. +- This historical asymmetry is required for exact Gwyddion 2.71 compatibility and must not be "corrected" by using $\min(x_{\mathrm{res}}, y_{\mathrm{res}})$. + +## 7. Local mean and RMS semantics + +For filter size $k = s // 2 > 0$, local moving windows use asymmetric negative and positive extensions: + +$$ +k_- = (k - 1) // 2, \qquad k_+ = k // 2 +$$ + +For each pixel $(r, c)$, the window bounds are truncated at image borders: + +$$ +r_{\mathrm{start}} = \max(0, r - k_-), \qquad r_{\mathrm{stop}} = \min(y_{\mathrm{res}} - 1, r + k_+) +$$ + +$$ +c_{\mathrm{start}} = \max(0, c - k_-), \qquad c_{\mathrm{stop}} = \min(x_{\mathrm{res}} - 1, c + k_+) +$$ + +Properties: +- Truncated window support at boundaries without zero-padding. +- Window sums are normalized by the effective pixel count in the window. +- Odd $k$ yields a symmetric centered window; even $k$ places one extra sample to the right and bottom. + +Local mean $\mu_{\mathrm{local}}$ is the window arithmetic mean. + +Local RMS $\sigma_{\mathrm{local}}$ for $k > 1$ is calculated as: + +$$ +\sigma_{\mathrm{local}} = \sqrt{\max\left(E[F^2] - E[F]^2, 0\right)} +$$ + +Special filter size semantics: +- When $k = 0$: $\mu_{\mathrm{local}} = F$ (unfiltered copy) and $\sigma_{\mathrm{local}} = F$ (unfiltered copy). +- When $k = 1$: $\mu_{\mathrm{local}} = F$ (unfiltered copy) and $\sigma_{\mathrm{local}} = 0.0$. +- SPMKit reproduces these exact numerical outputs. +- SPMKit does not emit Gwyddion's `GwyProcess-CRITICAL` GLib diagnostic warnings. + +Note: The independent Python oracle uses direct window summation loops, while Gwyddion uses 1D rolling sums. Both yield identical floating-point results within sub-ULP rounding tolerances. + +## 8. Outlier-trimmed field + +The outlier-trimmed field $T$ is computed element-by-element as: + +$$ +T = \max\left(F, \mu_{\mathrm{local}} - 2.5 \sigma_{\mathrm{local}}\right) +$$ + +For $k = 0$, where $\mu_{\mathrm{local}} = F$ and $\sigma_{\mathrm{local}} = F$, this simplifies to: + +$$ +T = \max(F, -1.5 F) +$$ + +$T$ represents an intermediate trimmed field and is not the final background. + +## 9. Two-dimensional envelope + +The background $B_{ij}$ at pixel $(i, j)$ is extracted as the lower envelope of $T$ relative to the scaled sphere kernel $S$: + +$$ +B_{ij} = \min_{\substack{a \in [-s, s], b \in [-s, s] \\ (i+a, j+b) \text{ valid} \\ S_{s+a, s+b} \ge -q}} \left[ T_{i+a, j+b} - S_{s+a, s+b} \right] +$$ + +Since $S = -q z$, this is equivalent to: + +$$ +B_{ij} = \min \left[ T_{i+a, j+b} + q z_{s+a, s+b} \right] +$$ + +Key requirements: +- Full 2D minimization loop over valid kernel offsets. +- Support points with $S < -q$ are excluded from minimization. +- Truncated boundary support without padding. +- Normal corrected field: $C = F - B$. + +## 10. Radius-one historical semantics + +Executable probe evidence confirms: +- For $r = 1.0$, $s = 1$, $n = 3$, and $k = 0$. +- Gwyddion 2.71 emits two GLib critical diagnostic warnings (`size > 0` assertion failures) because $k = 0$. +- The Gwyddion execution continues normally, returning exit code 0, finite outputs, and exact reconstruction. +- SPMKit preserves the exact numerical output ($T = \max(F, -1.5F)$ passed through the 2D envelope) while executing cleanly without diagnostics. + +## 11. Constant-field q=0 semantics + +For constant input fields ($F_{ij} = c$): +- Global RMS is $0.0$, yielding $q = 0.0$. +- The scaled sphere kernel $S$ consists entirely of zeros. +- The condition $S \ge -q$ ($0.0 \ge 0.0$) holds for all kernel positions. +- Background $B_{ij} = c$ and corrected $C_{ij} = 0.0$. +- All outputs are finite and exhibit exact zero error. + +## 12. Inverted reference execution defect + +### EXECUTABLE_CONFIRMED_REFERENCE_DEFECT + +Executable campaign evidence across 15 inverted reference cases confirms: +- In Gwyddion 2.71's `sphere-revolve.c`, when `inverted=TRUE`, line 320 executes `gwy_object_unref(field); field = invfield;`. +- At line 328, `gwy_data_field_subtract_fields(args->field, field)` uses `args->field` which was not reassigned to `invfield`. +- 15 out of 15 inverted normal cases crashed with exit code 139 (SIGSEGV). +- 15 out of 15 inverted ASan cases crashed with exit code 134 (SIGABRT). +- Probe output recorded `execute_started=1` but `execute_returned=0`. +- The crash occurs inside `gwy_data_field_check_compatibility()` due to a read of unallocated memory during final subtraction. +- ASan output did not emit the literal string `heap-use-after-free`, so that specific string is not used as an exact diagnostic tag. +- Gwyddion 2.71 provides no valid reference output for `inverted=TRUE`. +- The reference `inverted=TRUE` C wrapper cannot serve as a valid numerical oracle. + +## 13. Safe inverted semantics in SPMKit + +To provide mathematically sound inversion without inheriting reference crashes, SPMKit defines safe inverted semantics: + +Inverted Background Dual: + +$$ +B_{\mathrm{inv}}(F) = -B_{\mathrm{normal}}(-F) +$$ + +Inverted Corrected Field: + +$$ +C_{\mathrm{inv}}(F) = F - B_{\mathrm{inv}}(F) +$$ + +Evidence hierarchy: +- Direct external reference: Normal route (`inverted=False`). +- Derived external reference: Inverted background $B_{\mathrm{inv}}(F) = -B_{\mathrm{normal}}(-F)$, verified by running Gwyddion's normal C kernel on 10 explicitly negated inputs (`input_negation_max_abs = 0.0`, $q$ difference $= 0.0$, dual reconstruction max abs error $= 8.88 \times 10^{-16}$). +- Safe deliberate divergence: Inverted corrected field $C_{\mathrm{inv}}(F) = F - B_{\mathrm{inv}}(F)$, ensuring exact reconstruction identity without undefined behavior. + +## 14. Independent oracle evidence + +An independent Python oracle (`/tmp/spmkit_gwyddion_sphere_oracle.py`) evaluated 20 valid cases (10 original normal, 10 negated normal): +- Implemented in pure Python 3 and NumPy without SciPy or SPMKit imports. +- Evaluated using direct 2D window loops. +- Max $q$ absolute error: `0.0`. +- Max background absolute error: `4.4408920985006262e-16`. +- Max background ULP error: 2 ULP. +- Max corrected absolute error: `8.8817841970012523e-16`. +- Max reconstruction error: `8.8817841970012523e-16`. +- All outputs 100% finite. +- Large raw corrected ULP (`4377498837804122113`) resulted from comparing `-4.44e-16` against positive zero `0.0`. +- Raw ULP near zero is not an acceptance criterion. + +## 15. Acceptance criteria + +Numerical acceptance criteria for external validation fixtures: + +```text +background_max_abs_error = 5e-14 +corrected_max_abs_error = 5e-14 +reconstruction_max_abs_error = 5e-14 +rtol = 0 +``` + +Explanation: +- Provides a safety margin above the observed maximum numerical discrepancy (`8.88e-16`). +- Matches the tolerance scale established for Gwyddion Revolve Arc compatibility. +- Applies absolute comparison (`atol = 5e-14`, `rtol = 0`). +- ULP distance is logged as a diagnostic metric only. +- Applies to frozen external validation fixtures and does not constitute universal equivalence. + +## 16. Planned implementation architecture + +Implementation will add one private module and modify two existing files: +- New file: `src/spmkit/core/analysis/_gwyddion_sphere_revolution.py` +- Modify: `src/spmkit/core/analysis/background.py` +- Modify: `src/spmkit/core/analysis/__init__.py` + +Private API signatures in `_gwyddion_sphere_revolution.py`: + +```python +_gwyddion_sphere_background( + data: FloatArray, + radius: float, +) -> FloatArray +``` + +```python +_gwyddion_sphere_result( + data: FloatArray, + radius: float, + *, + inverted: bool = False, +) -> tuple[FloatArray, FloatArray] +``` + +```python +_gwyddion_sphere_corrected( + data: FloatArray, + radius: float, + *, + inverted: bool = False, +) -> FloatArray +``` + +Architecture rules: +- `_gwyddion_sphere_background` computes authoritative normal background. +- `_gwyddion_sphere_result` centralizes inversion dual and corrected calculation. +- `_gwyddion_sphere_corrected` delegates to `_gwyddion_sphere_result`. +- Public adapters delegate to `_gwyddion_sphere_result`. +- No duplication of algorithm logic. +- No sharing of private helper functions with Arc Revolution in this phase. +- Existing physical Sphere Revolution code remains completely untouched. + +## 17. Required tests + +Required test matrix: + +### Private/core tests (`tests/core/test_gwyddion_sphere_revolution_background.py`) +- `test_default_radius_is_20` +- `test_radius_boundary_1_accepted` +- `test_radius_boundary_1000_accepted` +- `test_invalid_nonfinite_radius_rejected` +- `test_invalid_range_radius_rejected` +- `test_bool_radius_rejected` +- `test_float64_conversion` +- `test_c_contiguous_output` +- `test_readonly_array_output` +- `test_input_array_not_mutated` +- `test_constant_field_zero_corrected` +- `test_radius_one_semantics` +- `test_very_flat_branch_execution` +- `test_rectangular_asymmetry` +- `test_safe_inversion_dual_identity` +- `test_reconstruction_identity` +- `test_private_result_agreement` + +### Public adapter tests (`tests/core/test_gwyddion_sphere_revolution_background.py`) +- `test_estimate_delegates_correctly` +- `test_remove_delegates_correctly` +- `test_analyze_returns_background_result` +- `test_method_string_is_gwyddion_sphere_revolution` +- `test_parameters_dict_contents` +- `test_channel_context_preservation` +- `test_physical_sphere_api_unchanged` +- `test_public_private_numerical_agreement` + +### External validation tests (`tests/validation/test_sphere_revolution_vs_gwyddion.py`) +- `test_gwyddion_sphere_direct_normal_background_matches_gwyddion_2_71` +- `test_gwyddion_sphere_direct_normal_corrected_matches_gwyddion_2_71` +- `test_gwyddion_sphere_negated_normal_background_matches_gwyddion_2_71` +- `test_gwyddion_sphere_derived_inverted_background_matches_gwyddion_2_71` +- `test_gwyddion_sphere_safe_inverted_corrected_matches_gwyddion_2_71` +- `test_gwyddion_sphere_reconstruction_identity` +- `test_gwyddion_sphere_reference_inverted_failure_evidence_is_documented` +- `test_gwyddion_sphere_public_result_matches_gwyddion_2_71` +- `test_gwyddion_sphere_fixture_hashes_are_stable` + +## 18. Fixture and provenance requirements + +Fixture location: +`tests/validation/fixtures/gwyddion/sphere_revolution/` + +Artifacts: +- `gwyddion_2_71_sphere.npz` (uncompressed NPZ containing input, reference background, reference corrected, and derived arrays). +- `gwyddion_2_71_sphere.json` (JSON metadata with SHA-256 hashes, canonical array hashes, source/probe/runner/oracle hashes, case metadata, acceptance tolerances, and reference execution status). + +Requirements: +- Distinguish direct normal cases, negated-normal cases, derived inverted arrays, and failed original inverted executions. +- Do not store dummy or synthetic arrays for crashing reference cases. + +## 19. Scientific claim boundaries + +Claim status before implementation: +- Design specified and normative contract established. +- External reference and independent oracle characterized. +- SPMKit implementation pending. + +Claim status after implementation and validation: +- Normal route: Level 3 CROSS_VALIDATED within external fixture. +- Inverted background: Derived external cross-validation (`-B_normal(-F)`). +- Inverted corrected: Safe deliberate divergence (`F - B_inv(F)`). +- Universal equivalence across arbitrary inputs or platforms is not claimed. +- Physical Sphere Revolution maintains its independent maturity and physical claims. + +## 20. Explicit non-goals + +The following non-goals are explicitly established: +- Do not replace or modify physical Sphere Revolution. +- Do not modify or patch upstream Gwyddion 2.71 C source code. +- Do not reproduce upstream C memory corruption crashes. +- Do not reproduce GLib warning messages. +- Do not use physical units (metres, nanometres) in Gwyddion compatibility APIs. +- Do not add mask support in this phase. +- Do not add border padding or extension policies. +- Do not add direction parameters (`direction` is for Arc, not Sphere). +- Do not expand radius range beyond `1.0 <= radius_px <= 1000.0`. +- Do not perform premature performance optimization before closing parity. +- Do not parallelize execution loops. +- Do not refactor Arc Revolution implementation. +- Do not claim universal numerical equivalence beyond frozen fixtures. + +## 21. Provenance ledger + +| Artifact | SHA-256 | Role | +|---|---|---| +| `sphere-revolve.c` | `4218cd4e303634c610e9be5f18656d12715c68df95a9b30930b33232b3d8cbe9` | Gwyddion 2.71 reference C module source | +| `sphere_revolve_behavior_probe.c` | `97248b51df742937ed5dc0a975b8b1ca08b1b6eeb5add95eda4526118337b188` | C probe source (schema_version 2, 35 cases) | +| `run_sphere_probe_campaign.sh` | `d673393126833277bda41c77403f1dbaf5dc965d6d8b63ee73994238bec8f7a7` | Campaign runner script v2 | +| `sphere_oracle.py` | `f1598e5f7cd0e173ec72ea928e270038ac8ab5f4c61d57b31a60373e50d40e4b` | Independent Python oracle script | +| `precision_audit.py` | `58f1acd0d3c3d644c93adcc7c754889e883726976341695e974d4c09a34f72e4` | Floating-point precision audit script | +| `implementation_dossier.md` | `fd9e83b28027a130025528130f96d3e4f4f03bdc792830666861ec335efef3a2` | Implementation dossier report | + +## 22. Implementation sequence + +Execution order: +1. Block A: Create normative specification `docs/design/GWYDDION_SPHERE_REVOLUTION_COMPATIBILITY.md` (completed in this step). +2. Block B: Implement private numerical kernel `src/spmkit/core/analysis/_gwyddion_sphere_revolution.py`. +3. Block C: Implement private unit tests `tests/core/test_gwyddion_sphere_revolution_background.py`. +4. Block D: Implement public adapters in `src/spmkit/core/analysis/background.py` and `__init__.py`. +5. Block E: Implement public API unit tests in `tests/core/test_gwyddion_sphere_revolution_background.py`. +6. Block F: Create frozen external validation fixtures in `tests/validation/fixtures/gwyddion/sphere_revolution/`. +7. Block G: Implement external validation tests in `tests/validation/test_sphere_revolution_vs_gwyddion.py`. +8. Block H: Update documentation files (`docs/api.md`, `docs/scientific-status.md`, `docs/validation/index.md`). +9. Block I: Run focal regression test suite and static type checking. +10. Block J: Run global regression test suite (`pytest`). +11. Block K: Atomic git commit and push. + +Each block must be verified before proceeding to the next block. diff --git a/docs/scientific-status.md b/docs/scientific-status.md index 3d4c042..fe9d638 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -39,6 +39,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Physical arc-revolution background | `core.analysis.background` | 55 unit and synthetic tests, including a test-local brute-force 1D oracle, inversion duality, physical-unit equivalence, anisotropic spacing, border policies and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence or physical-reference campaign | | Gwyddion-compatible Revolve Arc background | `core.analysis.background`, `core.analysis._gwyddion_arc_revolution` | Frozen Gwyddion 2.71 source semantics, focal kernel probes, one asymmetric 5×7 directional fixture, 6/6 background routes and 5/5 valid corrected routes within `5e-14`; repaired reconstruction for the defective horizontal-inverted wrapper | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes and frozen JSON/NPZ fixture | Radius is in samples; no masks or non-finite data; one-sample processing axes use a documented safe definition; no physical validation, tip reconstruction, performance equivalence or universal-equivalence claim | | Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | +| Gwyddion-compatible Sphere-revolution background | `core.analysis.background`, `core.analysis._gwyddion_sphere_revolution` | Frozen Gwyddion 2.71 source semantics, focal probes, 10 original surfaces, 10 normal executions on negated inputs (20 valid external runs per build), 15/15 inverted runs failing in normal build and under ASan; direct external reference for normal, derived external cross-validation for inverted background, safe deliberate divergence for inverted corrected (`atol=5e-14`, `rtol=0.0`); independent Python oracle | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes, independent Python oracle and frozen JSON/NPZ fixture | Radius is in samples; no non-finite data or masks; inverted corrected does not claim equivalence with Gwyddion's crashing wrapper; no physical validation, tip deconvolution or universal-equivalence claim; physical sphere-revolution maintains its independent software verification | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | @@ -57,6 +58,19 @@ and tolerance. It never transfers automatically to an adjacent feature. - [Nanoscope `.spm` pilot summary](https://github.com/kegouro/spmkit-validation/blob/main/evidence/campaigns/nanoscope_spm_parser_pilot_v0.1_summary.json) - [Nanoscope incident and final audit](https://github.com/kegouro/spmkit-validation/blob/main/docs/campaigns/nanoscope_spm_parser_pilot_v0.1_audit.md) - [Flatten Base Gwyddion 2.71 frozen end-to-end fixture](https://github.com/kegouro/spmkit/blob/flatten-base-gwyddion-parity-v1/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json) +- [Sphere Revolution Gwyddion 2.71 frozen fixture](https://github.com/kegouro/spmkit/blob/feat/gwyddion-leveling-parity/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json) + +### Gwyddion Sphere Revolution + +SPM-Kit's `gwyddion_sphere_revolution` implementation is maintained separately from physical sphere revolution. It reproduces Gwyddion 2.71's Revolve Sphere numerical semantics: +- **Campaign Scope:** 10 original surface matrices across WIDE_ASYMMETRIC, TALL_ASYMMETRIC, CONSTANT_ZERO_RMS, and SIGNED_MICRO_GRID families, plus 10 normal executions on explicitly negated inputs (20 valid external runs per build). +- **Inverted Route Failure Evidence:** 15/15 executions of `inverted=True` crash in Gwyddion 2.71's C module (exit code 139 in normal build, exit code 134 under ASan at `sphere-revolve.c:328 gwy_data_field_subtract_fields`). +- **Evidence Classes:** + - `normal` route: Direct external reference from Gwyddion 2.71 stdout. + - `inverted` background: Derived external cross-validation from `-B(-data)`. + - `inverted` corrected: Safe deliberate divergence (`original - background`) reconstructing original data without invoking Gwyddion's crashing subtract wrapper. +- **Independent Oracle & Tolerances:** Verified against an independent Python oracle (`atol=5e-14`, `rtol=0.0`). The maximum observed numerical discrepancy across all comparisons is `8.881784e-16` (well below `5e-14`). +- **Claim:** LEVEL 3 CROSS_VALIDATED within the frozen fixture scope. No universal equivalence or physical validation is claimed. Physical sphere revolution (`estimate_sphere_revolution_background`) maintains its independent software verification. ## Test-count policy diff --git a/docs/validation/index.md b/docs/validation/index.md index 39dd4ea..dd2f392 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -37,6 +37,7 @@ references, tolerances, outputs, hashes, and limitations. | Gwyddion roughness 48 v0.1 | Sa, Sq, Sz on 48 canonical synthetic matrices | 144/144 within tolerance | CROSS_VALIDATED | No preprocessing; shared matrices; not physical validation | | Real-data roughness pilot v0.1 | Sa, Sq, Sz on 12 public GWY matrices | 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the algorithm track | Parser/end-to-end observations are separate; real data are not ground truth | | Gwyddion Revolve Arc 2.71 v1 | Data-adaptive arc-envelope background on a frozen asymmetric 5×7 field, six direction/inversion routes and focal kernel cases | 6/6 backgrounds and 5/5 valid corrected outputs within `5e-14`; horizontal-inverted reference defect preserved as evidence and repaired by reconstruction | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; known wrapper and one-sample reference defects documented; not physical validation or universal equivalence | +| Gwyddion Revolve Sphere 2.71 v1 | Data-adaptive sphere-envelope background on 10 logical pairs (20 normal runs per build) and 15 failing inverted runs; direct normal external reference and derived inverted background within 5e-14; safe inverted corrected reconstruction | 20/20 valid external runs and 10/10 derived inverted backgrounds within 5e-14 | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; 15/15 inverted wrapper crashes documented as reference failures; not physical validation or universal equivalence | | Nanoscope `.spm` pilot v0.1 | Six demonstrated files | 18/18 metric comparisons within tolerance | NUMERICALLY_VERIFIED limited parser claim | Partial support and `ACCIDENTAL_PRE_FREEZE_UNBLINDING` | See [Scientific status](../scientific-status.md) for the complete mapping and diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index 9b14aa8..0231bc5 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -20,6 +20,7 @@ GwyddionArcDirection, analyze_arc_revolution_background, analyze_gwyddion_arc_revolution_background, + analyze_gwyddion_sphere_revolution_background, analyze_median_background, analyze_polynomial_background, analyze_rolling_ball_background, @@ -27,6 +28,7 @@ analyze_spline_background, estimate_arc_revolution_background, estimate_gwyddion_arc_revolution_background, + estimate_gwyddion_sphere_revolution_background, estimate_median_background, estimate_polynomial_background, estimate_rolling_ball_background, @@ -34,6 +36,7 @@ estimate_spline_background, remove_arc_revolution_background, remove_gwyddion_arc_revolution_background, + remove_gwyddion_sphere_revolution_background, remove_median_background, remove_polynomial_background, remove_rolling_ball_background, @@ -67,6 +70,7 @@ "GwyddionArcDirection", "analyze_arc_revolution_background", "analyze_gwyddion_arc_revolution_background", + "analyze_gwyddion_sphere_revolution_background", "analyze_median_background", "analyze_polynomial_background", "analyze_rolling_ball_background", @@ -74,6 +78,7 @@ "analyze_spline_background", "estimate_arc_revolution_background", "estimate_gwyddion_arc_revolution_background", + "estimate_gwyddion_sphere_revolution_background", "estimate_median_background", "estimate_polynomial_background", "estimate_rolling_ball_background", @@ -81,6 +86,7 @@ "estimate_spline_background", "remove_arc_revolution_background", "remove_gwyddion_arc_revolution_background", + "remove_gwyddion_sphere_revolution_background", "remove_median_background", "remove_polynomial_background", "remove_rolling_ball_background", diff --git a/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py b/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py new file mode 100644 index 0000000..a54de27 --- /dev/null +++ b/src/spmkit/core/analysis/_gwyddion_sphere_revolution.py @@ -0,0 +1,300 @@ +"""Numerical kernels compatible with Gwyddion's Revolve Sphere operation. + +This module implements the numerical semantics of the Gwyddion 2.71 +``sphere-revolve`` process independently in NumPy. It intentionally remains +separate from SPMKit's physical sphere-revolution estimator because the two +operations use different radius, scaling, and boundary conventions. +""" + +from __future__ import annotations + +import math +import sys + +import numpy as np +from numpy.typing import NDArray + +FloatArray = NDArray[np.float64] + + +def _validated_data_array(data: object, operation: str) -> FloatArray: + """Validate that input data is a real, finite, non-empty 2D array.""" + data_arr = np.asarray(data) + + if ( + data_arr.ndim != 2 + or data_arr.size == 0 + or not np.issubdtype(data_arr.dtype, np.number) + or np.iscomplexobj(data_arr) + or isinstance(data, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires data to be a real 2D array") + + float_arr = np.array(data_arr, dtype=np.float64, order="C", copy=True) + + if not np.all(np.isfinite(float_arr)): + raise ValueError(f"{operation} requires data to be finite") + + return float_arr + + +def _validated_radius(radius: object, operation: str) -> float: + """Validate that radius is a real scalar finite number between 1.0 and 1000.0.""" + radius_arr = np.asarray(radius) + + if ( + radius_arr.ndim != 0 + or not np.issubdtype(radius_arr.dtype, np.number) + or np.iscomplexobj(radius_arr) + or isinstance(radius, (bool, np.bool_)) + ): + raise TypeError(f"{operation} requires radius to be a real scalar") + + val = float(radius_arr.item()) + + if not math.isfinite(val): + raise ValueError(f"{operation} requires radius to be finite") + + if not 1.0 <= val <= 1000.0: + raise ValueError(f"{operation} requires radius to be between 1.0 and 1000.0 samples") + + return val + + +def _validated_inverted(inverted: object, operation: str) -> bool: + """Validate that inverted option is a boolean.""" + if not isinstance(inverted, (bool, np.bool_)): + raise TypeError(f"{operation} requires inverted to be a boolean") + return bool(inverted) + + +def _readonly_float_array(values: NDArray[np.float64]) -> FloatArray: + """Return a C-contiguous, read-only float64 copy of the input array.""" + array = np.array(values, dtype=np.float64, order="C", copy=True) + array.setflags(write=False) + return array + + +def _gwyddion_sphere_background( + data: FloatArray, + radius: float, +) -> FloatArray: + """Calculate Gwyddion 2.71 Sphere Revolution background on 2D float64 data. + + Parameters + ---------- + data: + Two-dimensional real finite float64 array. + radius: + Sphere radius in samples (1.0 through 1000.0). + + Returns + ------- + numpy.ndarray + Read-only C-contiguous float64 background matrix. + """ + array = _validated_data_array(data, "_gwyddion_sphere_background") + radius_val = _validated_radius(radius, "_gwyddion_sphere_background") + + yres, xres = array.shape + + # 1. Serial global mean (C-order) + total = 0.0 + for value in array.ravel(order="C"): + total += float(value) + mean = total / float(array.size) + + # 2. Serial global population RMS (C-order) + sum2 = 0.0 + for value in array.ravel(order="C"): + delta = float(value) - mean + sum2 += delta * delta + rms = math.sqrt(sum2 / float(array.size)) + + # 3. Global scaling parameter q + q = rms / math.sqrt(5.0 / 6.0) + + # 4. Discrete sphere dimensions + sphere_size = math.floor(min(radius_val, float(xres)) + 0.5) + sphere_resolution = 2 * sphere_size + 1 + local_filter_size = sphere_size // 2 + very_flat = (radius_val / 8.0) > float(xres) + + center = sphere_size + sphere_z = np.zeros((sphere_resolution, sphere_resolution), dtype=np.float64, order="C") + + # 5. Discrete sphere construction (quadrant loop) + for i in range(sphere_size + 1): + u = i / radius_val + for j in range(sphere_size + 1): + v = j / radius_val + r2 = u * u + v * v + if very_flat: + z = (r2 / 2.0) * (1.0 + (r2 / 4.0) * (1.0 + r2 / 2.0)) + else: + z = 2.0 if r2 > 1.0 else 1.0 - math.sqrt(1.0 - r2) + sphere_z[center - i, center - j] = z + sphere_z[center - i, center + j] = z + sphere_z[center + i, center - j] = z + sphere_z[center + i, center + j] = z + + # Correction 1: explicit scalar loop scaling + sphere_scaled = np.zeros( + (sphere_resolution, sphere_resolution), + dtype=np.float64, + order="C", + ) + for row in range(sphere_resolution): + for column in range(sphere_resolution): + sphere_scaled[row, column] = ( + -q * float(sphere_z[row, column]) + ) + + # 6. Direct local mean field + if local_filter_size == 0: + local_mean = np.array(array, dtype=np.float64, order="C", copy=True) + else: + neg_ext = (local_filter_size - 1) // 2 + pos_ext = local_filter_size // 2 + local_mean = np.zeros((yres, xres), dtype=np.float64, order="C") + for r in range(yres): + r_start = max(0, r - neg_ext) + r_stop = min(yres - 1, r + pos_ext) + for c in range(xres): + c_start = max(0, c - neg_ext) + c_stop = min(xres - 1, c + pos_ext) + sum_val = 0.0 + count = 0 + for rr in range(r_start, r_stop + 1): + for cc in range(c_start, c_stop + 1): + sum_val += float(array[rr, cc]) + count += 1 + local_mean[r, c] = sum_val / float(count) + + # 7. Direct local RMS field + if local_filter_size == 0: + local_rms = np.array(array, dtype=np.float64, order="C", copy=True) + elif local_filter_size == 1: + local_rms = np.zeros((yres, xres), dtype=np.float64, order="C") + else: + neg_ext = (local_filter_size - 1) // 2 + pos_ext = local_filter_size // 2 + local_rms = np.zeros((yres, xres), dtype=np.float64, order="C") + for r in range(yres): + r_start = max(0, r - neg_ext) + r_stop = min(yres - 1, r + pos_ext) + for c in range(xres): + c_start = max(0, c - neg_ext) + c_stop = min(xres - 1, c + pos_ext) + sum_val = 0.0 + sum_sq = 0.0 + count = 0 + for rr in range(r_start, r_stop + 1): + for cc in range(c_start, c_stop + 1): + val = float(array[rr, cc]) + sum_val += val + sum_sq += val * val + count += 1 + m = sum_val / float(count) + m_sq = sum_sq / float(count) + var = m_sq - m * m + if var < 0.0: + var = 0.0 + local_rms[r, c] = math.sqrt(var) + + # 8. Outlier-trimmed field T + trimmed = np.zeros((yres, xres), dtype=np.float64, order="C") + for r in range(yres): + for c in range(xres): + thresh = local_mean[r, c] - 2.5 * local_rms[r, c] + val = float(array[r, c]) + trimmed[r, c] = thresh if thresh > val else val + + # 9. Two-dimensional lower envelope minimization + bg = np.zeros((yres, xres), dtype=np.float64, order="C") + for r in range(yres): + for c in range(xres): + ifrom = max(0, r - sphere_size) - r + ito = min(r + sphere_size, yres - 1) - r + jfrom = max(0, c - sphere_size) - c + jto = min(c + sphere_size, xres - 1) - c + minimum = sys.float_info.max + for ii in range(ifrom, ito + 1): + for jj in range(jfrom, jto + 1): + sph_val = float(sphere_scaled[center + ii, center + jj]) + if sph_val >= -q: + data_val = float(trimmed[r + ii, c + jj]) + cand = data_val - sph_val + if cand < minimum: + minimum = cand + bg[r, c] = minimum + + return _readonly_float_array(bg) + + +def _gwyddion_sphere_result( + data: FloatArray, + radius: float, + *, + inverted: bool = False, +) -> tuple[FloatArray, FloatArray]: + """Calculate Gwyddion 2.71 Sphere Revolution (background, corrected) tuple. + + Parameters + ---------- + data: + Two-dimensional real finite float64 array. + radius: + Sphere radius in samples (1.0 through 1000.0). + inverted: + Apply safe dual inversion ``-B(-data)``. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + Read-only float64 (background, corrected) array pair. + """ + array = _validated_data_array(data, "_gwyddion_sphere_result") + _validated_radius(radius, "_gwyddion_sphere_result") + inv_bool = _validated_inverted(inverted, "_gwyddion_sphere_result") + + if not inv_bool: + bg_arr = _gwyddion_sphere_background(array, radius) + bg_raw = np.asarray(bg_arr) + else: + negated = -array + neg_bg = _gwyddion_sphere_background(negated, radius) + bg_raw = -np.asarray(neg_bg) + + corr_raw = np.zeros(array.shape, dtype=np.float64, order="C") + yres, xres = array.shape + for r in range(yres): + for c in range(xres): + corr_raw[r, c] = float(array[r, c]) - float(bg_raw[r, c]) + + return _readonly_float_array(bg_raw), _readonly_float_array(corr_raw) + + +def _gwyddion_sphere_corrected( + data: FloatArray, + radius: float, + *, + inverted: bool = False, +) -> FloatArray: + """Calculate Gwyddion 2.71 Sphere Revolution corrected field. + + Parameters + ---------- + data: + Two-dimensional real finite float64 array. + radius: + Sphere radius in samples (1.0 through 1000.0). + inverted: + Apply safe dual inversion ``-B(-data)``. + + Returns + ------- + numpy.ndarray + Read-only C-contiguous float64 corrected matrix. + """ + return _gwyddion_sphere_result(data, radius, inverted=inverted)[1] diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 70e9376..6dbbb8a 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -17,6 +17,9 @@ GwyddionArcDirection, _gwyddion_arc_result, ) +from spmkit.core.analysis._gwyddion_sphere_revolution import ( + _gwyddion_sphere_result, +) from spmkit.core.analysis._pspline import ( PSplineSurfaceFit, fit_pspline_surface, @@ -33,6 +36,7 @@ BackgroundMethod = Literal[ "arc_revolution", "gwyddion_arc_revolution", + "gwyddion_sphere_revolution", "sphere_revolution", "rolling_ball", "median", @@ -603,6 +607,98 @@ def remove_gwyddion_arc_revolution_background( return corrected +def _gwyddion_sphere_channels( + channel: SPMChannel, + radius_px: object, + *, + inverted: object, + operation: str, +) -> tuple[ + SPMChannel, + SPMChannel, + float, + bool, +]: + """Validate one request and return background and corrected channels.""" + data = _validated_channel_data( + channel, + operation=operation, + ) + radius_value = _validated_gwyddion_radius_px( + radius_px, + operation=operation, + ) + + if not isinstance(inverted, (bool, np.bool_)): + raise TypeError( + f"{operation} requires inverted to be a boolean" + ) + + inverted_value = bool(inverted) + + background_data, corrected_data = _gwyddion_sphere_result( + data, + radius_value, + inverted=inverted_value, + ) + + return ( + channel.with_data(background_data), + channel.with_data(corrected_data), + radius_value, + inverted_value, + ) + + +def estimate_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> SPMChannel: + """Estimate a Gwyddion 2.71-compatible Sphere Revolution background. + + Parameters + ---------- + channel: + Real, finite, non-empty two-dimensional channel. The Z unit is + preserved and need not represent geometric length. + radius_px: + Sphere radius in samples. The public Gwyddion-compatible range is + inclusive from 1.0 through 1000.0. + inverted: + Apply the exact dual ``-B(-data)``. + + Notes + ----- + This is a data-adaptive compatibility estimator, not SPMKit's physical + sphere-revolution model, tip deconvolution, or surface reconstruction. + """ + background, _, _, _ = _gwyddion_sphere_channels( + channel, + radius_px, + inverted=inverted, + operation="estimate_gwyddion_sphere_revolution_background", + ) + return background + + +def remove_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> SPMChannel: + """Subtract a Gwyddion 2.71-compatible Sphere Revolution background.""" + _, corrected, _, _ = _gwyddion_sphere_channels( + channel, + radius_px, + inverted=inverted, + operation="remove_gwyddion_sphere_revolution_background", + ) + return corrected + + def _sphere_structure( *, radius: float, @@ -1502,6 +1598,36 @@ def analyze_gwyddion_arc_revolution_background( ) +def analyze_gwyddion_sphere_revolution_background( + channel: SPMChannel, + radius_px: float = 20.0, + *, + inverted: bool = False, +) -> BackgroundResult: + """Estimate and subtract a compatible Sphere Revolution background once.""" + ( + background, + corrected, + radius_value, + inverted_value, + ) = _gwyddion_sphere_channels( + channel, + radius_px, + inverted=inverted, + operation="analyze_gwyddion_sphere_revolution_background", + ) + + return BackgroundResult( + background=background, + corrected=corrected, + method="gwyddion_sphere_revolution", + parameters={ + "radius_px": radius_value, + "inverted": inverted_value, + }, + ) + + def analyze_sphere_revolution_background( channel: SPMChannel, radius: float, diff --git a/tests/core/test_gwyddion_sphere_revolution_background.py b/tests/core/test_gwyddion_sphere_revolution_background.py new file mode 100644 index 0000000..7c14895 --- /dev/null +++ b/tests/core/test_gwyddion_sphere_revolution_background.py @@ -0,0 +1,371 @@ +"""Tests for private Gwyddion 2.71 Sphere Revolution numerical kernel.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.analysis import ( + BackgroundResult, + analyze_gwyddion_sphere_revolution_background, + estimate_gwyddion_sphere_revolution_background, + remove_gwyddion_sphere_revolution_background, +) +from spmkit.core.analysis._gwyddion_sphere_revolution import ( + _gwyddion_sphere_background, + _gwyddion_sphere_corrected, + _gwyddion_sphere_result, +) +from spmkit.core.models import SPMChannel + + +def test_gwyddion_sphere_rejects_non_2d_input() -> None: + with pytest.raises(TypeError, match="real 2D array"): + _gwyddion_sphere_background(np.array([1.0, 2.0, 3.0]), 5.0) + + with pytest.raises(TypeError, match="real 2D array"): + _gwyddion_sphere_background(np.ones((2, 2, 2)), 5.0) + + +def test_gwyddion_sphere_rejects_empty_dimensions() -> None: + with pytest.raises(TypeError, match="real 2D array"): + _gwyddion_sphere_background(np.zeros((0, 5)), 5.0) + + with pytest.raises(TypeError, match="real 2D array"): + _gwyddion_sphere_background(np.zeros((5, 0)), 5.0) + + +def test_gwyddion_sphere_rejects_boolean_radius() -> None: + data = np.ones((5, 5), dtype=np.float64) + + with pytest.raises(TypeError, match="radius to be a real scalar"): + _gwyddion_sphere_background(data, True) + + with pytest.raises(TypeError, match="radius to be a real scalar"): + _gwyddion_sphere_background(data, False) + + +def test_gwyddion_sphere_rejects_nonfinite_radius() -> None: + data = np.ones((5, 5), dtype=np.float64) + + with pytest.raises(ValueError, match="radius to be finite"): + _gwyddion_sphere_background(data, float("nan")) + + with pytest.raises(ValueError, match="radius to be finite"): + _gwyddion_sphere_background(data, float("inf")) + + +def test_gwyddion_sphere_rejects_radius_below_one() -> None: + data = np.ones((5, 5), dtype=np.float64) + + with pytest.raises(ValueError, match="between 1.0 and 1000.0 samples"): + _gwyddion_sphere_background(data, 0.9) + + +def test_gwyddion_sphere_rejects_radius_above_thousand() -> None: + data = np.ones((5, 5), dtype=np.float64) + + with pytest.raises(ValueError, match="between 1.0 and 1000.0 samples"): + _gwyddion_sphere_background(data, 1000.1) + + +def test_gwyddion_sphere_accepts_radius_boundaries() -> None: + data = np.ones((5, 5), dtype=np.float64) + + bg_min = _gwyddion_sphere_background(data, 1.0) + assert bg_min.shape == (5, 5) + assert np.all(np.isfinite(bg_min)) + + bg_max = _gwyddion_sphere_background(data, 1000.0) + assert bg_max.shape == (5, 5) + assert np.all(np.isfinite(bg_max)) + + +def test_gwyddion_sphere_converts_input_to_float64() -> None: + data_int = np.array([[1, 2], [3, 4]], dtype=np.int32) + bg = _gwyddion_sphere_background(data_int, 2.0) # type: ignore[arg-type] + + assert bg.dtype == np.float64 + + data_f32 = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + bg32 = _gwyddion_sphere_background(data_f32, 2.0) # type: ignore[arg-type] + + assert bg32.dtype == np.float64 + + +def test_gwyddion_sphere_does_not_mutate_input() -> None: + data = np.array([[-4.0, -2.0, 0.0], [1.0, 3.0, 7.0], [-1.0, 2.0, 5.0]], dtype=np.float64) + data_copy = data.copy() + + _gwyddion_sphere_background(data, 1.0) + np.testing.assert_array_equal(data, data_copy) + + _gwyddion_sphere_result(data, 1.0, inverted=True) + np.testing.assert_array_equal(data, data_copy) + + +def test_gwyddion_sphere_background_is_c_contiguous_and_read_only() -> None: + data = np.ones((5, 5), dtype=np.float64) + bg = _gwyddion_sphere_background(data, 2.5) + + assert bg.flags.c_contiguous + assert not bg.flags.writeable + + with pytest.raises(ValueError, match="read-only"): + bg[0, 0] = 99.0 + + +def test_gwyddion_sphere_result_arrays_are_distinct_and_read_only() -> None: + data = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64) + bg, corr = _gwyddion_sphere_result(data, 2.0, inverted=False) + + assert bg is not corr + assert bg.base is not corr + assert bg.flags.c_contiguous and not bg.flags.writeable + assert corr.flags.c_contiguous and not corr.flags.writeable + + +def test_gwyddion_sphere_constant_field_returns_identity_background() -> None: + data = np.full((7, 7), 5.0, dtype=np.float64) + bg = _gwyddion_sphere_background(data, 3.0) + + np.testing.assert_allclose(bg, data, atol=0.0, rtol=0.0) + + +def test_gwyddion_sphere_constant_field_corrected_is_zero() -> None: + data = np.full((7, 7), 5.0, dtype=np.float64) + bg, corr = _gwyddion_sphere_result(data, 3.0, inverted=False) + + np.testing.assert_allclose(corr, 0.0, atol=0.0, rtol=0.0) + + +def test_gwyddion_sphere_radius_one_signed_field_matches_frozen_values() -> None: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + + expected_bg = np.array( + [ + [4.5694174231575584, 3.0, 0.0], + [1.0, 3.0, 3.5694174231575579], + [1.5, 2.0, 5.0], + ], + dtype=np.float64, + ) + + expected_corr = np.array( + [ + [-8.5694174231575584, -5.0, 0.0], + [0.0, 0.0, 3.4305825768424421], + [-2.5, 0.0, 0.0], + ], + dtype=np.float64, + ) + + bg, corr = _gwyddion_sphere_result(data, 1.0, inverted=False) + + np.testing.assert_allclose(bg, expected_bg, atol=5e-14, rtol=0.0) + np.testing.assert_allclose(corr, expected_corr, atol=5e-14, rtol=0.0) + + +def test_gwyddion_sphere_very_flat_branch_returns_finite_values() -> None: + data = np.arange(25, dtype=np.float64).reshape((5, 5)) + bg = _gwyddion_sphere_background(data, 50.0) + + assert bg.shape == (5, 5) + assert np.all(np.isfinite(bg)) + + +def test_gwyddion_sphere_uses_xres_for_sphere_size() -> None: + # On non-square grid (5 rows, 10 cols), radius=8 => sphere_size = min(8, 10) = 8. + # On transposed grid (10 rows, 5 cols), radius=8 => sphere_size = min(8, 5) = 5. + data_asym = np.arange(50, dtype=np.float64).reshape((5, 10)) + bg_orig = _gwyddion_sphere_background(data_asym, 8.0) + + data_transposed = data_asym.T + bg_trans = _gwyddion_sphere_background(data_transposed, 8.0) + + # Verify that transposed background is valid and finite + assert bg_orig.shape == (5, 10) + assert bg_trans.shape == (10, 5) + assert np.all(np.isfinite(bg_orig)) + assert np.all(np.isfinite(bg_trans)) + + +def test_gwyddion_sphere_safe_inversion_duality() -> None: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + + bg_inv, _ = _gwyddion_sphere_result(data, 2.5, inverted=True) + neg_bg = _gwyddion_sphere_background(-data, 2.5) + + np.testing.assert_allclose(bg_inv, -neg_bg, atol=0.0, rtol=0.0) + + +def test_gwyddion_sphere_result_reconstructs_input() -> None: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + + for inv in (False, True): + bg, corr = _gwyddion_sphere_result(data, 2.5, inverted=inv) + reconstruction = corr + bg + np.testing.assert_allclose(reconstruction, data, atol=5e-14, rtol=0.0) + + +def test_gwyddion_sphere_corrected_delegates_to_result() -> None: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + + for inv in (False, True): + corr_direct = _gwyddion_sphere_corrected(data, 2.5, inverted=inv) + _, corr_result = _gwyddion_sphere_result(data, 2.5, inverted=inv) + np.testing.assert_allclose(corr_direct, corr_result, atol=0.0, rtol=0.0) + + +def test_gwyddion_sphere_accepts_singleton_2d_fields() -> None: + shapes = [(1, 1), (1, 5), (5, 1)] + for shape in shapes: + data = np.arange(shape[0] * shape[1], dtype=np.float64).reshape(shape) + bg, corr = _gwyddion_sphere_result(data, 2.0, inverted=False) + + assert bg.shape == shape + assert corr.shape == shape + assert np.all(np.isfinite(bg)) + assert np.all(np.isfinite(corr)) + np.testing.assert_allclose(corr + bg, data, atol=5e-14, rtol=0.0) + + +def _channel() -> SPMChannel: + data = np.array( + [ + [-4.0, -2.0, 0.0], + [1.0, 3.0, 7.0], + [-1.0, 2.0, 5.0], + ], + dtype=np.float64, + ) + return SPMChannel( + name="Test Sphere", + data=data, + unit="m", + x_range=8.0e-6, + y_range=5.0e-6, + metadata={"source": "test_gwyddion_sphere"}, + ) + + +def test_estimate_gwyddion_sphere_background_matches_private_result() -> None: + ch = _channel() + for inv in (False, True): + pub_bg = estimate_gwyddion_sphere_revolution_background(ch, 2.5, inverted=inv) + priv_bg, _ = _gwyddion_sphere_result(ch.data, 2.5, inverted=inv) + + np.testing.assert_allclose(pub_bg.data, priv_bg, atol=0.0, rtol=0.0) + + +def test_remove_gwyddion_sphere_background_matches_private_result() -> None: + ch = _channel() + for inv in (False, True): + pub_corr = remove_gwyddion_sphere_revolution_background(ch, 2.5, inverted=inv) + _, priv_corr = _gwyddion_sphere_result(ch.data, 2.5, inverted=inv) + + np.testing.assert_allclose(pub_corr.data, priv_corr, atol=0.0, rtol=0.0) + + +def test_analyze_gwyddion_sphere_background_returns_consistent_result() -> None: + ch = _channel() + for inv in (False, True): + res = analyze_gwyddion_sphere_revolution_background(ch, 2.5, inverted=inv) + + assert isinstance(res, BackgroundResult) + assert res.method == "gwyddion_sphere_revolution" + assert res.parameters == {"radius_px": 2.5, "inverted": inv} + + priv_bg, priv_corr = _gwyddion_sphere_result(ch.data, 2.5, inverted=inv) + np.testing.assert_allclose(res.background.data, priv_bg, atol=0.0, rtol=0.0) + np.testing.assert_allclose(res.corrected.data, priv_corr, atol=0.0, rtol=0.0) + np.testing.assert_allclose( + res.corrected.data + res.background.data, + ch.data, + atol=5e-14, + rtol=0.0, + ) + + +def test_gwyddion_sphere_public_method_and_parameters() -> None: + ch = _channel() + res = analyze_gwyddion_sphere_revolution_background(ch, 15.0, inverted=True) + + assert res.method == "gwyddion_sphere_revolution" + assert res.parameters == {"radius_px": 15.0, "inverted": True} + + +def test_gwyddion_sphere_public_preserves_channel_context() -> None: + ch = _channel() + res = analyze_gwyddion_sphere_revolution_background(ch, 2.5) + + assert res.background.unit == ch.unit + assert res.background.x_range == ch.x_range + assert res.background.y_range == ch.y_range + assert res.background.metadata == ch.metadata + + assert res.corrected.unit == ch.unit + assert res.corrected.x_range == ch.x_range + assert res.corrected.y_range == ch.y_range + assert res.corrected.metadata == ch.metadata + + +def test_gwyddion_sphere_public_does_not_mutate_channel() -> None: + ch = _channel() + ch_data_copy = ch.data.copy() + + estimate_gwyddion_sphere_revolution_background(ch, 2.5) + remove_gwyddion_sphere_revolution_background(ch, 2.5) + analyze_gwyddion_sphere_revolution_background(ch, 2.5) + + np.testing.assert_array_equal(ch.data, ch_data_copy) + + +def test_gwyddion_sphere_public_exports_are_available() -> None: + import spmkit.core.analysis as analysis_mod + + assert hasattr(analysis_mod, "estimate_gwyddion_sphere_revolution_background") + assert hasattr(analysis_mod, "remove_gwyddion_sphere_revolution_background") + assert hasattr(analysis_mod, "analyze_gwyddion_sphere_revolution_background") + + assert "estimate_gwyddion_sphere_revolution_background" in analysis_mod.__all__ + assert "remove_gwyddion_sphere_revolution_background" in analysis_mod.__all__ + assert "analyze_gwyddion_sphere_revolution_background" in analysis_mod.__all__ + + +def test_gwyddion_sphere_physical_api_remains_distinct() -> None: + import spmkit.core.analysis as analysis_mod + + phys_estimate = analysis_mod.estimate_sphere_revolution_background + gwy_estimate = analysis_mod.estimate_gwyddion_sphere_revolution_background + + assert phys_estimate is not gwy_estimate + diff --git a/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json b/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json new file mode 100644 index 0000000..b8b8a29 --- /dev/null +++ b/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json @@ -0,0 +1,485 @@ +{ + "schema_version": 2, + "reference_software": "Gwyddion", + "reference_version": "2.71", + "operation": "sphere-revolve", + "spmkit_method": "gwyddion_sphere_revolution", + "generated_at": "2026-08-02T01:59:23.826557+00:00", + "branch": "feat/gwyddion-leveling-parity", + "head": "c693fc97e94a5829f57c8b2acf8f522f4f05fb4f", + "case_order": [ + "wide_r1", + "wide_r2_5", + "wide_r4", + "wide_r1000", + "tall_r2_5", + "tall_r1000", + "constant_r1", + "constant_r2_5", + "signed_r1", + "signed_r2_5" + ], + "acceptance": { + "background_atol": 5e-14, + "corrected_atol": 5e-14, + "reconstruction_atol": 5e-14, + "rtol": 0.0 + }, + "evidence_classes": { + "normal_route": "direct external reference", + "inverted_background": "derived external reference", + "inverted_corrected": "safe deliberate divergence" + }, + "source_and_artifact_hashes": { + "sphere_revolve_c": "4218cd4e303634c610e9be5f18656d12715c68df95a9b30930b33232b3d8cbe9", + "probe_c": "97248b51df742937ed5dc0a975b8b1ca08b1b6eeb5add95eda4526118337b188", + "runner_sh": "d673393126833277bda41c77403f1dbaf5dc965d6d8b63ee73994238bec8f7a7", + "oracle_py": "f1598e5f7cd0e173ec72ea928e270038ac8ab5f4c61d57b31a60373e50d40e4b", + "precision_audit_py": "58f1acd0d3c3d644c93adcc7c754889e883726976341695e974d4c09a34f72e4", + "normative_spec_md": "3db2e7ea0c965dfa9bcd803ade6d3dae2fc38b216a132c05df36d52a9256b1c8", + "kernel_py": "ef00ec0033de3966ff1ab7642fdba3f24e0d9030550e5c7995fb55318eb75a38", + "core_test_py": "d0a46f20d6260327f80d68ddae8bfa5360def162b6ca6db77511baf5df5e6ef5" + }, + "npz_sha256": "a0713ec37da9d8865717eaf29d876a3c616d0e3e374b217d40f4d02b30288256", + "canonical_array_hashes": { + "input__wide_r1": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd", + "direct_background__wide_r1": "119858aaa90729cac657572c2a188d6da3039a1d7e51b1c0a3715620cbc2609f", + "direct_corrected__wide_r1": "e3a6738890370f7564f8bc70bbf7ff92e1a3a79e4ce43df467b6c8e519b5377e", + "negated_input__wide_r1": "d2211b505a1a4de29c21d15c53f10d1f2a3c93f4f4c60df039edaccaa5d0b8ab", + "direct_negated_background__wide_r1": "918eaa4348b9e202f52207444b63649ed3842c2b53246ba44515211fe7191df3", + "direct_negated_corrected__wide_r1": "63ba46649b09a9b0a878f7cae78e7a28888eaac9a8179dbc83a6c9f2ade96615", + "derived_inverted_background__wide_r1": "8c6002c171f33dda2eec6ddc3e9c32529d79db216a49d2e18120893a65b2c0e4", + "safe_inverted_corrected__wide_r1": "470527380b8f8d9a1b3eebc89ed6172859893dd6336292c6b7a2dbc71b43fbd3", + "input__wide_r2_5": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd", + "direct_background__wide_r2_5": "647cdbd3a59f8402b3a035cfc95b2761857fc6f1d12698873a1baf7baf12e781", + "direct_corrected__wide_r2_5": "a1c3a4dfa3af93867b8c09bab8863a360bdfe11261f489473778e6c287a2ba8a", + "negated_input__wide_r2_5": "d2211b505a1a4de29c21d15c53f10d1f2a3c93f4f4c60df039edaccaa5d0b8ab", + "direct_negated_background__wide_r2_5": "5b127b1e0d3915b0c7be45a51184e3480434372569640f9619cc59f973a703c1", + "direct_negated_corrected__wide_r2_5": "67f689918a9bfe5e37dd6d5a31eb73f57189875378523fba37da20ab870ec6c9", + "derived_inverted_background__wide_r2_5": "8c097d290cc5f026a6166d1cd02e7969000c3b5ef02331f5d61a5f0020932e90", + "safe_inverted_corrected__wide_r2_5": "9e6037fe4d5b7fd93ab88b324afe82e884543338ba5f064824f3280028b37041", + "input__wide_r4": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd", + "direct_background__wide_r4": "b7cebb17fe54bd7d9db06cc430c14980e7e4d44d375aff041272d96b3690be94", + "direct_corrected__wide_r4": "30c5edf90cbf0cc9828d4b7de4cb05922f7c80fadd74c0fff7161a57c7a23632", + "negated_input__wide_r4": "d2211b505a1a4de29c21d15c53f10d1f2a3c93f4f4c60df039edaccaa5d0b8ab", + "direct_negated_background__wide_r4": "56180d83a286b67f17aa60547a1c80a805ecd1131ef59080df41c474da3cda72", + "direct_negated_corrected__wide_r4": "696949157f2bb11c57057b3380759a2d10699e7fb3babf605e7ff95f8cd72853", + "derived_inverted_background__wide_r4": "d928831acd34aa4fd87bf48d37a8f3fc54bacab2ab2f0df696511537aadd60ed", + "safe_inverted_corrected__wide_r4": "71d6cfcfbcb00ac883c006bf3326bdc31a27af5f441441b1a8eb70db948e4bfb", + "input__wide_r1000": "69146737b4b7cb993261a33fabaff6adf3f1e47eb07f61431a48ac1b31f725fd", + "direct_background__wide_r1000": "aaad7ed8d28a89d2c3ddcb0ea9a523cb2f587b81405ad24221b02bcdf0a3e218", + "direct_corrected__wide_r1000": "cbe37ea045576e23a8edb245b92aad4d7de176570ce5335bedd4733a373932a9", + "negated_input__wide_r1000": "d2211b505a1a4de29c21d15c53f10d1f2a3c93f4f4c60df039edaccaa5d0b8ab", + "direct_negated_background__wide_r1000": "15d9fe68194a00adfa0e8b72754ca2296cd0323f18ecb0c1ebd59ff800a8bc83", + "direct_negated_corrected__wide_r1000": "5ba0ed1dec2f5572fc47664049f5833e39339789f7f6137fea936dc6d205dcc8", + "derived_inverted_background__wide_r1000": "2d7981d21aade5a65c7815be10c2862a6254cad412e117800abb4af3127d70ee", + "safe_inverted_corrected__wide_r1000": "2370363919119f1adbe2900a6584cea0f6c333e6dcd3063309442e7c79235812", + "input__tall_r2_5": "3a837baf13a1cb0bea03702753db0d2b667893ee9003d21b28a07bd1bbd3a132", + "direct_background__tall_r2_5": "be5c9715e05c682598b6f94a76142a644ffc437216286b6f6e8860d339350b7e", + "direct_corrected__tall_r2_5": "77e9b4e6cb3bfe1567f88c7c3f5cc25bc6314b305ded3dda19e1614dcd3bfb08", + "negated_input__tall_r2_5": "007e29d4098488b51ee87aa3a00786dce3919b58ebb5cedb6efb8dcc597862c9", + "direct_negated_background__tall_r2_5": "88abc8164c05d9a31c556a99aca0c681adbfc0626453d416b894cefdf7de8d0e", + "direct_negated_corrected__tall_r2_5": "cca9cf55cc415101c85a532f9e84b8cb701e559cbb304db2a161c0960806aa6f", + "derived_inverted_background__tall_r2_5": "b86fcbe1697bd5398350e385e8982c9cfb23e6e42b36276179e04f447ffe8830", + "safe_inverted_corrected__tall_r2_5": "8645ac63b1a9f4eb53931501ca68fab23717d9ae79132832498e4a77f8912e4c", + "input__tall_r1000": "3a837baf13a1cb0bea03702753db0d2b667893ee9003d21b28a07bd1bbd3a132", + "direct_background__tall_r1000": "a94c005361b3f0ea87f8e19ed26776ab79adbeed63dd0a279fcddf15dad42d56", + "direct_corrected__tall_r1000": "78d4da412c0679a8d0dbc8dfc5890f59f62160dc6f38a8c48246c5afd73eed77", + "negated_input__tall_r1000": "007e29d4098488b51ee87aa3a00786dce3919b58ebb5cedb6efb8dcc597862c9", + "direct_negated_background__tall_r1000": "194cd005221041d281c2a9540091b646eba4a4efc15d73775994c79b6fd283cf", + "direct_negated_corrected__tall_r1000": "7955d363e6d608bf21e52ba3e1dd486e2128823508285955ddba0619a2c95776", + "derived_inverted_background__tall_r1000": "f5c91313e76e58115530d065ac9ce1a5cb6d088b6a40c589acda6eedf3c20b2c", + "safe_inverted_corrected__tall_r1000": "739b3e3bccae514ab39ff603f36084af7acf211a6a0e1377d24ff15f3078aa41", + "input__constant_r1": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "direct_background__constant_r1": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "direct_corrected__constant_r1": "737f4c9a20fb6c951e5eafcb1fd7757796eff40c5c26ed5d81553aac100f5b47", + "negated_input__constant_r1": "858c6b006727b712ee48e328f67bc5c5bed8cfaff7cf5d9eb4bef3b0e7eecc82", + "direct_negated_background__constant_r1": "c492bf11d8ec721daae6bf83d888a283715408712ba6e5f40edee693fa05c7d5", + "direct_negated_corrected__constant_r1": "acd6960f3e73d5c21ba808b9fbdbdd90fdc1bfc38c2177d435aae82c790f9a0b", + "derived_inverted_background__constant_r1": "a2c6c9c0b75b5c307d548aea99671f656eee00ac723849c09b90f8d652b36a4c", + "safe_inverted_corrected__constant_r1": "f1ec68565055de1c7e54a960fc7e8fdce650dc5afe8f5e91c581acc63fd04815", + "input__constant_r2_5": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "direct_background__constant_r2_5": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "direct_corrected__constant_r2_5": "737f4c9a20fb6c951e5eafcb1fd7757796eff40c5c26ed5d81553aac100f5b47", + "negated_input__constant_r2_5": "858c6b006727b712ee48e328f67bc5c5bed8cfaff7cf5d9eb4bef3b0e7eecc82", + "direct_negated_background__constant_r2_5": "858c6b006727b712ee48e328f67bc5c5bed8cfaff7cf5d9eb4bef3b0e7eecc82", + "direct_negated_corrected__constant_r2_5": "737f4c9a20fb6c951e5eafcb1fd7757796eff40c5c26ed5d81553aac100f5b47", + "derived_inverted_background__constant_r2_5": "d32ea6dde7d52e807e1204be1be53d453ed891e71d9b7dd1345a94ee2907c6ed", + "safe_inverted_corrected__constant_r2_5": "737f4c9a20fb6c951e5eafcb1fd7757796eff40c5c26ed5d81553aac100f5b47", + "input__signed_r1": "c5d49a60375fbb0f0bd983aa6e15f3b0ae4aca85c28a9e4c612611cd0a9e4eea", + "direct_background__signed_r1": "bc5b006f2703d632ddd05cb532e6369dd451a3010390c1c842844a4cbffaed87", + "direct_corrected__signed_r1": "8d30b75f1ad6cbb6d578018a2cb07253fd8a9781b9ea84e1c4e5b05e055ad69f", + "negated_input__signed_r1": "18c1108964b720fb0059c59f858ffe2ab057b5fa65122f8ffd00dd1704b53f90", + "direct_negated_background__signed_r1": "f87fe6aeb2a2349c236f7385d397521bc7b3b0685b435eef080c08db1bb5bbe2", + "direct_negated_corrected__signed_r1": "30f4c167e4db33122f486c327acb3afceb473a6cf95f7bedc0fa4709f113bfb7", + "derived_inverted_background__signed_r1": "fb8cea53a478af8ac307a4a7c4e096951aba0232ee5b3a26f15cb588895dacd0", + "safe_inverted_corrected__signed_r1": "25258b05b2dc1d72723fdc024709831ca4f1b583c8d5ffb84ee7da556e2b18c6", + "input__signed_r2_5": "c5d49a60375fbb0f0bd983aa6e15f3b0ae4aca85c28a9e4c612611cd0a9e4eea", + "direct_background__signed_r2_5": "a5dcc566f0ce17f4512757dfa6b20be5506d71d70f22a2bff313273663a84790", + "direct_corrected__signed_r2_5": "c710507228e274f51cddbeeda3a60aab800b10dad2f2b0f87383230b97c42503", + "negated_input__signed_r2_5": "18c1108964b720fb0059c59f858ffe2ab057b5fa65122f8ffd00dd1704b53f90", + "direct_negated_background__signed_r2_5": "3321dacf524ad9b13efaa2a952068d1dc4e9113507260411d166b50b6881328a", + "direct_negated_corrected__signed_r2_5": "1a82f3c4fea7ac5ba986af6de8820a2898814565524a960a8bdaa9c108d013be", + "derived_inverted_background__signed_r2_5": "0b642ee63eb39e0b3f2fae3bc7461237e3eafee3c971335edea8ca94bffe380e", + "safe_inverted_corrected__signed_r2_5": "9073b6b0d8a1cd520bf01fe4132957ad11421e9da1ae4c9ab44ed644d8352c61" + }, + "cases": { + "wide_r1": { + "pair_id": "wide_r1", + "original_case": "wide_r1_normal", + "negated_case": "wide_r1_negated_normal", + "family": "WIDE_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 7, + "yres": 5, + "radius": 1.0, + "q_original": 1.1162423626132756, + "q_negated": 1.1162423626132756, + "sphere_size": 1, + "sphere_resolution": 3, + "local_filter_size": 0, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 8.881784197001252e-16 + }, + "wide_r2_5": { + "pair_id": "wide_r2_5", + "original_case": "wide_r2_5_normal", + "negated_case": "wide_r2_5_negated_normal", + "family": "WIDE_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 7, + "yres": 5, + "radius": 2.5, + "q_original": 1.1162423626132756, + "q_negated": 1.1162423626132756, + "sphere_size": 3, + "sphere_resolution": 7, + "local_filter_size": 1, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "wide_r4": { + "pair_id": "wide_r4", + "original_case": "wide_r4_normal", + "negated_case": "wide_r4_negated_normal", + "family": "WIDE_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 7, + "yres": 5, + "radius": 4.0, + "q_original": 1.1162423626132756, + "q_negated": 1.1162423626132756, + "sphere_size": 4, + "sphere_resolution": 9, + "local_filter_size": 2, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "wide_r1000": { + "pair_id": "wide_r1000", + "original_case": "wide_r1000_normal", + "negated_case": "wide_r1000_negated_normal", + "family": "WIDE_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 7, + "yres": 5, + "radius": 1000.0, + "q_original": 1.1162423626132756, + "q_negated": 1.1162423626132756, + "sphere_size": 7, + "sphere_resolution": 15, + "local_filter_size": 3, + "very_flat": true, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "tall_r2_5": { + "pair_id": "tall_r2_5", + "original_case": "tall_r2_5_normal", + "negated_case": "tall_r2_5_negated_normal", + "family": "TALL_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 5, + "yres": 7, + "radius": 2.5, + "q_original": 1.0649069433390794, + "q_negated": 1.0649069433390794, + "sphere_size": 3, + "sphere_resolution": 7, + "local_filter_size": 1, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "tall_r1000": { + "pair_id": "tall_r1000", + "original_case": "tall_r1000_normal", + "negated_case": "tall_r1000_negated_normal", + "family": "TALL_ASYMMETRIC", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 5, + "yres": 7, + "radius": 1000.0, + "q_original": 1.0649069433390794, + "q_negated": 1.0649069433390794, + "sphere_size": 5, + "sphere_resolution": 11, + "local_filter_size": 2, + "very_flat": true, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + }, + "constant_r1": { + "pair_id": "constant_r1", + "original_case": "constant_r1_normal", + "negated_case": "constant_r1_negated_normal", + "family": "CONSTANT_ZERO_RMS", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 4, + "yres": 3, + "radius": 1.0, + "q_original": 0.0, + "q_negated": 0.0, + "sphere_size": 1, + "sphere_resolution": 3, + "local_filter_size": 0, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 0.0 + }, + "constant_r2_5": { + "pair_id": "constant_r2_5", + "original_case": "constant_r2_5_normal", + "negated_case": "constant_r2_5_negated_normal", + "family": "CONSTANT_ZERO_RMS", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 4, + "yres": 3, + "radius": 2.5, + "q_original": 0.0, + "q_negated": 0.0, + "sphere_size": 3, + "sphere_resolution": 7, + "local_filter_size": 1, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 0.0 + }, + "signed_r1": { + "pair_id": "signed_r1", + "original_case": "signed_r1_normal", + "negated_case": "signed_r1_negated_normal", + "family": "SIGNED_MICRO_GRID", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 3, + "yres": 3, + "radius": 1.0, + "q_original": 3.569417423157558, + "q_negated": 3.569417423157558, + "sphere_size": 1, + "sphere_resolution": 3, + "local_filter_size": 0, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 0.0 + }, + "signed_r2_5": { + "pair_id": "signed_r2_5", + "original_case": "signed_r2_5_normal", + "negated_case": "signed_r2_5_negated_normal", + "family": "SIGNED_MICRO_GRID", + "input_variant_original": "original", + "input_variant_negated": "negated", + "xres": 3, + "yres": 3, + "radius": 2.5, + "q_original": 3.569417423157558, + "q_negated": 3.569417423157558, + "sphere_size": 3, + "sphere_resolution": 7, + "local_filter_size": 1, + "very_flat": false, + "direct_execution_status": "exit_code_0_success", + "reconstruction_max_abs": 4.440892098500626e-16 + } + }, + "inverted_reference_failures": [ + { + "case": "wide_r1_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r2_5_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r4_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 4.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r1000_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 1000.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "tall_r2_5_inverted", + "family": "TALL_ASYMMETRIC", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "tall_r1000_inverted", + "family": "TALL_ASYMMETRIC", + "radius": 1000.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "constant_r1_inverted", + "family": "CONSTANT_ZERO_RMS", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "constant_r2_5_inverted", + "family": "CONSTANT_ZERO_RMS", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "signed_r1_inverted", + "family": "SIGNED_MICRO_GRID", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "signed_r2_5_inverted", + "family": "SIGNED_MICRO_GRID", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r1_negated_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r2_5_negated_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "wide_r4_negated_inverted", + "family": "WIDE_ASYMMETRIC", + "radius": 4.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "tall_r2_5_negated_inverted", + "family": "TALL_ASYMMETRIC", + "radius": 2.5, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + }, + { + "case": "constant_r1_negated_inverted", + "family": "CONSTANT_ZERO_RMS", + "radius": 1.0, + "normal_exit_code": 139, + "asan_exit_code": 134, + "execute_started": true, + "execute_returned": false, + "failure_site": "sphere-revolve.c:328 gwy_data_field_subtract_fields", + "arrays_available": false + } + ] +} \ No newline at end of file diff --git a/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.npz b/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..132d9a97233e91d66bf115075a4d90d5f2c2e28c GIT binary patch literal 39566 zcmeI52V4|a*Z+qO%kHuuO#wyhie1!zvd3Oyh#f((fLIU^Q52(Mudx?YG+4l{pjc7o zqNu3RAXrc%YBY%^7Bna};GJ>L9d;IW;dx&3|G@v1=i@WEduM)o=bZCBbMM?cvtB(6 z3>z}4zv|5Mx(1I6@z08}W=02xP7L?+ix}-6;1|}kQE=!KJx0Mq>#6pq+f}{#ee30| z$4p|T*6|ORFe0puyK|j3BU{&Tb*?iqBrH5^Sg>D6n12BOdFNq469Tx;CyW{v8o-t7 zy16>Hs^{uF&H2CnlVI~9=-Vppk80^b>cW;|2i4JswF}RuE&Qkt{R}S_+)g$CGl!k4 zPc1iuV`~OxTCXyKm(MO9t8Q7SJTk|$QE$S4%N57koiFP_Uvlb)tqJ-tBz)8>lS_8c z?81@geH#p6eGn{rf7J+1y$KCIT%|yHF{S0$QQtFg^-BF#g97#7di$)GeFF61<@)PZ znTGd#wU;sUKHhBY>%x!9<9{7U$~jP=e9-*v@fxcc=w_04 zqsgy&ps#1t=vFTSc)X~M|BAo3 G}dD)RG) zkTBlg0Tss0NcET7T=}8nGv7A`@Z}W6N{8Mp>i9Oriikh`- =3lp zj=H%D=jPymz+t?1D^$uRn6e3`Y=SA9V9F+#vI(Yaf+?HDlua;Y6HM6zQ#QerO)zB> zOxY}^Y=SA9V9F+#vI(Yaf+?F|%4RWTvzW4#h%&*HO)zB>OxXleHj62n#gt7jWfM%< z1XDJ_lug7wp3>alZ|dXPMJQ8OSGd$|3NGPwZe2fS2Ju&~#hQ4S!|<#1_Vt<~g&Ff2 z7Mx!%gCFaD8sd0c4&6g%ZT_y20uGK}*Zp=+6R^4lkSYd0B0{Z+&S3p8lzS z=C3n{KmYKI8QV|>U0T>?w=%MTyUPa`&R?tm^BEm(oliG{3(tMN@j7P$)Zg^@wNbIp z*GXis^uxKK^BY;f+VCDLLry9nUlO15rqB?iIkV^I8kh**_k7W7=ypj0vu2u{YyVaX z{nH28-tn`5cc-kozNu}kk-F>dX&gcQU8}giD_RP-CKn#Wn)-&=5I0vt F0>Czq>dynmKdnbb~hWO^sB3bAQ1z0%<^mB{0`MaM_xSd%f#_5*gF zwq$gZ=MI;B?TDM~rO`Q;TBKm| ^i!+B={tjX{` zNicnnHCfo_>fbvHY{^)%aKIcdd*X33aOwWrmC2dTuL@eNav&{~$4oyqv?5bC{@mF9 zmr6qFo@U 0!|Qcf`AhQT=+f&oFL!?0VfE!hR0v0J!nro zUX%0qgkd8CxGOfgyTb7e$TuM0fP4e;4ahej-++7r@(sv0Am4y|1M&^XHz41Dd;{_g z$TuM0fP4e;4ahej-#|NK$TuM0fP4e;4ahej-++7r@(sv0Am4y|Yov4a(X=(^ZBf^( znV;L&@pMHVPq*7K_%3DHx#(hDJZDz`PtWn|CcAT*ft !( z;^v4G25@_Xl{6{R5Ppn&wPoFRMv#MdPHJ|pHRnW)T-IZAMqIb-w=6AZRWn)iLx=%1 z$2%uSYcuily^Vm@SE}CsO4o-U*aI7T%nO GHyq6yY7fpGQDCud-a>EWD*XoSUkLol4){u%>uJ|N^ NbFgZn0M`!%4vQ|!0UmdKX>#?jGB##q{s_I}%H+ZZ zKi;%7g+Gi8Qex7ia7<5c+TELS$gtbm=0;Bou- <@uPu63|=5d+!K~g=UoEsN*jj zr}t6l@w9$n>YZfAT7_));D`01b~0uDzJ8g1HkGz05bmSQ$i6dj<&dKGQhnOKBhMOy z&mL_-5*j#dXwxj8J-V}o_p056?71EL()v5;k?C8Ko%5IJlTYJb&dymQC3OFi@1xXx z{T-nmuTzx3smCi+BG*XZ`>(fs%1kqbzU@0FM4OnyqdBK<`cyWDhY9|V{lm=QM4H3Q z&Xz_HyMM9mwQu$-8TS(hBaOEyecLu-eOpR25;(&cpGj}Cz+D2rZ<6%i)yx>kvzVLV z@d-+B-n0AVxj5za<=ve0+M0pw!WIj!ZZd~%FAtuH9%c^7efqfP_cep 3vBbN5 C-51|EwT#1k?5pqDt0U-yvf64P((~j(^O?}SaEG=bhU5@>Yoir9ZX)H2Mka2>H z6J(qq;{+LJk#QCoC)i0Nloe#0AmaoXXOVFh87Ig%LBViRhL^o$FFOe z{qt}Cu*ai vwu&2z3APD`yYSy8DT3p6NC{cYHoe%e0=hXJq0* zpGR?qK=tYVbYxA-w4S!#&}7yiqh3#h_E3GgKP_v1-xT{tlbmK6YK*LV?tWc~`dQap zb-8tO=4Jbkk*~}k?MJUGUWrmzYZJ5MwTm2HU;N~gH%|_A(;tq5eKLR{gH}W> Zp?G(nPlWnK{_+knQN}AqSI3`}VEaU;#ekA4X=_C}6AO)rBUXoWN++FDBMY z R+rYDSGKn~of0`n3Te#-2HaUuG_Jn7>L>5!V-*lNw9lxlH3~QzT ?q#Yn`huw?4+p+t*RG&QW!Y&k-SImuarL-1__5uz@btZy1uD ze>vlg+?G5HKGcaA+mW7Uu2px7vLn-apRRi{uBc4wmsg#cRM@)`S-)*W9rG@>Wb3H+ zKR<3#v_BnF)8lD f8VaTGubsMh3tk7r{Hxv8 b-fHIV>CfasS>B2}ruikB#M)6zuH0X-Tk? z3H)&8v~%heb8s=dIbrr%Da1cMef`D;dP|gH((au*c8OcEu*d(PBz+}!T=@2gl!<4R z|9msdefH1hkZaMVZ|!&~q_(@<$9AI>%*}Ut-o0fGqosctcIqvG-0{jK&IKkgxm6qQ z6W-^PMxJeb4EIQcO9cB `%wj^mtm2fd9wehkz>)KtO &5Vz6tV8kZ*!~6Xcr}ds}@pZNuF?5lwkCZPKL4*8z1!9#BWLG!?Ga)r; pQbFgO 4z7FnvBRa zhsh2tUAnxN!rQ(cn@@j`!PWu4B|N_>hn7FrGj6-g0#Y+it+Z&SfP&_KG;lwp0N<0n z2fgv(`cw@PmmFOs1qj&x%bv|LSQ$9-uE`uZcn$Dta^jE^EV^LsW!g#sHcy;8HN2(( zN0)~- _?_vQ}w~bkq3JO?k81{VV zD{k1l?)0P8LnP2FZec>_z2-2m_b;~xjK9qqZwM{O$dQ3-haImkHMW2W@{q?1dnw@F z gEPB^WSYURfywKKG%$zXmeuLFC{PL)rwp=LcYwQHuyfy%HZB%0+0`}v z!w?0e eASPq=S$m9 z%T!;S!~2c38$?}R!V-lFmCFY{j5cg~Q3?sE_ilc-%^VgC>*aW7iv&KnPWfri6*D-s z(J#qIW(M;&uFH&QZ3?UVxh7R>X#y?YJ5GoN87x_p)$k#=)L&xp{oWV(5;%Q-Ov_dF z5_ll}SnHSmH`xbS z4E4zg zfs-GOkU$ONzOm8$%wU-3ym|@;Q +f|zmT=+|n8APw&^J-3^3EVh);?d^G#<1sj%i(Sp< ohlxh8c`+bNxAclY#3+<))w0*Q5F$ct4 zIG&y_Z9gqjeKF?S8qeBAozAaIDP_vKJUbjICrCL#$_Y|VkaB{Q6QrCV }@tt4?Jr4x)gLb(CCjR zOn)rgF;g3PsdvySjHmyx>S U&>MDVrN5egFn-6<@`Zji zO#>YafR^jzJ#BIN&PR4&SfbC$vH2`5(|US5t@lJv(=x56`_toTJuTCE+f9`=b#4Di zNz0nYyG?(X+NpuQhNrEaYYR_qmAoRnqBxp?%}Xbk=I&8~x1@#f?X61Y{<9999G5DY z=wE_7BEy9h;6q2htbf6cTL?Hr_PSN;IAv6|(E2}ZU#FaCbK%=T4U48bUO3FGzMor& zxb;@G?{AHf!TT7O`1KClN^gnf-dVDE wAXp-bQ17I4yL!L$C= zO~HHH&iPwTOJT^91A0!s$-(#8A@5czEMS0z(^_Xn4!f(x{1Lr4M>#mb+aapJ6yRr{ zkvZJ7P2jov!zVY8!cec>x8{CK(dd?)tlj23P1q%@2(Kt!E_H-m*5|6r`W&_9bJLi( ziRN?Dn$JmN;#O)tCyj}l#l+2G;wG552_|lWiJM^JW-)QIn79chZX)i7{lz}=rd}># zxK*JN_!Rf6)zd>5a&6LIy}OLpBf~tV?rOt5f@{BIxz9z39eF&aeLz959XUJxq>WO- zkj5v{WzB84mDsDie6nn8g?c($y?5}9xZN!a*}EsQt9D6bwCYU``pscOrvf&S?P%xI zl_81-Sw7x@Mc+^LZ5(&5Sy7-*Mh4|K9h9g?`seOG 3f0o)`-Idv2X~;le6?(o}V+7CcV(r}|wL3d!HA^|g51Y5BJa)Z@BWgqM57 zH4}KjGxpD_M@?bF(m^jwtC+)tYsr^gT+AU 5Mf(u0+VS>LKJxgd7lZVSlQR z5CTFD2st3+!W)YaT0qE!{Sk6N$c6qm-Jj|s AK k;WzFMhJ-w4c%bNQ1 zcv?@(R8RB!HIJwDVv=_bwm3tt)@pZB)aE_Pzo$b^+)B7P)&jha>=}BG8?fdKf8)6R zq5=Z0>+MWvZVAKv(g$R%vINhm(`Sy(mBXUVXRq7XDnP&6!rWyW72w^b>Zi7HOE}Z= zO^4hW)! ei^1@z#sMuNzaKOPRwi|1N*BDR+g@C2nE&7@H-=+*VO=+LL=vC^<9r z)h>P;|8DOUe(UR+aG2kZ%}GDN?-wyWa=3au9b%VNZ|>hIwRye7^xvF$y&DskWU7BZ z)vwOjhZ}PBUFF{dtM5 TKl#KN&G`Lmtf}|= z=WU>`7ymu hDGFnGY+1!2xoxH)#u!f|uBo1z03IJhQTfN|vo z5kIYwgVSkmv#pb4@ZkIVQ+KtJ!q49?zG`ww0UIjG+L=}5)(hb^Bk@Y}_P-vL!4D5& znzrmDg)4V%Wk*_b*BsaBZAT1KK-P}EQ;q~$fJu+tkDGDpmF8{xadDIGQgC_tXn&=K zX53>;d4<2nDd0}vvuUd@T7cn*8o4*Ba*r$B%RDFPCe_I0_LTz#kC!@!U12Z>#0C&^ zK+FL#2gC#rD?ki|dOFBPtV-2G3;{6*#9a7(s*l(KVlM1Y+e_Pnm;>FP>eKen_9Nzi zm;+)CHB^`j$BQu^SgpR`be(h9<=^2*IYG(^QcjR^f|L`aoFL@{DJMv|Fv>>C2~tjw za)Oi-q?{n-1Suy-IYG(^QcjR^f|L`aoFL@{DJMudLCOhIPLOhfloOkt=om+g7S!_p8HS5c7 z#bBu0$)#x7p890MSDR^EK_n5WHEC?A4e{0b3Khm=82kUiMjU2I=P$9;Wc;J@bXjPH z!;&DDUwkXJ*SuM6R#SU@o(RzX_NNLHYqdaSyDz^L+pbIS+bPs`C5+-KAf{1GRpIt8 zY{jPg)8W%gYC2c`YM^U ypzuRBqpLZ?c_fd+X>#Nm1$4YfZy~kFg$Z92C3d+6F zF#i9AjTlPxJ{v#1p|#Ie?k(m>RL1)9TkRJ6eEBR(ukEY#K08MY>E|1BV(Wc{RZP4% z^wh7Qjra^#ww_!04?aM2zxY;cKlLjFKj11u->ux+P8=^^vLv?M<3|Uo(0;Gl^CA;Q z2L^LL4^s4td}-JOy8Ykw+?s!pJ8cSr&7xZ@=HOaBQ&4?C@5JTMbv^jG=Fj6a2xr?; zYpAUXP`{&A?k)4; ?Jk)B-9yFieqU?%uz4to~9uWAqL!#%Qt3#>$n2ABrmZ zd4zIrn7oJ<`xn)yPE@9_#;vNP{{OX{8+Exc-W*h)84~q6{=dZlbxYnTykM||_50=C zDyp+nfND$K&qS%p6c|&YD$`yV4I>6Wx7N6YpFN?~1`1LMQ?nHcDAk!4vykdzhJs@S zVxXvnOhE-?6^$FwC#JNSK`o?veRu^4A+$%)CQ9|66rid^1t7hXLwB^s&+BM}(1ZpC ztEhoWT~A#>Y*g%eg*JMJ*Ci{0K}1y6DZ&qfNW(_iZ?4a!HqyENxPsV+#)Rh95LBO3 z1&kpq)BUXR^Ew)ksrPQwW)i72)OvxmUqiS5fA(*D7x+D-@uFqMumahp#_A?5j%<>V z@c2$oU#v1DwCr@xXvRPJ64FMx-cZtt5$b6fs?O`FKX;?Xlhf|+5>8Uvlsq$7VNAm? zo$C%873A?ziKYRo?)FvgUbTXyFJ`gtr&k7x4z*{Vk-5N=o{KZ?)p@V{Y!L^A$}$d` z-D34VP6;!B6~->buHjLt1X7e{Rpqkwa_;UwWJE)zl$pK?U?WuwSQCs9qKcU+#65Fs zcwRSKp-gS0ZGUn9I6S)3B5EVuGkO)kL=>AY5h?e7V4V@GDxflv71UH^Bv6@%VN@ov zN9-)2D`F`&(WJNYrPM^F&fHZ1D`8*=$p!-ntz;`$4FgF?Iy5=a{l!)q^U9|YwUW*m zx(Z++j3a_^B%p 5i9nItKXI#2OkFP2^{#Q<+g!w8RTKuoXQ!OWvR;q!ZrFLUjhZO%BIx1B~ zB(=wg(VW}Rp~L^$HPxvt(phFAG>J}?sE<)~X!5_POf_qXG-sF<(=6NR^1s?kCF_bL zgUvZfU3Z6dG?f2kSgKl6q&if_XuoHwLs322tVN2wEHt7Gd)k`u#{a@4ZIiP|!PQbQ zUU5fgQR9C_k*ZY_so7Q%)QY)4i PE=L?iNV7N@G>TqkucHQq{8RF5_r5g8x2%df~C^f_S7faBkgOdC~QByqd3V7=m= z&?d*fg_p{40W17P>7_Om $ nJp Tts){}Ov~w}lvYs^m)(|QxzT)2L z%8BZVKb?)`-%LSu#p(ZQcOBuUQ&T =Ta@lT`DhKX~acVD4l+9Ob#EdRtKl@$ke z&;G?^%Y5GaQ*~5ToC7@va;l}rptk1nPi|3RF)_^tl^TTFMETV+54g<5|Ks}4!e5kL zO+VOWYDzXIg}r^5dehjr8tK~K#erO5HC z_o$q>YkJ79SRj_7#XpTewZsYL(NLjjB}HN>y8KFgsw #7NV zQF?<$aI(5$v6Ke$E4-<)n4M^U9l= vr&R#iEhy%s2-EfBEi7XI%2X6YGOC9A0sqRcT{RoRzGoZGP^al znDT#O0CgWV5*fgH94B11$Sgyn#gO(5E>mo3(dFkq)3~ p>qEKgJ`IQ7zSKMFh6`>;n zb!w_dpZH20Jh@o(X^CKd#+tTEe5IZ~MKEGX@ux+PpXH@`;w$xrNI|azXK0aAk3sR3 z`tVe(F{nk0pG~Aq6T9}x4?@#)MWGg5eg=-}im%kKrWMmI`>FG@R#aA;!mVd;vSo=x kEe+;pdZ=<;5svOrWr#&V)ySAX*o*t0V`YZ%oynK}2csd8YybcN literal 0 HcmV?d00001 diff --git a/tests/validation/test_sphere_revolution_vs_gwyddion.py b/tests/validation/test_sphere_revolution_vs_gwyddion.py new file mode 100644 index 0000000..603782b --- /dev/null +++ b/tests/validation/test_sphere_revolution_vs_gwyddion.py @@ -0,0 +1,306 @@ +"""Validation tests for SPMKit Gwyddion Sphere Revolution against frozen 2.71 fixtures.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np + +from spmkit.core.analysis import ( + BackgroundResult, + analyze_gwyddion_sphere_revolution_background, +) +from spmkit.core.analysis._gwyddion_sphere_revolution import ( + _gwyddion_sphere_background, + _gwyddion_sphere_result, +) +from spmkit.core.models import SPMChannel + +_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "gwyddion" / "sphere_revolution" +_NPZ_PATH = _FIXTURE_DIR / "sphere_revolution_reference.npz" +_JSON_PATH = _FIXTURE_DIR / "sphere_revolution_reference.json" + +with _JSON_PATH.open("r", encoding="utf-8") as _f: + _METADATA = json.load(_f) + +_ACCEPTANCE_ATOL = float(_METADATA["acceptance"]["background_atol"]) +_ACCEPTANCE_RTOL = float(_METADATA["acceptance"]["rtol"]) + + +def _canonical_array_sha256(array: np.ndarray) -> str: + canonical = np.ascontiguousarray(array, dtype=np.float64) + digest = hashlib.sha256() + digest.update(str(canonical.dtype).encode("ascii")) + digest.update(b"\0") + digest.update(",".join(str(value) for value in canonical.shape).encode("ascii")) + digest.update(b"\0") + digest.update(canonical.tobytes(order="C")) + return digest.hexdigest() + + +def test_sphere_fixture_metadata_and_artifact_hashes() -> None: + assert _METADATA["schema_version"] == 2 + assert _METADATA["reference_software"] == "Gwyddion" + assert _METADATA["reference_version"] == "2.71" + assert _METADATA["operation"] == "sphere-revolve" + assert _METADATA["spmkit_method"] == "gwyddion_sphere_revolution" + assert _METADATA["branch"] == "feat/gwyddion-leveling-parity" + assert _METADATA["head"] == "c693fc97e94a5829f57c8b2acf8f522f4f05fb4f" + assert len(_METADATA["case_order"]) == 10 + + npz_sha256 = hashlib.sha256(_NPZ_PATH.read_bytes()).hexdigest() + assert npz_sha256 == _METADATA["npz_sha256"] + + source_hashes = _METADATA["source_and_artifact_hashes"] + expected_sources = { + "sphere_revolve_c": "4218cd4e303634c610e9be5f18656d12715c68df95a9b30930b33232b3d8cbe9", + "probe_c": "97248b51df742937ed5dc0a975b8b1ca08b1b6eeb5add95eda4526118337b188", + "runner_sh": "d673393126833277bda41c77403f1dbaf5dc965d6d8b63ee73994238bec8f7a7", + "oracle_py": "f1598e5f7cd0e173ec72ea928e270038ac8ab5f4c61d57b31a60373e50d40e4b", + "precision_audit_py": "58f1acd0d3c3d644c93adcc7c754889e883726976341695e974d4c09a34f72e4", + "normative_spec_md": "3db2e7ea0c965dfa9bcd803ade6d3dae2fc38b216a132c05df36d52a9256b1c8", + "kernel_py": "ef00ec0033de3966ff1ab7642fdba3f24e0d9030550e5c7995fb55318eb75a38", + "core_test_py": "d0a46f20d6260327f80d68ddae8bfa5360def162b6ca6db77511baf5df5e6ef5", + } + for k, v in expected_sources.items(): + assert source_hashes[k] == v + + +def test_sphere_fixture_canonical_array_hashes() -> None: + expected_hashes = _METADATA["canonical_array_hashes"] + npz_data = np.load(_NPZ_PATH) + + assert len(npz_data.files) == 80 + assert len(expected_hashes) == 80 + assert set(npz_data.files) == set(expected_hashes.keys()) + + for array_name in npz_data.files: + array = npz_data[array_name] + assert array.dtype == np.float64 + assert array.flags.c_contiguous + assert np.all(np.isfinite(array)) + assert _canonical_array_sha256(array) == expected_hashes[array_name] + + +def test_gwyddion_sphere_direct_normal_background() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + expected_bg = npz_data[f"direct_background__{pair_id}"] + + inp_copy = inp.copy() + bg = _gwyddion_sphere_background(inp, radius) + + assert bg.dtype == np.float64 + assert bg.flags.c_contiguous + assert not bg.flags.writeable + np.testing.assert_array_equal(inp, inp_copy) + + np.testing.assert_allclose( + bg, + expected_bg, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_direct_normal_corrected() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + expected_corr = npz_data[f"direct_corrected__{pair_id}"] + + inp_copy = inp.copy() + _, corr = _gwyddion_sphere_result(inp, radius, inverted=False) + + assert corr.dtype == np.float64 + assert corr.flags.c_contiguous + assert not corr.flags.writeable + np.testing.assert_array_equal(inp, inp_copy) + + np.testing.assert_allclose( + corr, + expected_corr, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_normal_on_negated_input() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + neg_inp = npz_data[f"negated_input__{pair_id}"] + expected_neg_bg = npz_data[f"direct_negated_background__{pair_id}"] + + neg_bg = _gwyddion_sphere_background(neg_inp, radius) + + np.testing.assert_allclose( + neg_bg, + expected_neg_bg, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_derived_inverted_background() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + expected_inv_bg = npz_data[f"derived_inverted_background__{pair_id}"] + + inv_bg, _ = _gwyddion_sphere_result(inp, radius, inverted=True) + + assert inv_bg.dtype == np.float64 + assert inv_bg.flags.c_contiguous + assert not inv_bg.flags.writeable + + np.testing.assert_allclose( + inv_bg, + expected_inv_bg, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_safe_inverted_corrected() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + expected_safe_corr = npz_data[f"safe_inverted_corrected__{pair_id}"] + + _, inv_corr = _gwyddion_sphere_result(inp, radius, inverted=True) + + assert inv_corr.dtype == np.float64 + assert inv_corr.flags.c_contiguous + assert not inv_corr.flags.writeable + + np.testing.assert_allclose( + inv_corr, + expected_safe_corr, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + + +def test_gwyddion_sphere_reconstruction() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + + inp = npz_data[f"input__{pair_id}"] + neg_inp = npz_data[f"negated_input__{pair_id}"] + + # 1. Normal route + bg, corr = _gwyddion_sphere_result(inp, radius, inverted=False) + np.testing.assert_allclose(corr + bg, inp, atol=_ACCEPTANCE_ATOL, rtol=0.0) + + # 2. Negated normal route + neg_bg, neg_corr = _gwyddion_sphere_result(neg_inp, radius, inverted=False) + np.testing.assert_allclose(neg_corr + neg_bg, neg_inp, atol=_ACCEPTANCE_ATOL, rtol=0.0) + + # 3. Inverted route + inv_bg, inv_corr = _gwyddion_sphere_result(inp, radius, inverted=True) + np.testing.assert_allclose(inv_corr + inv_bg, inp, atol=_ACCEPTANCE_ATOL, rtol=0.0) + + +def test_gwyddion_sphere_frozen_inverted_failure_evidence() -> None: + inv_failures = _METADATA["inverted_reference_failures"] + + assert len(inv_failures) == 15 + + for failure in inv_failures: + assert failure["arrays_available"] is False + assert failure["execute_started"] is True + assert failure["execute_returned"] is False + assert failure["normal_exit_code"] != 0 + assert failure["asan_exit_code"] != 0 + + +def test_gwyddion_sphere_public_analyze_against_fixture() -> None: + npz_data = np.load(_NPZ_PATH) + cases = _METADATA["cases"] + + for pair_id in _METADATA["case_order"]: + case_info = cases[pair_id] + radius = float(case_info["radius"]) + inp = npz_data[f"input__{pair_id}"] + + channel = SPMChannel( + name=f"Channel_{pair_id}", + data=inp, + unit="nm", + x_range=1.0e-6, + y_range=1.0e-6, + metadata={"pair_id": pair_id}, + ) + + for inv in (False, True): + res = analyze_gwyddion_sphere_revolution_background( + channel, + radius, + inverted=inv, + ) + + assert isinstance(res, BackgroundResult) + assert res.method == "gwyddion_sphere_revolution" + assert res.parameters == {"radius_px": radius, "inverted": inv} + + assert res.background.unit == "nm" + assert res.background.x_range == 1.0e-6 + assert res.background.y_range == 1.0e-6 + assert res.background.metadata == {"pair_id": pair_id} + + assert res.corrected.unit == "nm" + assert res.corrected.x_range == 1.0e-6 + assert res.corrected.y_range == 1.0e-6 + assert res.corrected.metadata == {"pair_id": pair_id} + + if not inv: + expected_bg = npz_data[f"direct_background__{pair_id}"] + expected_corr = npz_data[f"direct_corrected__{pair_id}"] + else: + expected_bg = npz_data[f"derived_inverted_background__{pair_id}"] + expected_corr = npz_data[f"safe_inverted_corrected__{pair_id}"] + + np.testing.assert_allclose( + res.background.data, + expected_bg, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) + np.testing.assert_allclose( + res.corrected.data, + expected_corr, + atol=_ACCEPTANCE_ATOL, + rtol=_ACCEPTANCE_RTOL, + ) From 2becd72f7fdbcfbeb2ece6b0febb0afe774257a1 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:07:48 -0400 Subject: [PATCH 58/82] style(tests): normalize Sphere Revolution test EOF --- tests/core/test_gwyddion_sphere_revolution_background.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/core/test_gwyddion_sphere_revolution_background.py b/tests/core/test_gwyddion_sphere_revolution_background.py index 7c14895..8482695 100644 --- a/tests/core/test_gwyddion_sphere_revolution_background.py +++ b/tests/core/test_gwyddion_sphere_revolution_background.py @@ -368,4 +368,3 @@ def test_gwyddion_sphere_physical_api_remains_distinct() -> None: gwy_estimate = analysis_mod.estimate_gwyddion_sphere_revolution_background assert phys_estimate is not gwy_estimate - From 818dbd3cbf7a2778050884bc4a8d08a7d88d376b Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:10:06 -0400 Subject: [PATCH 59/82] test(validation): freeze Gwyddion Median Background evidence --- ...WYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md | 156 ++ .../median_background_reference.json | 1570 +++++++++++++++++ .../median_background_reference.npz | Bin 0 -> 45742 bytes ...est_median_background_fixture_integrity.py | 267 +++ 4 files changed, 1993 insertions(+) create mode 100644 docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md create mode 100644 tests/validation/fixtures/gwyddion/median_background/median_background_reference.json create mode 100644 tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz create mode 100644 tests/validation/test_median_background_fixture_integrity.py diff --git a/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md b/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md new file mode 100644 index 0000000..aace822 --- /dev/null +++ b/docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md @@ -0,0 +1,156 @@ +# Gwyddion Median Background Compatibility + +## 0. Status and normative scope + +**FREEZE_AUDIT_APPROVED** applies to frozen Gwyddion 2.71 Median Background evidence. +This is a normative design specification for the next mini-batch; no production code is +delivered here. Evidence is limited to the frozen 36-case campaign, represents both rank +filter backends, does not validate an SPMKit implementation, and does not exclude errors +outside the frozen domain. + +## 1. Reference identity + +**SOURCE_CONFIRMED** reference software is Gwyddion 2.71. The frozen module is +`.reference/gwyddion-2.71/source/modules/process/median-bg.c`, SHA-256 +`5021fff407531459ed47aff7a47e4f5b2ce2ea7df13d04ca4405f05581258729`. +The manifest records the probe, runner, oracle, campaign, and fixture identities. + +## 2. User-visible operation + +`median_bg` estimates a local rank-filter background and returns corrected data by +subtracting that background from input. Its radius is a pixel-sample quantity, not a +physical lateral-unit quantity. + +## 3. Parameter contract + +**SOURCE_CONFIRMED** radius is an integer from 1 through 1024, with default 20. Future +compatibility APIs shall use `radius_px=20` and expose no configurable border, shape, or +rank parameter. Fixture-domain inputs are finite two-dimensional `float64`; mutation is +forbidden. + +## 4. Digital elliptical kernel + +**SOURCE_CONFIRMED** kernel resolution is `2*radius + 1`. The active region is an +inclusive digital ellipse over pixel centres: `kernel_index + 0.5` with squared ellipse +condition `<= radius_squared`. Offsets subtract `radius` and enumerate row-major. +Cardinalities for radii 1, 2, 3, 4, 20, and 1024 are 9, 21, 37, 69, 1313, and 3297401. + +## 5. Border extension + +**SOURCE_CONFIRMED** exterior handling is `GWY_EXTERIOR_BORDER_EXTEND`: an exterior +sample maps to the nearest valid edge pixel. No alternate border policy belongs here. + +## 6. Rank selection + +The rank is `kernel_active_count//2`. The direct reference path applies when active +count is at most 25; the radixtree reference path applies when active count exceeds 25. + +## 7. Background and corrected fields + +For input `F` and background `B`, corrected data are `C = F - B`. Frozen outputs are +finite, C-contiguous `float64` arrays with shapes equal to the corresponding input. + +## 8. Direct and radix-tree reference paths + +**EXECUTABLE_EXTERNAL_REFERENCE** covers direct radii 1 and 2, and radixtree radii 3, +4, 20, and 1024. A future implementation shall match observed fields without reproducing +Gwyddion's internal radixtree structure. + +## 9. External probe campaign + +The campaign contains 36 logical cases and 72 executions: 36 normal and 36 ASan. All +exit codes are zero; timeouts, GLib detections, and ASan detections are zero. Normal and +ASan stdout are byte-identical in all 36 pairs. Coverage includes wide, tall, constants, +signed fields, impulses, monotonic fields, singleton dimensions, oversized radii, edges, +and corners. + +## 10. Independent oracle + +**INDEPENDENT_ORACLE_CONFIRMED** is a Python and NumPy oracle with no SPMKit or SciPy +import, subprocess, or Gwyddion execution. It uses `numpy.partition` rather than the +internal selection path, calculates from metadata and `input_*` before loading reference +arrays, and selects no acceptance tolerance. All frozen background and corrected arrays +are bitwise equal to their reference counterparts. + +## 11. Frozen fixture + +The fixture has exactly 108 arrays: `input__ `, `background__ `, and +`corrected__ ` for each case. Background and corrected arrays are copied from the +approved external-reference arrays. Canonical array hashes use SHA-256 of `dtype.str`, a +NUL byte, comma-separated shape, a NUL byte, and C-order bytes. + +## 12. Future SPMKit API contract + +**FUTURE_IMPLEMENTATION_REQUIREMENT** reserves `estimate_gwyddion_median_background`, +`remove_gwyddion_median_background`, and `analyze_gwyddion_median_background`. Planned +parameters are `channel`, `radius_px=20`, and keyword-only parameters where applicable. +`BackgroundResult` shall use `gwyddion_median_background` and metadata `radius_px`, +`kernel_resolution`, `kernel_active_count`, `rank_index`, `rank_backend_reference`, +`border_policy="gwyddion_border_extend"`, and +`kernel_geometry="gwyddion_digital_ellipse"`. These are future requirements, not APIs +already present. + +## 13. Acceptance contract + +Background and corrected comparisons require bitwise exact `float64` equality. Output +shape must equal input; C-contiguity and finiteness are required for fixture inputs; input +mutation is forbidden. Reconstruction requires `input == background + corrected` with +absolute tolerance `1e-15` and relative tolerance `0`. No acceptance relaxation may be +introduced merely to satisfy tests. Any discrepancy must first adjudicate source, probe, +oracle, fixture, and implementation evidence. + +## 14. Evidence classification + +| Classification | Meaning | +|---|---| +| **SOURCE_CONFIRMED** | Frozen source establishes parameter, mask, border, rank, and subtraction semantics. | +| **EXECUTABLE_EXTERNAL_REFERENCE** | Normal and ASan Gwyddion 2.71 probe outputs. | +| **INDEPENDENT_ORACLE_CONFIRMED** | Independent NumPy oracle matches external arrays. | +| **FREEZE_AUDIT_APPROVED** | Audit approval within the frozen domain. | +| **FUTURE_IMPLEMENTATION_REQUIREMENT** | Requirement for later work, not delivered code. | +| **NON_CLAIM** | Boundary that must not be inferred from evidence. | +| **TOOLING_LIMITATION** | Non-blocking campaign tooling observation. | + +## 15. Explicit non-claims + +**NON_CLAIM:** the fixture does not establish behavior for matrices, radii, input +families, or Gwyddion paths outside the frozen campaign. It does not by itself establish +the behavior of a future SPMKit implementation or every Gwyddion feature. + +## 16. Known tooling limitations + +**TOOLING_LIMITATION:** runner compilation commands use `|| true`, so their stored status +does not preserve the original compiler exit. Its auxiliary parser also recognizes broad +`background_` and `corrected_` prefixes. These do not invalidate the campaign: both +binaries executed, all 72 processes returned, outputs were valid, stderr was empty, +normal and ASan stdout were byte-identical, and arrays and metrics were recalculated +independently. + +## 17. Scientific-integrity rule + +Tests judge algorithms against valid evidence; algorithms must not be deformed +merely to make tests pass. + +Any discrepancy shall be adjudicated before production changes. Acceptance shall not be +relaxed for convenience. Deliberate divergences and reference defects, if evidenced, +shall be preserved explicitly. Existing generic or physical median operations shall +remain separate from this compatibility operation. + +## 18. Required implementation workflow + +1. Preserve this fixture and manifest unchanged before implementation. +2. Compare candidate background and corrected fields against the fixture bitwise. +3. Check dtype, shape, C-order, finiteness, input immutability, and reconstruction. +4. Adjudicate a mismatch against source, probe, oracle, and fixture evidence before + modifying production behavior. + +## 19. Artifact inventory + +The manifest records the frozen module, probe source, runner, campaign summary, oracle +script, summary, source NPZ, provenance, report, log, and permanent NPZ fixture. `/tmp` +paths are ephemeral source artifacts whose identity is frozen by SHA-256. + +## 20. Freeze decision + +**FREEZE_AUDIT_APPROVED:** artifacts are suitable as external evidence and an independent +oracle within the explicit domain of this campaign. diff --git a/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json b/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json new file mode 100644 index 0000000..aee5653 --- /dev/null +++ b/tests/validation/fixtures/gwyddion/median_background/median_background_reference.json @@ -0,0 +1,1570 @@ +{ + "acceptance_contract": { + "background_comparison": "bitwise exact float64 equality", + "corrected_comparison": "bitwise exact float64 equality", + "future_discrepancy_rule": "any future discrepancy must first adjudicate source, probe, oracle, fixture and implementation evidence", + "input_mutation": "forbidden", + "no_acceptance_relaxation": "no acceptance relaxation may be introduced merely to satisfy tests", + "output_c_contiguous": "required", + "output_dtype": "float64", + "output_finiteness": "required for finite inputs in this fixture", + "output_shape": "identical to input", + "reconstruction": { + "absolute_tolerance": 1e-15, + "relation": "input == background + corrected", + "relative_tolerance": 0.0 + } + }, + "campaign": { + "asan_detections": 0, + "asan_executions": 36, + "asan_exit_zero": 36, + "glib_detections": 0, + "input_mutation_maximum": 0.0, + "normal_asan_stdout_identical": 36, + "normal_executions": 36, + "normal_exit_zero": 36, + "radius_inventory": { + "1": { + "active_count": 9, + "backend": "direct", + "rank": 4, + "resolution": 3 + }, + "1024": { + "active_count": 3297401, + "backend": "radixtree", + "rank": 1648700, + "resolution": 2049 + }, + "2": { + "active_count": 21, + "backend": "direct", + "rank": 10, + "resolution": 5 + }, + "20": { + "active_count": 1313, + "backend": "radixtree", + "rank": 656, + "resolution": 41 + }, + "3": { + "active_count": 37, + "backend": "radixtree", + "rank": 18, + "resolution": 7 + }, + "4": { + "active_count": 69, + "backend": "radixtree", + "rank": 34, + "resolution": 9 + } + }, + "reconstruction_maximum": 4.440892098500626e-16, + "timeouts": 0, + "total_executions": 72, + "total_logical_cases": 36 + }, + "capability": "gwyddion_median_background", + "cases": [ + { + "arrays": { + "background": "background__wide_r1", + "corrected": "corrected__wide_r1", + "input": "input__wide_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "ee5a77b1472039236347ef7ef98215d3663c51c273e4d94977355c7f77163170", + "canonical_hashes": { + "background": "ed8bfcc683183f523f966253ba59a441b5d04a3145ef5625bc2a342aa9c8a9e7", + "corrected": "aa024e640cdfdeaa1b91b8fa964deaf67c4a1ef8e9917c32c71026ebc36927c1", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "wide_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "ee5a77b1472039236347ef7ef98215d3663c51c273e4d94977355c7f77163170", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__wide_r2", + "corrected": "corrected__wide_r2", + "input": "input__wide_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "6aef0c2c556e148c225ee9015982cf227064b69300ba4f815a1b2f7586949c4f", + "canonical_hashes": { + "background": "af4db96f382b13f356f4516f0178ac0487a4c5a7665252ef0c57c00cc0cc43f6", + "corrected": "bd35648198dec90ab5233c591109c687c0421994f7d209c99284aace73129862", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "wide_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "6aef0c2c556e148c225ee9015982cf227064b69300ba4f815a1b2f7586949c4f", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__wide_r3", + "corrected": "corrected__wide_r3", + "input": "input__wide_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "94e409e5857751215cc6f58e6ef30f04a844305e26832ceab763aa30b924f62e", + "canonical_hashes": { + "background": "fb59c7348c40e18e23f4bc438e36db88c87454ed57db382c06486a6ac0cdb9f5", + "corrected": "38e571bd73d4bd79cc7ed18ebecb3ff77ef1d70ec1bba750201079daa47603e4", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "wide_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "94e409e5857751215cc6f58e6ef30f04a844305e26832ceab763aa30b924f62e", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__wide_r4", + "corrected": "corrected__wide_r4", + "input": "input__wide_r4" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "74f3b7a01c85452d0ebd6ea3bdf149e03f71c6759be46fc39672ed551925d380", + "canonical_hashes": { + "background": "fbcefdd158ac239a19ff9cc7671d29a95c72550f70247875ae67ba294e299de3", + "corrected": "72150de5367cc6a8e2b4288439f777bb4324656afaeef20f10e96b2a9b38e80d", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 69, + "kernel_resolution": 9, + "name": "wide_r4", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "74f3b7a01c85452d0ebd6ea3bdf149e03f71c6759be46fc39672ed551925d380", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 4, + "rank_backend_reference": "radixtree", + "rank_index": 34, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__wide_r20", + "corrected": "corrected__wide_r20", + "input": "input__wide_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "e78cd9f92b7450558b91f79e1dd2f7ca956b88db081ee37c76920feee4893461", + "canonical_hashes": { + "background": "c4f959c3e789cc2d132d64bf19b5e275568ada2d1aa003b8d449c991479103f9", + "corrected": "b793073e8a658e6d0bd7f8d019fcd804056f9074c330b8d319d3b66b9d82f2a5", + "input": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a" + }, + "family": "WIDE_ASYMMETRIC", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "wide_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "e78cd9f92b7450558b91f79e1dd2f7ca956b88db081ee37c76920feee4893461", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 7 + ], + "xres": 7, + "yres": 5 + }, + { + "arrays": { + "background": "background__tall_r1", + "corrected": "corrected__tall_r1", + "input": "input__tall_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "1c3eae268263c2942688421781a36b7d79601a7f15df2e22e148314b44310186", + "canonical_hashes": { + "background": "3dd16ee6c30ad2cd3ef78c35c3b32eeb293a8724c16d506d4b62731260d875c4", + "corrected": "2096acc9eaf4969b92e9a156e8af1d08747083128ceff92b647f88a3c5236a25", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "tall_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "1c3eae268263c2942688421781a36b7d79601a7f15df2e22e148314b44310186", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__tall_r2", + "corrected": "corrected__tall_r2", + "input": "input__tall_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "c4fb1bab61dea587929df374eb586c78d2174f9b1164cb4d2f9609568cabb8e6", + "canonical_hashes": { + "background": "10070900cd33e8ced89e4b7bc99119676c21422a769383cfad4af189e6c9eb41", + "corrected": "db4cd2345bbde0d1ad8353c0b2f16eb3b8943a319175836ef5ebe62fcbd3177a", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "tall_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "c4fb1bab61dea587929df374eb586c78d2174f9b1164cb4d2f9609568cabb8e6", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__tall_r3", + "corrected": "corrected__tall_r3", + "input": "input__tall_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "784348f9e52f7b9ae3ec2e43735ae912863acabba8b1aa1f031ed0c362424cab", + "canonical_hashes": { + "background": "0cc64e208ea1a477acc88cfd2f0b6be19305df2550137e766b51549c63e256f6", + "corrected": "8a6493968843f6a590800f45a820f9b1349510dc4345be287efefbb8b28dfe32", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "tall_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "784348f9e52f7b9ae3ec2e43735ae912863acabba8b1aa1f031ed0c362424cab", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__tall_r4", + "corrected": "corrected__tall_r4", + "input": "input__tall_r4" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "527a50e7e291c883fc7ef9bf8611f3ea66c8ae3ada97eecc0ba113427a5d10f4", + "canonical_hashes": { + "background": "6b5fef03b136cab88588e25d3fa51f34b6e3e2b9defae8d598d004b50a753a2f", + "corrected": "bfe4717df0982a911f39bc8f3afd0c0a1571e708dc50215a3a8aad3d6c9593f6", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 69, + "kernel_resolution": 9, + "name": "tall_r4", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "527a50e7e291c883fc7ef9bf8611f3ea66c8ae3ada97eecc0ba113427a5d10f4", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 4, + "rank_backend_reference": "radixtree", + "rank_index": 34, + "reference_reconstruction_maximum": 4.440892098500626e-16, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__tall_r20", + "corrected": "corrected__tall_r20", + "input": "input__tall_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "36a7270e69665f030a293cd1aad36d64a2554295c5ea332da93b350fc3a0a783", + "canonical_hashes": { + "background": "6b1552c3b22919f4d934b54714d7e2af63d2a6e99b08d25bc5145f92b187a5f4", + "corrected": "fbd461fcb22ebc5122c696d084111ff4f81d5435bd3a33c7a8d4ad1eb5e8f427", + "input": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827" + }, + "family": "TALL_ASYMMETRIC", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "tall_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "36a7270e69665f030a293cd1aad36d64a2554295c5ea332da93b350fc3a0a783", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 4.440892098500626e-16, + "shape": [ + 7, + 5 + ], + "xres": 5, + "yres": 7 + }, + { + "arrays": { + "background": "background__constant_r1", + "corrected": "corrected__constant_r1", + "input": "input__constant_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "0f9e8a47b96cfeedbb1009af226a753adac5cbbc429aa6d47dc718ff1169ccbc", + "canonical_hashes": { + "background": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "corrected": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "input": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d" + }, + "family": "CONSTANT_NONZERO", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "constant_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "0f9e8a47b96cfeedbb1009af226a753adac5cbbc429aa6d47dc718ff1169ccbc", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 4 + ], + "xres": 4, + "yres": 3 + }, + { + "arrays": { + "background": "background__constant_r3", + "corrected": "corrected__constant_r3", + "input": "input__constant_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "63faa4e94c8239e10abed8150465d259ce0fdc4199160d31e43f4c8eab57aa66", + "canonical_hashes": { + "background": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "corrected": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "input": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d" + }, + "family": "CONSTANT_NONZERO", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "constant_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "63faa4e94c8239e10abed8150465d259ce0fdc4199160d31e43f4c8eab57aa66", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 4 + ], + "xres": 4, + "yres": 3 + }, + { + "arrays": { + "background": "background__constant_r20", + "corrected": "corrected__constant_r20", + "input": "input__constant_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "c22d1cf50a37185c37e73d2409e7072ffb13be2ea7cf2079f5316152041e8709", + "canonical_hashes": { + "background": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "corrected": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "input": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d" + }, + "family": "CONSTANT_NONZERO", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "constant_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "c22d1cf50a37185c37e73d2409e7072ffb13be2ea7cf2079f5316152041e8709", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 4 + ], + "xres": 4, + "yres": 3 + }, + { + "arrays": { + "background": "background__signed_r1", + "corrected": "corrected__signed_r1", + "input": "input__signed_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "5bbc8a33fdc2f4f65de201cca101313eef6c3ed46c8198b15e6da608db7eaaae", + "canonical_hashes": { + "background": "a132b970a12538921d3d9ebe1afa3c9eefea29e3ce069f432ed5bd60e850921b", + "corrected": "34c540f12aed27673d1483c377141fd0de3cffa96ab3d9b30537ed7f7dcccbc5", + "input": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b" + }, + "family": "SIGNED_EXPLICIT", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "signed_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "5bbc8a33fdc2f4f65de201cca101313eef6c3ed46c8198b15e6da608db7eaaae", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 3 + ], + "xres": 3, + "yres": 3 + }, + { + "arrays": { + "background": "background__signed_r2", + "corrected": "corrected__signed_r2", + "input": "input__signed_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "676315256497615ea251205778866fe9240f7a03b9c7828e59cd2821c22966b0", + "canonical_hashes": { + "background": "16c24b162b2baa3f5d4d22e1779ab002f8af3c8fe2df12a5dfad3a0812fca745", + "corrected": "42c169eb8fb0911f6d4ea70e6a48040ff8c933f481cb77907d148cd2b9d52969", + "input": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b" + }, + "family": "SIGNED_EXPLICIT", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "signed_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "676315256497615ea251205778866fe9240f7a03b9c7828e59cd2821c22966b0", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 3 + ], + "xres": 3, + "yres": 3 + }, + { + "arrays": { + "background": "background__signed_r3", + "corrected": "corrected__signed_r3", + "input": "input__signed_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "f2fa6d42a4520d70ee9d38fd51ef89a7792dc8b7ae8294da416b4bbffc673207", + "canonical_hashes": { + "background": "c9626a23c50d43caeb07c7008c212607a5580def6d5c33f33914d8f57226b295", + "corrected": "ab5822d7ecf9b27c5353b2bd4203b0fbb4f4c241583b273ce352c62eb09b59ea", + "input": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b" + }, + "family": "SIGNED_EXPLICIT", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "signed_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "f2fa6d42a4520d70ee9d38fd51ef89a7792dc8b7ae8294da416b4bbffc673207", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 3 + ], + "xres": 3, + "yres": 3 + }, + { + "arrays": { + "background": "background__signed_r20", + "corrected": "corrected__signed_r20", + "input": "input__signed_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "685ac6941cde1c12699c60df73786bbb2d93d28b2021403ee239e371500eae93", + "canonical_hashes": { + "background": "8c5d90f8bcf1b0e5ed1aed6ef09cf8914f39930d6775cbb95b4596cd0a48a340", + "corrected": "23dc233138657fd8a0b43948d8b9b615075262136ef8b15588713864c544e259", + "input": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b" + }, + "family": "SIGNED_EXPLICIT", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "signed_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "685ac6941cde1c12699c60df73786bbb2d93d28b2021403ee239e371500eae93", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 3, + 3 + ], + "xres": 3, + "yres": 3 + }, + { + "arrays": { + "background": "background__singleton_1x1_r1", + "corrected": "corrected__singleton_1x1_r1", + "input": "input__singleton_1x1_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "9f23d5ab27921bcb281195f1aa4242f6d567822c58db2289c72e654bb91679ec", + "canonical_hashes": { + "background": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "input": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f" + }, + "family": "SINGLETON_SCALAR", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "singleton_1x1_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "9f23d5ab27921bcb281195f1aa4242f6d567822c58db2289c72e654bb91679ec", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 1 + ], + "xres": 1, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_1x1_r3", + "corrected": "corrected__singleton_1x1_r3", + "input": "input__singleton_1x1_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "5350c8cf313880fe9d9c7d66322447619c031d76e533245b670e0fce615ddf9f", + "canonical_hashes": { + "background": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "input": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f" + }, + "family": "SINGLETON_SCALAR", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "singleton_1x1_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "5350c8cf313880fe9d9c7d66322447619c031d76e533245b670e0fce615ddf9f", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 1 + ], + "xres": 1, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_1x1_r20", + "corrected": "corrected__singleton_1x1_r20", + "input": "input__singleton_1x1_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "935a44ebec138342eba5fd54e8ae4356d3cc8a0f693b50da86735b13915b6998", + "canonical_hashes": { + "background": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "input": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f" + }, + "family": "SINGLETON_SCALAR", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "singleton_1x1_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "935a44ebec138342eba5fd54e8ae4356d3cc8a0f693b50da86735b13915b6998", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 1 + ], + "xres": 1, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_1x1_r1024", + "corrected": "corrected__singleton_1x1_r1024", + "input": "input__singleton_1x1_r1024" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "a3aa53016ac652fa2c6658a0dc08a40178cfdcde8250cc9efdea16a7234aa200", + "canonical_hashes": { + "background": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "corrected": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "input": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f" + }, + "family": "SINGLETON_SCALAR", + "kernel_active_count": 3297401, + "kernel_resolution": 2049, + "name": "singleton_1x1_r1024", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "a3aa53016ac652fa2c6658a0dc08a40178cfdcde8250cc9efdea16a7234aa200", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1024, + "rank_backend_reference": "radixtree", + "rank_index": 1648700, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 1 + ], + "xres": 1, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_row_r1", + "corrected": "corrected__singleton_row_r1", + "input": "input__singleton_row_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "049d6a8c610eec979a4a849aafd127cd03441dc5b09dd9716637acc4833820bd", + "canonical_hashes": { + "background": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "corrected": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "input": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57" + }, + "family": "SINGLETON_ROW", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "singleton_row_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "049d6a8c610eec979a4a849aafd127cd03441dc5b09dd9716637acc4833820bd", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 5 + ], + "xres": 5, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_row_r3", + "corrected": "corrected__singleton_row_r3", + "input": "input__singleton_row_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "5cde1b61afe9b14f6c0b067c10bb88cdd9f571631297140dfad8bb139bdf6995", + "canonical_hashes": { + "background": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "corrected": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "input": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57" + }, + "family": "SINGLETON_ROW", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "singleton_row_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "5cde1b61afe9b14f6c0b067c10bb88cdd9f571631297140dfad8bb139bdf6995", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 5 + ], + "xres": 5, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_row_r20", + "corrected": "corrected__singleton_row_r20", + "input": "input__singleton_row_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "fcc806e053dd97ba2cd197a545e8cae79c89eebcc9fce56ea158883bccc0224f", + "canonical_hashes": { + "background": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "corrected": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "input": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57" + }, + "family": "SINGLETON_ROW", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "singleton_row_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "fcc806e053dd97ba2cd197a545e8cae79c89eebcc9fce56ea158883bccc0224f", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 1, + 5 + ], + "xres": 5, + "yres": 1 + }, + { + "arrays": { + "background": "background__singleton_column_r1", + "corrected": "corrected__singleton_column_r1", + "input": "input__singleton_column_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "69bda8da85155b03afe65486f742e6c9d5a5fe15ae7cd96f6bfa5a6aa80e1420", + "canonical_hashes": { + "background": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "corrected": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "input": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9" + }, + "family": "SINGLETON_COLUMN", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "singleton_column_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "69bda8da85155b03afe65486f742e6c9d5a5fe15ae7cd96f6bfa5a6aa80e1420", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 1 + ], + "xres": 1, + "yres": 5 + }, + { + "arrays": { + "background": "background__singleton_column_r3", + "corrected": "corrected__singleton_column_r3", + "input": "input__singleton_column_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "b683797214f377eb700e8104b2c38a2138bdc34667228b70895d6fcbaa95330a", + "canonical_hashes": { + "background": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "corrected": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "input": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9" + }, + "family": "SINGLETON_COLUMN", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "singleton_column_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "b683797214f377eb700e8104b2c38a2138bdc34667228b70895d6fcbaa95330a", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 1 + ], + "xres": 1, + "yres": 5 + }, + { + "arrays": { + "background": "background__singleton_column_r20", + "corrected": "corrected__singleton_column_r20", + "input": "input__singleton_column_r20" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "df16cae389eb5a7f7a3e1b89247d564d94b0e62cde63259b781d235b8de22ed0", + "canonical_hashes": { + "background": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "corrected": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "input": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9" + }, + "family": "SINGLETON_COLUMN", + "kernel_active_count": 1313, + "kernel_resolution": 41, + "name": "singleton_column_r20", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "df16cae389eb5a7f7a3e1b89247d564d94b0e62cde63259b781d235b8de22ed0", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 20, + "rank_backend_reference": "radixtree", + "rank_index": 656, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 1 + ], + "xres": 1, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_positive_r1", + "corrected": "corrected__impulse_positive_r1", + "input": "input__impulse_positive_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "e6cae67ec4b60be3c67305e2bb4a8cd7ea9ebf2853d28865cafc9721e82008e2", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa" + }, + "family": "IMPULSE_POSITIVE", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "impulse_positive_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "e6cae67ec4b60be3c67305e2bb4a8cd7ea9ebf2853d28865cafc9721e82008e2", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_positive_r2", + "corrected": "corrected__impulse_positive_r2", + "input": "input__impulse_positive_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "d54909e63485b8123a710f92495c51b333122ceb4c56b9581478c011b5031e3b", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa" + }, + "family": "IMPULSE_POSITIVE", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "impulse_positive_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "d54909e63485b8123a710f92495c51b333122ceb4c56b9581478c011b5031e3b", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_positive_r3", + "corrected": "corrected__impulse_positive_r3", + "input": "input__impulse_positive_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "33503698dbc21880eec0a1c6b2cca79af69c06fc5142d84cdff725f613fb8cf2", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa" + }, + "family": "IMPULSE_POSITIVE", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "impulse_positive_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "33503698dbc21880eec0a1c6b2cca79af69c06fc5142d84cdff725f613fb8cf2", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_negative_r1", + "corrected": "corrected__impulse_negative_r1", + "input": "input__impulse_negative_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "136cef3c87081f62d1a58ea34ba4cec53c3ec0c3a70e546c7781dabc03672900", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894" + }, + "family": "IMPULSE_NEGATIVE", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "impulse_negative_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "136cef3c87081f62d1a58ea34ba4cec53c3ec0c3a70e546c7781dabc03672900", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_negative_r2", + "corrected": "corrected__impulse_negative_r2", + "input": "input__impulse_negative_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "13cacbc9901651a60cee59fb99f6001b7cc1923cb19077175dc2ef900320eaef", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894" + }, + "family": "IMPULSE_NEGATIVE", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "impulse_negative_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "13cacbc9901651a60cee59fb99f6001b7cc1923cb19077175dc2ef900320eaef", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__impulse_negative_r3", + "corrected": "corrected__impulse_negative_r3", + "input": "input__impulse_negative_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "d82063e40817af9aea7bbdee7e0ae6c456dac52d238a4eb3aacacc7117d80fbe", + "canonical_hashes": { + "background": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "corrected": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894" + }, + "family": "IMPULSE_NEGATIVE", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "impulse_negative_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "d82063e40817af9aea7bbdee7e0ae6c456dac52d238a4eb3aacacc7117d80fbe", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 5, + 5 + ], + "xres": 5, + "yres": 5 + }, + { + "arrays": { + "background": "background__monotonic_r1", + "corrected": "corrected__monotonic_r1", + "input": "input__monotonic_r1" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "322b1a1a28cb3b1ca4be0790b3bf7ca57a92fa18b75530d222608d0c2e43fdff", + "canonical_hashes": { + "background": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "corrected": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "input": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905" + }, + "family": "MONOTONIC", + "kernel_active_count": 9, + "kernel_resolution": 3, + "name": "monotonic_r1", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "322b1a1a28cb3b1ca4be0790b3bf7ca57a92fa18b75530d222608d0c2e43fdff", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 1, + "rank_backend_reference": "direct", + "rank_index": 4, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 4, + 4 + ], + "xres": 4, + "yres": 4 + }, + { + "arrays": { + "background": "background__monotonic_r2", + "corrected": "corrected__monotonic_r2", + "input": "input__monotonic_r2" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "f7b29e736c9e9e870da37aa5690ba0ef99d397ff87790d208aa6e4e0ec9720f1", + "canonical_hashes": { + "background": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "corrected": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "input": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905" + }, + "family": "MONOTONIC", + "kernel_active_count": 21, + "kernel_resolution": 5, + "name": "monotonic_r2", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "f7b29e736c9e9e870da37aa5690ba0ef99d397ff87790d208aa6e4e0ec9720f1", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 2, + "rank_backend_reference": "direct", + "rank_index": 10, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 4, + 4 + ], + "xres": 4, + "yres": 4 + }, + { + "arrays": { + "background": "background__monotonic_r3", + "corrected": "corrected__monotonic_r3", + "input": "input__monotonic_r3" + }, + "asan_exit_code": 0, + "asan_stdout_sha256": "809f2c550b73dd1f9a7f963f2ebdde1c14c7524faf94b70b587d4dda49310350", + "canonical_hashes": { + "background": "3d69ac8275fae70542a8bb19f0a68a83dbdf1a9d075da1ae3e67f0754c94a57c", + "corrected": "88fd2936e40e0605702b266a858c911967dd8229c60c1dfb66a0c3d3ee8274f6", + "input": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905" + }, + "family": "MONOTONIC", + "kernel_active_count": 37, + "kernel_resolution": 7, + "name": "monotonic_r3", + "normal_asan_stdout_byte_identical": true, + "normal_exit_code": 0, + "normal_stdout_sha256": "809f2c550b73dd1f9a7f963f2ebdde1c14c7524faf94b70b587d4dda49310350", + "oracle_reference_background_bitwise_exact": true, + "oracle_reference_corrected_bitwise_exact": true, + "radius": 3, + "rank_backend_reference": "radixtree", + "rank_index": 18, + "reference_reconstruction_maximum": 0.0, + "shape": [ + 4, + 4 + ], + "xres": 4, + "yres": 4 + } + ], + "evidence_classification": { + "external_probe": "EXECUTABLE_EXTERNAL_REFERENCE", + "freeze_audit": "MEDIAN_BACKGROUND_ORACLE_FREEZE_APPROVED", + "independent_oracle": "INDEPENDENT_PYTHON_ORACLE", + "spmkit_implementation": "NOT_YET_IMPLEMENTED" + }, + "fixture": { + "all_finite": true, + "array_count": 108, + "arrays_per_case": 3, + "canonical_hash_algorithm": "SHA-256 of dtype.str, NUL, comma-separated shape, NUL, and C-order bytes", + "case_count": 36, + "compressed": false, + "dimension_count": 2, + "dtype": "float64", + "npz_relative_path": "tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz", + "npz_sha256": "a581893e44d8887939d2335bc7eda6650a301e78e4025a8c7aa77020db442b20", + "order": "C" + }, + "operation": "median_bg", + "oracle": { + "background_bitwise_exact_percentage_maximum": 100.0, + "background_bitwise_exact_percentage_minimum": 100.0, + "background_maximum_absolute_difference": 0.0, + "background_maximum_ulp": 0, + "canonical_source_array_hash_count": 180, + "canonical_source_array_hashes": { + "input__constant_r1": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "input__constant_r20": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "input__constant_r3": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "input__impulse_negative_r1": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input__impulse_negative_r2": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input__impulse_negative_r3": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "input__impulse_positive_r1": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input__impulse_positive_r2": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input__impulse_positive_r3": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "input__monotonic_r1": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905", + "input__monotonic_r2": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905", + "input__monotonic_r3": "516a1bf69f91e9c4f763f5ddf322d5ac3b6632b7c1712427373d3398aee70905", + "input__signed_r1": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b", + "input__signed_r2": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b", + "input__signed_r20": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b", + "input__signed_r3": "b0c13406214e251df81d7859813566e2c43c107310f36f0906906ba3f090375b", + "input__singleton_1x1_r1": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_1x1_r1024": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_1x1_r20": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_1x1_r3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_column_r1": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "input__singleton_column_r20": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "input__singleton_column_r3": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "input__singleton_row_r1": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "input__singleton_row_r20": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "input__singleton_row_r3": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "input__tall_r1": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__tall_r2": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__tall_r20": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__tall_r3": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__tall_r4": "a7db6ca92ecd92a9c68dfc06110cdeb0c79b46796fd9e4396ba59cf32bcc2827", + "input__wide_r1": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "input__wide_r2": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "input__wide_r20": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "input__wide_r3": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "input__wide_r4": "9eec98d0332346e0928a2bc242bd93ea9b8affe7f135040379a23a100d0f972a", + "oracle_background__constant_r1": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "oracle_background__constant_r20": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "oracle_background__constant_r3": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "oracle_background__impulse_negative_r1": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_negative_r2": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_negative_r3": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_positive_r1": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_positive_r2": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__impulse_positive_r3": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "oracle_background__monotonic_r1": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "oracle_background__monotonic_r2": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "oracle_background__monotonic_r3": "3d69ac8275fae70542a8bb19f0a68a83dbdf1a9d075da1ae3e67f0754c94a57c", + "oracle_background__signed_r1": "a132b970a12538921d3d9ebe1afa3c9eefea29e3ce069f432ed5bd60e850921b", + "oracle_background__signed_r2": "16c24b162b2baa3f5d4d22e1779ab002f8af3c8fe2df12a5dfad3a0812fca745", + "oracle_background__signed_r20": "8c5d90f8bcf1b0e5ed1aed6ef09cf8914f39930d6775cbb95b4596cd0a48a340", + "oracle_background__signed_r3": "c9626a23c50d43caeb07c7008c212607a5580def6d5c33f33914d8f57226b295", + "oracle_background__singleton_1x1_r1": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "oracle_background__singleton_1x1_r1024": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "oracle_background__singleton_1x1_r20": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "oracle_background__singleton_1x1_r3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "oracle_background__singleton_column_r1": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "oracle_background__singleton_column_r20": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "oracle_background__singleton_column_r3": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "oracle_background__singleton_row_r1": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "oracle_background__singleton_row_r20": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "oracle_background__singleton_row_r3": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "oracle_background__tall_r1": "3dd16ee6c30ad2cd3ef78c35c3b32eeb293a8724c16d506d4b62731260d875c4", + "oracle_background__tall_r2": "10070900cd33e8ced89e4b7bc99119676c21422a769383cfad4af189e6c9eb41", + "oracle_background__tall_r20": "6b1552c3b22919f4d934b54714d7e2af63d2a6e99b08d25bc5145f92b187a5f4", + "oracle_background__tall_r3": "0cc64e208ea1a477acc88cfd2f0b6be19305df2550137e766b51549c63e256f6", + "oracle_background__tall_r4": "6b5fef03b136cab88588e25d3fa51f34b6e3e2b9defae8d598d004b50a753a2f", + "oracle_background__wide_r1": "ed8bfcc683183f523f966253ba59a441b5d04a3145ef5625bc2a342aa9c8a9e7", + "oracle_background__wide_r2": "af4db96f382b13f356f4516f0178ac0487a4c5a7665252ef0c57c00cc0cc43f6", + "oracle_background__wide_r20": "c4f959c3e789cc2d132d64bf19b5e275568ada2d1aa003b8d449c991479103f9", + "oracle_background__wide_r3": "fb59c7348c40e18e23f4bc438e36db88c87454ed57db382c06486a6ac0cdb9f5", + "oracle_background__wide_r4": "fbcefdd158ac239a19ff9cc7671d29a95c72550f70247875ae67ba294e299de3", + "oracle_corrected__constant_r1": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "oracle_corrected__constant_r20": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "oracle_corrected__constant_r3": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "oracle_corrected__impulse_negative_r1": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "oracle_corrected__impulse_negative_r2": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "oracle_corrected__impulse_negative_r3": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "oracle_corrected__impulse_positive_r1": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "oracle_corrected__impulse_positive_r2": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "oracle_corrected__impulse_positive_r3": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "oracle_corrected__monotonic_r1": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "oracle_corrected__monotonic_r2": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "oracle_corrected__monotonic_r3": "88fd2936e40e0605702b266a858c911967dd8229c60c1dfb66a0c3d3ee8274f6", + "oracle_corrected__signed_r1": "34c540f12aed27673d1483c377141fd0de3cffa96ab3d9b30537ed7f7dcccbc5", + "oracle_corrected__signed_r2": "42c169eb8fb0911f6d4ea70e6a48040ff8c933f481cb77907d148cd2b9d52969", + "oracle_corrected__signed_r20": "23dc233138657fd8a0b43948d8b9b615075262136ef8b15588713864c544e259", + "oracle_corrected__signed_r3": "ab5822d7ecf9b27c5353b2bd4203b0fbb4f4c241583b273ce352c62eb09b59ea", + "oracle_corrected__singleton_1x1_r1": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "oracle_corrected__singleton_1x1_r1024": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "oracle_corrected__singleton_1x1_r20": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "oracle_corrected__singleton_1x1_r3": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "oracle_corrected__singleton_column_r1": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "oracle_corrected__singleton_column_r20": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "oracle_corrected__singleton_column_r3": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "oracle_corrected__singleton_row_r1": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "oracle_corrected__singleton_row_r20": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "oracle_corrected__singleton_row_r3": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "oracle_corrected__tall_r1": "2096acc9eaf4969b92e9a156e8af1d08747083128ceff92b647f88a3c5236a25", + "oracle_corrected__tall_r2": "db4cd2345bbde0d1ad8353c0b2f16eb3b8943a319175836ef5ebe62fcbd3177a", + "oracle_corrected__tall_r20": "fbd461fcb22ebc5122c696d084111ff4f81d5435bd3a33c7a8d4ad1eb5e8f427", + "oracle_corrected__tall_r3": "8a6493968843f6a590800f45a820f9b1349510dc4345be287efefbb8b28dfe32", + "oracle_corrected__tall_r4": "bfe4717df0982a911f39bc8f3afd0c0a1571e708dc50215a3a8aad3d6c9593f6", + "oracle_corrected__wide_r1": "aa024e640cdfdeaa1b91b8fa964deaf67c4a1ef8e9917c32c71026ebc36927c1", + "oracle_corrected__wide_r2": "bd35648198dec90ab5233c591109c687c0421994f7d209c99284aace73129862", + "oracle_corrected__wide_r20": "b793073e8a658e6d0bd7f8d019fcd804056f9074c330b8d319d3b66b9d82f2a5", + "oracle_corrected__wide_r3": "38e571bd73d4bd79cc7ed18ebecb3ff77ef1d70ec1bba750201079daa47603e4", + "oracle_corrected__wide_r4": "72150de5367cc6a8e2b4288439f777bb4324656afaeef20f10e96b2a9b38e80d", + "reference_background__constant_r1": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "reference_background__constant_r20": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "reference_background__constant_r3": "1cf44ec5f4c9b1418956213c4c073602be40a3b9ff83253313c95b184324334d", + "reference_background__impulse_negative_r1": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_negative_r2": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_negative_r3": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_positive_r1": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_positive_r2": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__impulse_positive_r3": "0db221a6c1a230bbb1d9c6bd2a89e3711812b8cb975c80161c7ed7b696922519", + "reference_background__monotonic_r1": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "reference_background__monotonic_r2": "60754f4f2727f46508e7d407861187159d7e4487ebf92d55e2fa0f43eb0b71c4", + "reference_background__monotonic_r3": "3d69ac8275fae70542a8bb19f0a68a83dbdf1a9d075da1ae3e67f0754c94a57c", + "reference_background__signed_r1": "a132b970a12538921d3d9ebe1afa3c9eefea29e3ce069f432ed5bd60e850921b", + "reference_background__signed_r2": "16c24b162b2baa3f5d4d22e1779ab002f8af3c8fe2df12a5dfad3a0812fca745", + "reference_background__signed_r20": "8c5d90f8bcf1b0e5ed1aed6ef09cf8914f39930d6775cbb95b4596cd0a48a340", + "reference_background__signed_r3": "c9626a23c50d43caeb07c7008c212607a5580def6d5c33f33914d8f57226b295", + "reference_background__singleton_1x1_r1": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "reference_background__singleton_1x1_r1024": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "reference_background__singleton_1x1_r20": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "reference_background__singleton_1x1_r3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "reference_background__singleton_column_r1": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "reference_background__singleton_column_r20": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "reference_background__singleton_column_r3": "424b7adea6f6a2417030b7203ee7b7323ae361c00237a5e1393af739665399b9", + "reference_background__singleton_row_r1": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "reference_background__singleton_row_r20": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "reference_background__singleton_row_r3": "5fd3797e9aa57a4f15237d46f86a84e0b52a8669e369748b45a53bb8a859ed57", + "reference_background__tall_r1": "3dd16ee6c30ad2cd3ef78c35c3b32eeb293a8724c16d506d4b62731260d875c4", + "reference_background__tall_r2": "10070900cd33e8ced89e4b7bc99119676c21422a769383cfad4af189e6c9eb41", + "reference_background__tall_r20": "6b1552c3b22919f4d934b54714d7e2af63d2a6e99b08d25bc5145f92b187a5f4", + "reference_background__tall_r3": "0cc64e208ea1a477acc88cfd2f0b6be19305df2550137e766b51549c63e256f6", + "reference_background__tall_r4": "6b5fef03b136cab88588e25d3fa51f34b6e3e2b9defae8d598d004b50a753a2f", + "reference_background__wide_r1": "ed8bfcc683183f523f966253ba59a441b5d04a3145ef5625bc2a342aa9c8a9e7", + "reference_background__wide_r2": "af4db96f382b13f356f4516f0178ac0487a4c5a7665252ef0c57c00cc0cc43f6", + "reference_background__wide_r20": "c4f959c3e789cc2d132d64bf19b5e275568ada2d1aa003b8d449c991479103f9", + "reference_background__wide_r3": "fb59c7348c40e18e23f4bc438e36db88c87454ed57db382c06486a6ac0cdb9f5", + "reference_background__wide_r4": "fbcefdd158ac239a19ff9cc7671d29a95c72550f70247875ae67ba294e299de3", + "reference_corrected__constant_r1": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "reference_corrected__constant_r20": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "reference_corrected__constant_r3": "be1cf123657b083340a21ba2ddfe026545ed615e8f9eca5888c9371bdc63c2e7", + "reference_corrected__impulse_negative_r1": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "reference_corrected__impulse_negative_r2": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "reference_corrected__impulse_negative_r3": "21b11b545d6270bbdd4eb4fc6d6e3163dc5ec6a8d107321998116ba6582f2894", + "reference_corrected__impulse_positive_r1": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "reference_corrected__impulse_positive_r2": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "reference_corrected__impulse_positive_r3": "368ccc46b91920521792dea92d5544ad77455bbb0b83aec7908ad8416b67b1aa", + "reference_corrected__monotonic_r1": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "reference_corrected__monotonic_r2": "ec15989faf678e6c93b7a2770f66a8c9a18971872c4dc179df88e31b83632f1c", + "reference_corrected__monotonic_r3": "88fd2936e40e0605702b266a858c911967dd8229c60c1dfb66a0c3d3ee8274f6", + "reference_corrected__signed_r1": "34c540f12aed27673d1483c377141fd0de3cffa96ab3d9b30537ed7f7dcccbc5", + "reference_corrected__signed_r2": "42c169eb8fb0911f6d4ea70e6a48040ff8c933f481cb77907d148cd2b9d52969", + "reference_corrected__signed_r20": "23dc233138657fd8a0b43948d8b9b615075262136ef8b15588713864c544e259", + "reference_corrected__signed_r3": "ab5822d7ecf9b27c5353b2bd4203b0fbb4f4c241583b273ce352c62eb09b59ea", + "reference_corrected__singleton_1x1_r1": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "reference_corrected__singleton_1x1_r1024": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "reference_corrected__singleton_1x1_r20": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "reference_corrected__singleton_1x1_r3": "8a39439d008124c73db4f3c41b941530a9e08999c00c2b80178156f471d722a6", + "reference_corrected__singleton_column_r1": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "reference_corrected__singleton_column_r20": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "reference_corrected__singleton_column_r3": "b90348918aa0c746039e4891c95c72882d8a271f09eadb60ffd523798b23d03d", + "reference_corrected__singleton_row_r1": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "reference_corrected__singleton_row_r20": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "reference_corrected__singleton_row_r3": "8d3f028c79a314dc26cf9da56ea9c801def54293f49340d6a041d42178d4d8e0", + "reference_corrected__tall_r1": "2096acc9eaf4969b92e9a156e8af1d08747083128ceff92b647f88a3c5236a25", + "reference_corrected__tall_r2": "db4cd2345bbde0d1ad8353c0b2f16eb3b8943a319175836ef5ebe62fcbd3177a", + "reference_corrected__tall_r20": "fbd461fcb22ebc5122c696d084111ff4f81d5435bd3a33c7a8d4ad1eb5e8f427", + "reference_corrected__tall_r3": "8a6493968843f6a590800f45a820f9b1349510dc4345be287efefbb8b28dfe32", + "reference_corrected__tall_r4": "bfe4717df0982a911f39bc8f3afd0c0a1571e708dc50215a3a8aad3d6c9593f6", + "reference_corrected__wide_r1": "aa024e640cdfdeaa1b91b8fa964deaf67c4a1ef8e9917c32c71026ebc36927c1", + "reference_corrected__wide_r2": "bd35648198dec90ab5233c591109c687c0421994f7d209c99284aace73129862", + "reference_corrected__wide_r20": "b793073e8a658e6d0bd7f8d019fcd804056f9074c330b8d319d3b66b9d82f2a5", + "reference_corrected__wide_r3": "38e571bd73d4bd79cc7ed18ebecb3ff77ef1d70ec1bba750201079daa47603e4", + "reference_corrected__wide_r4": "72150de5367cc6a8e2b4288439f777bb4324656afaeef20f10e96b2a9b38e80d" + }, + "cases": 36, + "corrected_bitwise_exact_percentage_maximum": 100.0, + "corrected_bitwise_exact_percentage_minimum": 100.0, + "corrected_maximum_absolute_difference": 0.0, + "corrected_maximum_ulp": 0, + "independence": { + "gwyddion_execution": false, + "numpy_partition_used_instead_of_internal_gwyddion_selection": true, + "reference_arrays_loaded_only_after_oracle_outputs_calculated": true, + "scipy_import": false, + "spmkit_import": false, + "subprocess_import": false, + "tolerance_selected": false + }, + "nonfinite_cases": 0, + "oracle_reconstruction_maximum": 4.440892098500626e-16, + "reference_reconstruction_maximum": 4.440892098500626e-16, + "source_arrays": 180 + }, + "reference_software": { + "name": "Gwyddion", + "version": "2.71" + }, + "schema_version": 1, + "scope": { + "assertions": [ + "evidence is limited to the frozen 36-case campaign", + "both Gwyddion rank-filter backends are represented", + "no universal equivalence is claimed", + "no SPMKit implementation is validated by this fixture alone", + "errors outside the frozen domain are not excluded" + ] + }, + "semantics": { + "active_condition": "squared ellipse expression <= radius_squared", + "active_offsets": "centred by subtracting radius", + "corrected": "input - background", + "direct_backend_condition": "kernel_active_count <= 25", + "exterior": "GWY_EXTERIOR_BORDER_EXTEND", + "exterior_interpretation": "nearest valid edge pixel", + "input_mutation": false, + "kernel_geometry": "inclusive digital ellipse over pixel centres", + "kernel_resolution": "2*radius + 1", + "offset_order": "row-major", + "pixel_centre": "kernel_index + 0.5", + "radius_default": 20, + "radius_maximum": 1024, + "radius_minimum": 1, + "radius_type": "integer", + "radixtree_backend_condition": "kernel_active_count > 25", + "rank": "kernel_active_count//2" + }, + "source_artifacts": { + "campaign_summary": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_probe/campaign-summary.tsv", + "sha256": "7876d9cf3bc61375ecff5ca42c16789e11f4f0cc651f330ed7da57b36fc493b2" + }, + "median_bg_c": { + "path": "/home/kegouro/HIBRIS/Workshop \u2044 Proyectos/SPMKIT ALL/spmkit-core-first-audit/.reference/gwyddion-2.71/source/modules/process/median-bg.c", + "sha256": "5021fff407531459ed47aff7a47e4f5b2ce2ea7df13d04ca4405f05581258729" + }, + "oracle_log": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle.log", + "sha256": "87eec8d8509d6d879ab41d748e9c21cc1ae428e6157131320c5fe7de0635d059" + }, + "oracle_provenance": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle_provenance.json", + "sha256": "fe759bafbd7180394f3262da13e19275475ae569c3f79f879a51c6b8025e0d74" + }, + "oracle_report": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle_report.md", + "sha256": "1a11f8caec6456de78feb9679e3fe0bec81e101058bfd73806d9953665b8dd31" + }, + "oracle_script": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle.py", + "sha256": "2696798b180fcce779bbded49131d106cdc8f159c30aa1df873008f04d66084b" + }, + "oracle_source_npz": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle_arrays.npz", + "sha256": "d56117cb4bbfc182d9fdfb8a9f6d2b400b5d5d8c17e051075342387b98dacc09" + }, + "oracle_summary": { + "note": "ephemeral source artifact; identity frozen by SHA-256", + "path": "/tmp/spmkit_gwyddion_median_background_oracle_summary.tsv", + "sha256": "0325066fd9a13cbc2b70d21473d2aeca783faa469a37d867fc6c11801c51b69f" + }, + "probe_c": { + "path": "/home/kegouro/HIBRIS/Workshop \u2044 Proyectos/SPMKIT ALL/spmkit-core-first-audit/.reference/gwyddion-2.71/median-background-parity/median_background_behavior_probe.c", + "sha256": "8e1956a6dbc69afcf5244098bc930f529c733bdec2768909cb7372ccea260f10" + }, + "runner": { + "path": "/home/kegouro/HIBRIS/Workshop \u2044 Proyectos/SPMKIT ALL/spmkit-core-first-audit/.reference/gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh", + "sha256": "8c1c42dba8fc8a36bb93d1a82e60257dca3ff5044a2987b452608f99115ab594" + } + } +} diff --git a/tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz b/tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..3680a230f7f0c67c2b5c47be2cc24e9de802995f GIT binary patch literal 45742 zcmeHQ3w#XM{+~_aQF2vCgLrKsB-J2^AVSSF)zssLh(}3DL=q8+O>VMCN^7-LaS@Md z+-m nR*0=lU5MO_-@94A)eMX!vHZ3C|QTRMOE;TdJ{CVb> zxb#Hxd%sTp+Ac5qYv0vA^{bB9eJ(Y^GxSnx4foy5&MDEpPTb;|d-lzI;KU7fDJVI; z-I;r#>B>bra$LAA9}hk1zOV*&^LoJ+FSp0+SJP^@k1{fxcVV+O;RiI_Amfh7dHGIU zjDF0mTKgMuL3_W-UGonYZb>RP@BX10+>X2Hy3L*?Y{9P1DPsmO+@Zp@U80jT+_A7z zHwPv &HtjTRF8K#f%c*Mpmc;~Oi^^e&t(dU=7 zaxUSWd-&*6n>%s!zI>^7;&5kf(}2?-uPbojnuovm?V`w bx)Xo&Ai_~w;9{J*XJS;- 2cNERRcwpfBQ5`G> z2I}BBBLf$LgFVa+j!( U63u$2`h+`sc(CctiScw@3W(8OvR6(RXIyTHeqmriYvNS{AmqXs4fU`PQu2JXgry z|Fp?6HXuFii?lcL`N((vJ^6mKe71XBpIMtfe$i;LpZI}s;l29-5zAP^5dF_PMz5## z6W>47_2A_B>u$5Ce-*_!$RW=5EY4lMr{96xC5yA&JD4WUFpA=%ubMcENkbidY2pmx z4ub+N&M=BvSDd@m+novyCW^DI`xn oO+@*n6(sTIdD9#-n;%v|2JR{{H54lSgXPb90E_V2# z(f>-Eq2h+h7b f&*0IHWObwqUTcrv(VMovD+Zxt4iv5*7XGC!}1f0zt-7%jX zdg<0Kj}ykS? UPgAhJ%1)`3MbI;=k7l4C_;%w{wWekCrY87|cz#2a{^-tWN zS83uLu%fu}szE#X@>b3u&XJc&!h=`yykX4S7q8D-$g7F-GS?IL9 7cy z+xM`o%QMI_wB?m&*dAJZKU|(c)=_yr-}&lJ@USrcTFg4zyn}6Cp9AUyjBt(ETVZ|P z;@Rn6otVRX`XJ5bJIrQY9EW&u9J0`7mvLQ(&u@DdE1vs|TGa_0EL#)Q$EBuL)|y}! ze{HAdv?d6(4b&$1EF`P}mUqn$U*lVGB&UO}gyHa6Mtn_=%kBO5EhkHO{J^!t#7U{% zC;r3E?w@pKTHYfzsKd#l1N|AUptuKnYPp6B+I-}~ncmLa$nz(66_2jTrLS(WU!U`k zJvFT`_wu60Y*@<2tAoaCxNfb6dzbWa#?}Nce}C>r@GV|ER}lw;y3rgAi?t94FddB- z$1no{fdIuV?qE<&2n5`5;9zhZI1IJ}$KejfzlZI>!GMRVbMVgD&B4J~@oXRd72QW~ z)YbqC`#t<2L+i@HnjaPE;*FgJU1nIU@mXx&c#v1;W|O2fJ=2DnD|Vd7XQLiGXcCYs zn{MVh6olN0XX_8@c&7FLZ F?*5 z7W)N $DHHu}Q%(86}g;+z$o(-Is^6lYuaucS@1Un(lj)R_xRoMDa6;_qVO4B!7c=k4`9 zcUX$^(C&A7m^8cj!-L{4H*LdPimnK7IPJURcmH-x5@%7w%eJq$I9JY+f d_kO-&3pIy;@1xGBj;Rm zy7%RN-l$0$I*VV$GZh!-%2`ryi1Tw2=XjqRCdgf~INQ8~p&|y60&xb>1+fKDgXsb| zcO66*-wrBLI9DA+7q$bj$G3y&1=tQm8UMS02mJyZ7(4(6wgb_I?O>h^-wqY$ReyXX zU8g`4XIuBLbixL{CTJXRBDLL5|B}Snkl!}^z?2QF!T*Nt_E%;x7hl`19M}}{hARha zeulmU>-x@Gz%o}uk6+85B#9#v-K~38|3$1chg^EfGjH&1^IFUGYj`eL^ZWVxU+~Pi zyJ5+*-e+OXIP2J)fg_Uk^ZN7a!_Lg8Zyf1$@>KBLZ9EflBYecZ81~rG1J~W_oZyR^ z)_X9u<#C?5FwCd*%>q6ry!#7nNA3sF<$Hx~Y8zd0iH%$t(0zB(X@1MO-(D^F 9XSWsttP0-do46@~DOP3SC4ru!3 z&-U~H4-4b3Y)#PC9ek?E#Rj+DI>TqJtrvFE??*oUlVKU%LU;3A^5{g5pO*86@Q|i| zc3#ak8@;u|*oNnMzdK9wVh11NbK>rPJLSP%-ZcE+Mt$Fn{M7IDMz`LsT*eo3SI@a; z;&bZ0vUSbDll L&vY~AEWc%1^x`QA zM|i`)_bz#!-pw;&@!ZR^?udr|zwmc-UR?X!3;f!NT=2NJ-Hcj(z@B5*_elCY_w(%h zjZ?1hCw~9p*p15<`CiL>^-*_E@mk-bfiW3n`s{c5rw5BJoMK&rJKn8Vw}{WG`Q^GH zF6a21DJjFh9!}He a0pX(8 zvY>Jp%N!(LOt})!#%(1=dnGjV1QbdfixIHwC I?$QYQ{hxhn)OodWLI^h6P1=^~aGCN&E;vha~DP!X%QA-&HUSfbiDna1j zD$-NiqX0}oNf~>w2%ZX}1wdEb#rKTGt3oLw)x(e~&{oZq* z$3KVB1wM!O3^wq#T)*@Hn1qrtHg1vHM$r_|f@QeUUm#@~i|^rUut;spBJ#rQCCkFB ztbT=DfwpSq%&mwiH6cKRi7ok^x1bKOb(=sEfa{?x|L3TxYX0+b2-rl>5khHZ>n160 z)<6LdCZi3#d%A3E0VWZS{lqDS%w%0sYN9?(7aN!rC_Ae8NW_EonQ|x4UmIwZqnc)z z6v# #QIkiap!LX@jwqt7GgAu17SvD-3<#3WJA)vE*7k$3m2xoJ zA31qAdMk#b?Q_Ee^22u?M?vvH9HTSRCdz8Xm13<3?XyOsm9EYfdj*G)P#DAErVmz2 z%1P5}$*Rcw&z(UJ!QIqa)1HV+$3hmrpofWam($~1Dr_LWp{??9Ud5P~KhEuhxJ7tN zAJF5RSnEDX?y|B80vuZV4|IAhTqUW?Bb{0++ZA&uvMqi=L;cgY+?K;wMy#dd(^AKe z(^XdWVt(F)b!NJNW_ha@Id6KcT}8=XTX^m-;B;y&ZfD#f04&?54EV|!)9fVRs~GtT z1-zYbw+wXngdXt1(Tc^y9VB`s)iX8`@jle)l&Z>J9`eEfN1Xn%H}VkSmMt3dgY#0{ zV1ScVHOb@B$D1E`6`P)xnXFHqAb*NhFYzf_&^}WMZw`6u&dFHvwazEQIP2ihQBc3k zJlZn={DN0q4(HIVSV`ZY&XFe#RuwvYgL&Y2Cyv}Vih?Gc4hTX9twYc-7e3`%G-oO% zXw}>~PigTA?1nl=o;0e0R?U55vw|iaje(Fs>kzaGB! bDl}bUQk>(E)0`E X%IUGTgUNA4R%L96bSv(ef)g5#k%Q!zoS=FY)QY5n)8bL2^*DrnW*H#RG1)xB~y zT0curL^NkACTP{%IjA8k;~nap3JF>@_s#Pbw0?u~(%_I&cGshEX}UD?1&Yb>vXizL zad{KkXPP_ jZNYss;N}2u#WP&PspU!$yv5c>Y}<&fpV+n)TYs@_kDbbX z;pawPU`YI~NcLk*nib&$ers9N(yFLr?EU2u3)U3Q5|^EMU`NK}nI|u}q8i%LPp$Vq zIIXNjjj5jFQWXig4Xq=`on6|Ap7LVKnd3HYO$ZbG;vCz|aa%X0-Q>7bSV3+>>&S6= zj_ aS&$V zwnQ4IcFtu3(C>cEMY-lOT=;1qc1marPfBy~tlWvo32;^-zQ1Xu+fvxS5wia|GlKjs zHVPnfMg}ef5Dx@ohchFu)D$r2L_Vv*!0E-M`#%`~a3Wp3r{94-phU83k%$mMy4BmA z3Lq$v=obh?h!}Jtp>;7LPof+rG9%?74}Cz1c>IM#ga|S!J8uYpphW(xM<7DPpc6S+ zA0zVQl6ag*%vpX9^Z_NZ(HyCkp+yAgdHmo406~c?awiZWV#tX!M8Z>VhD36t^lWvU ziSUGb=tN@#V1W6pB@;4=@Dz1GLG97elR$+GB0Mz(K+t@5Zzge(J@yx;B0LQm_D4HW z+#IE%03tkb7(h^`{MZs l*pV`BqBr*;pv `b6S z1`(bvVKPgO@PpdJFGS)}cH8K{_PX?JJm=*YP0cv)GH4>ZqI6V%gqz2k%zFzzX33IX z2F<&n2qP67d52I^tGW?L5u;U(fs*>MJB<_(h)$~HRSGF0j=V#tyW6q^QtIwD>$&tY zXa;gLR%9eOEz~|!U!%|>0|_^J+-L5H@M9iwrI$goHca9Lw>TumpK#Y|KxiWEFVv{t zypEDmgc0ua3@~U`{?HR)q=-VNbMXxeIz+)2?kEiXK ^%$bWl8h#&*#bdp{1%NS+*%8- zJLFjAnp}DrG{t5t1#i*- ;4uo?2aD*a9A{w2eAx4QK5lr4S zG&i$jX&lMnFGoUiGi{_INiv+AC2F&=aY`)7Xu{n^ATH3{Oc+I=3726LM-w>~nw!Jp zX+#wPg)76Mb7*c3OQ29y#FKXrB|A7#fh;jT p zDoVGUfb!0vk({PO*ggdwO15sg0$F0YPba(lZ)#+TfON84rV3eAJe2G?y#iTcR?x}n zC#aDn0@BI4WvP%Q;t8j*LFl1m*JN9e#V$E0e;B_piA+`nP~JH-jDMa?BwGPIl zzgdmZnJeu_5?TBtD?EOMGhqM(t-+FKqAb;rgfqPW39VSao`uj;1ETYC`8$ahF%sy! zBIZze5s~P;KAlVBMFgVrx;l@aW20R9-|RVMQO1P=8HXK;tC>)u<`d QC#mLt2hCY_aG~(-zp;K^IRF3v literal 0 HcmV?d00001 diff --git a/tests/validation/test_median_background_fixture_integrity.py b/tests/validation/test_median_background_fixture_integrity.py new file mode 100644 index 0000000..8bbb917 --- /dev/null +++ b/tests/validation/test_median_background_fixture_integrity.py @@ -0,0 +1,267 @@ +"""Integrity checks for frozen Gwyddion 2.71 Median Background evidence.""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np + +_FIXTURE_DIR = Path(__file__).parent / "fixtures" / "gwyddion" / "median_background" +_NPZ_PATH = _FIXTURE_DIR / "median_background_reference.npz" +_MANIFEST_PATH = _FIXTURE_DIR / "median_background_reference.json" +_EXPECTED_CASES = [ + "wide_r1", "wide_r2", "wide_r3", "wide_r4", "wide_r20", + "tall_r1", "tall_r2", "tall_r3", "tall_r4", "tall_r20", + "constant_r1", "constant_r3", "constant_r20", + "signed_r1", "signed_r2", "signed_r3", "signed_r20", + "singleton_1x1_r1", "singleton_1x1_r3", "singleton_1x1_r20", + "singleton_1x1_r1024", + "singleton_row_r1", "singleton_row_r3", "singleton_row_r20", + "singleton_column_r1", "singleton_column_r3", "singleton_column_r20", + "impulse_positive_r1", "impulse_positive_r2", "impulse_positive_r3", + "impulse_negative_r1", "impulse_negative_r2", "impulse_negative_r3", + "monotonic_r1", "monotonic_r2", "monotonic_r3", +] +_EXPECTED_RADIUS_INVENTORY = { + "1": {"active_count": 9, "backend": "direct", "rank": 4, "resolution": 3}, + "2": {"active_count": 21, "backend": "direct", "rank": 10, "resolution": 5}, + "3": {"active_count": 37, "backend": "radixtree", "rank": 18, "resolution": 7}, + "4": {"active_count": 69, "backend": "radixtree", "rank": 34, "resolution": 9}, + "20": {"active_count": 1313, "backend": "radixtree", "rank": 656, "resolution": 41}, + "1024": { + "active_count": 3297401, + "backend": "radixtree", + "rank": 1648700, + "resolution": 2049, + }, +} + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _canonical_array_hash(array: np.ndarray) -> str: + contiguous = np.ascontiguousarray(array) + digest = hashlib.sha256() + digest.update(contiguous.dtype.str.encode("ascii")) + digest.update(b"\0") + digest.update(",".join(str(value) for value in contiguous.shape).encode("ascii")) + digest.update(b"\0") + digest.update(contiguous.tobytes(order="C")) + return digest.hexdigest() + + +def _load_manifest() -> dict[str, object]: + with _MANIFEST_PATH.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def _load_arrays_read_only() -> dict[str, np.ndarray]: + arrays: dict[str, np.ndarray] = {} + with np.load(_NPZ_PATH, allow_pickle=False) as archive: + for name in archive.files: + array = np.ascontiguousarray(archive[name]).copy(order="C") + array.setflags(write=False) + arrays[name] = array + return arrays + + +def _cases(manifest: dict[str, object]) -> list[dict[str, object]]: + return manifest["cases"] + + +def test_manifest_schema_and_identity() -> None: + manifest = _load_manifest() + + assert manifest["schema_version"] == 1 + assert manifest["capability"] == "gwyddion_median_background" + assert manifest["operation"] == "median_bg" + assert manifest["reference_software"] == {"name": "Gwyddion", "version": "2.71"} + assert manifest["fixture"]["case_count"] == 36 + assert manifest["oracle"]["canonical_source_array_hash_count"] == 180 + assert len(manifest["oracle"]["canonical_source_array_hashes"]) == 180 + assert "manifest_self_hash" not in manifest["fixture"] + + +def test_case_order_is_exact() -> None: + manifest = _load_manifest() + + assert [case["name"] for case in _cases(manifest)] == _EXPECTED_CASES + + +def test_fixture_exists_and_hash_matches_manifest() -> None: + manifest = _load_manifest() + + assert _NPZ_PATH.is_file() + assert _sha256_file(_NPZ_PATH) == manifest["fixture"]["npz_sha256"] + + +def test_fixture_contains_exactly_108_arrays() -> None: + arrays = _load_arrays_read_only() + + assert len(arrays) == 108 + + +def test_fixture_contains_exactly_three_arrays_per_case() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + names = set(case["arrays"].values()) + assert len(names) == 3 + assert names <= arrays.keys() + + +def test_fixture_array_names_are_exact() -> None: + arrays = _load_arrays_read_only() + expected_names = { + f"{role}__{case}" + for case in _EXPECTED_CASES + for role in ("input", "background", "corrected") + } + + assert set(arrays) == expected_names + + +def test_fixture_array_dtypes_are_float64() -> None: + arrays = _load_arrays_read_only() + + assert all(array.dtype == np.float64 for array in arrays.values()) + + +def test_fixture_arrays_are_two_dimensional() -> None: + arrays = _load_arrays_read_only() + + assert all(array.ndim == 2 for array in arrays.values()) + + +def test_fixture_arrays_are_c_contiguous() -> None: + arrays = _load_arrays_read_only() + + assert all(array.flags.c_contiguous for array in arrays.values()) + + +def test_fixture_arrays_are_finite() -> None: + arrays = _load_arrays_read_only() + + assert all(np.isfinite(array).all() for array in arrays.values()) + + +def test_fixture_shapes_match_manifest() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + expected_shape = tuple(case["shape"]) + for name in case["arrays"].values(): + assert arrays[name].shape == expected_shape + + +def test_fixture_canonical_hashes_match_manifest() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + for role, name in case["arrays"].items(): + assert _canonical_array_hash(arrays[name]) == case["canonical_hashes"][role] + + +def test_fixture_helper_returns_read_only_arrays() -> None: + arrays = _load_arrays_read_only() + + assert all(not array.flags.writeable for array in arrays.values()) + + +def test_background_and_corrected_shapes_match_input() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + input_array = arrays[case["arrays"]["input"]] + background = arrays[case["arrays"]["background"]] + corrected = arrays[case["arrays"]["corrected"]] + assert background.shape == input_array.shape + assert corrected.shape == input_array.shape + + +def test_fixture_reconstruction_contract() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + reconstruction = manifest["acceptance_contract"]["reconstruction"] + + for case in _cases(manifest): + input_array = arrays[case["arrays"]["input"]] + background = arrays[case["arrays"]["background"]] + corrected = arrays[case["arrays"]["corrected"]] + np.testing.assert_allclose( + input_array, + background + corrected, + atol=reconstruction["absolute_tolerance"], + rtol=reconstruction["relative_tolerance"], + ) + + +def test_input_does_not_share_memory_with_outputs() -> None: + manifest = _load_manifest() + arrays = _load_arrays_read_only() + + for case in _cases(manifest): + input_array = arrays[case["arrays"]["input"]] + background = arrays[case["arrays"]["background"]] + corrected = arrays[case["arrays"]["corrected"]] + assert not np.shares_memory(input_array, background) + assert not np.shares_memory(input_array, corrected) + + +def test_radius_and_backend_inventory_is_exact() -> None: + manifest = _load_manifest() + + assert manifest["campaign"]["radius_inventory"] == _EXPECTED_RADIUS_INVENTORY + assert {case["rank_backend_reference"] for case in _cases(manifest)} == { + "direct", + "radixtree", + } + + +def test_evidence_classification_is_exact() -> None: + manifest = _load_manifest() + + assert manifest["evidence_classification"] == { + "external_probe": "EXECUTABLE_EXTERNAL_REFERENCE", + "freeze_audit": "MEDIAN_BACKGROUND_ORACLE_FREEZE_APPROVED", + "independent_oracle": "INDEPENDENT_PYTHON_ORACLE", + "spmkit_implementation": "NOT_YET_IMPLEMENTED", + } + + +def test_acceptance_contract_is_exact() -> None: + manifest = _load_manifest() + contract = manifest["acceptance_contract"] + + assert contract["background_comparison"] == "bitwise exact float64 equality" + assert contract["corrected_comparison"] == "bitwise exact float64 equality" + assert contract["output_dtype"] == "float64" + assert contract["output_shape"] == "identical to input" + assert contract["output_c_contiguous"] == "required" + assert contract["output_finiteness"] == "required for finite inputs in this fixture" + assert contract["input_mutation"] == "forbidden" + assert contract["reconstruction"] == { + "absolute_tolerance": 1e-15, + "relation": "input == background + corrected", + "relative_tolerance": 0.0, + } + assert contract["no_acceptance_relaxation"] == ( + "no acceptance relaxation may be introduced merely to satisfy tests" + ) + + +def test_fixture_contains_no_oracle_or_reference_arrays() -> None: + arrays = _load_arrays_read_only() + + assert all(not name.startswith(("oracle_", "reference_")) for name in arrays) From a53c3bb5581233bae819c9f1a161fe153deb278a Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:33:56 -0400 Subject: [PATCH 60/82] feat(analysis): add Gwyddion median background kernel --- .../core/analysis/_median_background.py | 177 ++++++++ ...test_gwyddion_median_background_private.py | 396 ++++++++++++++++++ 2 files changed, 573 insertions(+) create mode 100644 src/spmkit/core/analysis/_median_background.py create mode 100644 tests/core/test_gwyddion_median_background_private.py diff --git a/src/spmkit/core/analysis/_median_background.py b/src/spmkit/core/analysis/_median_background.py new file mode 100644 index 0000000..403e3ab --- /dev/null +++ b/src/spmkit/core/analysis/_median_background.py @@ -0,0 +1,177 @@ +"""Private numerical kernel for Gwyddion 2.71 Median Background. + +This module reproduces the frozen pixel-domain semantics independently with +NumPy. It deliberately has no public adapter: the future public API owns +channels, metadata, and result objects. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from functools import lru_cache +from typing import Literal + +import numpy as np +from numpy.typing import NDArray + +FloatArray = NDArray[np.float64] +IntArray = NDArray[np.int_] +_GwyddionMedianBackgroundBackend = Literal["direct", "radixtree"] + + +@dataclass(frozen=True) +class _MedianBackgroundKernelSpec: + """Immutable discrete-kernel specification for Median Background.""" + + radius_px: int + kernel_resolution: int + kernel_active_count: int + rank_index: int + rank_backend_reference: _GwyddionMedianBackgroundBackend + + +def _validated_median_background_radius(radius_px: object) -> int: + """Return a Gwyddion Median Background radius in the frozen range.""" + if isinstance(radius_px, (bool, np.bool_)): + raise TypeError( + "Gwyddion Median Background radius_px must be a Python or NumPy " + "integer scalar; booleans are not valid" + ) + if not isinstance(radius_px, (int, np.integer)): + raise TypeError( + "Gwyddion Median Background radius_px must be a Python or NumPy " + "integer scalar; booleans are not valid" + ) + + radius = int(radius_px) + if not 1 <= radius <= 1024: + raise ValueError( + "Gwyddion Median Background radius_px must be in the inclusive " + "range 1..1024" + ) + + return radius + + +def _validated_median_background_data(data: object) -> FloatArray: + """Return a finite, non-empty, C-contiguous float64 copy of 2D data.""" + try: + source = np.asarray(data) + except (TypeError, ValueError) as exc: + raise TypeError("Gwyddion Median Background requires array-compatible data") from exc + + if source.ndim != 2: + raise ValueError("Gwyddion Median Background requires two-dimensional data") + if 0 in source.shape: + raise ValueError("Gwyddion Median Background requires non-empty dimensions") + if ( + not np.issubdtype(source.dtype, np.number) + or np.iscomplexobj(source) + or isinstance(data, (bool, np.bool_)) + ): + raise TypeError("Gwyddion Median Background requires real numeric data") + + values = np.array(source, dtype=np.float64, order="C", copy=True) + if not np.all(np.isfinite(values)): + raise ValueError("Gwyddion Median Background requires finite data") + + return values + + +@lru_cache(maxsize=8) +def _cached_median_background_active_offsets(radius_px: int) -> IntArray: + """Build read-only active offsets in the frozen row-major order.""" + diameter = 2 * radius_px + 1 + radius_square = diameter * diameter + + active_count = 0 + max_columns_by_row: list[int] = [] + for dr in range(-radius_px, radius_px + 1): + remaining = radius_square - 4 * dr * dr + max_abs_dc = math.isqrt(remaining // 4) + max_columns_by_row.append(max_abs_dc) + active_count += 2 * max_abs_dc + 1 + + offsets = np.empty((active_count, 2), dtype=np.int_, order="C") + position = 0 + for dr, max_abs_dc in zip( + range(-radius_px, radius_px + 1), max_columns_by_row, strict=True + ): + row_count = 2 * max_abs_dc + 1 + stop = position + row_count + offsets[position:stop, 0] = dr + offsets[position:stop, 1] = np.arange(-max_abs_dc, max_abs_dc + 1, dtype=np.int_) + position = stop + + if position != active_count: + raise RuntimeError("Gwyddion Median Background offset count is inconsistent") + if active_count % 2 != 1: + raise RuntimeError("Gwyddion Median Background active count must be odd") + if not np.array_equal(offsets[active_count // 2], np.array([0, 0], dtype=np.int_)): + raise RuntimeError("Gwyddion Median Background offsets must contain the centre") + if not offsets.flags.c_contiguous: + raise RuntimeError("Gwyddion Median Background offsets must be C-contiguous") + + offsets.setflags(write=False) + return offsets + + +def _median_background_active_offsets(radius_px: object) -> IntArray: + """Return cached active digital-ellipse offsets for ``radius_px``.""" + return _cached_median_background_active_offsets( + _validated_median_background_radius(radius_px) + ) + + +def _median_background_kernel_spec(radius_px: object) -> _MedianBackgroundKernelSpec: + """Construct the immutable Gwyddion Median Background kernel specification.""" + radius = _validated_median_background_radius(radius_px) + active_count = _cached_median_background_active_offsets(radius).shape[0] + backend: _GwyddionMedianBackgroundBackend = ( + "direct" if active_count <= 25 else "radixtree" + ) + + return _MedianBackgroundKernelSpec( + radius_px=radius, + kernel_resolution=2 * radius + 1, + kernel_active_count=active_count, + rank_index=active_count // 2, + rank_backend_reference=backend, + ) + + +def _gwyddion_median_background_result( + data: object, + radius_px: object, +) -> tuple[FloatArray, FloatArray, _MedianBackgroundKernelSpec]: + """Calculate frozen Gwyddion 2.71 Median Background fields. + + Exterior samples are clamped to the nearest valid edge pixel. The + selected rank is found independently with :func:`numpy.partition`; no + Gwyddion selection data structure is reproduced. + """ + values = _validated_median_background_data(data) + spec = _median_background_kernel_spec(radius_px) + offsets = _cached_median_background_active_offsets(spec.radius_px) + yres, xres = values.shape + + row_indices = np.clip( + np.arange(yres, dtype=np.int_)[:, np.newaxis] + offsets[np.newaxis, :, 0], + 0, + yres - 1, + ) + column_indices = np.clip( + np.arange(xres, dtype=np.int_)[:, np.newaxis] + offsets[np.newaxis, :, 1], + 0, + xres - 1, + ) + + background = np.empty(values.shape, dtype=np.float64, order="C") + for row in range(yres): + for column in range(xres): + samples = values[row_indices[row], column_indices[column]] + background[row, column] = np.partition(samples, spec.rank_index)[spec.rank_index] + + corrected = np.array(values - background, dtype=np.float64, order="C", copy=True) + return background, corrected, spec diff --git a/tests/core/test_gwyddion_median_background_private.py b/tests/core/test_gwyddion_median_background_private.py new file mode 100644 index 0000000..c0a62a0 --- /dev/null +++ b/tests/core/test_gwyddion_median_background_private.py @@ -0,0 +1,396 @@ +"""Focused tests for the private Gwyddion 2.71 Median Background kernel.""" + +from __future__ import annotations + +import json +from functools import cache, lru_cache +from pathlib import Path + +import numpy as np +import pytest + +from spmkit.core.analysis._median_background import ( + _gwyddion_median_background_result, + _median_background_active_offsets, + _median_background_kernel_spec, +) + +_FIXTURE_DIR = ( + Path(__file__).parents[1] + / "validation" + / "fixtures" + / "gwyddion" + / "median_background" +) +_FIXTURE_PATH = _FIXTURE_DIR / "median_background_reference.npz" +_MANIFEST_PATH = _FIXTURE_DIR / "median_background_reference.json" +_KERNEL_INVENTORY = { + 1: (3, 9, 4, "direct"), + 2: (5, 21, 10, "direct"), + 3: (7, 37, 18, "radixtree"), + 4: (9, 69, 34, "radixtree"), + 20: (41, 1313, 656, "radixtree"), + 1024: (2049, 3297401, 1648700, "radixtree"), +} +_UINT64_MASK = (1 << 64) - 1 +_UINT64_SIGN = 1 << 63 + + +@lru_cache(maxsize=1) +def _fixture() -> tuple[dict[str, object], dict[str, np.ndarray]]: + """Load the frozen fixture only as test evidence.""" + manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + arrays: dict[str, np.ndarray] = {} + with np.load(_FIXTURE_PATH, allow_pickle=False) as archive: + for name in archive.files: + arrays[name] = np.array(archive[name], dtype=np.float64, order="C", copy=True) + return manifest, arrays + + +def _case(case_name: str) -> dict[str, object]: + manifest, _ = _fixture() + for case in manifest["cases"]: # type: ignore[index] + if case["name"] == case_name: + return case + raise AssertionError(f"frozen fixture case was not found: {case_name}") + + +@cache +def _result_for_case(case_name: str) -> tuple[np.ndarray, np.ndarray, object]: + case = _case(case_name) + _, arrays = _fixture() + keys = case["arrays"] + return _gwyddion_median_background_result( + arrays[keys["input"]], # type: ignore[index] + case["radius"], + ) + + +def _ordered_uint64(bits: int) -> int: + """Map IEEE-754 bits to an ordering suitable for a ULP distance.""" + if bits & _UINT64_SIGN: + return (-bits) & _UINT64_MASK + return bits | _UINT64_SIGN + + +def _assert_bitwise_equal( + case_name: str, + array_name: str, + expected: np.ndarray, + actual: np.ndarray, +) -> None: + expected_bits = expected.view(np.uint64) + actual_bits = actual.view(np.uint64) + mismatch = np.argwhere(expected_bits != actual_bits) + if mismatch.size == 0: + return + + row, column = (int(value) for value in mismatch[0]) + expected_bit = int(expected_bits[row, column]) + actual_bit = int(actual_bits[row, column]) + expected_value = float(expected[row, column]) + actual_value = float(actual[row, column]) + ulp_distance = abs(_ordered_uint64(expected_bit) - _ordered_uint64(actual_bit)) + raise AssertionError( + f"case={case_name} array={array_name} coordinate=({row}, {column}) " + f"expected={expected_value!r} actual={actual_value!r} " + f"expected_uint64={expected_bit} actual_uint64={actual_bit} " + f"absolute_difference={abs(expected_value - actual_value)!r} " + f"ulp_distance={ulp_distance}" + ) + + +def test_kernel_inventory_is_exact() -> None: + for radius, expected in _KERNEL_INVENTORY.items(): + specification = _median_background_kernel_spec(radius) + assert ( + specification.kernel_resolution, + specification.kernel_active_count, + specification.rank_index, + specification.rank_backend_reference, + ) == expected + + +def test_kernel_resolution_is_two_radius_plus_one() -> None: + for radius in _KERNEL_INVENTORY: + assert _median_background_kernel_spec(radius).kernel_resolution == 2 * radius + 1 + + +def test_kernel_active_count_is_odd() -> None: + for radius in _KERNEL_INVENTORY: + assert _median_background_kernel_spec(radius).kernel_active_count % 2 == 1 + + +def test_kernel_rank_is_half_the_active_count() -> None: + for radius in _KERNEL_INVENTORY: + specification = _median_background_kernel_spec(radius) + assert specification.rank_index == specification.kernel_active_count // 2 + + +def test_kernel_backend_reference_uses_frozen_threshold() -> None: + assert _median_background_kernel_spec(2).rank_backend_reference == "direct" + assert _median_background_kernel_spec(3).rank_backend_reference == "radixtree" + + +def test_offsets_are_in_row_major_order() -> None: + offsets = _median_background_active_offsets(20) + keys = offsets[:, 0] * 10000 + offsets[:, 1] + assert np.array_equal(keys, np.sort(keys)) + + +def test_offsets_obey_the_inclusive_integer_ellipse_condition() -> None: + radius = 20 + offsets = _median_background_active_offsets(radius) + left = 4 * (offsets[:, 0] * offsets[:, 0] + offsets[:, 1] * offsets[:, 1]) + assert np.all(left <= (2 * radius + 1) ** 2) + + +def test_offsets_exclude_immediately_exterior_integer_positions() -> None: + radius = 20 + offsets = _median_background_active_offsets(radius) + radius_square = (2 * radius + 1) ** 2 + for dr in range(-radius, radius + 1): + row_offsets = offsets[offsets[:, 0] == dr, 1] + next_dc = int(np.max(np.abs(row_offsets))) + 1 + assert 4 * (dr * dr + next_dc * next_dc) > radius_square + + +def test_offsets_contain_the_central_pixel() -> None: + offsets = _median_background_active_offsets(20) + assert np.any(np.all(offsets == np.array([0, 0]), axis=1)) + + +def test_offsets_are_c_contiguous_and_read_only() -> None: + offsets = _median_background_active_offsets(20) + assert offsets.flags.c_contiguous + assert not offsets.flags.writeable + + +def test_offset_cache_preserves_content() -> None: + first = _median_background_active_offsets(20) + second = _median_background_active_offsets(np.int64(20)) + assert first is second + assert np.array_equal(first, second) + + +@pytest.mark.parametrize( + "radius", + [True, 0, -1, 1025, 1.0, 1.5, "20"], +) +def test_radius_validation_rejects_values_outside_the_contract(radius: object) -> None: + expected_error = TypeError if isinstance(radius, (bool, float, str)) else ValueError + with pytest.raises(expected_error): + _median_background_kernel_spec(radius) + + +@pytest.mark.parametrize( + ("radius", "expected"), + [ + (1, 1), + (1024, 1024), + (np.int8(2), 2), + (np.int64(20), 20), + (np.uint8(3), 3), + (np.uint64(1024), 1024), + ], +) +def test_radius_validation_accepts_python_and_numpy_integer_scalars( + radius: object, + expected: int, +) -> None: + specification = _median_background_kernel_spec(radius) + assert type(specification.radius_px) is int + assert specification.radius_px == expected + + +@pytest.mark.parametrize( + "radius", + [ + True, + False, + np.bool_(True), + np.bool_(False), + np.array(2, dtype=np.int64), + np.array(2, dtype=np.uint64), + np.array(True), + 2.0, + "2", + ], +) +def test_radius_validation_rejects_non_integer_scalars_with_type_error(radius: object) -> None: + with pytest.raises(TypeError, match="Python or NumPy integer scalar"): + _median_background_kernel_spec(radius) + + +@pytest.mark.parametrize( + "radius", + [0, -1, 1025, 10**100, -(10**100), np.uint64(1025)], +) +def test_radius_validation_rejects_all_out_of_range_integers_with_value_error( + radius: object, +) -> None: + with pytest.raises(ValueError, match=r"1\.\.1024"): + _median_background_kernel_spec(radius) + + +@pytest.mark.parametrize("radius", [True, False, np.bool_(True), np.bool_(False)]) +def test_radius_validation_explains_that_booleans_are_invalid(radius: object) -> None: + with pytest.raises(TypeError, match="booleans are not valid"): + _median_background_kernel_spec(radius) + + +@pytest.mark.parametrize( + "data, exception, message", + [ + (np.array(1.0), ValueError, "two-dimensional"), + (np.array([1.0, 2.0]), ValueError, "two-dimensional"), + (np.ones((1, 1, 1)), ValueError, "two-dimensional"), + (np.empty((0, 2)), ValueError, "non-empty"), + (np.array([[np.nan]]), ValueError, "finite"), + (np.array([[np.inf]]), ValueError, "finite"), + (np.array([[-np.inf]]), ValueError, "finite"), + ], +) +def test_input_validation_rejects_outside_the_frozen_domain( + data: np.ndarray, + exception: type[Exception], + message: str, +) -> None: + with pytest.raises(exception, match=message): + _gwyddion_median_background_result(data, 1) + + +def test_compatible_input_is_converted_to_float64() -> None: + background, corrected, _ = _gwyddion_median_background_result([[1, 2], [3, 4]], 1) + assert background.dtype == np.float64 + assert corrected.dtype == np.float64 + + +def test_input_is_not_mutated() -> None: + data = np.array([[1, -2, 3], [4, 5, -6]], dtype=np.float32) + before = data.copy() + _gwyddion_median_background_result(data, 2) + assert np.array_equal(data, before) + + +def test_outputs_do_not_share_memory_with_input_or_each_other() -> None: + data = np.arange(12, dtype=np.float64).reshape(3, 4) + background, corrected, _ = _gwyddion_median_background_result(data, 1) + assert not np.shares_memory(data, background) + assert not np.shares_memory(data, corrected) + assert not np.shares_memory(background, corrected) + + +def test_outputs_are_float64_two_dimensional_c_contiguous_and_finite() -> None: + background, corrected, _ = _gwyddion_median_background_result([[1, 2], [3, 4]], 1) + for output in (background, corrected): + assert output.dtype == np.float64 + assert output.ndim == 2 + assert output.flags.c_contiguous + assert np.all(np.isfinite(output)) + + +def test_output_shape_is_preserved() -> None: + data = np.arange(15, dtype=np.float64).reshape(3, 5) + background, corrected, _ = _gwyddion_median_background_result(data, 2) + assert background.shape == data.shape + assert corrected.shape == data.shape + + +def test_border_extension_clamps_to_the_nearest_edge() -> None: + data = np.array([[0.0, 10.0], [20.0, 30.0]]) + background, corrected, _ = _gwyddion_median_background_result(data, 1) + assert background[0, 0] == 10.0 + assert corrected[0, 0] == -10.0 + + +def test_constant_field_has_identity_background_and_zero_corrected() -> None: + data = np.full((4, 5), 7.25, dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 3) + assert np.array_equal(background.view(np.uint64), data.view(np.uint64)) + assert np.array_equal(corrected.view(np.uint64), np.zeros_like(corrected).view(np.uint64)) + + +def test_signed_field_produces_finite_reconstructable_outputs() -> None: + data = np.array([[-4.0, -1.0, 2.0], [3.0, -5.0, 7.0]], dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 2) + assert np.all(np.isfinite(background)) + assert np.all(np.isfinite(corrected)) + np.testing.assert_allclose(data, background + corrected, atol=1e-15, rtol=0.0) + + +def test_positive_impulse_uses_the_rank_background() -> None: + data = np.zeros((5, 5), dtype=np.float64) + data[2, 2] = 100.0 + background, corrected, _ = _gwyddion_median_background_result(data, 1) + assert np.array_equal(background.view(np.uint64), np.zeros_like(background).view(np.uint64)) + assert corrected[2, 2] == 100.0 + + +def test_negative_impulse_uses_the_rank_background() -> None: + data = np.zeros((5, 5), dtype=np.float64) + data[2, 2] = -100.0 + background, corrected, _ = _gwyddion_median_background_result(data, 1) + assert np.array_equal(background.view(np.uint64), np.zeros_like(background).view(np.uint64)) + assert corrected[2, 2] == -100.0 + + +def test_singleton_one_by_one_field() -> None: + data = np.array([[3.5]], dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 1024) + assert np.array_equal(background.view(np.uint64), data.view(np.uint64)) + assert np.array_equal(corrected.view(np.uint64), np.zeros_like(corrected).view(np.uint64)) + + +def test_singleton_row_field() -> None: + data = np.array([[2.0, -1.0, 5.0, 0.0]], dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 3) + assert background.shape == data.shape + assert corrected.shape == data.shape + + +def test_singleton_column_field() -> None: + data = np.array([[2.0], [-1.0], [5.0], [0.0]], dtype=np.float64) + background, corrected, _ = _gwyddion_median_background_result(data, 3) + assert background.shape == data.shape + assert corrected.shape == data.shape + + +def test_all_fixture_backgrounds_are_bitwise_exact() -> None: + manifest, arrays = _fixture() + for case in manifest["cases"]: # type: ignore[index] + name = case["name"] + background, _, _ = _result_for_case(name) + _assert_bitwise_equal(name, "background", arrays[case["arrays"]["background"]], background) + + +def test_all_fixture_corrected_fields_are_bitwise_exact() -> None: + manifest, arrays = _fixture() + for case in manifest["cases"]: # type: ignore[index] + name = case["name"] + _, corrected, _ = _result_for_case(name) + _assert_bitwise_equal(name, "corrected", arrays[case["arrays"]["corrected"]], corrected) + + +def test_fixture_metadata_matches_the_private_kernel_specification() -> None: + manifest, _ = _fixture() + for case in manifest["cases"]: # type: ignore[index] + _, _, specification = _result_for_case(case["name"]) + assert specification.radius_px == case["radius"] + assert specification.kernel_resolution == case["kernel_resolution"] + assert specification.kernel_active_count == case["kernel_active_count"] + assert specification.rank_index == case["rank_index"] + assert specification.rank_backend_reference == case["rank_backend_reference"] + + +def test_fixture_results_obey_the_reconstruction_contract() -> None: + manifest, arrays = _fixture() + for case in manifest["cases"]: # type: ignore[index] + name = case["name"] + background, corrected, _ = _result_for_case(name) + np.testing.assert_allclose( + arrays[case["arrays"]["input"]], + background + corrected, + atol=1e-15, + rtol=0.0, + ) From ed5c837b08659fe5a7aaa9ffa4315d832070b85f Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:56:08 -0400 Subject: [PATCH 61/82] feat(analysis): expose Gwyddion median background API --- src/spmkit/core/analysis/__init__.py | 6 + src/spmkit/core/analysis/background.py | 148 +++++++- tests/core/test_gwyddion_median_background.py | 342 ++++++++++++++++++ 3 files changed, 481 insertions(+), 15 deletions(-) create mode 100644 tests/core/test_gwyddion_median_background.py diff --git a/src/spmkit/core/analysis/__init__.py b/src/spmkit/core/analysis/__init__.py index 0231bc5..d1bd414 100644 --- a/src/spmkit/core/analysis/__init__.py +++ b/src/spmkit/core/analysis/__init__.py @@ -20,6 +20,7 @@ GwyddionArcDirection, analyze_arc_revolution_background, analyze_gwyddion_arc_revolution_background, + analyze_gwyddion_median_background, analyze_gwyddion_sphere_revolution_background, analyze_median_background, analyze_polynomial_background, @@ -28,6 +29,7 @@ analyze_spline_background, estimate_arc_revolution_background, estimate_gwyddion_arc_revolution_background, + estimate_gwyddion_median_background, estimate_gwyddion_sphere_revolution_background, estimate_median_background, estimate_polynomial_background, @@ -36,6 +38,7 @@ estimate_spline_background, remove_arc_revolution_background, remove_gwyddion_arc_revolution_background, + remove_gwyddion_median_background, remove_gwyddion_sphere_revolution_background, remove_median_background, remove_polynomial_background, @@ -70,6 +73,7 @@ "GwyddionArcDirection", "analyze_arc_revolution_background", "analyze_gwyddion_arc_revolution_background", + "analyze_gwyddion_median_background", "analyze_gwyddion_sphere_revolution_background", "analyze_median_background", "analyze_polynomial_background", @@ -78,6 +82,7 @@ "analyze_spline_background", "estimate_arc_revolution_background", "estimate_gwyddion_arc_revolution_background", + "estimate_gwyddion_median_background", "estimate_gwyddion_sphere_revolution_background", "estimate_median_background", "estimate_polynomial_background", @@ -86,6 +91,7 @@ "estimate_spline_background", "remove_arc_revolution_background", "remove_gwyddion_arc_revolution_background", + "remove_gwyddion_median_background", "remove_gwyddion_sphere_revolution_background", "remove_median_background", "remove_polynomial_background", diff --git a/src/spmkit/core/analysis/background.py b/src/spmkit/core/analysis/background.py index 6dbbb8a..211e0a4 100644 --- a/src/spmkit/core/analysis/background.py +++ b/src/spmkit/core/analysis/background.py @@ -20,6 +20,10 @@ from spmkit.core.analysis._gwyddion_sphere_revolution import ( _gwyddion_sphere_result, ) +from spmkit.core.analysis._median_background import ( + _gwyddion_median_background_result, + _MedianBackgroundKernelSpec, +) from spmkit.core.analysis._pspline import ( PSplineSurfaceFit, fit_pspline_surface, @@ -36,6 +40,7 @@ BackgroundMethod = Literal[ "arc_revolution", "gwyddion_arc_revolution", + "gwyddion_median_background", "gwyddion_sphere_revolution", "sphere_revolution", "rolling_ball", @@ -418,11 +423,14 @@ def estimate_arc_revolution_background( operation=operation, ) - direction_value = _validated_choice( - direction, - name="direction", - allowed=("horizontal", "vertical", "both"), - operation=operation, + direction_value = cast( + ArcDirection, + _validated_choice( + direction, + name="direction", + allowed=("horizontal", "vertical", "both"), + operation=operation, + ), ) side_value = _validated_choice( side, @@ -430,11 +438,14 @@ def estimate_arc_revolution_background( allowed=("below", "above"), operation=operation, ) - border_value = _validated_choice( - border, - name="border", - allowed=("nearest", "reflect"), - operation=operation, + border_value = cast( + ArcBorder, + _validated_choice( + border, + name="border", + allowed=("nearest", "reflect"), + operation=operation, + ), ) data_metres = length_values_to_metres( @@ -699,6 +710,73 @@ def remove_gwyddion_sphere_revolution_background( return corrected +def _gwyddion_median_background_channels( + channel: SPMChannel, + radius_px: object, +) -> tuple[ + SPMChannel, + SPMChannel, + _MedianBackgroundKernelSpec, +]: + """Calculate one Median Background result and preserve channel context.""" + background_data, corrected_data, kernel_spec = _gwyddion_median_background_result( + channel.data, + radius_px, + ) + + return ( + channel.with_data(background_data), + channel.with_data(corrected_data), + kernel_spec, + ) + + +def estimate_gwyddion_median_background( + channel: SPMChannel, + radius_px: object = 20, +) -> SPMChannel: + """Estimate a Gwyddion 2.71-compatible Median Background. + + ``radius_px`` is an integer pixel radius from 1 through 1024, with a + default of 20. The digital ellipse and nearest-edge border extension + are fixed by Gwyddion semantics. The input channel is not mutated; + finite two-dimensional input is required. + + Returns + ------- + SPMChannel + The estimated background with the input channel context preserved. + """ + background, _, _ = _gwyddion_median_background_channels( + channel, + radius_px, + ) + return background + + +def remove_gwyddion_median_background( + channel: SPMChannel, + radius_px: object = 20, +) -> SPMChannel: + """Return the corrected Gwyddion 2.71-compatible Median Background field. + + ``radius_px`` is an integer pixel radius from 1 through 1024, with a + default of 20. The digital ellipse and nearest-edge border extension + are fixed by Gwyddion semantics. The input channel is not mutated; + finite two-dimensional input is required. + + Returns + ------- + SPMChannel + The corrected field with the input channel context preserved. + """ + _, corrected, _ = _gwyddion_median_background_channels( + channel, + radius_px, + ) + return corrected + + def _sphere_structure( *, radius: float, @@ -857,11 +935,14 @@ def estimate_sphere_revolution_background( allowed=("below", "above"), operation=operation, ) - border_value = _validated_choice( - border, - name="border", - allowed=("nearest", "reflect"), - operation=operation, + border_value = cast( + ArcBorder, + _validated_choice( + border, + name="border", + allowed=("nearest", "reflect"), + operation=operation, + ), ) data_metres = length_values_to_metres( @@ -1628,6 +1709,43 @@ def analyze_gwyddion_sphere_revolution_background( ) +def analyze_gwyddion_median_background( + channel: SPMChannel, + radius_px: object = 20, +) -> BackgroundResult: + """Estimate and remove a Gwyddion 2.71-compatible Median Background. + + ``radius_px`` is an integer pixel radius from 1 through 1024, with a + default of 20. The digital ellipse and nearest-edge border extension + are fixed; border, shape, rank, and backend are not public options. The + input channel is not mutated and must contain finite two-dimensional data. + + Returns + ------- + BackgroundResult + The background, corrected field, method, and fixed kernel metadata. + """ + background, corrected, kernel_spec = _gwyddion_median_background_channels( + channel, + radius_px, + ) + + return BackgroundResult( + background=background, + corrected=corrected, + method="gwyddion_median_background", + parameters={ + "radius_px": kernel_spec.radius_px, + "kernel_resolution": kernel_spec.kernel_resolution, + "kernel_active_count": kernel_spec.kernel_active_count, + "rank_index": kernel_spec.rank_index, + "rank_backend_reference": kernel_spec.rank_backend_reference, + "border_policy": "gwyddion_border_extend", + "kernel_geometry": "gwyddion_digital_ellipse", + }, + ) + + def analyze_sphere_revolution_background( channel: SPMChannel, radius: float, diff --git a/tests/core/test_gwyddion_median_background.py b/tests/core/test_gwyddion_median_background.py new file mode 100644 index 0000000..a2dba01 --- /dev/null +++ b/tests/core/test_gwyddion_median_background.py @@ -0,0 +1,342 @@ +"""Public-contract tests for Gwyddion 2.71 Median Background.""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Callable +from pathlib import Path + +import numpy as np +import pytest + +import spmkit.core.analysis as analysis +import spmkit.core.analysis.background as background_module +from spmkit.core.analysis import ( + BackgroundResult, + analyze_gwyddion_median_background, + estimate_gwyddion_median_background, + remove_gwyddion_median_background, +) +from spmkit.core.analysis._median_background import _gwyddion_median_background_result +from spmkit.core.models import SPMChannel + +_FIXTURE_DIRECTORY = ( + Path(__file__).resolve().parents[1] + / "validation" + / "fixtures" + / "gwyddion" + / "median_background" +) +_FIXTURE_PATH = _FIXTURE_DIRECTORY / "median_background_reference.npz" +_MANIFEST_PATH = _FIXTURE_DIRECTORY / "median_background_reference.json" +_PUBLIC_OPERATIONS: tuple[Callable[[SPMChannel, object], object], ...] = ( + estimate_gwyddion_median_background, + remove_gwyddion_median_background, + analyze_gwyddion_median_background, +) + + +def _manifest() -> dict[str, object]: + return json.loads(_MANIFEST_PATH.read_text(encoding="utf-8")) + + +def _cases() -> tuple[dict[str, object], ...]: + manifest = _manifest() + cases = manifest["cases"] + assert isinstance(cases, list) + return tuple(cases) + + +def _case(name: str) -> dict[str, object]: + return next(case for case in _cases() if case["name"] == name) + + +def _case_arrays(case: dict[str, object]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + arrays = case["arrays"] + assert isinstance(arrays, dict) + with np.load(_FIXTURE_PATH, allow_pickle=False) as archive: + return ( + np.array(archive[arrays["input"]], dtype=np.float64, order="C", copy=True), + np.array(archive[arrays["background"]], dtype=np.float64, order="C", copy=True), + np.array(archive[arrays["corrected"]], dtype=np.float64, order="C", copy=True), + ) + + +def _channel(data: np.ndarray) -> SPMChannel: + return SPMChannel( + name="Median Background fixture", + data=data, + unit="V", + x_range=9.5e-6, + y_range=6.5e-6, + direction="backward", + group="Frozen external evidence", + metadata={ + "source": "gwyddion-2.71-median-background", + "context": {"campaign": "frozen"}, + }, + ) + + +def _ordered_bits(bits: int) -> int: + sign_bit = 1 << 63 + return ((~bits + 1) & ((1 << 64) - 1)) if bits & sign_bit else bits | sign_bit + + +def _assert_bitwise_equal( + actual: np.ndarray, + expected: np.ndarray, + *, + case: str, + operation: str, + array: str, +) -> None: + actual_bits = actual.view(np.uint64) + expected_bits = expected.view(np.uint64) + if np.array_equal(actual_bits, expected_bits): + return + + row, column = np.argwhere(actual_bits != expected_bits)[0] + actual_bit_value = int(actual_bits[row, column]) + expected_bit_value = int(expected_bits[row, column]) + ulp_distance = abs( + _ordered_bits(actual_bit_value) - _ordered_bits(expected_bit_value) + ) + pytest.fail( + f"case={case} operation={operation} array={array} " + f"coordinate=({row}, {column}) expected={expected[row, column]!r} " + f"actual={actual[row, column]!r} expected_uint64={expected_bit_value} " + f"actual_uint64={actual_bit_value} " + f"absolute_difference={abs(actual[row, column] - expected[row, column])!r} " + f"ulp_distance={ulp_distance}" + ) + + +def _expected_parameters(case: dict[str, object]) -> dict[str, object]: + return { + "radius_px": case["radius"], + "kernel_resolution": case["kernel_resolution"], + "kernel_active_count": case["kernel_active_count"], + "rank_index": case["rank_index"], + "rank_backend_reference": case["rank_backend_reference"], + "border_policy": "gwyddion_border_extend", + "kernel_geometry": "gwyddion_digital_ellipse", + } + + +def test_public_exports_and_signature_contract() -> None: + expected_names = { + "estimate_gwyddion_median_background", + "remove_gwyddion_median_background", + "analyze_gwyddion_median_background", + } + + assert expected_names <= set(analysis.__all__) + for name in expected_names: + assert getattr(analysis, name) is not None + + for operation in _PUBLIC_OPERATIONS: + signature = inspect.signature(operation) + assert list(signature.parameters) == ["channel", "radius_px"] + assert signature.parameters["radius_px"].default == 20 + assert signature.parameters["radius_px"].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + + for private_name in ( + "_MedianBackgroundKernelSpec", + "_validated_median_background_radius", + "_median_background_active_offsets", + "_median_background_kernel_spec", + "_gwyddion_median_background_result", + ): + assert private_name not in analysis.__all__ + assert not hasattr(analysis, private_name) + + +@pytest.mark.parametrize( + "case_name", + ["wide_r1", "wide_r3", "wide_r20", "singleton_1x1_r1024"], +) +def test_analyze_reports_exact_frozen_metadata(case_name: str) -> None: + case = _case(case_name) + data, _, _ = _case_arrays(case) + + result = analyze_gwyddion_median_background( + _channel(data), + case["radius"], + ) + + assert isinstance(result, BackgroundResult) + assert result.method == "gwyddion_median_background" + assert result.parameters == _expected_parameters(case) + + +@pytest.mark.parametrize("case", _cases(), ids=lambda case: str(case["name"])) +def test_analyze_matches_all_frozen_cases_bitwise(case: dict[str, object]) -> None: + data, expected_background, expected_corrected = _case_arrays(case) + source = _channel(data) + original = source.data.copy() + + result = analyze_gwyddion_median_background(source, case["radius"]) + + _assert_bitwise_equal( + result.background.data, + expected_background, + case=str(case["name"]), + operation="analyze", + array="background", + ) + _assert_bitwise_equal( + result.corrected.data, + expected_corrected, + case=str(case["name"]), + operation="analyze", + array="corrected", + ) + assert result.parameters == _expected_parameters(case) + assert np.array_equal(source.data, original) + assert np.max(np.abs(source.data - (result.background.data + result.corrected.data))) <= 1e-15 + + +@pytest.mark.parametrize("case_name", ["wide_r1", "wide_r3"]) +def test_estimate_and_remove_match_private_and_fixture(case_name: str) -> None: + case = _case(case_name) + data, expected_background, expected_corrected = _case_arrays(case) + source = _channel(data) + private_background, private_corrected, _ = _gwyddion_median_background_result( + source.data, + case["radius"], + ) + + estimated = estimate_gwyddion_median_background(source, case["radius"]) + removed = remove_gwyddion_median_background(source, case["radius"]) + + for actual, expected, operation, array in ( + (estimated.data, private_background, "estimate", "background-private"), + (removed.data, private_corrected, "remove", "corrected-private"), + (estimated.data, expected_background, "estimate", "background-fixture"), + (removed.data, expected_corrected, "remove", "corrected-fixture"), + ): + _assert_bitwise_equal( + actual, + expected, + case=case_name, + operation=operation, + array=array, + ) + + +def test_context_array_contract_and_memory_independence_are_preserved() -> None: + case = _case("signed_r3") + data, _, _ = _case_arrays(case) + source = _channel(data) + result = analyze_gwyddion_median_background(source, case["radius"]) + + for output in (result.background, result.corrected): + assert output.name == source.name + assert output.unit == source.unit + assert output.x_range == source.x_range + assert output.y_range == source.y_range + assert output.direction == source.direction + assert output.group == source.group + assert output.metadata == source.metadata + assert output.metadata is not source.metadata + assert output.data.shape == source.data.shape + assert output.data.dtype == np.float64 + assert output.data.flags.c_contiguous + assert np.all(np.isfinite(output.data)) + assert not np.shares_memory(output.data, source.data) + + assert not np.shares_memory(result.background.data, result.corrected.data) + result.background.metadata["adapter"] = "background" + assert "adapter" not in source.metadata + assert "adapter" not in result.corrected.metadata + + +@pytest.mark.parametrize( + ("radius_px", "exception"), + [ + (True, TypeError), + (np.array(2, dtype=np.int64), TypeError), + (0, ValueError), + (1025, ValueError), + (10**100, ValueError), + ], +) +@pytest.mark.parametrize("operation", _PUBLIC_OPERATIONS) +def test_invalid_radius_is_delegated_without_relaxation( + radius_px: object, + exception: type[Exception], + operation: Callable[[SPMChannel, object], object], +) -> None: + case = _case("wide_r1") + data, _, _ = _case_arrays(case) + + with pytest.raises(exception): + operation(_channel(data), radius_px) + + +@pytest.mark.parametrize("operation", _PUBLIC_OPERATIONS) +@pytest.mark.parametrize("nonfinite", [np.nan, np.inf, -np.inf]) +def test_nonfinite_data_rejection_is_delegated( + operation: Callable[[SPMChannel, object], object], + nonfinite: float, +) -> None: + data = np.ones((2, 3), dtype=np.float64) + data[0, 1] = nonfinite + + with pytest.raises(ValueError, match="finite data"): + operation(_channel(data), 1) + + +@pytest.mark.parametrize("operation", _PUBLIC_OPERATIONS) +def test_each_public_operation_invokes_private_kernel_once( + monkeypatch: pytest.MonkeyPatch, + operation: Callable[[SPMChannel, object], object], +) -> None: + case = _case("wide_r3") + data, _, _ = _case_arrays(case) + call_count = 0 + original = background_module._gwyddion_median_background_result + + def counted_result( + received_data: object, + received_radius_px: object, + ) -> tuple[np.ndarray, np.ndarray, object]: + nonlocal call_count + call_count += 1 + return original(received_data, received_radius_px) + + monkeypatch.setattr( + background_module, + "_gwyddion_median_background_result", + counted_result, + ) + + operation(_channel(data), case["radius"]) + + assert call_count == 1 + + +def test_default_radius_and_to_dict_are_publicly_stable() -> None: + case = _case("wide_r20") + data, expected_background, expected_corrected = _case_arrays(case) + + result = analyze_gwyddion_median_background(_channel(data)) + + _assert_bitwise_equal( + result.background.data, + expected_background, + case="wide_r20", + operation="analyze-default", + array="background", + ) + _assert_bitwise_equal( + result.corrected.data, + expected_corrected, + case="wide_r20", + operation="analyze-default", + array="corrected", + ) + assert result.parameters == _expected_parameters(case) + assert result.to_dict()["method"] == "gwyddion_median_background" From 7894560476a86e43a718b693d5bb3894e96803a5 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:01:42 -0400 Subject: [PATCH 62/82] docs(validation): close Gwyddion Median Background parity --- docs/api.md | 55 +++++++++++++++++++++++++++++++++++++++ docs/scientific-status.md | 53 +++++++++++++++++++++++++++++++++++++ docs/validation/index.md | 47 +++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/docs/api.md b/docs/api.md index c32afb1..1b8dd97 100644 --- a/docs/api.md +++ b/docs/api.md @@ -226,6 +226,61 @@ This estimator is distinct from SPMKit's physical sphere-revolution model (`estimate_sphere_revolution_background`), which operates with physical metric radii in metres, circular footprints in physical coordinates, and explicit physical border policies. +## Gwyddion 2.71 Median Background + +SPM-Kit provides the frozen Gwyddion 2.71 Median Background semantics through three public +operations: + +- `estimate_gwyddion_median_background(channel, radius_px=20) -> SPMChannel` +- `remove_gwyddion_median_background(channel, radius_px=20) -> SPMChannel` +- `analyze_gwyddion_median_background(channel, radius_px=20) -> BackgroundResult` + +```python +from spmkit.core.analysis import ( + analyze_gwyddion_median_background, + estimate_gwyddion_median_background, + remove_gwyddion_median_background, +) + +background = estimate_gwyddion_median_background( + channel, + radius_px=20, +) + +corrected = remove_gwyddion_median_background( + channel, + radius_px=20, +) + +result = analyze_gwyddion_median_background( + channel, + radius_px=20, +) +print(result.method, result.parameters) +``` + +`radius_px` is an integer pixel radius with default `20` and inclusive range `1..1024`. +The kernel is Gwyddion's fixed inclusive digital ellipse and exterior samples use fixed +nearest-edge `gwyddion_border_extend`; the public API intentionally exposes no border, shape, +rank, or backend option. `estimate_gwyddion_median_background()` returns the background as an +`SPMChannel`, `remove_gwyddion_median_background()` returns `input - background` as an +`SPMChannel`, and `analyze_gwyddion_median_background()` returns a `BackgroundResult`. + +The result method is `"gwyddion_median_background"`. Its metadata records `radius_px`, +`kernel_resolution`, `kernel_active_count`, `rank_index`, `rank_backend_reference`, +`border_policy="gwyddion_border_extend"`, and +`kernel_geometry="gwyddion_digital_ellipse"`. `rank_backend_reference` identifies the +observed Gwyddion reference route, not an SPM-Kit backend. + +Inputs must be finite two-dimensional data; NaN and infinite values are rejected. The source +channel is not mutated, and output channels preserve its shape, units, ranges, direction, group, +and copied metadata according to the `SPMChannel` contract. The public implementation does not +require Gwyddion at runtime. + +This capability is CROSS_VALIDATED only within its frozen 36-case Gwyddion 2.71 campaign. Its +scope, frozen evidence, semantics, and non-claims are specified in the +[Gwyddion Median Background compatibility specification](design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md). + ## KPFM statistics ```python diff --git a/docs/scientific-status.md b/docs/scientific-status.md index fe9d638..0b075d3 100644 --- a/docs/scientific-status.md +++ b/docs/scientific-status.md @@ -40,6 +40,7 @@ and tolerance. It never transfers automatically to an adjacent feature. | Gwyddion-compatible Revolve Arc background | `core.analysis.background`, `core.analysis._gwyddion_arc_revolution` | Frozen Gwyddion 2.71 source semantics, focal kernel probes, one asymmetric 5×7 directional fixture, 6/6 background routes and 5/5 valid corrected routes within `5e-14`; repaired reconstruction for the defective horizontal-inverted wrapper | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes and frozen JSON/NPZ fixture | Radius is in samples; no masks or non-finite data; one-sample processing axes use a documented safe definition; no physical validation, tip reconstruction, performance equivalence or universal-equivalence claim | | Physical sphere-revolution background | `core.analysis.background` | 51 unit and synthetic tests, including independent brute-force 2D oracles for nearest and reflect borders, physical anisotropy, non-separability, unit equivalence and reconstruction identity | SOFTWARE_VERIFIED | None | Python API only; finite geometric Z data; no masks, Gwyddion equivalence, performance campaign or physical-reference campaign | | Gwyddion-compatible Sphere-revolution background | `core.analysis.background`, `core.analysis._gwyddion_sphere_revolution` | Frozen Gwyddion 2.71 source semantics, focal probes, 10 original surfaces, 10 normal executions on negated inputs (20 valid external runs per build), 15/15 inverted runs failing in normal build and under ASan; direct external reference for normal, derived external cross-validation for inverted background, safe deliberate divergence for inverted corrected (`atol=5e-14`, `rtol=0.0`); independent Python oracle | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 source, compiled probes, independent Python oracle and frozen JSON/NPZ fixture | Radius is in samples; no non-finite data or masks; inverted corrected does not claim equivalence with Gwyddion's crashing wrapper; no physical validation, tip deconvolution or universal-equivalence claim; physical sphere-revolution maintains its independent software verification | +| Gwyddion 2.71 Median Background | `core.analysis.background`, `core.analysis._median_background` | Frozen executable reference campaign: 36 logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, direct and radixtree reference paths; public background and corrected fields 36/36 bitwise exact, maximum absolute difference 0 and maximum ULP 0; input mutation maximum 0 and reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen 36-case campaign | Gwyddion 2.71 source, executable probe, independent Python oracle, frozen NPZ/JSON fixture | Finite two-dimensional inputs only; no universal equivalence, performance-equivalence, future-Gwyddion, all-radii, or all-matrices claim; `rank_backend_reference` describes Gwyddion, not an SPM-Kit backend | | Hertz / conical contact and DMT paths | `core.analysis.forcecurve` | Unit and synthetic-recovery tests; Hertz/conical modulus recovery gates | NUMERICALLY_VERIFIED within synthetic test scope | Analytical construction | No certified cantilever/tip calibration or broad experimental campaign | | Adhesive JKR | `core.analysis.experimental` | Synthetic recovery of reduced modulus and work of adhesion; Hertz-limit test | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | Experimental module; no physical-reference campaign | | WLC and FJC chain models | `core.analysis.chain` | Analytical synthetic-recovery tests | NUMERICALLY_VERIFIED within synthetic scope | Analytical construction | No cross-software or experimental population campaign | @@ -59,6 +60,7 @@ and tolerance. It never transfers automatically to an adjacent feature. - [Nanoscope incident and final audit](https://github.com/kegouro/spmkit-validation/blob/main/docs/campaigns/nanoscope_spm_parser_pilot_v0.1_audit.md) - [Flatten Base Gwyddion 2.71 frozen end-to-end fixture](https://github.com/kegouro/spmkit/blob/flatten-base-gwyddion-parity-v1/tests/validation/fixtures/gwyddion/flatten_base/gwyddion_2_71_end_to_end.json) - [Sphere Revolution Gwyddion 2.71 frozen fixture](https://github.com/kegouro/spmkit/blob/feat/gwyddion-leveling-parity/tests/validation/fixtures/gwyddion/sphere_revolution/sphere_revolution_reference.json) +- [Median Background Gwyddion 2.71 frozen manifest](../tests/validation/fixtures/gwyddion/median_background/median_background_reference.json) ### Gwyddion Sphere Revolution @@ -72,6 +74,57 @@ SPM-Kit's `gwyddion_sphere_revolution` implementation is maintained separately f - **Independent Oracle & Tolerances:** Verified against an independent Python oracle (`atol=5e-14`, `rtol=0.0`). The maximum observed numerical discrepancy across all comparisons is `8.881784e-16` (well below `5e-14`). - **Claim:** LEVEL 3 CROSS_VALIDATED within the frozen fixture scope. No universal equivalence or physical validation is claimed. Physical sphere revolution (`estimate_sphere_revolution_background`) maintains its independent software verification. +### Gwyddion 2.71 Median Background + +**Claim:** `CROSS_VALIDATED` only for the frozen campaign. Gwyddion 2.71 is the executable +external reference; the campaign contains 36 logical cases and 72 executions (36 normal and +36 ASan) over radii 1, 2, 3, 4, 20, and 1024. Both Gwyddion reference paths are represented: +`direct` and `radixtree`. Public `estimate_gwyddion_median_background`, +`remove_gwyddion_median_background`, and `analyze_gwyddion_median_background` reproduce the +frozen background and corrected arrays bitwise in 36/36 cases: maximum absolute difference 0, +maximum ULP 0, input mutation maximum 0, and reconstruction maximum +`4.4408920985006262e-16`. + +The fixed semantics are the inclusive digital ellipse, `gwyddion_border_extend`, middle rank +`kernel_active_count//2`, and `corrected = input - background`. The public API preserves the +`SPMChannel` context and requires finite two-dimensional data. The private kernel is independent +of Gwyddion at runtime. The fixture and independent Python oracle remain frozen evidence outside +production. + +**Traceability:** + +```text +.reference/gwyddion-2.71/source/modules/process/median-bg.c + → .reference/gwyddion-2.71/median-background-parity/median_background_behavior_probe.c + → .reference/gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh + → independent Python oracle recorded by docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md + → tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz + → tests/validation/fixtures/gwyddion/median_background/median_background_reference.json + → src/spmkit/core/analysis/_median_background.py + → src/spmkit/core/analysis/background.py + → tests/core/test_gwyddion_median_background_private.py + → tests/core/test_gwyddion_median_background.py + → tests/validation/test_median_background_fixture_integrity.py + → docs/scientific-status.md +``` + +The evidence was frozen in `818dbd3` (freeze evidence), the private kernel in `a53c3bb`, and +the public API in `ed5c837`. The focal inventory is 20 fixture-integrity, 67 private Median +Background, and 72 public Median Background tests; the preceding combined focal run collected +442 tests. These are focal-campaign counts, not a project-wide total. + +**Non-claims:** no universal equivalence; no guarantee outside the 36 cases; no NaN or infinity +coverage; no reproduction of Gwyddion's internal radixtree; no performance-equivalence claim; +no claim for future Gwyddion versions; no claim for every radius or matrix; and no validation of +configurable border, shape, or rank parameters because the API exposes none. + +**Non-blocking tooling limitations:** the probe runner uses `|| true` during compilation and does +not retain the original compiler exit code; its auxiliary parser recognizes broad `background_` +and `corrected_` prefixes. The campaign remains valid because both binaries executed, 72 +processes returned, stderr was empty, normal and ASan stdout were byte-identical, outputs were +parsed and recalculated independently, oracle/reference results were bitwise exact, and the +fixture stores canonical hashes. + ## Test-count policy The collection total is measured with: diff --git a/docs/validation/index.md b/docs/validation/index.md index dd2f392..9ebe871 100644 --- a/docs/validation/index.md +++ b/docs/validation/index.md @@ -38,6 +38,7 @@ references, tolerances, outputs, hashes, and limitations. | Real-data roughness pilot v0.1 | Sa, Sq, Sz on 12 public GWY matrices | 36/36 shared-matrix comparisons within tolerance | CROSS_VALIDATED for the algorithm track | Parser/end-to-end observations are separate; real data are not ground truth | | Gwyddion Revolve Arc 2.71 v1 | Data-adaptive arc-envelope background on a frozen asymmetric 5×7 field, six direction/inversion routes and focal kernel cases | 6/6 backgrounds and 5/5 valid corrected outputs within `5e-14`; horizontal-inverted reference defect preserved as evidence and repaired by reconstruction | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; known wrapper and one-sample reference defects documented; not physical validation or universal equivalence | | Gwyddion Revolve Sphere 2.71 v1 | Data-adaptive sphere-envelope background on 10 logical pairs (20 normal runs per build) and 15 failing inverted runs; direct normal external reference and derived inverted background within 5e-14; safe inverted corrected reconstruction | 20/20 valid external runs and 10/10 derived inverted backgrounds within 5e-14 | CROSS_VALIDATED for the frozen campaign | Gwyddion 2.71 only; radius in samples; 15/15 inverted wrapper crashes documented as reference failures; not physical validation or universal equivalence | +| Gwyddion Median Background 2.71 v1 | Local rank background on 36 frozen logical cases, 72 executions (36 normal, 36 ASan), radii 1/2/3/4/20/1024, and both direct/radixtree reference paths | Public background and corrected fields 36/36 bitwise exact; maximum absolute difference 0, maximum ULP 0, input mutation maximum 0, reconstruction maximum `4.4408920985006262e-16` | CROSS_VALIDATED within the frozen campaign | Gwyddion 2.71 only; finite inputs; no universal, performance, future-version, all-radii, or all-matrices claim; no public border/shape/rank configuration | | Nanoscope `.spm` pilot v0.1 | Six demonstrated files | 18/18 metric comparisons within tolerance | NUMERICALLY_VERIFIED limited parser claim | Partial support and `ACCIDENTAL_PRE_FREEZE_UNBLINDING` | See [Scientific status](../scientific-status.md) for the complete mapping and @@ -50,6 +51,52 @@ The `.nid` path also provides byte-level inspection through `spmkit verify` and conversion, finiteness, and orientation rules. Integrity and parser traceability do not establish physical correctness. +### Gwyddion 2.71 Median Background + +The frozen campaign trace is: + +```text +Gwyddion source +→ external probe +→ independent Python oracle +→ frozen fixture +→ private SPMKit kernel +→ public API +→ public bitwise tests +→ scientific status +``` + +The concrete records are `.reference/gwyddion-2.71/source/modules/process/median-bg.c`, +`.reference/gwyddion-2.71/median-background-parity/median_background_behavior_probe.c`, +`.reference/gwyddion-2.71/median-background-parity/run_median_background_probe_campaign.sh`, +`docs/design/GWYDDION_MEDIAN_BACKGROUND_COMPATIBILITY.md`, +`tests/validation/fixtures/gwyddion/median_background/median_background_reference.npz`, +`tests/validation/fixtures/gwyddion/median_background/median_background_reference.json`, +`src/spmkit/core/analysis/_median_background.py`, +`src/spmkit/core/analysis/background.py`, +`tests/core/test_gwyddion_median_background_private.py`, +`tests/core/test_gwyddion_median_background.py`, +`tests/validation/test_median_background_fixture_integrity.py`, and +`docs/scientific-status.md`. + +The chain was frozen by `818dbd3` (evidence), `a53c3bb` (private kernel), and `ed5c837` +(public API). The permanent fixture contains canonical hashes; the original oracle and its +ephemeral source artifacts are identified in the fixture manifest. The campaign's runner +limitations are non-blocking: `|| true` does not preserve an original compiler exit, and the +auxiliary parser accepts broad `background_` and `corrected_` prefixes. Both binaries still +executed; 72 processes returned with empty stderr; normal/ASan stdout was byte-identical; and +the parsed outputs, oracle/reference equality, and canonical hashes were independently checked. + +#### Focal test inventory + +- Fixture integrity: 20 tests. +- Private Median Background: 67 tests. +- Public Median Background: 72 tests. +- Combined focal campaign: 442 tests. + +These counts describe the frozen focal validation campaign for this capability. They are not +the global test total of the SPMKit project. + ## What remains open - redistributable multi-instrument fixtures for built-in and adapter readers; From 2ba366eb9f976c12d8cbfd7c57862206fdf4183c Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:33:29 -0400 Subject: [PATCH 63/82] test(validation): freeze Gwyddion flat-disc morphology evidence --- ...DION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md | 54 ++ .../flat_disc_morphology_reference.json | 751 ++++++++++++++++++ .../flat_disc_morphology_reference.npz | Bin 0 -> 515805 bytes ..._flat_disc_morphology_fixture_integrity.py | 39 + 4 files changed, 844 insertions(+) create mode 100644 docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md create mode 100644 tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json create mode 100644 tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz create mode 100644 tests/validation/test_flat_disc_morphology_fixture_integrity.py diff --git a/docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md b/docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md new file mode 100644 index 0000000..4dccdf7 --- /dev/null +++ b/docs/design/GWYDDION_FLAT_DISC_MORPHOLOGY_COMPATIBILITY.md @@ -0,0 +1,54 @@ +# Gwyddion Flat-Disc Morphology Compatibility + +## Status and scope + +`gwyddion_filter_flat_disc_morphology` records Gwyddion 2.71 Filter-tool +flat-disc Opening and Closing for finite, non-empty, full-field 2D data with +mask policy fixed to ignore. It is limited to the frozen 12-field campaign +and sizes 2, 3, 4, 5, 30 and 31. + +## Reference and parameters + +The reference is the audited installed Gwyddion 2.71 executable path through +`gwy_data_field_area_filter_min_max`. `size_px` is an integer in `2..31`; +the planned public default is 5. The kernel is a K by K digital ellipse, +where K equals `size_px`. + +## Numerical semantics + +The exterior policy is nearest valid edge pixel. Minimum/erosion uses the +unreflected RLE mask and anchor `(K-1)//2`; maximum/dilation uses the rotated, +row-sorted RLE mask and anchor `K//2`. Opening is dilation after erosion; +Closing is erosion after dilation. + +The executable hierarchy is not generic C-language tie semantics. The +audited Gwyddion 2.71 library (`libgwyprocess2.so.0.51.1`, SHA-256 +`5f5b53cb544068638d1a3be8d6703345e49d5626d3fa4791106ce11bc051d3d7`, +Build ID `04187a41d4102c827e2705bb867292ba77ae37f4`) recursively constructs +Each/Even row reductions. Its compiled MINSD/MAXSD composition sites select +the second operand on equal values, preserving signed zero. The later RLE +aggregation uses strict comparison and retains the earlier row-major segment. + +## Evidence and fixture + +The external canonical reference is SHA-256 +`907bd347cc8c213d1061b786b6efe5692c87ffd8be62db1f6de2bd9bc78acdbd` and +its provenance is `c3777cdcfdd868a705ef09c63a7548a5b1c0eb0b79d053e02ac2f45e9c97e5af`. +The independent oracle V2 is `bf4129fe4fd871dda3132d5457d45d0833d69e9acd5cf3bb765fc0f3a8d9792e`; +its executable reduction model is `43089668a7fe0c699093be440402c8b1b11b42dfea4eb720785241788306c543`. + +The fixture stores 12 inputs, 30 masks for sizes 2..31, 72 Opening outputs, +and 72 Closing outputs. It records kernels 30/30 and both operations 72/72 +bitwise exact, max absolute difference 0, max ULP 0, signed-zero mismatches 0, +and input mutation 0. + +The rejected uninitialised-kernel microprobe is invalid evidence: elliptic +fill writes active pixels only. Approved probes zero-initialize the kernel +before filling it. + +## Non-claims + +This is not universal equivalence. It excludes NaN, infinities, ROI, masks, +ASF, tip morphology, other Gwyddion versions/builds, and performance parity. +It records the audited executable path, not a claim that all C compilers lower +the source identically. diff --git a/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json b/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json new file mode 100644 index 0000000..2540a8d --- /dev/null +++ b/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.json @@ -0,0 +1,751 @@ +{ + "capability": "gwyddion_filter_flat_disc_morphology", + "cases": [ + { + "case_id": "singleton_1x1", + "input_key": "input__singleton_1x1", + "shape": [ + 1, + 1 + ], + "sizes": [ + { + "closing_key": "closing__singleton_1x1__2", + "opening_key": "opening__singleton_1x1__2", + "size_px": 2 + }, + { + "closing_key": "closing__singleton_1x1__3", + "opening_key": "opening__singleton_1x1__3", + "size_px": 3 + }, + { + "closing_key": "closing__singleton_1x1__4", + "opening_key": "opening__singleton_1x1__4", + "size_px": 4 + }, + { + "closing_key": "closing__singleton_1x1__5", + "opening_key": "opening__singleton_1x1__5", + "size_px": 5 + }, + { + "closing_key": "closing__singleton_1x1__30", + "opening_key": "opening__singleton_1x1__30", + "size_px": 30 + }, + { + "closing_key": "closing__singleton_1x1__31", + "opening_key": "opening__singleton_1x1__31", + "size_px": 31 + } + ] + }, + { + "case_id": "singleton_row_1x7", + "input_key": "input__singleton_row_1x7", + "shape": [ + 1, + 7 + ], + "sizes": [ + { + "closing_key": "closing__singleton_row_1x7__2", + "opening_key": "opening__singleton_row_1x7__2", + "size_px": 2 + }, + { + "closing_key": "closing__singleton_row_1x7__3", + "opening_key": "opening__singleton_row_1x7__3", + "size_px": 3 + }, + { + "closing_key": "closing__singleton_row_1x7__4", + "opening_key": "opening__singleton_row_1x7__4", + "size_px": 4 + }, + { + "closing_key": "closing__singleton_row_1x7__5", + "opening_key": "opening__singleton_row_1x7__5", + "size_px": 5 + }, + { + "closing_key": "closing__singleton_row_1x7__30", + "opening_key": "opening__singleton_row_1x7__30", + "size_px": 30 + }, + { + "closing_key": "closing__singleton_row_1x7__31", + "opening_key": "opening__singleton_row_1x7__31", + "size_px": 31 + } + ] + }, + { + "case_id": "singleton_column_7x1", + "input_key": "input__singleton_column_7x1", + "shape": [ + 7, + 1 + ], + "sizes": [ + { + "closing_key": "closing__singleton_column_7x1__2", + "opening_key": "opening__singleton_column_7x1__2", + "size_px": 2 + }, + { + "closing_key": "closing__singleton_column_7x1__3", + "opening_key": "opening__singleton_column_7x1__3", + "size_px": 3 + }, + { + "closing_key": "closing__singleton_column_7x1__4", + "opening_key": "opening__singleton_column_7x1__4", + "size_px": 4 + }, + { + "closing_key": "closing__singleton_column_7x1__5", + "opening_key": "opening__singleton_column_7x1__5", + "size_px": 5 + }, + { + "closing_key": "closing__singleton_column_7x1__30", + "opening_key": "opening__singleton_column_7x1__30", + "size_px": 30 + }, + { + "closing_key": "closing__singleton_column_7x1__31", + "opening_key": "opening__singleton_column_7x1__31", + "size_px": 31 + } + ] + }, + { + "case_id": "wide_large_gradient", + "input_key": "input__wide_large_gradient", + "shape": [ + 35, + 39 + ], + "sizes": [ + { + "closing_key": "closing__wide_large_gradient__2", + "opening_key": "opening__wide_large_gradient__2", + "size_px": 2 + }, + { + "closing_key": "closing__wide_large_gradient__3", + "opening_key": "opening__wide_large_gradient__3", + "size_px": 3 + }, + { + "closing_key": "closing__wide_large_gradient__4", + "opening_key": "opening__wide_large_gradient__4", + "size_px": 4 + }, + { + "closing_key": "closing__wide_large_gradient__5", + "opening_key": "opening__wide_large_gradient__5", + "size_px": 5 + }, + { + "closing_key": "closing__wide_large_gradient__30", + "opening_key": "opening__wide_large_gradient__30", + "size_px": 30 + }, + { + "closing_key": "closing__wide_large_gradient__31", + "opening_key": "opening__wide_large_gradient__31", + "size_px": 31 + } + ] + }, + { + "case_id": "tall_large_gradient", + "input_key": "input__tall_large_gradient", + "shape": [ + 39, + 35 + ], + "sizes": [ + { + "closing_key": "closing__tall_large_gradient__2", + "opening_key": "opening__tall_large_gradient__2", + "size_px": 2 + }, + { + "closing_key": "closing__tall_large_gradient__3", + "opening_key": "opening__tall_large_gradient__3", + "size_px": 3 + }, + { + "closing_key": "closing__tall_large_gradient__4", + "opening_key": "opening__tall_large_gradient__4", + "size_px": 4 + }, + { + "closing_key": "closing__tall_large_gradient__5", + "opening_key": "opening__tall_large_gradient__5", + "size_px": 5 + }, + { + "closing_key": "closing__tall_large_gradient__30", + "opening_key": "opening__tall_large_gradient__30", + "size_px": 30 + }, + { + "closing_key": "closing__tall_large_gradient__31", + "opening_key": "opening__tall_large_gradient__31", + "size_px": 31 + } + ] + }, + { + "case_id": "constant_nonzero", + "input_key": "input__constant_nonzero", + "shape": [ + 4, + 6 + ], + "sizes": [ + { + "closing_key": "closing__constant_nonzero__2", + "opening_key": "opening__constant_nonzero__2", + "size_px": 2 + }, + { + "closing_key": "closing__constant_nonzero__3", + "opening_key": "opening__constant_nonzero__3", + "size_px": 3 + }, + { + "closing_key": "closing__constant_nonzero__4", + "opening_key": "opening__constant_nonzero__4", + "size_px": 4 + }, + { + "closing_key": "closing__constant_nonzero__5", + "opening_key": "opening__constant_nonzero__5", + "size_px": 5 + }, + { + "closing_key": "closing__constant_nonzero__30", + "opening_key": "opening__constant_nonzero__30", + "size_px": 30 + }, + { + "closing_key": "closing__constant_nonzero__31", + "opening_key": "opening__constant_nonzero__31", + "size_px": 31 + } + ] + }, + { + "case_id": "signed_monotonic", + "input_key": "input__signed_monotonic", + "shape": [ + 4, + 7 + ], + "sizes": [ + { + "closing_key": "closing__signed_monotonic__2", + "opening_key": "opening__signed_monotonic__2", + "size_px": 2 + }, + { + "closing_key": "closing__signed_monotonic__3", + "opening_key": "opening__signed_monotonic__3", + "size_px": 3 + }, + { + "closing_key": "closing__signed_monotonic__4", + "opening_key": "opening__signed_monotonic__4", + "size_px": 4 + }, + { + "closing_key": "closing__signed_monotonic__5", + "opening_key": "opening__signed_monotonic__5", + "size_px": 5 + }, + { + "closing_key": "closing__signed_monotonic__30", + "opening_key": "opening__signed_monotonic__30", + "size_px": 30 + }, + { + "closing_key": "closing__signed_monotonic__31", + "opening_key": "opening__signed_monotonic__31", + "size_px": 31 + } + ] + }, + { + "case_id": "checker_step", + "input_key": "input__checker_step", + "shape": [ + 6, + 8 + ], + "sizes": [ + { + "closing_key": "closing__checker_step__2", + "opening_key": "opening__checker_step__2", + "size_px": 2 + }, + { + "closing_key": "closing__checker_step__3", + "opening_key": "opening__checker_step__3", + "size_px": 3 + }, + { + "closing_key": "closing__checker_step__4", + "opening_key": "opening__checker_step__4", + "size_px": 4 + }, + { + "closing_key": "closing__checker_step__5", + "opening_key": "opening__checker_step__5", + "size_px": 5 + }, + { + "closing_key": "closing__checker_step__30", + "opening_key": "opening__checker_step__30", + "size_px": 30 + }, + { + "closing_key": "closing__checker_step__31", + "opening_key": "opening__checker_step__31", + "size_px": 31 + } + ] + }, + { + "case_id": "positive_impulse", + "input_key": "input__positive_impulse", + "shape": [ + 7, + 7 + ], + "sizes": [ + { + "closing_key": "closing__positive_impulse__2", + "opening_key": "opening__positive_impulse__2", + "size_px": 2 + }, + { + "closing_key": "closing__positive_impulse__3", + "opening_key": "opening__positive_impulse__3", + "size_px": 3 + }, + { + "closing_key": "closing__positive_impulse__4", + "opening_key": "opening__positive_impulse__4", + "size_px": 4 + }, + { + "closing_key": "closing__positive_impulse__5", + "opening_key": "opening__positive_impulse__5", + "size_px": 5 + }, + { + "closing_key": "closing__positive_impulse__30", + "opening_key": "opening__positive_impulse__30", + "size_px": 30 + }, + { + "closing_key": "closing__positive_impulse__31", + "opening_key": "opening__positive_impulse__31", + "size_px": 31 + } + ] + }, + { + "case_id": "negative_impulse", + "input_key": "input__negative_impulse", + "shape": [ + 7, + 7 + ], + "sizes": [ + { + "closing_key": "closing__negative_impulse__2", + "opening_key": "opening__negative_impulse__2", + "size_px": 2 + }, + { + "closing_key": "closing__negative_impulse__3", + "opening_key": "opening__negative_impulse__3", + "size_px": 3 + }, + { + "closing_key": "closing__negative_impulse__4", + "opening_key": "opening__negative_impulse__4", + "size_px": 4 + }, + { + "closing_key": "closing__negative_impulse__5", + "opening_key": "opening__negative_impulse__5", + "size_px": 5 + }, + { + "closing_key": "closing__negative_impulse__30", + "opening_key": "opening__negative_impulse__30", + "size_px": 30 + }, + { + "closing_key": "closing__negative_impulse__31", + "opening_key": "opening__negative_impulse__31", + "size_px": 31 + } + ] + }, + { + "case_id": "corner_edge_large_irregular", + "input_key": "input__corner_edge_large_irregular", + "shape": [ + 37, + 37 + ], + "sizes": [ + { + "closing_key": "closing__corner_edge_large_irregular__2", + "opening_key": "opening__corner_edge_large_irregular__2", + "size_px": 2 + }, + { + "closing_key": "closing__corner_edge_large_irregular__3", + "opening_key": "opening__corner_edge_large_irregular__3", + "size_px": 3 + }, + { + "closing_key": "closing__corner_edge_large_irregular__4", + "opening_key": "opening__corner_edge_large_irregular__4", + "size_px": 4 + }, + { + "closing_key": "closing__corner_edge_large_irregular__5", + "opening_key": "opening__corner_edge_large_irregular__5", + "size_px": 5 + }, + { + "closing_key": "closing__corner_edge_large_irregular__30", + "opening_key": "opening__corner_edge_large_irregular__30", + "size_px": 30 + }, + { + "closing_key": "closing__corner_edge_large_irregular__31", + "opening_key": "opening__corner_edge_large_irregular__31", + "size_px": 31 + } + ] + }, + { + "case_id": "plateau_signed_zero_irregular", + "input_key": "input__plateau_signed_zero_irregular", + "shape": [ + 6, + 6 + ], + "sizes": [ + { + "closing_key": "closing__plateau_signed_zero_irregular__2", + "opening_key": "opening__plateau_signed_zero_irregular__2", + "size_px": 2 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__3", + "opening_key": "opening__plateau_signed_zero_irregular__3", + "size_px": 3 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__4", + "opening_key": "opening__plateau_signed_zero_irregular__4", + "size_px": 4 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__5", + "opening_key": "opening__plateau_signed_zero_irregular__5", + "size_px": 5 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__30", + "opening_key": "opening__plateau_signed_zero_irregular__30", + "size_px": 30 + }, + { + "closing_key": "closing__plateau_signed_zero_irregular__31", + "opening_key": "opening__plateau_signed_zero_irregular__31", + "size_px": 31 + } + ] + } + ], + "display_name": "Gwyddion 2.71 Filter Flat-Disc Opening and Closing", + "evidence_roles": { + "external_gwyddion_outputs": "opening__* and closing__* arrays copied bitwise from canonical normal records", + "final_bitwise_agreement": "kernels 30/30; Opening 72/72; Closing 72/72", + "independently_derived_kernel_masks": "kernel__2 through kernel__31 derived from the frozen digital-ellipse rule", + "independently_regenerated_input_fields": "input__* arrays regenerated from frozen field provenance", + "oracle_outputs": "Oracle V2 agreed bitwise with every external operation output before fixture freeze" + }, + "external_evidence": { + "canonical_reference_sha256": "907bd347cc8c213d1061b786b6efe5692c87ffd8be62db1f6de2bd9bc78acdbd", + "provenance_sha256": "c3777cdcfdd868a705ef09c63a7548a5b1c0eb0b79d053e02ac2f45e9c97e5af" + }, + "fixture": { + "array_count": 186, + "array_hashes": { + "closing__checker_step__2": "549faf4f123fed4aa75090d49a29af404e5894caea1d1ba9accb136795607e66", + "closing__checker_step__3": "00177cea6073c7c1aef8caec87d45274802ed58d657f05c41f280901fec5a914", + "closing__checker_step__30": "c7605b54aec87be38b51b424273cbc38014c12be04f78d09b6816fc3ceeaa6d2", + "closing__checker_step__31": "c7605b54aec87be38b51b424273cbc38014c12be04f78d09b6816fc3ceeaa6d2", + "closing__checker_step__4": "00177cea6073c7c1aef8caec87d45274802ed58d657f05c41f280901fec5a914", + "closing__checker_step__5": "00177cea6073c7c1aef8caec87d45274802ed58d657f05c41f280901fec5a914", + "closing__constant_nonzero__2": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__3": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__30": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__31": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__4": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__constant_nonzero__5": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "closing__corner_edge_large_irregular__2": "30d788eb30c28fdc14865cd57a223de101b525ccf8e0bd4c4ef0292f8165af16", + "closing__corner_edge_large_irregular__3": "d564d7850db6827ba3fbbed2fe07735aa5668259052b776c79473bb75fa8dde0", + "closing__corner_edge_large_irregular__30": "5350879dff7b92f4157021827ac241652b34decb362228c7c164be5d3a8fc694", + "closing__corner_edge_large_irregular__31": "76151275f43aecc801213fde0071e1cca128b0b491d5a31fef4c1981468d591c", + "closing__corner_edge_large_irregular__4": "aec763989f7c5c535d4b1e1a66054952284f5c271aee69f879244b8bdf33ec23", + "closing__corner_edge_large_irregular__5": "daa3ce3051807673a70435a81a84843614be3b09dc797cd7a2bbc3ba22d5f083", + "closing__negative_impulse__2": "33e796f5927b062c3093049847baa9a1747f871ef8ecc380e119d5ce7e0235ca", + "closing__negative_impulse__3": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__negative_impulse__30": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__negative_impulse__31": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__negative_impulse__4": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__negative_impulse__5": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "closing__plateau_signed_zero_irregular__2": "35de89582f988c2c742c1cd318e43e36be7ac7c438255fb6a9b8d58a6a651509", + "closing__plateau_signed_zero_irregular__3": "8c4d247411130e8b14b12d00f3f83b398f4bf51d81dcbe44cbe2c0f372dfaff7", + "closing__plateau_signed_zero_irregular__30": "780be79a7e2f743626bcdc61491d302ab66ef7c65f911655083135fb9d698c16", + "closing__plateau_signed_zero_irregular__31": "780be79a7e2f743626bcdc61491d302ab66ef7c65f911655083135fb9d698c16", + "closing__plateau_signed_zero_irregular__4": "314a7ffcc66e2fd62d31f198bbe4232088b52204210ed70cc531d819ccb58c71", + "closing__plateau_signed_zero_irregular__5": "b6ca05886b04ffb4355809f7d12d038979484108822c7e548ad152df1511295d", + "closing__positive_impulse__2": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "closing__positive_impulse__3": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "closing__positive_impulse__30": "1bbb1f0181809223f299c464be224d16812e2096df00ff229dfec1c23edf4bb9", + "closing__positive_impulse__31": "1bbb1f0181809223f299c464be224d16812e2096df00ff229dfec1c23edf4bb9", + "closing__positive_impulse__4": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "closing__positive_impulse__5": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "closing__signed_monotonic__2": "af4ab808820ac2155947d283d6fe21071ffe7f429e86e71f68fd3208d26ba578", + "closing__signed_monotonic__3": "03ee92fda9e395956ac96b8b05157de03757720086db9786d6b2bfce348e9a6f", + "closing__signed_monotonic__30": "8959adcac1341add2c49169d03667e6ff2052cd523bfd406e33ffbe281a4c9a8", + "closing__signed_monotonic__31": "8959adcac1341add2c49169d03667e6ff2052cd523bfd406e33ffbe281a4c9a8", + "closing__signed_monotonic__4": "b8ed900f335f6793043dff898a4c67152d9afcbab032597bfa5dcc8eef50be1e", + "closing__signed_monotonic__5": "f83360854be41f1e1a04d31bbb2e293153bb5c97db606b019c6126c5a1a990a1", + "closing__singleton_1x1__2": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__30": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__31": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__4": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_1x1__5": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "closing__singleton_column_7x1__2": "3b8628be65941ecee0a1db198b5f74b886f46b3533ddaa3c4f4e863163ac760d", + "closing__singleton_column_7x1__3": "638cea73109bdeefe6aaeb42b79f504d5292bf197d06c17e747fdbbe5fe1e96a", + "closing__singleton_column_7x1__30": "a6e67ec9314298513a44ebca1df1ed53e2cfe864c01726de0da46c4eca3b07f6", + "closing__singleton_column_7x1__31": "a6e67ec9314298513a44ebca1df1ed53e2cfe864c01726de0da46c4eca3b07f6", + "closing__singleton_column_7x1__4": "638cea73109bdeefe6aaeb42b79f504d5292bf197d06c17e747fdbbe5fe1e96a", + "closing__singleton_column_7x1__5": "638cea73109bdeefe6aaeb42b79f504d5292bf197d06c17e747fdbbe5fe1e96a", + "closing__singleton_row_1x7__2": "ec64dc8cdb866d776124e7b1ba4dc738dbd0ac7bf7125a57874f50f59e1fbe1f", + "closing__singleton_row_1x7__3": "5f0cf54986d51349a9e2222343234a6150c3b951f573ba216ce99177050d6ffa", + "closing__singleton_row_1x7__30": "8fb04fdba76775b3f1d4665e0d54af74642f533ef253f3c8b85cea4645d1cade", + "closing__singleton_row_1x7__31": "8fb04fdba76775b3f1d4665e0d54af74642f533ef253f3c8b85cea4645d1cade", + "closing__singleton_row_1x7__4": "ec64dc8cdb866d776124e7b1ba4dc738dbd0ac7bf7125a57874f50f59e1fbe1f", + "closing__singleton_row_1x7__5": "880142629b49de6c81553da4bd885ff4b528ef303d349c027acb5beec5f16d8e", + "closing__tall_large_gradient__2": "7f68eaee56f96fc0aa421abc8a558ac0c6862c79b186c3716b0c066db58e058b", + "closing__tall_large_gradient__3": "3802a4db20fab3c87830454b5f04c638248392ddc9067b2d373ae4bb82e96e81", + "closing__tall_large_gradient__30": "7e4fda56d5afaab125afddba11c2ff7409471e0d9951eb2310d75d2e5316f1ff", + "closing__tall_large_gradient__31": "c55d1ae466f23dbb6423c0eb6934c46d46389e3e34e2e5990ef39f2a684b7e66", + "closing__tall_large_gradient__4": "e9ad8eb63810e87e48ed7008fd30532dcce2926c0b76ce88289feda761d8192f", + "closing__tall_large_gradient__5": "188ebc037d1cd3607984fdf75c0b2b58cc42cef9684c32519f71a3a014648c11", + "closing__wide_large_gradient__2": "ee5e1d0b350d52ee11f4136d259364c1a24be9db926f3bf8420f7ce32f5c2e25", + "closing__wide_large_gradient__3": "0740e5ae87db3729b88ecee4970d4d3c73f46da29643271ebb7d4abcbcdf3cfa", + "closing__wide_large_gradient__30": "fbd533fdb7ca5ceba54f0ca6f5c34769f1f7be80fc1a136bd92d7d9264034aad", + "closing__wide_large_gradient__31": "fc293e98538b12616575d6ab5653b18ff1f8a7a75b70c8c5477c24c0fa53fe28", + "closing__wide_large_gradient__4": "216dc6378a409bc61c6625b579bc59dfdd8526cf6de74b4acbb8866be3f03234", + "closing__wide_large_gradient__5": "56551dff49fcbc35ea761879e2c6c82f78b4ba98b2ff298b64a2ac78b128113d", + "input__checker_step": "10d0e45c0cee7e54cae872e70cc1f2da9abfa23110b94ea78554c593e27e3c68", + "input__constant_nonzero": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "input__corner_edge_large_irregular": "c65221e19a3f07e0f27701f0894284efa06e6f4170f81d3c7ad0e2fd5e99ea77", + "input__negative_impulse": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "input__plateau_signed_zero_irregular": "56345c343c12cf83f4b20665b0c47eae34a7816cd7345933f7474454c3eb36eb", + "input__positive_impulse": "d2ded403737d66c677cdb6ddf4e6f23e653cf0db0ac122689ad5cd13567d93af", + "input__signed_monotonic": "af4ab808820ac2155947d283d6fe21071ffe7f429e86e71f68fd3208d26ba578", + "input__singleton_1x1": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "input__singleton_column_7x1": "46ad2761807736956fb408501485f5da0d73240a4247578ef7458fb9aaa804f3", + "input__singleton_row_1x7": "7f069ded918cb1c64e81b0d3c624eb3255498176befd8502593e17e8684dc5ab", + "input__tall_large_gradient": "7f68eaee56f96fc0aa421abc8a558ac0c6862c79b186c3716b0c066db58e058b", + "input__wide_large_gradient": "ee5e1d0b350d52ee11f4136d259364c1a24be9db926f3bf8420f7ce32f5c2e25", + "kernel__10": "5c70caa3ba2e6af3ed08232ed54240877a2ab2c9c60346f38401270b886f4f1d", + "kernel__11": "43d0d734008f7d8563be5e615d2ffb988dc524e009a4bd81d70bb37f8a40510a", + "kernel__12": "02f34f132d062512c439053764974fe4b2838578dc283311d93dde5f838456bb", + "kernel__13": "b2ff89cf2be45056773496e642aaab203ac62a140a6820ec91297625e37dd220", + "kernel__14": "bbea56dc715cffc1bfcd65a5798e99fb369b9d1ee0b516f144d7908547b0695f", + "kernel__15": "53acff94afe7b09554bedf0bc9dd309813d92c1ca96ee2c225c27ca6e1646207", + "kernel__16": "45fe81ab93f7e7d7cd747de2b832549c9b89bf6826fc34f99ad087604dc24d40", + "kernel__17": "07548696aea5a8f66ee5cc355873e8798576a34ba1247c66bb3ded63b46ff959", + "kernel__18": "6c9b729120fa04e0ef1cb168a84bd17b6bf06e00e87b00b1eba7587fcd0da3b1", + "kernel__19": "ecf1fe17f401651a4d364d6a12c4048d95cae3df742a3ba6df7047321009e4be", + "kernel__2": "d4179f7562080b20aa5758e8f77f7de2390b33886e4cddd798baafdf0bd8181d", + "kernel__20": "f1bdfd0eb3c7100d1cfabeb9a8c8bde5af05b676f9201c8861b6add2f7577306", + "kernel__21": "83c2e4242be54d74cdd01d4bf6971aa45b57c6d323b52095c79b0e97117341f4", + "kernel__22": "44b82fdaec19762a954fd5f6ca935e16a1cf3c2f7bf99c8e905687d842b81939", + "kernel__23": "8930b0c95e940f4bddbe48da7c82e7fc4c8ff8bc384f22f90b4ca8f1ac9ee95b", + "kernel__24": "7a107d522c9c0c5dd893d12a99d52f86bcf4e2c85555f42cfa8db7e5dfd1e1db", + "kernel__25": "1000540487c0356dfdc67fa37971459086ec0f3c3b387e177a7d42ad4883c61f", + "kernel__26": "dc1d314252a2bc35b31b85739ecb8bd4e4cae4afddc02537686cbc3cfdb461ef", + "kernel__27": "528ed618156c27d796f338720c0617792b33ae51239bf64e3baae6d37165256a", + "kernel__28": "74aa89788ebbb447a13d7bf330600731df45110aa5d78f06c1ccd14b0d941836", + "kernel__29": "a641959b84f07ca1d0da938841e9ac7ef6ae32513c0b825fea9970e94ad8c22c", + "kernel__3": "3ddc8773256fdade5c945f5bef0182bd29ff8cb0880f3850e5a27d37f797be20", + "kernel__30": "4060bc6edc617d162096c1a2440821e061f61193289c1a0068e99329e2848288", + "kernel__31": "04c7b79f82b1c0e0c68b3da94e33e9f8537cd555b4bc90ee602df85f064100e3", + "kernel__4": "a73d20c787bdf5392a6fd8edfbe76dec3938661cb51fca4f48398be9bbf8b49c", + "kernel__5": "9084089098214cf292025671cdb3caf7ed7fa23d0bab7284d719ba1a96af450b", + "kernel__6": "3d350c6e17e741516f2fd0beaad2bf6721ab953c4174ec96f08798d5c632f9a3", + "kernel__7": "8ba9c1acba082ff24ad22770b59996798b8916efc0d3e39b1c9ffd5be06a9787", + "kernel__8": "0af9a49e943bae75644c5cf347b5f18701c18e8cbd410372518f0b73c3d9ce91", + "kernel__9": "3eca03815aa18f289d2576af932c27e2d0d8d09a255675df01b9e52f6287748b", + "opening__checker_step__2": "0c07891198c9bb1dce2cafdab7033ab8cd71c7c8e6d99dc166433b14a72cd69d", + "opening__checker_step__3": "ae9e571893791b924efe61ccea35a121f28e19342e937764a7f30c885c4a5cea", + "opening__checker_step__30": "ab77e9652a2393ec9753e0657dd193dc63fc01fd52c729a3430fc2365da2cab7", + "opening__checker_step__31": "ab77e9652a2393ec9753e0657dd193dc63fc01fd52c729a3430fc2365da2cab7", + "opening__checker_step__4": "0c07891198c9bb1dce2cafdab7033ab8cd71c7c8e6d99dc166433b14a72cd69d", + "opening__checker_step__5": "ae9e571893791b924efe61ccea35a121f28e19342e937764a7f30c885c4a5cea", + "opening__constant_nonzero__2": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__3": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__30": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__31": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__4": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__constant_nonzero__5": "dae580a20e0027b3071e62a048ab15a1e7546ba9db97a8db66a8c637e8144302", + "opening__corner_edge_large_irregular__2": "05c2b74c9feef6179fbefdcb3c424f932a94400db35230433ba29a4e3acf624f", + "opening__corner_edge_large_irregular__3": "d6317d7b4bb1b6c99b701fa88fe44524726e05bf49328c428a174c2df73193fc", + "opening__corner_edge_large_irregular__30": "c0bac360fc2a7942309334aa50783919aa46cc7580082cdacb61147128197293", + "opening__corner_edge_large_irregular__31": "545ac6253a65d37488d01371f13ddf8a87ba684073ee25ced462899e70097a2d", + "opening__corner_edge_large_irregular__4": "5d497cce0eff2fcfdac3955342462271914e322b5726e27f6ab7bec293022c9b", + "opening__corner_edge_large_irregular__5": "8177a29e983b3de0bfc2f90ba0337a64f37efd1c5cc9aff57d5b0640c777b260", + "opening__negative_impulse__2": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "opening__negative_impulse__3": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "opening__negative_impulse__30": "c14be15f646a1a7525df6a453588f7915272720df730751ccc2369e6640918bb", + "opening__negative_impulse__31": "c14be15f646a1a7525df6a453588f7915272720df730751ccc2369e6640918bb", + "opening__negative_impulse__4": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "opening__negative_impulse__5": "2095e704f85c2fb2bda5201c43521b634e6755b9b8acae6b3c90e886bb5a2d0d", + "opening__plateau_signed_zero_irregular__2": "c1f17f225427e1d05943ec639e6a098532cdc59c604d6d5546e3a69046842d96", + "opening__plateau_signed_zero_irregular__3": "169f330642c47c2b5226f0d9a03ee5d7332e606ac5f86b4beee8d0e4250f9509", + "opening__plateau_signed_zero_irregular__30": "ca838e3d3ba38029761bf68da23a0136a73514928dc162b0733db65cf4452f8e", + "opening__plateau_signed_zero_irregular__31": "ca838e3d3ba38029761bf68da23a0136a73514928dc162b0733db65cf4452f8e", + "opening__plateau_signed_zero_irregular__4": "d786aa450bfe27248e22bb2dd4787b10261e2a475d125490bd783de8b31f13b4", + "opening__plateau_signed_zero_irregular__5": "049473bb0e3f3422c1b3c011d456356f70991b87b010444b799c4c0c0faa6e94", + "opening__positive_impulse__2": "335583a571361ab9904774510167583c48ab75f30813d80d6693e30ac69b6126", + "opening__positive_impulse__3": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__positive_impulse__30": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__positive_impulse__31": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__positive_impulse__4": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__positive_impulse__5": "62240ab337c97bb1aa1bb839b004903cf8cdc0e4e9de20f1a76d318eb6905ed2", + "opening__signed_monotonic__2": "af4ab808820ac2155947d283d6fe21071ffe7f429e86e71f68fd3208d26ba578", + "opening__signed_monotonic__3": "69dbaeab3157138b23bceeba6bf90063bbd9a85280660e57cb2217316afaf979", + "opening__signed_monotonic__30": "8d3e394a381329abe9629998aafe02e9fd773a30a46fbb8a9083f55276edee1e", + "opening__signed_monotonic__31": "8d3e394a381329abe9629998aafe02e9fd773a30a46fbb8a9083f55276edee1e", + "opening__signed_monotonic__4": "2dd5931263888cdec9ceb257cf6bbe509dfcde78999f74e62c27885502e126d3", + "opening__signed_monotonic__5": "cd704a6e56172f084f114a55967085576ca8aefc0a280e60213d207fae793c46", + "opening__singleton_1x1__2": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__3": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__30": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__31": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__4": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_1x1__5": "532e03267162c2ccaef12fb31a3af6be53b459bbcd1c7f0765badbe16f738c3f", + "opening__singleton_column_7x1__2": "2bfc1127cd5e93cc78476d75d6d6a90e4640aa259bcd65010aba8b9730814d17", + "opening__singleton_column_7x1__3": "dbe9110f97777b6b19ad78c4d588f0dba77388aab20e34b15c5c6a19006dafa9", + "opening__singleton_column_7x1__30": "1520e3230762667c58530f3fc3ca081bf101ad1664c1c8bf3f7570cd1c72acd8", + "opening__singleton_column_7x1__31": "1520e3230762667c58530f3fc3ca081bf101ad1664c1c8bf3f7570cd1c72acd8", + "opening__singleton_column_7x1__4": "47cbefedcedc22d64d4fc36edfef2c8931d9db4de2c836281b9a85fb6878c530", + "opening__singleton_column_7x1__5": "9dd8b068fad65ebdccd934c8732464bd6428649b79add9b5c439cc6ccec1d3b0", + "opening__singleton_row_1x7__2": "3f056136013619ca2212d2b39b52746919a5d2bcc0e1b97a7e8a63e44734078c", + "opening__singleton_row_1x7__3": "a7a0701a59476221b8a4f64eefbaaf7c9be9b6f99c0cad06ace821064ca6f999", + "opening__singleton_row_1x7__30": "5676978c6bad3f9e0cf6a8569ed772ecff878f39ea67744c0450944d943840b1", + "opening__singleton_row_1x7__31": "5676978c6bad3f9e0cf6a8569ed772ecff878f39ea67744c0450944d943840b1", + "opening__singleton_row_1x7__4": "0573eb990c9972d8d0d72e341b525ae85a9119f442487c53fe94d78506ad72a9", + "opening__singleton_row_1x7__5": "0573eb990c9972d8d0d72e341b525ae85a9119f442487c53fe94d78506ad72a9", + "opening__tall_large_gradient__2": "7f68eaee56f96fc0aa421abc8a558ac0c6862c79b186c3716b0c066db58e058b", + "opening__tall_large_gradient__3": "1b7fa61d9665dc1d6e7a2c1d8f5cce9036936f654fdc49518b946bde730b4a44", + "opening__tall_large_gradient__30": "62f00af3fa414fc6117a257a2983e23c888f3bf448b9ef5887f9b289a44c88bd", + "opening__tall_large_gradient__31": "6c6913d38f16b6d30a9477134f81dcdc3d85806908757007ff32e37927e35fdf", + "opening__tall_large_gradient__4": "772e3cb4b2df37da6d478a349e461f9f8958a1643aa90e3df39f9395a56d262e", + "opening__tall_large_gradient__5": "068b5174c6d476fbcd8c7b47f3919ee9392c2f682c839c36804536ef7957f6bf", + "opening__wide_large_gradient__2": "ee5e1d0b350d52ee11f4136d259364c1a24be9db926f3bf8420f7ce32f5c2e25", + "opening__wide_large_gradient__3": "46f729c6cc1f704814ff25d33110709dd7029122e26935302ee8d7371b1bb57d", + "opening__wide_large_gradient__30": "b732b91bf8c44a662fbdc8a3b6cd19579ca57abfc91832eb017f972b7c4e0373", + "opening__wide_large_gradient__31": "136e13a491d7ed38f86d4319cd243d97f562104de4ea8b13e7079483e0641e7e", + "opening__wide_large_gradient__4": "54e2941a7f7fdc2c356d4572eef880520bd3bf8dbb883d34660ba2036508c26b", + "opening__wide_large_gradient__5": "54c94ae164daa05701ff493558cf268c764797eadaaa9199dc34229b4d26378f" + }, + "hash_definition": "dtype.str, NUL, comma-separated shape, NUL, C-order bytes", + "npz_relative_path": "flat_disc_morphology_reference.npz", + "npz_sha256": "7cf0cbbd988376c361f9bc97c0c91cc8b0c3df2110596c2be6b1f92d0af787ef" + }, + "kernel_masks": { + "10": "kernel__10", + "11": "kernel__11", + "12": "kernel__12", + "13": "kernel__13", + "14": "kernel__14", + "15": "kernel__15", + "16": "kernel__16", + "17": "kernel__17", + "18": "kernel__18", + "19": "kernel__19", + "2": "kernel__2", + "20": "kernel__20", + "21": "kernel__21", + "22": "kernel__22", + "23": "kernel__23", + "24": "kernel__24", + "25": "kernel__25", + "26": "kernel__26", + "27": "kernel__27", + "28": "kernel__28", + "29": "kernel__29", + "3": "kernel__3", + "30": "kernel__30", + "31": "kernel__31", + "4": "kernel__4", + "5": "kernel__5", + "6": "kernel__6", + "7": "kernel__7", + "8": "kernel__8", + "9": "kernel__9" + }, + "metrics": { + "closing_bitwise_exact": "72/72", + "input_mutation": 0, + "kernels_bitwise_exact": "30/30", + "max_absolute_difference": 0.0, + "max_ulp_distance": 0, + "opening_bitwise_exact": "72/72", + "signed_zero_mismatches": 0 + }, + "oracle_evidence": { + "oracle_v2_sha256": "bf4129fe4fd871dda3132d5457d45d0833d69e9acd5cf3bb765fc0f3a8d9792e", + "reduction_model_sha256": "43089668a7fe0c699093be440402c8b1b11b42dfea4eb720785241788306c543", + "reduction_trace_manifest_sha256": "354117107e0e007c5187a4290049e340a980290aa47265057b4c4fed6621a52e" + }, + "schema_version": 1, + "scope": { + "finite_nonempty_2d": true, + "full_field": true, + "mask_policy": "ignore" + }, + "sizes_exercised": [ + 2, + 3, + 4, + 5, + 30, + 31 + ] +} diff --git a/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz b/tests/validation/fixtures/gwyddion/flat_disc_morphology/flat_disc_morphology_reference.npz new file mode 100644 index 0000000000000000000000000000000000000000..debbe77e673df4a61d3c665df90007ca35cb4593 GIT binary patch literal 515805 zcmeF)e_)g4z5o9kK`c-tLd7sD1_X&xAwZCT0mAPJMFJEErO+EF5Hvu508uJNQC1z! z3^^{kdQQ#`JIl6yw|vg3%RC*6zdNlOutI {AYE%utG 5}&vF8os|Dt_#nrzE51=_vcmLtE=z#`1x=p>(P5(z%x=R grq_zcHah7j+7q|M9zw zGKa5SA*&-$y-G&GGyI?bhWVvBim$9^6gRDD^3|6<7e?_9-k83(qfmyTjzHZNsio z%g3I4;c|DySC_2nZcx3-cZISG5`X76zkK}dN~8Phqqx+rxY|eIQMQ4KvcBA1u|)T; zkAHm>|KKy+S1*vSE{)aQpn8?;im)<+I{y7`;JCzJzqu=} t~4*q;!@k +X#Y-WWOH>(gjpZ#2#qCI7s=p^WJ}+8bB-H2!W+sCQWSg}NtH z>+X%WwpHDC1$(1$zBKvH_s0Kx?3qIS>u+9aT;co1RXz=MEBu`n?os!IYTdo@%d{Ow z)xFWJ;p>e?`ugNM-yG^?Si^pEb0l}Zb;sAo(a ??|}c!x6v*}#u5wS;R}DOEIS6~p>8e~%)#g^G|K3m ziPgE7IUU2(Fx`gkDOiz>hDo??0>;Oqd_R_Ep>`~~?!(Av%ov4*BTyQSxkE8D1k(ni z(~RW P<#_&W;pMdW1STPO_S-5Tt#xqeKjin<|I|5z9Fk-=s zAy_yFB{Sv@#E=Qo`lGWSmiI+{AB-R0e{sLb$FTG$YLB3+8Y7=!#z8DRfYN@ J|^G8>~}F(hN _FcGSYt=)d@P=eDf#G` zjg>N*@^HfpOiahD91Pe{Hx&yeV{{T4C!*Ji)%RoOI1G=)^fBnp#EMa97>VnKV|*ye z7Azf%+Ck{L7b62O!-UnzPs7rex6IQRK8fiy=su1W2{atVb%!y22<6YPG>+N>==u~R zpJ2v5ER3PF2Xl8}XeXxaKxY)ox1#=I^nZl4o6#1*k`GYxK04mRvcI9F42#~z uL&~C@rJT%Wm z-yE!&g;p7hXJX0>^i0RfX=t+HhAEiH#;i#gn1DJf7Tk}~EHsWq?|oQ38Z$>>cm$>o zNB2;y7=nhum^fE;ald7Kfq}EAJA(yDjGjW{N%Wq;>SLIh!0-`FKaB1}SaA>yaa^|_ z<5ehsf~9*=8$;J_j8tOAPAuGxQWSHyVyFVsK0@baEdLPoAE3V+Yu`oN->~Ez)Vz(3 zH?iz>w7iDW `2ko;kmWSq<=*z{L9JEfu;;EQ21wGkVIT1}0aKm^^jKi!f42(hDeONFWqa)Ec z0&AL~wJ8=i!4wHS=RUc(A1iCobQU*!j)~KlbqWJDs5^lL$1r*njYrU1jn$uF=0OY} z!1Vp-uEL6aXxNME_F#M$%9U8U1GU@HwGAU5V@3rQZb4}?=6;Bw_c5&;o$q3K8S3A` z_>WOui=|JXb`827!^oqUu^I~tP+En#4`FB}rmaAy3(J?HehK;)W9>q;Ik4mb)GR>9 zd@P%bmV7LljY%1^^DsCAQ*+UtgE1SLr=o8%)=Wa{L@c&q%6RmQ!^*K}8iUD}nB4+{ zH(+XWv^T@pb!cvazVrJo?!%fd&{~VdXE5b+^qj`ZlW3~J4aYH&z^tPfIE=b#Ecgtg zaWo!4@26P(31;rYa17J;pnDfq>_o#3To=XoR+K-+(vMKP1zi!0e1IA6WA!tbxemim zWBOC*{xMcOiH0X|-QyVdp!_J7K7!f;bUlm_H)gEF!sRHrFn1}2oS3#4oeQ!2LDWBh zemmCAL)%;|nS+|y=#a5&CR%1-(R55s!)zM{r(o)2v`@m=1T **jY56L0=MUPNDTA7N5YB sDd>A(Y)%x&pP!(X|XCOEANUg^N&Hh`A49 zXaT0#(K!#x^HDzs{j;z(4{bBCBo{T)(J>9nrlMsEO1EI{%@}HjX>HM|!SWkX-x~d` zu(k!-Qm~{sYOY5|Q!HzO772^a#V+p0WG!Zo*_dnne34jMbp|1p1C)%~7--!QyI6 zIfR~rSa|?V`*A}RCiY?0UJUF(-EJ(X#OMw*ZpWGz(CWkD=P~79(DNKt{tQiC-0)LO zti!CIVBjg#6=K0!j6Q+JHRyc|s~^S8)fg_o^i}A72rE{iVFj*pVSFjdOR#h?Y8Ro) zfsqF=V*wV G>P7m=sAw2qnJ2^fjG*aqIMrfc4K%a zx}#|L7~`AK{{h L&T!HS*X!ro*@1p-5w7rSZk?0+Uo*`&5W5R@iekk`rZ7+=Uz_1?O-O$hl BCN4Bd#%R;W+GSTpoBLF>6)7w2d>i^!R)E&X-XXrhEo+>o$#l$WQ z>_B-NYAY}j!SMU&eixHtF_?+=k*FJn(ZT4w7d<94^}|GO4D>{~2Ws^g>5Adb= TShQqfas&p4qJ1#x?!{<-^cvCA8%;eiaTf- oiTDN zhJS?aThNe(aSi%gqpc-sZa}FSh9q=;vGd{_^=B}43VkQgnn258OdiDGezbprx)??) z(YqZzTT!xMC>xy ?r!G(RvUq`!Tr>gL}|k ziSZfe&q3Q%)MTSH0Yl@^IR^EkF*Y217PJmR%Ro%tgTcONH=yorjNXafJJ54Gnsk^* z$3O>^+oQG}M%rLF72Pe+&>Z8}q5u5$i}SP9qULjyPGRUcI*+2h8e?(veTvq77@31% z8Qn9`kc06l=%0i(D{97}GzLSX&^a9S7L1wEHvp~oprtP+dtopG?RTQ?4vcm|?``Nw zM^gt(+>C*?D7QgvDn?Q;d_B6aL&N###W}{$qCbhYlc+h4(oqZ@LT4Nk3otMb
(ANj8z0i_@Nj(O;p}h<0Zo_Ct z^lH&_Gn(3B;zkU#LOBJs*JGp!hR0=yjlH0h;DvVm1cy zP|ii|G>lBf@I-WvM?)6IGtoa1ZNpGA1SK 8Olvidv5E+IY!Q4_%ym}Fu5FqOVPd hR32i6AdFVJ{0|f(RMFtOepokP;YehM12p8bw^)Uw01^ICrthb zgSVhP4RtqRv^9ELqUQ!QHN%92fiFJ3I7j&m##W)vjn?I8S&GR;7<>@zcGS(q=q&Wk zM9*|I*)Wlffe9$zkJ_;q8I9o)=pKrO!5ANi{{Cn)qNX=WJu!3_I=iF3E5>x_yA`cJ zLdz|fY==P&+FPTpB}SX0w<&rglpe3RILFXxbgn|Z8)Gi?EkWxdv^ @<0 zP~RS7ZPC{Tt*ID!X3NF7g`Y xB24S0$!A#LG9BRS&QK{==PxD5sW{C{uO9jh8ibI3o*0+o%2vX2V;5Y z%SG!nv`oR|L=28cdlu^M!{|u#4nxlnG?_7B!azTi`=GWLMtWdakM3@0=z{T1= O^mN|& ` zZus`AhG-GK70tJVCq?rua}dq9%t18Y?hjvh^Aydu%t18Y5+W7Nx6DB_-x8h_&9}@! zG~Y4@(R@pYR5af*2hn^>cv3XqG6&Io%N#`WEg@3Te9Ig}^DW^?(R|AsMDs0k5Y4xQ zX+`rbVOr6AOPE$P-!cc$d`p;CG~W`Y70tJVX+`rbVOr6AE1GXFd>>0R-_kfMnr{iy zisoCww4(V|G~Zr$e-zEPG}4OZTf(%W`Ia!PXuc&(E1GW!(~9QXD{a30;I@aO=apN- z&9{AfE^ ~x`5ykW!nc*LDCb?SoY#fHrI@+|?TazC z5X}zsJ%BZKw9d!kxtNlVp4nI_qbUzJ%)rES%*w%l4X@VlZDas{zv?8R(9F{qK8fiy z=su1W2{atVb%!y22<6YPG>+N>==v0|mGEt>l;=~s3T+Q#i5oR5(XkxMmZ4=S{!qiW z6+?LbiE|=+D}8yh{q6F?w`H$$ob)(~=3C~_Q1fkcB*)b>Lu*qkZh|Qidd?B{tgJ=T zS={hBCQf74DGb!0?gSPb!{|{o9zkz4R)2<>2QhpA)Aysh3M=;E|8@Abyo|p`{KqJ- z#nLBGy9Qm4VdPQFSdE1RD6PWWhcL7f(^jC CX{uGMvEpt%Ax2m-o?zgqU zlGdn6MbUgqNL6jVRZTp{{aNcV;6>fDSnv!+e}cxR(OZbsYccal46niT$I
hw@p=ckBx_dF&AH7EO^hQ%pOx%Tm?kIOfZD;(xg>Svv3Cl`03}vHp0_yL_*ckMU zM(YT)48`Oi3=YI=J$xJ7L%23R1N}K@n~Iuj6ye)%hHupnZe$Mkhh=onKtm3S=G$*J z->MC`i3Qvrn1}Kl)XEsi#qczAPeH>ZjE_fu7TWGZ%_x+HV`vCE&8Q!Mv3}_5gVtVX z$-tx@gWb^H1$DRKKRbNuKStO!x){9<^ejNrJWR~SKpx7usGWw9$rzr9?(t~I!gwb7 zN1|;QYKEX>#*hiG_V8`^G~w9fattm-`(o5NFltBdT=dLFQywO!W59;;WYkW?Yb|^m z_<|5=Y!&+4XkCt$rI=iV!3Xh&9=`Q9We(EgG`WUWqjMGN-57JBZwda;n{R_Hm_z)> zgjW4epzSf#tVU@ShE}4}h598JTZp~~&^jM2`IwY3I0NlDsGEw>N$9nrXB?WwU}6*o zhNEmj?I4T{!0
jhDR{|5c*f3 zZ5e8uC@sX$0{p&*Z~Yy49?=&F(R!ap&vR( ^uy>rgcS$T5XW` 6@Wj(R_P(;al6A-wfS~@GT)#c^hgg zFcLvEbnAYXwHmrr72#XLt)?BA*otcCR*tY%L$|6Td`lQs7sF^Js-auYR@Q3hR#k*= z3C~*gV{#v=p<8<;Yc+JMD#EvfXZ6(>i=!I4weDlBhHh0w_?B?0<~T}6Q4QTX sERztU{B7DnSOwBMMp&GiC z&k!P2L$|6Te9K&PEiu|0)zGa+Vy%X5RYmxgaIHmy$y8KBxAx|&)zGb~2;UN})wjo3 zTU0~0)>PJN=vGyPZwc3G(oyPwYUtM4mbDtXRTbe|!nKAj81IB?=+>rXt%hz@Mfmp8 zAyRiI9+wBuc&jR!Z?ClZwxpy@*4M+gi=A^9Em$-^FK^|7{JD9KSxe{7&6~e;R{nyy zi(GkmM$vpLNfp^N#u_H!x(OH`kMjLknuXf2=(-OhqcLL?7LGt^IOYz;&=5=;j7~F_ z4@CU{^!LZwerPjdNgveoMn^AH8*eQctVQ!Jq0p>9Vc=zKD6}k^Z<$l>Kn$6%q42F} zz9kfD{R 7N=Y$$vy znr{i4UTOGNG~Y6}tSoG(`PMjs<3#f z*nyWDz7@^4glWAo^z6ch!ndONmN0E_KiWURhQha^`Ia!PuNtifv7zv-Xuc&(8#<28 zqu5aRRy5xdru8S$b`l#3--_m2<`h0hc+`Cs8w%fw=3C|zXohkVY$$vynr{i$dRwCB z25cyNE1GW!(*`wYZ;cIwZ$ B!ndONmN0E79i1Jpq3~@)G~ZtQ|8M>- zglcWKVMF0t(R@poHmpZ?H*6?;D;iu0(_U_as~Wx)VPz$R+(6^4I i?K`5C~gm3p>yjV(o z2kmcR>yB zO%&l<=Ag4;!TlJ`LgQHU-iOtrQG{;^c{X 18CZh8>%p|4@LNvIcOK5%Yl&xFk=B0&PQn;itsJrS?xZI?8fj;bVt$fF~&Ee z2;UN>HN`Peg@L^&??&xTjBG;@z9mGfJA%>A(0c$qRcP9aiCrkdw}fXcHJD6b@G#mx zL)`(4eu5%=OL$g)24kntcLJ>mv>e9dK@{Oz=Ae;K`hqZR=yP Azq2WB?(fC;u;alclYKw^*G0+O-6x3di zktQg@w}fYPT8yTl_a^k*h^AJUxB*4@mhh~l6DEIz!CTOthPs x_ zyA`cJLdz|fY= V{!-FnaGrj|olv zFwq;YG<=&FBARcdFK@a9G~P;cuC)2~r7bUQ{(AWK(#^O1MDy*n(|oHo;EM1qA=1kY z-$sKRFPd+!EPPwum*YkAEuqoN4c}J2!ttW{mN{H*_%(Ff?UjXZqa!(9G~W^;z1;9^c^Sux=G!X^-&T(0c+q@I zi1c#9w~-GxUNqlcS@0950%0uPl5U_=w|0^X-*| zZ@t@ 1JT|ebw-RD(0ey}?m|;{Ox%tE9m=<& z_D2|LkKuOc)}Wy^##^AjIog_HV});ndz5hM3?-bJgSM%t$wp}chQ^_D4C+T?Y&iNX zXdQ%>ftb7pgMHC%K;7LKy%W86pyzfp=`fLwfet9QM{PTdw83yHx?5mlg>QYID&f>Q zN;p+k!l^TqaB2?5r=Wil+N`J m+M?VB8!LPpIz;GHG~ZrX_||`n(5Yy?y|VCa_%xwY(R_Pl;oHC$ zgib~C?UjXZy-hh@G~ZrX_%_&r<3;oBm4$D8Z8%;u-(FexHgq$`i{{%a3*Y)Xa(uvx z@-wJ?8Y62lyawGKG(3XwhtR(QZOc&OL}?+07NBz;>gQlA4}H04orabvn4E~g@o3LN z-F+AxiQZx88G?-!R#xYwD(1$#8){fq=XJqvHs7AS;gx&79=^SF^X*j~zO^nQ+*j9P z2luBuh@J&lIUh~)a6>*OW@FYY4CJA1CKlvkGzX2-&^r~YCu3$dh9_eB1ayzbig9Sj z!gXUXo{92kEFFoB)ks=*Ug}!Rjk%}wM`u4&L!|1QRK>h5JuiJ99+!_PAxg#4qpY<@ z&{d6*&oJX479K!pKjv0pXdkBSMduzY--Y^0^zXphDB8AR$;YUvK*ts=i=gF0EP5Z4 z?_u`47%aoaZobWSDd$xTE>+IE1nrA4wh+w@^gVzzcC^mN;<=cTkDl3BDWfS5H_X7q zbj-@ZfDLt1v0ySrC!uj7daYP}KW2`@@K|i@=G)BEN{CW1e3Er~4Z4qGMFI^+aou5z zA42&vERCb~0J=WK$S0Vw4+~=`?ZMn#7}|+xJJ1=$@~x=<82uk%?Pjz^u;c^OypN9e z@VjolwLQ$At6Jh#&bv}M?{ek5%h0kEi=3ETgxL!*_#mb}fOb2^=An5m`sQHGEVRm4 zJQGu9pl3Q(PD7IoH%!4qHfBx2zyxgU=G&| <)L{d z`f{-*2d&evcq*n$K~FYTPDIlL+%O&! >%FeX7MxSf@znIY(%+vKCEeal_}B zIE`7SFi?ZK6IgHzqeszr1ijT*{TXH+#P9)3-;eGptk{Q!y|``<#&@AyiKRQRv72w@ zwaR%FOP^5Ay9Qm4VdPQFSdE1RD6PWWhcL7f(^jC B0
+P}cqSu~$P zUlMChq4gvdpTLyk=t*GZ5i}jf4Tmss5VPVK*pH3fe4DjiIj>^CtDN^)EO-W^KSATu z=q<$RwV3%NhSy;F g6mdc{2`RxSh@nW%h9zABTF#DiG_<$T8Oz1VrT(2 zcJpoS&B}QdL+zCFwne7~%Wp(|YxK9m+7@U_!II{vxgH%&v8)MNBrG~d7&BRm*=I5M zIi@DjehOnXXg-0yV_0((tw*r98XLR$*6LHvt62QJa^8PI&vRJ$Gc 4zqrO zfu~Schy`mg`UD!+p!YGXeiSoTW4Hj*SE2hMtXPSL6}ZlY@ues)!P3Rp*v+?cN9DYV zr5%*>YSGmmBWak?4hwHWNrSmJVki~UTA{NAmfwK-=IC#Rwb!Ao3EIySZq?OdG>P7m z=sAw2qnJ2^fjG*aVq-Vo2F%KN73BfSdGEnU9}M?GcLo~n#CSLKcR|~2s7Xhu1BPx! zXIs>_!B{KwrJ(hCv|NYDbA(-kXVIQS-ARldL+=sv9KyzKzV!}M&a3DdqMX-^2@?kT zq1*?xy)e=P!+LagLqivgcS3(hv}sXuGfHhSbR#-jp*{s;&Cu5bt>*~8TFxpV*wYxS zLHjXm?B?5GrgC0I`$*-y!!SA+z4xNWgrlN0{t>!wK|>nGHRx}Rww9>50i|XblF<1DA=1WfzEwlF zsv>+#2-UJ5llxE&-P$Wz`<2kGstDf_Le*DeERJgE*1C^%SP9*#x<&XlEPZ*?94A~V z9Yr;C>x{Dw%=@PD9M)PHBe@u!hVCh7n1u21=+8pieW)3Q(r^q7L8lq@12EPPeSOf{ z3oRL#)MKz4+Pk3cHjH*euNFNwV`DepdL4vMRXqz>o91C+wh~UwLpc|<(=aj_!xPax z9t~L-&qV)7v<*Ye5R}XqGNH2{>ib} W$8xsPBQX z?}^*3Q`2&9^=`VNzA=a@Llmm|TRx2hnav-CT^$LhnrUOh=Oq6WJJ;fb#vQ9gC6C z7#@M{p=cP4@qy^?k2WJ}dZW}6LwBLGJ2rOnZD=)NQdQ?F)_OO_T yU zn2+{ZsGEt=9P~~_Pd1t+U}78w#-KbJwIeWM!SEn-4@5(MjQ2&q0d03 jRWpCC-C zYI}^eW;IHyFtie#F4Ql<*h2I@fY$kF$;YIO!5L`JLETi0PC~C0J>$?c1{0$&FdStI zY6oFt0EX{DcVBGm=G*Yogh^H1KW1%s0^=U^KZ3T0QL_>y7lxeZT!{JyFg6c;bI>ZI zWd oCC!x-Y(Jb`dhn`Vr8jgt}7%-ze02{mcRt-a{iZChRNJAHlcS1D`Y16Xy zK2P(js^>Y@CNCz|Vc;p0*P?a}Mjpj*0lFVT!wQTqMgL;7IZ(3zrFj^djm|vO=VHu; zzR75vh?en~9E-tBw2#EbZoZWVi00c1Umtw?=38B%)2)VYyGm)&rJHa28btN&_qY01 z(>or%uv_XXs&7e!)>nP2E8+3Fit1bDk~ nO-@+yzlRaD Pz41?&I;git1a^o%L1Urj_$}T}AaRbFoguVk@SMzqF$Imh@(Q)wkL$JYH8(eal?3 zZ5W(_sgp0QsJ RZyP{twXh9%{-it*E{wof@t{_hvMFaA`&LEos!i4wSc{w&K!?>RZyM z-WYmzp=rmZ)roZS#sA;i(46W%{zFf5*PX}Zu77B$_sgA2gU9_qeoCOhIW+KoR`>T` z>pxXd_xXNTYcaP=f8MNb&e!Ts@6`8vzpMMNwV!tSuIAFvpCjh>?Ovy1Zr^Th*UC>L zHuSpst{)e36Z=j2^7dWttNQ*tvEQzIzkRd%w&L+gC0|e9Ub_1B>Q3M4lunm_w%_*- znSb`Y)bokC@m)Z59+#__o0!{$&$5@hU%u^e-}imyocHba$+w-C`uoJ(zTMo#+^%eH z*UG%g^RMiAtFN;w^;(;kevX*ih0lZEwj=RM_s``Xf35B_|E$K@Z`b;~YV*FEx&2V) zR5vYoxv#sr$Nf;}rKU^k&ZF-7hdv+2G+viwQ_(U7)0B?e`V`%_q)DSY(6}8_l#bi_ z6y3MXseA_Nb5W~w+}5Y)z9oeldJUbMP^)y@)~D#cC57vM2W@YnN$I$)Ptko#3OD>d zy5B`LMOvRf^t5DRE8mR@e1viYElS62eTwc|x^%tU(X& VitZ!}s*pk*i~ z2Vrm^+WVuul>X#$Uj#-Vcz>PKU2IQlGT9fX#Fn7jvrebH_} z-Q5_y6TNq!=XNydFp-Xd4k))rZ99y#!Eh?NTcDvi#;-&Fc{*!twW#?VrBfI>j?Sal zQ1`7NhvVuqK845qlh9^G%{Y|CU}zLNhojztF*EuGp!FWK^u=T^3}&GHPSo9j(Jtt{ z4L#{->VS!xG0+y}HmFU-ND79pNB4DTI8WDW{4DyDXgi4wb>FHTxAiHyZ|Q{fA4A&_ zR6B0#Q*__b2^&6*?iy4(ZtGKY-!iAb7j(nQXHf09txwT?%bdJT(IcVSaa*6F`<5=< zU< e>-MUK0t=e^4SJ8b-mu~20bhcHxb)TloRa=jd zwLBhPgKiHR9>MrS=wE@hWvFqYv=BoJ&^ZtFb1;^NzFf3UL(3FQPQ>7Nv}d92K8%h; z?=bWXL6aF1CJgjLxesc4VWbC!_2}+~4RznD9k=x Tm0XuTf8=ROEu_;#)#iT-2gIfRKSly_rf8(KDF@LklsiP9!?{t072wEh;u z|AB^=(EmJoyqI_j 7K+6gYF2--)eLM2ySJ!+!ecM3yZEw+iE4pv>pU`pY-;1?- z(6$>(Dp9i&9ow;N8(OwvQ3WQqVD@GVeu$|bpuHSp@1pr{=z9li-a_k}So}JsyoR2? zV&z}ZREis3#Y6~2_bqeKjzHHij94&Z2o?@P$&8}=cK^lC8maG~{Vj~Wf#%oI7si@R zD7tT%L;4tWXJW-DG>pV`!!bS-MfWY;tD5)G@gA1_4J~C@^fo5nMA3cA9CTJJxF4fg zXdH{)`>=X6itbxFSJOU1=VmPb5cMCRzZ`4dMbUlB98#vBCmSm#qG |i@D7tUy zUe#_#*EWoNj2RVJxCN!nD7tT%LuxMCb1-H@^HlUr#+peex^L-TP2Yp=U0AUb4LfjM z6ysY_bl)-u&1`hYST++aGq7knCa0n3z9r?U`xFa4!RTHz#?ZSPt1D4--!g|ZJE}ij zr~dSg`qL=tPYI~0_qu6rHJx0aqWhLqsOY{`(y@!s<-o`Tn6Us0=c6 P8{o6;`zrB?G+e6vEdUSU~Ll=y9LVriJX;E`CN^LQ8BRX55 zJ_TdV(ANa5=lHl=&SLU325ZoM40VmXkLu2+zO^jo6_&od)i )Tg@#ZUXR~u zZej8M^{wV6-tYN*Q}6p&Jze}_Zr^Th;<`f2?aRxFE5Dwo=XKc7>sIWyZ@1rm=-2%X z?W_787jqN)?VtJnCidHv@3(Ju-=6yIdiU4Uw+(dPUe)Q_`npQ~S@(ZmJ5l~w^QrGV zVs6TJ6&vW>sXIS0H!-&hpKa^zi|=~ ecxxE zga0?LQ!%$IdwpIj^Qt`X&)IL++PqBr8+UHk>by2oHO}0w^?4=seYbP_q0CE7O 04(W$BXXUD@)%>Z*jcnz9mI?x#`>Z zyBsgNZ}~3C<)&{V5snw#w{+L)4q)^X^v2M$3)O#_Qgq+a>1s`& VsJm&KS5m# zqm?MSZ|QUu-M7p^bl=iVE4pv#;0+Byry2DFFxC%!ebCwqEg6{9W3U_AyP)nijCMq? z7CkqksVydM#6T;QQ&4+7Mw(#w9G$lAvuH?Syaq-0Epsrn!o&?2XohkV)SjceHF5?; z_pQ?TdlN=mqqik`Za`BrOh_oYZ EYOLwjQR*ZE(UwgE+ zLyHEJsVKT{>8{n>j*<>T>FDf$`t}%Wi=z9M?pnj081IJuE@- D zrHeM0iT07G8-~%r=)D&`CN%ZKL~jiAM7alQ^%&`jqWku{?!N6&`1w<6`nHplCM{Ux zT<*%tbIo!%@*K04&Yzn%f9b6J1#=g<8mIMkK!&bwzdJK@kLbSTt>uOM+3{7h)D=C1 z#+A6+g)5e#&WWLgXnhdR*m3 V?OvV={VV? IuC@p=*-{JA1x6t?o?hfOMO{goy z(4W!zCp=Sv>;DVQL9F=yaP{x7{ckY*pJ>~NiGRlz{|)=Rgq1I%%ZHtw$5)<1`Dd8? zDSFr8z^8G`kFnrMOk0DSJUC@FN(HE0j=`mv cc eJEJ&%d!@Wr2DpJ%ah9lD;zPKEf&lPIsjqzApLabN*%c?b(uV%l;Xya=~G zh@J(gosYqM%$beH^RQ?J8mHrKrL}Oy6xO &Kuu6DvmI>fzXaD24~4 z&5Vfw_+o$T(+?~Apv!=rdg3cRP`(qBcc8Z`4(x(kZo`6GaYK6?oQ7L(LQfmiw#Hyf z%t^uH*JDvrG&aHA=V;SgQH#1W7&?vClX&Jhu1}!(2v!`z)d#Wt0Ss56Z679-R>T*T zR>VF^D`KV6hUik-5Ia?H{41ML{t%Pp=zSLlmf@DSuyzw}D8<3A;?_T*=VjFX5raX@ z`8^*09Txotjlag-8*#-iQRm0dOKANCp7G)O=h3_YD}IKnpT+jiVEAdY6=LE^d~pr- zc?>I8qpJWrJ&docM0q(Tm!j8+0~g`M`B