From 67d2b3014ad7de53e4acbc14119557fe4f409f7c Mon Sep 17 00:00:00 2001 From: Andreas Lauser Date: Wed, 22 May 2024 14:06:27 +0200 Subject: [PATCH 1/7] refactor the compu methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit now, they work as they are defined by the XSD: all parameters are set by the fields specified by the basic `CompuMethod` class, and the specific behavior of the individual categories are implemented by subclasses. This encompasses extracting the relevant data from their generic form via `__post_init__()` methods in the subclasses and doing the actual computations to translate internal values to/from physical values. This approach has the advantages that it is quite a bit cleaner than the previous code (albeit still quite messy IMO), that all compu methods can be handled using the same output writing code and -- most importantly -- that the code now reflects the specification reasonably closely now. Signed-off-by: Andreas Lauser Signed-off-by: Katja Köhler --- examples/somersault.pdx | Bin 25067 -> 24635 bytes examples/somersaultecu.py | 86 ++-- odxtools/compumethods/compuconst.py | 31 ++ odxtools/compumethods/compudefaultvalue.py | 27 ++ odxtools/compumethods/compuinternaltophys.py | 38 ++ odxtools/compumethods/compuinversevalue.py | 20 + odxtools/compumethods/compumethod.py | 79 +++- odxtools/compumethods/compuphystointernal.py | 38 ++ odxtools/compumethods/compuscale.py | 33 +- odxtools/compumethods/createanycompumethod.py | 174 +------- odxtools/compumethods/identicalcompumethod.py | 37 +- odxtools/compumethods/linearcompumethod.py | 256 +++-------- odxtools/compumethods/linearsegment.py | 177 ++++++++ .../compumethods/scalelinearcompumethod.py | 108 +++-- odxtools/compumethods/tabintpcompumethod.py | 215 +++++----- odxtools/compumethods/texttablecompumethod.py | 150 +++++-- odxtools/dataobjectproperty.py | 17 +- odxtools/parameterinfo.py | 6 +- .../macros/printCompuMethod.xml.jinja2 | 156 +++++++ odxtools/templates/macros/printDOP.xml.jinja2 | 134 +----- odxtools/templates/macros/printMux.xml.jinja2 | 5 +- tests/test_compu_methods.py | 398 ++++++++++++------ tests/test_decoding.py | 113 +++-- tests/test_diag_coded_types.py | 64 ++- tests/test_diag_data_dictionary_spec.py | 7 +- tests/test_encoding.py | 45 +- tests/test_odxtools.py | 4 +- tests/test_singleecujob.py | 130 +++--- tests/test_unit_spec.py | 7 +- 29 files changed, 1584 insertions(+), 971 deletions(-) create mode 100644 odxtools/compumethods/compuconst.py create mode 100644 odxtools/compumethods/compudefaultvalue.py create mode 100644 odxtools/compumethods/compuinternaltophys.py create mode 100644 odxtools/compumethods/compuinversevalue.py create mode 100644 odxtools/compumethods/compuphystointernal.py create mode 100644 odxtools/compumethods/linearsegment.py create mode 100644 odxtools/templates/macros/printCompuMethod.xml.jinja2 diff --git a/examples/somersault.pdx b/examples/somersault.pdx index 9badca4424043942cd82f2c122033bfe65e26938..3ee52924afeea8a5d26e90d8649924b0bf7cf1d6 100644 GIT binary patch literal 24635 zcmZ76Q*16=6sYUkwr$(CZQHhO+g`P8+qS!Ew_m$zoPX=&>~l4faW|7OlP7Pck}N0~ z8W0c=6p)p7n-&M1S>P!&5RecX5D?aXQ)yL2LuO_!ZZ1O>LorQJVFd<9GcS5m*IV7| zgxpD#A3?>XiREOEwpjQ$e$K6%p$P2M&>@7hIP<~SmEI%O26#oYQVE<;-y{z1! zj(Ex(sc4k3J$-dGP5*CqcT>;6&)$bcN<0aN!&e-=RC}VEo#5k-g}(9CUh{e!su=o% zrryDcz6e6LMaN-n&CI(csZ6tUZ$?Ep@eLpN{wY*{eK69NdM2~?K(R4x3rL_C!DbWUI0s$;|GNkN!=2c3H zKhdPxm0FkWFn`o^*4ldNhTno zwkS?hjhb+<&=b>TYG)4{Y7q0tHrVO%^?DzR21oAOulL7 z@M0o%x($B80%33G(8jAe|HmdD&$r7h+Au*=8!t23mm%_ZkHU22;-cE_gq4vK){5q& zY_Vs@I;2j71jYaX(p@2Qm%!wc6d_PN)cZoB$@ZOn-|?f+0}ACQtk0-xFd%QXs%U9j zDcD~&XEK6VP7g@=f(i9zJn)0dOl)BUOFt=&0;OyIep;*Z%559S9MBY`Fy@C8k%10^ zGBx36#<$9@{Z{55AJEJ~RLJ z5vOSrmm(oQE-ak8Y`y$Zx1-Y`qo9OQLfbDU<@Ug&BBR1_TNA?!cD<^xPbXHSjsSd= zWm78C7Y5plb~`O0ATt&Aa%+|_e=2V2vG0f38NerdJ-(%nRh!WUrb5{nDPIXAqrRwf z3f8Lw( zPTI2pm}!N~nZzDO?X&_yZLlwbFN@`Hmx^-I!~`bbc1n^#V=kBs7qqwsjlv82FH|R! z$Ri#pq@+U>{xei{)CET}Kus{jZss)So5JW|n#2Lyj1RK&(OxCFgHytp1lLwP9P!IpZEhRncHm{<~XGg@6D#c{04 zhUH$|ody;^ql|qj<46RUgvIAKpN}c~QH=1uDw?b>D6*DDhZJGaiLP~bvk^lUF>$Ko zeH@6@d?bx>=SUr3PdLdD)*(8V*Ml7MK(V}wCB6!zqw>>O)F~m0w+$q7%^ixE9J@EB zEY<0Qjz)#xb+(X@7jjg46dNh;}zB zeCBS!=9K+=k_V&%T_tIX2Qo5H_g!_e-H+;^?ZG?TdQuO>MYR=A(hQlbf1Z`*DhKG@ zP~gtwi!l4*d-sZo3w*rwtQiA@UXMO;W;~YIezF*3@V6603;7d31a$X(0hb4f53Wl3?{I)8x<0r z6S+Wz(2onl&KPPF+{gpp(A98hX|WM5KB|TpQwFod7)apA#^pmNKsaA~=jAuCz?(q^ z;6V_CoO9XwRGd?IEoZJGR|BR18YgVv@g<5t(%nzyKV+UoQ&Y7}6_Rpr${Vn`_%|t% z#oDkj5{8*yDrOy)=vwQ*-?gi9cL?)Wc~l4Rb_ovJxTm7U3Sl;ksxdCUKsp$E?oNpy zj^e4CqIf=f5^G^gW&Gw6&D^VHe6g1^g2c4S#3I&)moL;Sw=ywakm!YEvT)ktm5ewn zedfj~>h$yFF%|_+Q#%V-%gOg3aWuA`XGt;`8P0~x8>Apzmvn5bgd7Dq@Mb_e$FZeY zrs{r16eXp*uQ(}sKE=Qy0I)2mf+B2-qcetuX-Mzk%4sQP@C-6iM-8)zV`q-dvxc1w z%*-$LM!zP(+>H!M&k7tc<5wFZ%2JYL&g2h8<6iUo&G<{5GEks?@q4A#-%rTZ1DI9o z{e;1{OQ4=ts8-x=-&I7QM+4ZMqgjv`Rdk|zf@!yeu{P?1-11G$Q<{3!Jv!v_c_cRU z11V)-&_i_}Wepsp>9Q?`QadROW97t@;VapKe~5mE2rt3B%aJOcN92~ao_YhW0!wHm zV0ck)MNvH=w^Z0(pctBkwiuivpXGFNh(2sbebs1_lFKJcsUQ1wA4D5C~nI zn~uj;Ap)0kLcc?4j_BIyI8e*XDr_J8O9rCrWD*192fy?nnSPjVWc!Xo_5viW4c&Dj zteC){RZvnVbqx|8#w5HR-!0pBUm58ME3)(ee2$B9cpe2o`yt%EW0^QiB4HoTZx~|i zgSR7LA9|G}DtBZ>ozZlEfx41v$r`InLD&t1P^g{Yfr+|--eX_q8M_r$gngBx#Z^gq$n&1~XQO(in0GT+ZU| zcU!P=z)q19P)_ADA!lA~VN*VS_cVNzS5YXiN6{zPPsjHhGBOGO6%cCM3?Jst>!kNv zb&!?KIcp9o-gL^Ff%(>X@DK{|A1~35@O|+id-8(2 z;N08m&}Lw0nA>n`fh5r8m2s-$X}Z=}_&98$SNPV>tR;TeMrgGTA}KpF9<2v&M_V2Y zKA(A(IcYtZ`a8~ap?WdS{IWz7!i5ZVO!PqFw%;3sh4-~A@OyhX%oBLgj42;!`Hrx2 z)DvYK#=44obsWuwJ`u-^i`5*BR&}5R=Rfb9shRooqW9ruiXdL+9MS#*C$8GRR`L2h zZsz95mL%lnGcHw_6GxF?UOcSWpq`}aufE~%T6u0#O)q0AZc4Ufc90SwJqawHzGM5}ZE7=yO4 zAXdprphNy29rmq9(6KYiO%@Y{ubaYU7l*2mB?AMUU(y&1^TR0Ch7 z;eyL3dkw{3+F?}lhdfV)U=V$70ScW~ZR;vr@~h}@jNFd@hYrbF|LL9etDg?=<(l`X zC*R#0fc)snI1M#s!oiYX7_|Hk9YX(49cH-xFC7LrKVIL3XeG4CJrjl^NzaPq5IdI? zB|_^H_fN5R5q7HWcB|cVq`_Q`vg+-e@Kn&M=NJXHc?I#1(@t#ZQ%w#{%_tSfvia7* zbI48V^;ED%2<6x5W03c73DB#NDtgVY*=}J3{y_nQT#CjXiG`avdh88q3jrRT@`7O?>2n6M}W5w{TKdW&}F> zjuXf#hfJYNKHUC08+gxYfEdvdE{t=Yx>+knM2{VPYOTG7Xlh*Pfb`NL9=(SIWCKgJ=ivO(cPcrTx+Z+j~`{$r<3`U;RIM2n`J$9s0j{xPoj1 z9+~K50D`*^8u1?wi_sg@iwFMWA#t3+e>~(NQ?`^Qmc-sn{2x3V&9-476nCe9)z2u) zpF(ghhDXNse-I|fnKi3cwDBdCW+WoHPFSx5<-y65 zH>m;LjsQ4xW-mB|k@QA$a7rM%2W6y*(^<7Hp+s^3t@un6i9MTmI-}t*96&+GfHrlt zpUzd3PUN+?CYURN#1@}cFy)$X`EngZ!1;^dXejfev$PM_0857dIQUgAZYJ4S!V(6d z6sd!0X@{5Uw)vGJl7Ur$z&IB}19k;3g{ODE*c)HfuUUV#sVM zoOv`se=o-Dq^=qpKKD27<1)8zX%cH8XvD|%u#)|+fwP81 zqIpO@tdO~cP^Z>e;V`7&3nu@9;s3g#5>NBGRW#9uP?VenM-|N=CPBIfAD5U1zJzKb z@(OV~{heUgS|Jd1Y0G?vIJD*pfFE9%0FrkhMQMHF0AhX2cMs_R)O*4xomc@n_6D{{-l51`rdzV|9 z;);C->%~+&kd^(P@U655GsPMoS7j4{YRl85n8>L;7G%eyxNJB| zxm?mBDy38mN<4tgF~Pq2c&iIcsY1ub@2=wHU!!!(9t5WC62#l753JKO38iwZSCV6=1Fzz1Iz+f_b?h?k2HhR>B)ZbU&XyLRn+Vk!T4p>Pq$k2r;}d z@l@Q$1@c7N>k`e9XdrjM0IBc+%|pIMp&i;`pYvXk3}br&2|7uiyI*=4k`oUeGk^!I zw@}l=1GW@-Ruuxys zeq}Rt8zQPnt)&aa^cUP_3g!>^ckd$-w<*Phgs~p{$vJY7^X3g2I9GJ1r0O6Efe#Bi zOarrp`9KZs{DUqP?!H43!CQzGH=)z#6@}5I&-zh+U^Fw214l+OK)j)*6r&UjP8l_d zY%G{6;Tp|$f;IYWtku!BF_JOwgDuGCLEs;LXL3R~;C%AJWw#W#bI-l&143jW>CC&Z zU(o+sb(^1{dkp{eKdB}lAe8^3I(AMDc0-o`llt_pU2>;0ehMotmKsVEl7;#!?DB^| z#%})XAScayDI*7)+Q=4xQhB`i&cV*6&Rsbmc>=Sfl-X6J#H{YsXxIMu?0uBv1`05q zT~r)(mfP=fbW-dHMXy!OZ^1t=%=hFbKcK@)glt+0 z#G-2Bz&qmJTHvONMrM3IZm~P!CKhyjZQAhj^RpK!}^+V8l)mdIpry)b8~>G+@LVvQFuC!YC6L})SJ#FQ?#UUtR* zi+zW}oB;&}U&j+=9*^I7eRfy&?f&BhZ41aFZu`Z#cUDYilwU!~YrNWONo2bmWZrD2 z^Nfu16}RKOX&)=6=#MK_fZO(Bxwqn7A^hD7iGD(PG>b{ZQH9dbT#eGa*6YNGLYAEH zu~KwKxKd)$2N=bEP;9}kQb6Hof-&>eK#~7Lz6R_c23BN2S*&BYVid zhYEQ@=7{@PznH$)<#vKzL^Egp9l<)b(?zu93kT8P2s6#{ABjEIVQ0b}OhRs?a3p((#uOivNj`u{^j~*sxO4s}@f$dMHs94+ z!U*3LKy9X3P_dkVp0T3pm8HLI3NKWgAeQzUZUIi#U*s(k06C)DyQ( zmhQnM6tA>cjPP=;;)6Y=)^Y&zSeTNNJ!!s*dHRrlY)A?f6DL*5mZ%?B5%oa&wddKg z8PDwJIQzqvhq~(TCbz-LP8)hpb}lR|$rOO&>f6Ocm)I{KR+3HxJVPcYrhO+T2bA!K z6?NqKrx`Bt=-hQg2-6GAIyUJnu+;hd>gx;cFji0kiJJO==QZaH=;L9c(ZWvvAI}jBieqe^w$0s8xn3tEE0(`pd@KYuXBt6rgpl-(m}Hf|&n|IwlSo zGNf3>AeGCElm1sX4!Xp9k}i>&SVda_d5Ku{{v*W?u`jiMxB(fu^)Jbw4>^667Inev zHSsnZjxoJ5QE9D~ZXU;Pqx?)RM8e^7s%b+;b~R~@aWDZf85NPRfHXV~Icgm=bgwKr zGCq!th7q-Bjfq4mO*L-;Kw%oYyuh_%VTOfk1m)sRv5I4_31CmR&jF+@8e9l91#$UO z4d+1rOgSCH1LzXrZXx;9wlgaK|fTaUNDFPHgwp`g4mB-`esx(!IbDY-};R zKvP0IR1%ex5``JdjP!B+gm$-|alPTj!K$zMX39ZFYW2NoTfz}xIGlAXPai2Ur=1Q7T3W+XB^%%=EnCFSD4UmT4`$Lb1krrqC3u}K`?YQ zq5_&fY~=_6| z7h3Va;F^Pp3UR;(Jw{erGpP+mCR-IKrP~>41bMrX8W|;caBSck5G0H&I0D(P!I>~+ zj*mQr*hB9U_6&DGA`pZTwwsswTgUP8J7%E5P=PYMsjF7CXH`Huwjvk(&QCB0#vcV`Tj1R~jv z!k*m$ps#>4jbIaVxYTm~Ji--{W%Ca#y@xsk51t)}^rRNEP>KE15c9fQFJ6=z5)rn0 zEiwo#FkvPRR_r@oRdW&1{l+qs96pz<*_-~e!Ciil!E4xNP8USy#fv~t^LrbcrO*d# z(*j-WV0F{N`T28TK|@eRO^$w!KGy=X_dzJp8JDqt zpm8wS8=RJONSU@s^9O8S14#1-z!n{7hTpc|;$rxYw$$0kNQaCXzBrr`ZKDiV#h>Ca zo4f2d!pOSgR|*w>aEni8Soi402ENxi&5z_&WQZ&m^}M3J9>JTL^p)ux`MBSx1%ifa zaN*4#bk(>n6fW7;g$ay$$a}CaoP|ayy9iXlh<{O_6!@3r5X|k@*{mChE6_|)*w6V^ zcxp#cQi)UyCbZ6M4cQp-*l-oSn>Y&S17{*F`=-hRIe9&fsx$rQe6WZBvi>8N5#jc1J>YS!XO%yPRX zH8R@Pi;5JpdGu7EB+y{!(ybJY$y|k9XCc8V3ZU{KG)s%_VkR6U9z1xE2^Ai($h&8@ zCTwm7l3X?HEh4aP@SRC%MXrL)$Z}{B-5lX2+hQuQB}Q}0#gz2urW0Aji$W0DXcdS% z7Ocj1?sy=&pJJkG_KI@9RmuI1-oT$@N-Gn;=rOhZfU{g-YG|Ut_hP1R_9p0(HMJ1P zY9WG!B_Wy8=QBvI72r#H1G?y)YRj!iw>(Xror-Q^V?*>St%uj5sCmt$tm4Yvg{}IU znn$6-{z9MKmWv5VzhR%=V5ULP)bzr~4NaMsqjH6#u&qx;t0#OdsgM+^0q*Y))}I=5 zd>CUGmb@WjMF!)G8=#t$S}cu=5Rv zV+}B&vrF7w5Cur0)}r5*DYP@hj$7G)N>U?SjxJR3eO#VW_z1R+tU(FIpw9fTFA>rP znKv%Uq5x1h-!_2_&_ULuUH!c%7S-4oZgJNUp^SRk9oPibgm9QJPkRh@$(aEx>QWR; z^Dyu?C3AeAyRaEXwMxI_I+zrnjoM@Ke_*xE8i*p`vc{WCLDGa=)s>l!Ckz=3=C;+l zNx5|xm)Z6QTM?!?$??#u(6`HPDCjq|LR5$u;GJuK1blzZf{K?o=|x=!8T4O(c<<75 zJ6Eg|b6@H&H8b?P7$KL0@4(`mH6Ya1Jb^MmG6&$D@{#sNGzMuZcZLiG@j5CuW`?$V zHZ^~huzt2N2Uy-IF-qFB7uD!d)5r(?s4rpG3$!bIR+!Wymj~354M;k?lRsue9bkrR z6KNaB5AGBDnLx1+p$#$Z@;>|C6K+RFEcnwXq#&Be3&D6kT$5CdAfysT_$Z^TC zXXta+b!~tpb59v;2;Nez=^3#QEJE!$aP98tHu9&pVD2_+ z|KcY{I$$3AWd!-A6y=%24cj(LuWCkJ>+6`cFbhFo)sOH%SWom}OJe&mdNugSnGqd` zwkH!Oq64WKky8TWlpt-PV82ZllCs1Hhav29P+-+Dy_wgNHlgtl5i#K-Cq@T>z+`#_ zGsDChUHdvD>Qim1VcG+peUr@C>7kc!Myr{X~VEIdnI( z)6*@jUrdcxb?D=Kz%V50OpI4yFeLn&!nth?3-t~4+8ba|t<)81#>R2Q3>UNrBdwKb zb20vvm4_P{+iU&IkAp>2C1$RlO71*HXu~dk0>KIryngSD0v=OL+MpP)!L&2YPwOvq z+^^r9zP!3i)A@szdcE0R@uvzWP|xoDuq(1+&X-f$sI|}>$FPl4X5-wD z?S(zMv-tWnLcxnzK$W`)p^pZ^HatDn_kt%wnzI@=S~){T13|oDN(POn{_q-LOohw9 ziE{|5mleBgmvd(RRZS$`&hF>>D&0-ZNM^6*m43A4c~fbb++atHuBfM7U!*-GA4Ncm zN+niulN8mC)|*6!`H1B*{#X`L;StL?+ECMV=QGfZ_?wtfe?WBJ#aTp`whN7Hh$j=$ zj4`Tms)b#fWyhPRGR+x9;f+k@y?Z{ujB1e7zt3?u*6&~qW%XP^V3ekehoLv#R~9hq=fcI*Uv5UE_Ab%m*lo^K$suFUHFE8 zYV~^1alDK$_fhv>LONa!C%i8a&Wp``5^XQB2`donr0if*VEt2-jP4c~!Lp z`eRMWgQq`p{CSWEKiG8C5LFD=QEM<jB=2)Ij~b|lrJE{!5i6KtR z*NHm3(3d#Uj@eWadDu{Y48PXETv)~VpfOKOUqPN9?6y@Uf|wOo4q0{a&^bbE;KQA( z27IPED%EnYz_+A`5GRh*NN=RW4NoFRsiRnvE_l^$!Fz_-a1mJb%~hc8TMT_to}tha^lf~BKl9*kSC z4cHA&>^rCUQ5|2i`v6vN8kQ9`f?*lXzK0$F4kyKqgL|$@e;p&ibO0>J1oSo{LhAY8 zGzhbTCUms)@uJP7(#Ht;VyWJTvXB!yMUWJmtx zgb#mpTyDqohZ^;=jDb3YdjLyTDXjjNKpw@9Li4_AEwT`Ce+)+;^WUFDww>9W;Db={}I;Fy+aD< zbyI|F=xY-(ibBEmH9V_vatp@G6=wE!hOmyiNWn#{hUhY>Bu@e=;l9fh@7Cu2-T znkSE&|NNsvyeGn>?c=B!!cDYryvX#FQv(5+kVBsVwGVe<0s79+!qxkpyPCRp2+$gp zA~BTWe@|m6fz9kUU1eZcu8<+ca5SDjFSnhwqjhiCm2Zr6+>@^bVqF^6J85Z|HljRE zvW?5nHqA6X|NZeq6?_ie6~4cy5DL&LJxafctKFX>X&tbR4{ON(h5Wzk;<1Ibx)VMS z5arSTzb>-=zjg7_TdhB4(qG_^$X7J`l$i6;qcXB(P@Cc z^W03IEq@ZJN-_jrEHP`ZR`$V|P(;P$ko-##v&+Y30oipTU>kv;PQZQ{&}S* zbyzzPy}}5$~N{T(!&MpNy>rOt?tFw{9<&ju&P1^9Pe!diS3LUnZ32NH6sX zLZ;t}E)!x+o@>llL)i+$*s|C^-8yhWYn={*QMCBFUN?f_Q@z2)OEmPkaRlzyLr#nd zZ=~AgHR`@fm)mZ7iE-H0U)cv=%4(lO6+cvj;&4xq@cx(dqJbH-dFL=p&yv{OB+pF(rlmR`pWI$dk#DlgW04AHL0mL{E>6aT&!1H;ol20Ls{J*eKA8=$ zL#|`J4W_=|COAsXd^xz#yuWnKD9!VMoIL4Isi~$xj6YRG%}c-PT}$qei;;oTocXyP zvNd1dZ-~u-KNG;*0^I!EbNHFU`%Rq9r5?KWY_z0Hk6)ztF~3gll?gYOW5~3mLp5@d zc@ve{Pr>WgR_tOP^-J9jGa21FsBcXLplq)odB=hplAo3jo|9IUoIWEemx&_AS}_HW zN#977$jrw|#q33UrDvOC$iL`BAu4^b~Zdn51f{i@#WFJR>;{|M2%*D#~NG|2QhdC)v-!UFx)KU+drf!zG#> z2%lc%`8LGAb!pfW##gpyz<|mmd>)(+e`z%^yp}<(Qjll!hfYP+CgtOFuhXp2o27~O z{mYLt8fx;gORD%`<`5KrjCJUNWeWI$0!ik(QiSMY9mE8ton$IJ9NVL!+9fZ5pF{QMi^w>-XA<{brcRc9 z_kR25iL#{LoQQUD=#t#TykuDQ&)w2DaR@O{-gq8g4{wfU@P!NS2hYtR;GfVQr>pyI zH;>he>p{(2um&{-%<4n5cbyjZn%?J?V%_?Jd-ttE?UcHsXGDDW`jx5ah|6X55+Q=m z^4DJ`$mg;NR(4+>))A}?KFemjnZ?EbmO@GkB??n^0ZQ?0fST1%J&o?XxTz7KJ(aAPB$nfq8F{+&~5y<0y$xV8$z{5?l5an&B z@wq3O*}}?#9e9d%w=WG=; zdXd0qC2`}`5(Qqgj{;%#)&2$YW*UpSk)5M=^f#}+KzS>$CvX|*(|#C4(ftrWUk5A- zq|m}fhYcxs7FGk&kE^);=l5@JX{QruZS ze+O01b?MAOYu%KsksJcHbB$b>fb;F4U1NC>nvW}0ArsI^$Mwe(fZ|7DzmU+Rvyv#Z8g+;$bvakPdR4I+9%zDu9v0C@0o-C0QRLm(oiP~XCm!~c z9gL2$q^qz+95sjdfJM4SPlG?Xag%Jh>xO8QD};$Zv{NQ!_V{GT+p9{fWQW#Z0j{-dA1*2K}iR1vB1rSiPYgecw+-#UMOv^2-W?jY!7cN%ah&!~t9G(c#Y zI25{V9BGZz*Mh|qezG5BTB3*w)@gsMNJPAoqmhYXSFV?7n?*=MO)DY?yib5XeO8@bY$*Lz@4A_OgqO zy)1C#0lx78*CPTb(G}Luz%r##F)62(UxFaxu{9G2h8kj%y zO?>fVkx+1fSL~#xu!2U7GLHk{@8cvqt7Lau@U(5Td`5}ZFZ8KdAZ=K&$K3C z>Zi!K99UgK+@Y)B5i{_qf219CoufB0;weP&hk>Y%I}0P)d38&6w&`uS)iga{5i6czA_8|Q{Q^mhelzSY1XmO`E;Z$^>y34)DxeFE)Sh|EP zZ2&TCdQ~d^F0l5DhX6UNTqy6>2wtfW-i}m*5l=pQA}Mrp$zWxu2U=1b#nE@7a*Aqc&Qs6yC30nLWrJSmOoHj^&nRXFa?*uIy z5T7DDM|DP{(!HIrj7>;X%Dwy7^$xC5TB9Y^gR-!gag#}F7_^qLfnUe{NGiSC67(UNv+mxxn zTu;?ci9>`GgkyZKk~p@pI3%FTpBY^kWuVU&(vy9uC=8sUns?xn(g_VjMT_fZ2E&D1_Ey9OmAXvAmt&7`Gd)1bpU+D5POSm@JNg8uNj zc^Cu5*po0Rv}cR90`hChRpEJJXC;4u^9L(?tYl>tK!gO;k3uOcW;K{KD(>$Lhbl>+ zy(c1cP@C{}v#mNcF-&WH`Z%9c`39V!{w_pE3xTkg59r|yB`&(I#Q5z$IX#KKiF20^ zKmL6p4Ud8n#D`bwSF1{3XyLph%Gz|riu7e-5H43ngad_2<%ZV@a-JPmyK*3SgCoU^ zU}*?Yw$XPl9-OGlA~(Mzdv-4`k9F1b$I-HZZd|>u>!a@Fm<~J+GNfVg&L(e8;pHyZ zVy}oG*J@&9wXju5pK7ZkD0bv$_Co2Ef1QZ-R2Q1w&Lli9xS-Gv$Xn0dzfY zJ|iD2Ehx5k#=irrM5KP8d#;Yy1qq*czvK*Po0lpiM*LF`R?Jo4l$Enm#mwQZ_;~-y zTSkoJt<7a5gB-_)i3|B&z4#}TEQ6st3fC=WWamopv_@$g50{N)y8Aw4{vj+9b%_m; zYD7?&5b@K}h0qangD?md3$-^HT&c_Llgm2dq9DNGR91B-~Al$N; zR*MkZZHL`emF`#=F``l|-5(XM5qS?iXeU!=M7W{e*N1?Bkm+fufdzyVadxQ1wb@ZC z5vr@nZz_)eFtR`x)1{}aFknM6CZa^qHQGB77OJ60Hj{q*<&$#&HZV!pN|^mQmFuV@eU-mz&NT z)}D)xi;4lLX?nxZf1gptOUTHXF&NW&;e^+uvwEE9|1#bBgt!F`Qk@|nnS++6r*y)E zeGe6Dy$SV5IDnxM1w5(*oFvr?mi!Sv9;@aQ{Wq9pEmrl$R2@lspcEFt&GqDz7;KGW z*gm=#WI)`hsl9nh|P^$h_39uz&M0U}yBdlH=?2oW1ToYP1(+Y=vJ zL_`M;FkA8(bO**Rkha7*&gzJV_-f z3<3&oM2?Bz$=L0HR)gxDlsC&@AonEuo|vEU~FAd@%BdS=UMFN zn(2V2)?x5?(YrZJ!;yQDQow}0o1cFb7!6Zr(^eB%r**^Wcfos1Ix2G@-nS zpMk&R-<8*ZYL~QDe2kSxx|>3sf#h5>cA_2V?pzDs9WR2g(Kit25-6;aGN7L_JT$BU zinoaDfMlMcAY=s{)QR)Vxj8dsfp#bmxH3%c$^q*PL-=Vb@?=(cuk~j}8LNi%y9*%A z`Tbop7J8t%R*>)L(@7dKEY^gpeSRF*-r^dgLD75JEQ5wPkAK3GVn0wC!PcI~$SzbW?F!$xc&f@khGY^tfV5sX;}+LUu75@f)tTfLoyvIBr2pg+iKMU7 z7*(7|aGBDiryaT3!8=q1ft^ik3#q_&1-x=61Lskb}g_K>- zkBwZSi@ska?!AO=OMJ~-6d@ErefpfO(@M|=}bq^mK01YBNiR9?=0qfWR3 z4nN@QMkd~LXQ}k~bb*DqLrhD9a*ed6ikY}m46uj}VrH33&6wjbR%)7jYKe35t#NqI z{rkO)izci4>u|omn~%NzHt^7>u(PZLWS15cU&IJKugu=eLAjp`_^8!)Vo1pdW1~|9In6X{KjwAjvZ)l0_RfO?fG(-lwEG!Tv2s^V zkx-F6nsWcJXX92{mdLHqD{V0p;Hr=s5djkxqj>*KUu-Ky^a_AsbEyJCBUu1xfUmVr zB``6@o;_0#`+%!@)ef6g#Eggr){=k;B{U6RW{c3{VZey&BV62&qo#Pcxb7L^1)Jyy z>QNjK!FOvN>c&mX*Tvcoi?>5d9Z}G{4$cP{YbBPl0pByb|7A8zw_Itg%(;L(tqdee zW0&)q0r*)lk?=Wr^Wl4L1w(%W03q!l4t%ErUn{EGqgIiQ7QT#O;-)6{R4WD+_&Z}j z%V)=O%Q@iIHg#ULYgLmaZx0)!4@fdv)GMa%ut}=RwN0Y_9Rr^blgIQYVj=ERejJ=t9RLlkt}-&uxW`?$~Z2jgHH$zfaGXPlypWMn+QdKa8|& zIeh))sseb!mM&k~x>2~|EO1Y$uuj3Z;W}Sxf$eCLVkl(SuWn(J!OB_HS+?Z|ZMtV$ zm(*1*OUisC4x=Qt#@mljx|oLk5;yS-wO(j%*II{6Z@>3|CmiU5v4r=$7n!(O45d{j zLs1iNS6W^aei^70*HBXmig~sd& ziIE!}Yz_zU5;B?Fk~=XVR-A+&A|3PCi~>+AhKzDOOu+y2=`<_yY>7aNFpJp0K=2^#U4cY0Eti%Uya-_j#`X;h~ zYs8RG9=W%C+#cBD17r^=2SO17Iob-l-e6N!JCbXpB05bWH`~9>xDv3DtrAC_W~Uxo zof1Ob@p_M^)pO@w5_jq4q(OaV%E2q2^16_{nKkq(Qr9a>u6y|_3g&iT?y~=Sdl+id zaN}ehp&?0x3}BiUR!~7BajizhQa+lGWHc7@^&iaynx?z{{0N7w(x4Y~j`Qi1oSpwK~p?sC!L60X<)xsR`V9qR! zUpb@PJkLZR$EZOBy-fy@NDaYmRG7SD4T`ZXO4ao8C+CsbO`cWZcv%1>4JdF=#Z-9x z;!1aoM^q;N5lAkP91eWZh$0Ov1^L7HAHN3e4&~Sit?JezCX)>=Fm-|6NX8Ct-l@svta&b6@9!N&#Wt;#@f7?p5E*xjZum9ha3ba@XSs-~b z=N-o-a0-A`FJ9Q`P!AM{kID&a@rqq*t4-!n`E^ABJ_fAq&|<_in6R!4SXl-PTQ)^` zm_!fS9GBkZUj5}a3YPu>s+^NaqDp$|7)jnr5$8wv3euJRqO!gpZitE4iA7>-lUivk zrZV49Pi!sn%Wb1&=x*Kmfc3_y+IqGBt!~d}&EF0nQ{n>f@%08@Wk>f?k@-w!-DP#&Aw|!#b&#T{dCaoNi2v5d+GYeviy1#Ho@sy2&?A5=U`72l zXTRy$R3@Mq1Fw~bketsDAI!!mHjOB7A#wCO!^Gm|SF5T&YYK_0fQ{VEH)gt;;f@Aq zwEh_`ob}i+bsHl2GieEaZnRyG!c9JIw1Knm@F_2ENs~oIS6DKIoOkitp7tZ}oht$x znKZ2Twm8~gLB;FldBw{qSoN? zGGdJ@05&^_<*j6<=DfVf6Q&!LHJROvavkgV4II|(&SssM(B9c8H|cr6?Fs7m?S%{o zZk~k$`Ckd0?uRb&P3nFeC*Znihg4Q}1Rq<9YQ?>2iD|81rxJcIPM+;%JUmT!M3Th~ z?rdRK>QJGqLcufyCi7y>_t6T#LrDlW6OHDKr6~2*n}9nk9bpWjaoW#aR30rOsGRG{ zkpc=&+lK@I>hV;cAj5m3^kg zTWK3VS}AlzEIwvSxy?v1396CKPiL2|McOQ0>Pt3c==$KQWeX&F|KmE1x6GyaM0}Rx z5O)I=4{;y5AE1#6q?7lN3-xkm3t?KA+X_od>R#15MRAb#0Uf6EWOhw``UfR}m87Zr zvYjl7Wp&u0xh-tAa&a%XcviJr08og#9Cy5?Ri{vsFnGX)VmX_Tq(?!U5cbxbk<75 zabXhItNk&2gIlJzpnk#SgvPOruZwAD)V66k6r*?rS^Z=u`hBoLnn3sSy3v{Bs`AWG z>N?G#0LWBY7XmdVDKN9opwfnsu(dMLll_wvAvut-g_$ zZYAXLL(OHTa^M$_@GcP-a3v($vP)S-dANcM=IW>#UZ?mGy*4*M2lU(oRmX8>rz*!jFaGR4ZhNq}?Tc-~qbTHj&I64w_ z(=Fw0p3O69Yda@G_Wh1?NkQ2m)6O8>fu$2~o8ph1R_wQI)$iBPTzSyky5;Blt!Yv6 zt$jhjwNO;ARyo=-=>YQc7WsPXfM<6swq5DN3F-h>g7vuB^1T6$!ONlTJX)3RIyHrQ zDJqPdTg$hG!xe{}rsWbX;~=*4xuM?tcD(qXdpr36+*r)G$8@P1u)+;;@%r6$xy|Y#%cD zx;dVFs(G6atyN#QN)6w1AN>D4KdX*T)$xP}0JM$)0GNNOKU^H`LC!9wZg#H!nVvO2 z5k7HWYHobnW8iw`yIY$DbxajmhdWgVXNT~c@HJXzK85d^K#9O*)+$Yneq6QWQ*ozx zP^bf$A)1RTXlq9ovnVVXFZtLiIJ~?(IYW`;Q~$~&Gc@M%Q>bTLq?F# zeS3LgL(O_CqE0lr)|Wefz)xvUlFK@)+%s3`dE^;rX3Ww$#2H!nc9iLvOBwiv+JVB{_zZH|nHUbLZOn_0+7?Ii%-v}d3`+M^!hXMeBPi^QzmT;|!) zS>~Q7rjt(@(;Bkyg^^b1xahC`YO0DnCPU34qi=`&3L&Uw?dM?Qyy8_f4MWwwjuqu{ zJ??S>i5vJ~`|}wv9tz(@V66@S`Uq+S{*?sUv0d^+zX2AnDaE`hc(TRj$Ylfud*Bu= z1c$;yn#?mpKbfDMG~xOzmQ^^*vZeelpsI`zr zHaQb7^~g%R$V?P9a_(bD?HVP(ljc{fpmM2av0y|UAkPHm4f7(p+;>*U%aq&L`qbSr z=HW>k)h*yZI=>N7X2Q=sX%3~2@a^uSmp3-;OIEY8ricMMbw1N45#AgF z8rB*SgI?haVh+S5a$rIWU`Tf!BfKU^O+qP`qK1+y9|`q(8;PLW(Mu$Uqb#nq;^L8s zECi&2xhjlkuxEPmz;i9MA=vff_4$erxPvYSPJk$7wbTQ`ULdXpIyQWErhRA)%z}0! zlpb+M-QsxGIQ0=I_yq50;}sHkDo}0|@-NgJxqykCXNO;jTXuGZw#13c_HZ&ypx?hY z>%C%jIjKb2#C-0B=GNhReI~@OG2k&G@E7@J{n#2ZH8_b3YVi)!N|C;Scqhu#sk$FU zFrr;+f1m${;b141CZB{w^bgVOdd4iZmhK(h+1RL|$1heJHG(!@kQt{?@Y`Ht4`z%BAO|}d?s8EAxyPsBE%AcIUj7K1ZNe~1xoepvmHHCtL{$a(2_S7kK z9>5M=QVnO%Dg1&1U7EfG|0@d#y+B}{n|8gXL3I8@GsQMRRQcLh-1d{jIahgh6;QOelfo&|jr)oQ;of6E1TF-j3d}Rw*R=vU;68s~cYx4ZLQ&+aU6B33IUh$=$N0X@x z^7=(fs;3(b0XzzcYp0YfOs2MM>HL~$2^&l=eNKp}CS?BXBv_-+(ZUubYhQ797egxce^tY}0tf=FX`VzqIn3Y)~^T#BUw z2a+YuBeN5#BMtG!M;GNd*}j9rmm!>TW7*v1tW5dO7lZx z|4xDs6HUq@i2OU_mgkJ-b-e{<(si!(d3g|?ffSae-2TvT)n^$B>+#mpLsQMCI?ckI zQqAZSo-)F%S@2CAsZA1L;J{;i$YPMW-x|-)j2+-1i-SZGkBJs#kK5<1QoUzCw?_jn%#_QIX z>a}XKcxErf;J7deqLk0(P{FSPFj5M$d94%PJ~qrwCigC_x-8!eHQp(jd`ssZo~XPC zN`E-@CdW1#X3&$~Z831V=BRK5*O!!381B0J*R?g;z168YBlXEelIcY%1JO_%6f$_E13$8>l_YTgMR^Ii`{ zlSJO7RGRgO!W>pt%!_n%YYbFnxx<0J^Ls=KFf-gno|)6#r{Jce!_vZd4X<#o^YE3P zcsnj0;cqy5e5c8q8QUv^pTj(Y;3?=v-?q{>ft`KrzlpF6JePd=MjB{qlH?Ox2xKsR z?ku~@wxa7li|Qcfl&K1-8;nZ0F9#Q%6fh<<5hpES%d+WEJW>05(1dGv=~Md(yhr>K z#%!v{115JmBIY|QkaLLI9sKZosJ-*IP*d$HQzq%mOZ5O*DO10%d5E-^#5|}tl93!k zp&-rhw;mse6h}gnR{;13xaw1RI08gc`?AuJC6t)u4RSrQjh=5hs3=w*62~_|r}W9x zSlM=RR9-YjJ;#bkN&0T=MC#-6>Vv#=8%u6lgV|*G(xmCLtnkdk^n{D4XHY+OHjb?v zPqtt^VQ8|Qk`zAq)Nv9pw0cjt{VHUCN1e5d0Hx5Nr+2#)uEpQkLGKO)KZk#x>Q5A77o(|&vTO%uo^+?mxru} zFF5Auu2WYRDi_>67E62NN~#W86qo-X0}q)w89^R$9?RcQE#Ep||L~LOs?qm3JY;Vh zSh^DN_8IYKKQLD0x`PGNTMZ~5Bi*epBHz0-R*o=kfG9i}7>yLT9SJ$jPYB=NVEDzfNJh_+T&4Jto{Bk_FHn6!lrS4`F_>Ry1ov=+NzMD3t;i(U zYA>Tq$!rNt?EWduYqDnLV9?K8BDyar-r@@*BPYL6Hr^uF=2kcJDUDTCZM?PJ{XK<* zo2ln3AN!9yM;(;!;>&h*tnNNUK;{%{UB+e#sTy6^_qv08O2PruC}^iho<~El-fOP0 z6RO>3PC?hjNCx)<89Nmgsrh)$JE3UqIs_p_u;C{1stCsNpDJtwd3N$p!{0d*pf)WG z>PATjNn*$%60P*25w;jpq!GtsF2rFduJqL8Le;W&bD}0GaaM4lyHeQaMtRu1lNmB! zohhXk`E-r=g{{j0CnT-K)+H>a=Y&1mq5a*FD`GE9lPPDPBUZldJ2>`=PzaP NtJg|=IVt3pK$fE}dO1Za3&+auD%c3l%$F5T6e0OG|HCAFo0C7^bm6-@1PG zu`IP&HhFepRoMc3nfGL+WRkKccoYY;+oV2!Mt3#tj4)FNRI?P`^TqdZE@_fh;$e zXUxK+vnek=Llvw-2Eh1Mpu>I6jSZU~*7Cz(zeL<{88ZBt7T$?bs?(~nN>8H$&Es2X zt>vro^dl~z(D9OcM|%*@^#D3prbwreDcLU(DVq<=LNdaslUk74V?*1E-%lUu z*`^7niJO-b_*YZT=IHkN1H119uhk~~s=w|W>$db?+8@sL?)RhS-!V%F?Oo39gpVSY zN&DNCXr)Uaq16|57SGh!V_5Lzz*4GUs`m+|&`cU##WNza8t^aR-k|x9MTl7zl}7{) zI*1r!rImnCjW6JJr-{ZZVi!1APofWj=44IshW0RR&Xn#u^JwiSKWmty?(82En&N^* zJ_9mkju@MSbrS@sH}AUL$mW%^c5T|vN$v>KFUL_AeOw)o>ci6lR|x)aJe0cFZPanz zNv9?vkbhw9nyI0iFgXwsk9QM}CG#*A zQa1QV;deIr<7;xYZ15VAt}o7%@=TPUC0FDT&=b;Wqaqke4ain_AK^;#h)aBN%{6&1 zjXWEap~xagT+U1hR5;)vH7e=}PxO6{&ki?XJw6CKixpQuLQM3j*Z$AXIU1{aT}=9k zX9qWw>GXL|=()2*%@4@XO3=qOIGD1&9C|}Wxp?Hu#OVjM(NY#pmA2IJNi=Vy=?qqA zk)){y#cpP1qE@woALmj z!Bq6sVi57?nq(f`Hdeha{Lb!JrMcpHL<7+s_=ta&AP-@e1uZyhm4>l$h}-!G%LbMm zF@vSaTf9BT6nH(OsoZ}F8IgIRP~9e1>q45@Jx-QzE`BeBGx61ybh||>420atgK~MWgGeN_-()w- zqhlf8?UR&@kdJgRZJkA|NHtlK224*ggtd@#?z-N4)bA?iwOqk^()ryoR+goC#Qngt zQ*!E`1*oAsDi3YrFJJ&;yME#T<&O1}ba?hBlINaJk}HeKVN(Ygx7QP&7Hg#BPN%WO zyJOEBE65@tIqh%xeK$L+Cu42Vf!@ z_HN6Bkz#}A;?MYFLGKrxflyt%gE_IkC$`yeErBT(gF6exo@EM^3obVDSI$n?CpB4F zL!{dm&P4k<%`IN9X4cCcr5>lP$m7F^9NLI|w6?x>B`Tb!J-^kystNO~bArn5!jj|H zGFoPjq^1;3U4}_OYyHcEiPOIy9!5ZX9pv{lpGL`Yw};qaz*rl}fTkn=DgdyzrMsp0 zxhe$3s4mrFJi{4(8u8FG+{6+da&M9D)AY6m-+(V>!l~b4B!~uP zhCaWz{I>a45bsOUWvk-vZ*I)-@tQvdhMDVt&CM=HTFX6c!PzOkd-!4t_2h#0-au|Y zH;=LzgW|0K?KE?ka)+6ma+3wtH)Utls2dw87LQR_n#a?<5U-doqO%UL>` zqv{u)iHSJvuc?%r`goyT2iUk^#4J|DD~U~0v}(6>1V84iUskU=#9jI=zNh5dQ};qt z+H7+<>5kPTChtl_pzA{mNXt&D$)-LUh+nr5~5|GqG`LlxdORX22GRC$xP|B%w>jqlI8`iyI5H zN)jov7PtdCSbGqDc_}nDl);^i_fq%Nt(cw~-3LY#r*@T6_5@jCk z)|o4o6md@py)9l|Q5mFyhMkGpSDWu^)9Pfu|2F*Y2QZXhziAKU*|qACE&I(hnMyBb z?pZ3(ib^wAmDT#gexyAlDzSPhY6OM7uKSKVU63j7#w-jwsH_D=E!tZO@M zC4cX4Ck+`~*OnOQHS^Uh^7XWh2|Um1T0Gh|^-ldZ`bH#VHOb?)Lk?lYgXgIk?OzrT zvC42Dccg5mr3$;UoX8-E<_(R^HL%1|c=FyNcZIT+!!041xahg(e8fY;<}|0QtETfaRB&!3?cIvj1{w=9@w+J$k#s%5VApg&4lbZqP+2%c;Yai>wdjtgGov$03KexJf@K98>R6{zB@Jf~4u#jnTZdw25!B@LZK1YEl& z233Q*_}86k2?XPrgPNWKM#tAN7(~5C=dOpekn=|0XMS+m5a)oR{2%>;-cgNmZ8P zzS$2)$?G?Ul$E0ZW}fY+w#-pMd{9 zQLfwQX>A4t0DOf50RAvA0oD%YAWv3Ld%N8f1;-#!oY1@1sG;L<(;6KGfP_~m0|vF3 zt+qL|q#SIF1QW0`xU|;LZMn70ay;8-qN(Mvh{a=r6L?-wjVen+7zB|&sx0M!H&DZf z-RvXTZEYD9;ll zNUslBx4m$P*q!HO6H^?%rWI+r4Gw3W>1NykUj#s73@X_T^h(94mXjoayn%WvIW;S| z>auO(UtV+dC|6a0ga1bp{GUDe&uuO+`bYF1+rgCd literal 25067 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

>1dVusJJReUj(5>hK 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 "CompuConst": + + v = et_element.findtext("V") + vt = et_element.findtext("VT") + + return CompuConst(v=v, vt=vt, data_type=data_type) + + def __post_init__(self) -> None: + self._value: Optional[AtomicOdxType] = self.vt + if self.v is not None: + self._value = self.data_type.from_string(self.v) + + @property + def value(self) -> Optional[AtomicOdxType]: + return self._value diff --git a/odxtools/compumethods/compudefaultvalue.py b/odxtools/compumethods/compudefaultvalue.py new file mode 100644 index 00000000..7567fd0f --- /dev/null +++ b/odxtools/compumethods/compudefaultvalue.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: MIT +from dataclasses import dataclass +from typing import List, Optional +from xml.etree import ElementTree + +from ..odxlink import OdxDocFragment +from .compuinversevalue import CompuInverseValue + + +@dataclass +class CompuDefaultValue: + v: Optional[str] + vt: Optional[str] + + compu_inverse_value: Optional[CompuInverseValue] + + @staticmethod + def from_et(et_element: ElementTree.Element, + doc_frags: List[OdxDocFragment]) -> "CompuDefaultValue": + v = et_element.findtext("V") + vt = et_element.findtext("VT") + + compu_inverse_value = None + if (civ_elem := et_element.find("COMPU-INVERSE-VALUE")) is not None: + compu_inverse_value = CompuInverseValue.from_et(civ_elem, doc_frags) + + return CompuDefaultValue(v=v, vt=vt, compu_inverse_value=compu_inverse_value) diff --git a/odxtools/compumethods/compuinternaltophys.py b/odxtools/compumethods/compuinternaltophys.py new file mode 100644 index 00000000..29137c69 --- /dev/null +++ b/odxtools/compumethods/compuinternaltophys.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: MIT +from dataclasses import dataclass +from typing import List, Optional +from xml.etree import ElementTree + +from ..odxlink import OdxDocFragment +from ..odxtypes import DataType +from ..progcode import ProgCode +from .compudefaultvalue import CompuDefaultValue +from .compuscale import CompuScale + + +@dataclass +class CompuInternalToPhys: + compu_scales: List[CompuScale] + prog_code: Optional[ProgCode] + compu_default_value: Optional[CompuDefaultValue] + + @staticmethod + def compu_internal_to_phys_from_et(et_element: ElementTree.Element, + doc_frags: List[OdxDocFragment], *, internal_type: DataType, + physical_type: DataType) -> "CompuInternalToPhys": + compu_scales = [ + CompuScale.compuscale_from_et( + cse, doc_frags, internal_type=internal_type, physical_type=physical_type) + for cse in et_element.iterfind("COMPU-SCALES/COMPU-SCALE") + ] + + prog_code = None + if (pce := et_element.find("PROG-CODE")) is not None: + prog_code = ProgCode.from_et(pce, doc_frags) + + compu_default_value = None + if (cdve := et_element.find("COMPU-DEFAULT-VALUE")) is not None: + compu_default_value = CompuDefaultValue.from_et(cdve, doc_frags) + + return CompuInternalToPhys( + compu_scales=compu_scales, prog_code=prog_code, compu_default_value=compu_default_value) diff --git a/odxtools/compumethods/compuinversevalue.py b/odxtools/compumethods/compuinversevalue.py new file mode 100644 index 00000000..daa666a1 --- /dev/null +++ b/odxtools/compumethods/compuinversevalue.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MIT +from dataclasses import dataclass +from typing import List, Optional +from xml.etree import ElementTree + +from ..odxlink import OdxDocFragment + + +@dataclass +class CompuInverseValue: + v: Optional[str] + vt: Optional[str] + + @staticmethod + def from_et(et_element: ElementTree.Element, + doc_frags: List[OdxDocFragment]) -> "CompuInverseValue": + v = et_element.findtext("V") + vt = et_element.findtext("VT") + + return CompuInverseValue(v=v, vt=vt) diff --git a/odxtools/compumethods/compumethod.py b/odxtools/compumethods/compumethod.py index e8b42f97..88f50f92 100644 --- a/odxtools/compumethods/compumethod.py +++ b/odxtools/compumethods/compumethod.py @@ -1,26 +1,81 @@ # SPDX-License-Identifier: MIT from dataclasses import dataclass -from typing import Literal +from enum import Enum +from typing import List, Optional +from xml.etree import ElementTree +from ..exceptions import odxraise +from ..odxlink import OdxDocFragment from ..odxtypes import AtomicOdxType, DataType +from .compuinternaltophys import CompuInternalToPhys +from .compuphystointernal import CompuPhysToInternal -CompuMethodCategory = Literal[ - "IDENTICAL", - "LINEAR", - "SCALE-LINEAR", - "TAB-INTP", - "TEXTTABLE", -] + +class CompuCategory(Enum): + IDENTICAL = "IDENTICAL" + LINEAR = "LINEAR" + SCALE_LINEAR = "SCALE-LINEAR" + TEXTTABLE = "TEXTTABLE" + COMPUCODE = "COMPUCODE" + TAB_INTP = "TAB-INTP" + RAT_FUNC = "RAT-FUNC" + SCALE_RAT_FUNC = "SCALE-RAT-FUNC" @dataclass class CompuMethod: - internal_type: DataType + """A compu method translates between the internal representation + of a value and their physical representation. + + There are many compu methods, but all of them are specified using + the same mechanism: The conversion from internal to physical + quantities is specified using the COMPU-INTERNAL-TO-PHYS subtag, + and the inverse is covered by + COMPU-PHYS-TO-INTERNAL. Alternatively to directly specifying the + parameters needed for conversion, it is also possible to specify a + Java program which does the conversion (doing this excludes using + ODX in non-Java contexts, though). + + For details, refer to ASAM specification MCD-2 D (ODX), section 7.3.6.6. + + """ + + category: CompuCategory + compu_internal_to_phys: Optional[CompuInternalToPhys] + compu_phys_to_internal: Optional[CompuPhysToInternal] + physical_type: DataType + internal_type: DataType - @property - def category(self) -> CompuMethodCategory: - raise NotImplementedError() + @staticmethod + def compu_method_from_et(et_element: ElementTree.Element, doc_frags: List[OdxDocFragment], *, + internal_type: DataType, physical_type: DataType) -> "CompuMethod": + cat_text = et_element.findtext("CATEGORY") + if cat_text is None: + odxraise("No category specified for compu method") + cat_text = "IDENTICAL" + + try: + category = CompuCategory(cat_text) + except ValueError: + odxraise(f"Encountered compu method of unknown category '{cat_text}'") + category = CompuCategory.IDENTICAL + + compu_internal_to_phys = None + if (citp_elem := et_element.find("COMPU-INTERNAL-TO-PHYS")) is not None: + compu_internal_to_phys = CompuInternalToPhys.compu_internal_to_phys_from_et( + citp_elem, doc_frags, internal_type=internal_type, physical_type=physical_type) + compu_phys_to_internal = None + if (cpti_elem := et_element.find("COMPU-PHYS-TO-INTERNAL")) is not None: + compu_phys_to_internal = CompuPhysToInternal.compu_phys_to_internal_from_et( + cpti_elem, doc_frags, internal_type=internal_type, physical_type=physical_type) + + return CompuMethod( + category=category, + compu_internal_to_phys=compu_internal_to_phys, + compu_phys_to_internal=compu_phys_to_internal, + physical_type=physical_type, + internal_type=internal_type) def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> AtomicOdxType: raise NotImplementedError() diff --git a/odxtools/compumethods/compuphystointernal.py b/odxtools/compumethods/compuphystointernal.py new file mode 100644 index 00000000..f59034c6 --- /dev/null +++ b/odxtools/compumethods/compuphystointernal.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: MIT +from dataclasses import dataclass +from typing import List, Optional +from xml.etree import ElementTree + +from ..odxlink import OdxDocFragment +from ..odxtypes import DataType +from ..progcode import ProgCode +from .compudefaultvalue import CompuDefaultValue +from .compuscale import CompuScale + + +@dataclass +class CompuPhysToInternal: + compu_scales: List[CompuScale] + prog_code: Optional[ProgCode] + compu_default_value: Optional[CompuDefaultValue] + + @staticmethod + def compu_phys_to_internal_from_et(et_element: ElementTree.Element, + doc_frags: List[OdxDocFragment], *, internal_type: DataType, + physical_type: DataType) -> "CompuPhysToInternal": + compu_scales = [ + CompuScale.compuscale_from_et( + cse, doc_frags, internal_type=internal_type, physical_type=physical_type) + for cse in et_element.iterfind("COMPU-SCALES/COMPU-SCALE") + ] + + prog_code = None + if (pce := et_element.find("PROG-CODE")) is not None: + prog_code = ProgCode.from_et(pce, doc_frags) + + compu_default_value = None + if (cdve := et_element.find("COMPU-DEFAULT-VALUE")) is not None: + compu_default_value = CompuDefaultValue.from_et(cdve, doc_frags) + + return CompuPhysToInternal( + compu_scales=compu_scales, prog_code=prog_code, compu_default_value=compu_default_value) diff --git a/odxtools/compumethods/compuscale.py b/odxtools/compumethods/compuscale.py index def5c5be..2dbea2d3 100644 --- a/odxtools/compumethods/compuscale.py +++ b/odxtools/compumethods/compuscale.py @@ -6,6 +6,7 @@ from ..odxlink import OdxDocFragment from ..odxtypes import AtomicOdxType, DataType from ..utils import create_description_from_et +from .compuconst import CompuConst from .compurationalcoeffs import CompuRationalCoeffs from .limit import Limit @@ -13,33 +14,14 @@ @dataclass class CompuScale: """A COMPU-SCALE represents one value range of a COMPU-METHOD. - - Example: - - For a TEXTTABLE compu method a compu scale within COMPU-INTERNAL-TO-PHYS - can be defined with - ``` - scale = CompuScale( - short_label="example_label", # optional: provide a label - description="

fancy description

", # optional: provide a description - lower_limit=Limit(0), # required: lower limit - upper_limit=Limit(3), # required: upper limit - compu_inverse_value=2, # required if lower_limit != upper_limit - compu_const="true", # required: physical value to be shown to the user - ) - ``` - - Almost all attributes are optional but there are compu-method-specific restrictions. - E.g., lower_limit must always be defined unless the COMPU-METHOD is of CATEGORY LINEAR or RAT-FUNC. - Either `compu_const` or `compu_rational_coeffs` must be defined but never both. """ short_label: Optional[str] description: Optional[str] lower_limit: Optional[Limit] upper_limit: Optional[Limit] - compu_inverse_value: Optional[AtomicOdxType] - compu_const: Optional[AtomicOdxType] + compu_inverse_value: Optional[CompuConst] + compu_const: Optional[CompuConst] compu_rational_coeffs: Optional[CompuRationalCoeffs] # the following two attributes are not specified for COMPU-SCALE @@ -59,8 +41,13 @@ def compuscale_from_et(et_element: ElementTree.Element, doc_frags: List[OdxDocFr upper_limit = Limit.limit_from_et( et_element.find("UPPER-LIMIT"), doc_frags, value_type=internal_type) - compu_inverse_value = internal_type.create_from_et(et_element.find("COMPU-INVERSE-VALUE")) - compu_const = physical_type.create_from_et(et_element.find("COMPU-CONST")) + compu_inverse_value = None + if (cive := et_element.find("COMPU-INVERSE-VALUE")) is not None: + compu_inverse_value = CompuConst.compuconst_from_et(cive, data_type=internal_type) + + compu_const = None + if (cce := et_element.find("COMPU-CONST")) is not None: + compu_const = CompuConst.compuconst_from_et(cce, data_type=physical_type) compu_rational_coeffs: Optional[CompuRationalCoeffs] = None if (crc_elem := et_element.find("COMPU-RATIONAL-COEFFS")) is not None: diff --git a/odxtools/compumethods/createanycompumethod.py b/odxtools/compumethods/createanycompumethod.py index 0f19522c..9226e213 100644 --- a/odxtools/compumethods/createanycompumethod.py +++ b/odxtools/compumethods/createanycompumethod.py @@ -1,184 +1,38 @@ # SPDX-License-Identifier: MIT -from typing import Any, Dict, List, Optional +from typing import List from xml.etree import ElementTree -from ..exceptions import odxassert, odxraise, odxrequire +from ..exceptions import odxraise, odxrequire from ..odxlink import OdxDocFragment from ..odxtypes import DataType from .compumethod import CompuMethod -from .compuscale import CompuScale from .identicalcompumethod import IdenticalCompuMethod -from .limit import Limit from .linearcompumethod import LinearCompuMethod from .scalelinearcompumethod import ScaleLinearCompuMethod from .tabintpcompumethod import TabIntpCompuMethod from .texttablecompumethod import TexttableCompuMethod -def _parse_compu_scale_to_linear_compu_method( - et_element: ElementTree.Element, - doc_frags: List[OdxDocFragment], - *, - internal_type: DataType, - physical_type: DataType, - is_scale_linear: bool = False, - **kwargs: Any, -) -> LinearCompuMethod: - odxassert(physical_type in [ - DataType.A_FLOAT32, - DataType.A_FLOAT64, - DataType.A_INT32, - DataType.A_UINT32, - ]) - odxassert(internal_type in [ - DataType.A_FLOAT32, - DataType.A_FLOAT64, - DataType.A_INT32, - DataType.A_UINT32, - ]) - - if physical_type.python_type == float: - computation_python_type = physical_type.from_string - else: - computation_python_type = internal_type.from_string - - kwargs = kwargs.copy() - kwargs["internal_type"] = internal_type - kwargs["physical_type"] = physical_type - - coeffs = odxrequire(et_element.find("COMPU-RATIONAL-COEFFS")) - nums = coeffs.iterfind("COMPU-NUMERATOR/V") - - offset = computation_python_type(odxrequire(next(nums).text)) - factor_el = next(nums, None) - factor = computation_python_type(odxrequire(factor_el.text) if factor_el is not None else "0") - denominator = 1.0 - if (string := coeffs.findtext("COMPU-DENOMINATOR/V")) is not None: - denominator = float(string) - if denominator == 0: - odxraise("CompuMethod: A denominator of zero will lead to divisions by zero.") - - # Read lower limit - internal_lower_limit = Limit.limit_from_et( - et_element.find("LOWER-LIMIT"), - doc_frags, - value_type=internal_type, - ) - - kwargs["internal_lower_limit"] = internal_lower_limit - - # Read upper limit - internal_upper_limit = Limit.limit_from_et( - et_element.find("UPPER-LIMIT"), - doc_frags, - value_type=internal_type, - ) - - kwargs["internal_upper_limit"] = internal_upper_limit - kwargs["denominator"] = denominator - kwargs["factor"] = factor - kwargs["offset"] = offset - - return LinearCompuMethod(**kwargs) - - -def create_compu_default_value(et_element: Optional[ElementTree.Element], - doc_frags: List[OdxDocFragment], internal_type: DataType, *, - physical_type: DataType) -> Optional[CompuScale]: - if et_element is None: - return None - compu_const = physical_type.create_from_et(et_element) - scale = CompuScale.compuscale_from_et( - et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) - scale.compu_const = compu_const - return scale - - def create_any_compu_method_from_et(et_element: ElementTree.Element, doc_frags: List[OdxDocFragment], *, internal_type: DataType, physical_type: DataType) -> CompuMethod: - compu_category = et_element.findtext("CATEGORY") - odxassert(compu_category in [ - "IDENTICAL", - "LINEAR", - "SCALE-LINEAR", - "TEXTTABLE", - "COMPUCODE", - "TAB-INTP", - "RAT-FUNC", - "SCALE-RAT-FUNC", - ]) - - if et_element.find("COMPU-PHYS-TO-INTERNAL") is not None: # TODO: Is this never used? - raise NotImplementedError(f"Found COMPU-PHYS-TO-INTERNAL for category {compu_category}") - - kwargs: Dict[str, Any] = { - "physical_type": physical_type, - "internal_type": internal_type, - } + compu_category = odxrequire(et_element.findtext("CATEGORY")) if compu_category == "IDENTICAL": - odxassert( - internal_type == physical_type or - (internal_type in [DataType.A_ASCIISTRING, DataType.A_UTF8STRING] and - physical_type == DataType.A_UNICODE2STRING), - f"Internal type '{internal_type}' and physical type '{physical_type}'" - f" must be the same for compu methods of category '{compu_category}'") - return IdenticalCompuMethod(internal_type=internal_type, physical_type=physical_type) - - if compu_category == "TEXTTABLE": - odxassert(physical_type == DataType.A_UNICODE2STRING) - compu_internal_to_phys = odxrequire(et_element.find("COMPU-INTERNAL-TO-PHYS")) - - internal_to_phys: List[CompuScale] = [] - for scale_elem in compu_internal_to_phys.iterfind("COMPU-SCALES/COMPU-SCALE"): - internal_to_phys.append( - CompuScale.compuscale_from_et( - scale_elem, doc_frags, internal_type=internal_type, - physical_type=physical_type)) - compu_default_value = create_compu_default_value( - et_element.find("COMPU-INTERNAL-TO-PHYS/COMPU-DEFAULT-VALUE"), doc_frags, **kwargs) - - return TexttableCompuMethod( - internal_to_phys=internal_to_phys, - compu_default_value=compu_default_value, - **kwargs, - ) - + return IdenticalCompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) elif compu_category == "LINEAR": - # Compu method can be described by the function f(x) = (offset + factor * x) / denominator - - scale_elem = odxrequire(et_element.find("COMPU-INTERNAL-TO-PHYS/COMPU-SCALES/COMPU-SCALE")) - return _parse_compu_scale_to_linear_compu_method(scale_elem, doc_frags, **kwargs) - + return LinearCompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) elif compu_category == "SCALE-LINEAR": - - scale_elems = et_element.iterfind("COMPU-INTERNAL-TO-PHYS/COMPU-SCALES/COMPU-SCALE") - linear_methods = [ - _parse_compu_scale_to_linear_compu_method(scale_elem, doc_frags, **kwargs) - for scale_elem in scale_elems - ] - return ScaleLinearCompuMethod(linear_methods=linear_methods, **kwargs) - + return ScaleLinearCompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) + elif compu_category == "TEXTTABLE": + return TexttableCompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) elif compu_category == "TAB-INTP": - internal_points = [] - physical_points = [] - for scale_elem in et_element.iterfind("COMPU-INTERNAL-TO-PHYS/COMPU-SCALES/COMPU-SCALE"): - internal_point = internal_type.from_string( - odxrequire(scale_elem.findtext("LOWER-LIMIT"))) - physical_point = physical_type.create_from_et( - odxrequire(scale_elem.find("COMPU-CONST"))) - - if not isinstance(internal_point, (float, int)): - odxraise() - if not isinstance(physical_point, (float, int)): - odxraise() - - internal_points.append(internal_point) - physical_points.append(physical_point) - - return TabIntpCompuMethod( - internal_points=internal_points, physical_points=physical_points, **kwargs) + return TabIntpCompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) # TODO: Implement all categories (never instantiate the CompuMethod base class!) odxraise(f"Warning: Computation category {compu_category} is not implemented!") diff --git a/odxtools/compumethods/identicalcompumethod.py b/odxtools/compumethods/identicalcompumethod.py index 08eb86b7..d266909b 100644 --- a/odxtools/compumethods/identicalcompumethod.py +++ b/odxtools/compumethods/identicalcompumethod.py @@ -1,16 +1,41 @@ # SPDX-License-Identifier: MIT from dataclasses import dataclass +from typing import List +from xml.etree import ElementTree -from ..odxtypes import AtomicOdxType -from .compumethod import CompuMethod, CompuMethodCategory +from ..exceptions import odxassert +from ..odxlink import OdxDocFragment +from ..odxtypes import AtomicOdxType, DataType +from ..utils import dataclass_fields_asdict +from .compumethod import CompuMethod @dataclass class IdenticalCompuMethod(CompuMethod): - - @property - def category(self) -> CompuMethodCategory: - return "IDENTICAL" + """Identical compu methods just pass through the internal value. + + For details, refer to ASAM specification MCD-2 D (ODX), section 7.3.6.6.2. + """ + + @staticmethod + def compu_method_from_et(et_element: ElementTree.Element, doc_frags: List[OdxDocFragment], *, + internal_type: DataType, + physical_type: DataType) -> "IdenticalCompuMethod": + cm = CompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) + kwargs = dataclass_fields_asdict(cm) + + odxassert( + internal_type == physical_type or + (internal_type + in [DataType.A_ASCIISTRING, DataType.A_UTF8STRING, DataType.A_UNICODE2STRING] and + physical_type + in [DataType.A_ASCIISTRING, DataType.A_UTF8STRING, DataType.A_UNICODE2STRING]), + f"Internal type and physical type must be the same for compu methods of category " + f"'{cm.category}' (internal type: '{internal_type.value}', physical type: " + f"'{physical_type.value}')") + + return IdenticalCompuMethod(**kwargs) def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> AtomicOdxType: return physical_value diff --git a/odxtools/compumethods/linearcompumethod.py b/odxtools/compumethods/linearcompumethod.py index 129b95d0..7046f44d 100644 --- a/odxtools/compumethods/linearcompumethod.py +++ b/odxtools/compumethods/linearcompumethod.py @@ -1,212 +1,88 @@ # SPDX-License-Identifier: MIT from dataclasses import dataclass -from typing import Optional +from typing import List, cast +from xml.etree import ElementTree -from ..exceptions import DecodeError, EncodeError, odxassert, odxraise +from ..exceptions import odxassert, odxraise +from ..odxlink import OdxDocFragment from ..odxtypes import AtomicOdxType, DataType -from .compumethod import CompuMethod, CompuMethodCategory -from .limit import Limit +from ..utils import dataclass_fields_asdict +from .compumethod import CompuCategory, CompuMethod +from .linearsegment import LinearSegment @dataclass class LinearCompuMethod(CompuMethod): - """Represents the decoding function d(y) = (offset + factor * y) / denominator - where d(y) is the physical value and y is the internal value. - - Examples - -------- - - Define the decoding function `d(y) = 4+2*y` (or equivalent encoding `e(x) = floor((x-4)/2)`) - on all integers `y` in the range -10..10 (and `x` in -16..25). - - ```python - method = LinearCompuMethod( - offset=4, - factor=2, - internal_type=DataType.A_INT32, - physical_type=DataType.A_INT32, - internal_lower_limit = Limit(-10, IntervalType.CLOSED), - internal_upper_limit = Limit(11, IntervalType.OPEN) - ) - ``` - - Decode an internal value: - - ```python - >>> method.convert_internal_to_physical(6) # == 4+2*6 - 16 - ``` - - Encode a physical value: - - ```python - >>> method.convert_physical_to_internal(6) # == 6/2-2 - 1 - ``` - - Get physical limits: - - ```python - >>> method.physical_lower_limit - Limit(value=-16, interval_type=IntervalType.CLOSED) - >>> method.physical_upper_limit - Limit(value=26, interval_type=IntervalType.OPEN) - ``` - - (Note that there may be additional restrictions to valid physical values by the surrounding data object prop. - For example, limits given by the bit length are not considered in the compu method.) - """ - - offset: float - factor: float - denominator: float - internal_lower_limit: Optional[Limit] - internal_upper_limit: Optional[Limit] + """A compu method which does linear interpoation - def __post_init__(self) -> None: - odxassert(self.denominator > 0) + i.e. internal values are converted to physical ones using the + function `f(x) = (offset + factor * x)/denominator` where `f(x)` + is the physical value and `x` is the internal value. In contrast + to `ScaleLinearCompuMethod`, this compu method only exhibits a + single segment) - self.__compute_physical_limits() + For details, refer to ASAM specification MCD-2 D (ODX), section 7.3.6.6.3. + """ @property - def category(self) -> CompuMethodCategory: - return "LINEAR" + def segment(self) -> LinearSegment: + return self._segment - @property - def physical_lower_limit(self) -> Optional[Limit]: - return self._physical_lower_limit + @staticmethod + def compu_method_from_et(et_element: ElementTree.Element, doc_frags: List[OdxDocFragment], *, + internal_type: DataType, + physical_type: DataType) -> "LinearCompuMethod": + cm = CompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) + kwargs = dataclass_fields_asdict(cm) - @property - def physical_upper_limit(self) -> Optional[Limit]: - return self._physical_upper_limit - - def __compute_physical_limits(self) -> None: - """Computes the physical limits and stores them in the properties - self._physical_lower_limit and self._physical_upper_limit. - This method is only called during the initialization of a LinearCompuMethod. - """ - - def convert_internal_to_physical_limit(internal_limit: Optional[Limit], - is_upper_limit: bool) -> Optional[Limit]: - """Helper method - - Parameters: - - internal_limit - the internal limit to be converted to a physical limit - is_upper_limit - True iff limit is the internal upper limit - """ - if internal_limit is None or internal_limit.value_raw is None: - return None - - internal_value = self.internal_type.from_string(internal_limit.value_raw) - physical_value = self._convert_internal_to_physical(internal_value) - - result = Limit( - value_raw=str(physical_value), - value_type=self.physical_type, - interval_type=internal_limit.interval_type) - - return result - - self._physical_lower_limit = None - self._physical_upper_limit = None - - if self.factor >= 0: - self._physical_lower_limit = convert_internal_to_physical_limit( - self.internal_lower_limit, False) - self._physical_upper_limit = convert_internal_to_physical_limit( - self.internal_upper_limit, True) - else: - # If the factor is negative, the lower and upper limit are swapped - self._physical_lower_limit = convert_internal_to_physical_limit( - self.internal_upper_limit, True) - self._physical_upper_limit = convert_internal_to_physical_limit( - self.internal_lower_limit, False) - - def _convert_internal_to_physical(self, internal_value: AtomicOdxType) -> AtomicOdxType: - if not isinstance(internal_value, (int, float)): - raise DecodeError(f"The type of internal values of linear compumethods must " - f"either int or float (is: {type(internal_value).__name__})") - - if self.denominator is None: - result = self.offset + self.factor * internal_value - else: - result = (self.offset + self.factor * internal_value) / self.denominator - - if self.physical_type in [ - DataType.A_INT32, - DataType.A_UINT32, - ]: - result = round(result) - - return self.physical_type.make_from(result) + return LinearCompuMethod(**kwargs) + + def __post_init__(self) -> None: + odxassert(self.category == CompuCategory.LINEAR, + "LinearCompuMethod must exhibit LINEAR category") + + odxassert(self.physical_type in [ + DataType.A_FLOAT32, + DataType.A_FLOAT64, + DataType.A_INT32, + DataType.A_UINT32, + ]) + odxassert(self.internal_type in [ + DataType.A_FLOAT32, + DataType.A_FLOAT64, + DataType.A_INT32, + DataType.A_UINT32, + ]) + + if self.compu_internal_to_phys is None: + odxraise("LINEAR compu methods require COMPU-INTERNAL-TO-PHYS") + return + + compu_scales = self.compu_internal_to_phys.compu_scales + + if len(compu_scales) == 0: + odxraise("LINEAR compu methods expect at least one compu scale within " + "COMPU-INTERNAL-TO-PHYS") + return cast(None, LinearCompuMethod) + elif len(compu_scales) > 1: + odxraise("LINEAR compu methods expect at most one compu scale within " + "COMPU-INTERNAL-TO-PHYS") + + scale = compu_scales[0] + self._segment = LinearSegment.from_compu_scale( + scale, internal_type=self.internal_type, physical_type=self.physical_type) def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> AtomicOdxType: - odxassert(self.is_valid_internal_value(internal_value)) - return self._convert_internal_to_physical(internal_value) + odxassert(self._segment.internal_applies(internal_value)) + return self._segment.convert_internal_to_physical(internal_value) def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> AtomicOdxType: - if not isinstance(physical_value, (int, float)): - odxraise( - "The type of physical values of linear compumethods must " - "either int or float", EncodeError) - return 0 - - odxassert( - self.is_valid_physical_value(physical_value), - f"physical value {physical_value} of type {type(physical_value)} " - f"is not valid. Expected type {self.physical_type} with internal " - f"range {self.internal_lower_limit} to {self.internal_upper_limit}") - if self.denominator is None: - result = (physical_value - self.offset) / self.factor - else: - result = ((physical_value * self.denominator) - self.offset) / self.factor - - if self.internal_type in [ - DataType.A_INT32, - DataType.A_UINT32, - ]: - result = round(result) - return self.internal_type.make_from(result) + odxassert(self._segment.physical_applies(physical_value)) + return self._segment.convert_physical_to_internal(physical_value) def is_valid_physical_value(self, physical_value: AtomicOdxType) -> bool: - # Do type checks - expected_type = self.physical_type.python_type - if issubclass(expected_type, float): - if not isinstance(physical_value, (int, float)): - return False - else: - if not isinstance(physical_value, expected_type): - return False - - # Check the limits - if self.physical_lower_limit is not None and not self.physical_lower_limit.complies_to_lower( - physical_value): - return False - if self.physical_upper_limit is not None and not self.physical_upper_limit.complies_to_upper( - physical_value): - return False - - return True + return self._segment.physical_applies(physical_value) def is_valid_internal_value(self, internal_value: AtomicOdxType) -> bool: - # Do type checks - expected_type = self.internal_type.python_type - if issubclass(expected_type, float): - if not isinstance(internal_value, (int, float)): - return False - else: - if not isinstance(internal_value, expected_type): - return False - - # Check the limits - if self.internal_lower_limit is not None and not self.internal_lower_limit.complies_to_lower( - internal_value): - return False - if self.internal_upper_limit is not None and not self.internal_upper_limit.complies_to_upper( - internal_value): - return False - - return True + return self._segment.internal_applies(internal_value) diff --git a/odxtools/compumethods/linearsegment.py b/odxtools/compumethods/linearsegment.py new file mode 100644 index 00000000..85749774 --- /dev/null +++ b/odxtools/compumethods/linearsegment.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: MIT +from dataclasses import dataclass +from typing import Optional, Union + +from ..exceptions import odxraise, odxrequire +from ..odxtypes import AtomicOdxType, DataType +from .compuscale import CompuScale +from .limit import Limit + + +@dataclass +class LinearSegment: + """Helper class to represent a segment of a piecewise-linear interpolation. + + Multiple compu methods (LINEAR, SCALE-LINEAR, TAB-INTP) require + linear interpolation. This class centralizes the required + parameters for a single such segment. (The required parameters are + extracted from the respective compu method's + COMPU-INTERNAL-TO-PHYS objects. We do it this way because the + internal-to-phys objects are rather clunky to work with and + feature a lot of irrelevant information.) + + """ + offset: float + factor: float + denominator: float + internal_lower_limit: Optional[Limit] + internal_upper_limit: Optional[Limit] + + internal_type: DataType + physical_type: DataType + + @staticmethod + def from_compu_scale(scale: CompuScale, *, internal_type: DataType, + physical_type: DataType) -> "LinearSegment": + coeffs = odxrequire(scale.compu_rational_coeffs) + + offset = coeffs.numerators[0] + factor = coeffs.numerators[1] + + denominator = 1.0 + if len(coeffs.denominators) > 0: + denominator = coeffs.denominators[0] + + internal_lower_limit = scale.lower_limit + internal_upper_limit = scale.upper_limit + + return LinearSegment( + offset=offset, + factor=factor, + denominator=denominator, + internal_lower_limit=internal_lower_limit, + internal_upper_limit=internal_upper_limit, + internal_type=internal_type, + physical_type=physical_type) + + @property + def physical_lower_limit(self) -> Optional[Limit]: + return self._physical_lower_limit + + @property + def physical_upper_limit(self) -> Optional[Limit]: + return self._physical_upper_limit + + def __post_init__(self) -> None: + self.__compute_physical_limits__() + + def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> Union[float, int]: + if not isinstance(internal_value, (int, float)): + odxraise(f"Internal values of linear compumethods must " + f"either be int or float (is: {type(internal_value).__name__})") + + result = (self.offset + self.factor * internal_value) / self.denominator + + if self.physical_type in [ + DataType.A_INT32, + DataType.A_UINT32, + ]: + result = round(result) + + return result + + def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> Union[float, int]: + if not isinstance(physical_value, (int, float)): + odxraise(f"Physical values of linear compumethods must " + f"either be int or float (is: {type(physical_value).__name__})") + + result = (physical_value * self.denominator - self.offset) / self.factor + + if self.internal_type in [ + DataType.A_INT32, + DataType.A_UINT32, + ]: + result = round(result) + + return result + + def __compute_physical_limits__(self) -> None: + """Computes the physical limits and stores them in the properties + self._physical_lower_limit and self._physical_upper_limit. + This method is called by `__post_init__()`. + """ + + def convert_internal_to_physical_limit(internal_limit: Optional[Limit], + is_upper_limit: bool) -> Optional[Limit]: + """Helper method to convert a single internal limit + """ + if internal_limit is None or internal_limit.value_raw is None: + return None + + internal_value = self.internal_type.from_string(internal_limit.value_raw) + physical_value = self.convert_internal_to_physical(internal_value) + + result = Limit( + value_raw=str(physical_value), + value_type=self.physical_type, + interval_type=internal_limit.interval_type) + + return result + + self._physical_lower_limit = None + self._physical_upper_limit = None + + if self.factor >= 0: + self._physical_lower_limit = convert_internal_to_physical_limit( + self.internal_lower_limit, False) + self._physical_upper_limit = convert_internal_to_physical_limit( + self.internal_upper_limit, True) + else: + # If the scaling factor is negative, the lower and upper + # limit are swapped + self._physical_lower_limit = convert_internal_to_physical_limit( + self.internal_upper_limit, True) + self._physical_upper_limit = convert_internal_to_physical_limit( + self.internal_lower_limit, False) + + def physical_applies(self, physical_value: AtomicOdxType) -> bool: + """Returns True iff the segment is applicable to a given physical value""" + # Do type checks + expected_type = self.physical_type.python_type + if issubclass(expected_type, float): + if not isinstance(physical_value, (int, float)): + return False + else: + if not isinstance(physical_value, expected_type): + return False + + if self._physical_lower_limit is not None and \ + not self._physical_lower_limit.complies_to_lower(physical_value): + return False + + if self._physical_upper_limit is not None and \ + not self._physical_upper_limit.complies_to_upper(physical_value): + return False + + return True + + def internal_applies(self, internal_value: AtomicOdxType) -> bool: + """Returns True iff the segment is applicable to a given internal value""" + # Do type checks + expected_type = self.internal_type.python_type + if issubclass(expected_type, float): + if not isinstance(internal_value, (int, float)): + return False + else: + if not isinstance(internal_value, expected_type): + return False + + if self.internal_lower_limit is not None and \ + not self.internal_lower_limit.complies_to_lower(internal_value): + return False + + if self.internal_upper_limit is not None and \ + not self.internal_upper_limit.complies_to_upper(internal_value): + return False + + return True diff --git a/odxtools/compumethods/scalelinearcompumethod.py b/odxtools/compumethods/scalelinearcompumethod.py index ba726e70..8116fadf 100644 --- a/odxtools/compumethods/scalelinearcompumethod.py +++ b/odxtools/compumethods/scalelinearcompumethod.py @@ -1,39 +1,95 @@ # SPDX-License-Identifier: MIT from dataclasses import dataclass -from typing import List +from typing import List, Union, cast +from xml.etree import ElementTree -from ..exceptions import odxassert -from ..odxtypes import AtomicOdxType -from .compumethod import CompuMethod, CompuMethodCategory -from .linearcompumethod import LinearCompuMethod +from ..exceptions import odxassert, odxraise +from ..odxlink import OdxDocFragment +from ..odxtypes import AtomicOdxType, DataType +from ..utils import dataclass_fields_asdict +from .compumethod import CompuCategory, CompuMethod +from .linearsegment import LinearSegment @dataclass class ScaleLinearCompuMethod(CompuMethod): - linear_methods: List[LinearCompuMethod] + """A piecewise linear compu method which may feature discontinuities. + + For details, refer to ASAM specification MCD-2 D (ODX), section 7.3.6.6.4. + """ @property - def category(self) -> CompuMethodCategory: - return "SCALE-LINEAR" - - def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> AtomicOdxType: - odxassert( - self.is_valid_physical_value(physical_value), - f"cannot convert the invalid physical value {physical_value!r} " - f"of type {type(physical_value)}") - lin_method = next( - scale for scale in self.linear_methods if scale.is_valid_physical_value(physical_value)) - return lin_method.convert_physical_to_internal(physical_value) - - def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> AtomicOdxType: - lin_method = next( - scale for scale in self.linear_methods if scale.is_valid_internal_value(internal_value)) - return lin_method.convert_internal_to_physical(internal_value) + def segments(self) -> List[LinearSegment]: + return self._segments + + @staticmethod + def compu_method_from_et(et_element: ElementTree.Element, doc_frags: List[OdxDocFragment], *, + internal_type: DataType, + physical_type: DataType) -> "ScaleLinearCompuMethod": + cm = CompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) + kwargs = dataclass_fields_asdict(cm) + + return ScaleLinearCompuMethod(**kwargs) + + def __post_init__(self) -> None: + self._segments: List[LinearSegment] = [] + + odxassert(self.category == CompuCategory.SCALE_LINEAR, + "ScaleLinearCompuMethod must exibit SCALE-LINEAR category") + + odxassert(self.physical_type in [ + DataType.A_FLOAT32, + DataType.A_FLOAT64, + DataType.A_INT32, + DataType.A_UINT32, + ]) + odxassert(self.internal_type in [ + DataType.A_FLOAT32, + DataType.A_FLOAT64, + DataType.A_INT32, + DataType.A_UINT32, + ]) + + if self.compu_internal_to_phys is None: + odxraise("SCALE-LINEAR compu methods require COMPU-INTERNAL-TO-PHYS") + return + + compu_scales = self.compu_internal_to_phys.compu_scales + + for scale in compu_scales: + self._segments.append( + LinearSegment.from_compu_scale( + scale, internal_type=self.internal_type, physical_type=self.physical_type)) + + def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> Union[float, int]: + applicable_segments = [ + seg for seg in self._segments if seg.physical_applies(physical_value) + ] + if not applicable_segments: + odxraise(r"No applicable segment for value {physical_value} found") + return cast(int, None) + elif len(applicable_segments): + odxraise(r"Multiple applicable segments for value {physical_value} found") + + seg = applicable_segments[0] + return seg.convert_physical_to_internal(physical_value) + + def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> Union[float, int]: + applicable_segments = [ + seg for seg in self._segments if seg.internal_applies(internal_value) + ] + if not applicable_segments: + odxraise(r"No applicable segment for value {internal_value} found") + return cast(int, None) + elif len(applicable_segments): + odxraise(r"Multiple applicable segments for value {internal_value} found") + + seg = applicable_segments[0] + return seg.convert_internal_to_physical(internal_value) def is_valid_physical_value(self, physical_value: AtomicOdxType) -> bool: - return any( - True for scale in self.linear_methods if scale.is_valid_physical_value(physical_value)) + return any(True for seg in self._segments if seg.physical_applies(physical_value)) def is_valid_internal_value(self, internal_value: AtomicOdxType) -> bool: - return any( - True for scale in self.linear_methods if scale.is_valid_internal_value(internal_value)) + return any(True for seg in self._segments if seg.internal_applies(internal_value)) diff --git a/odxtools/compumethods/tabintpcompumethod.py b/odxtools/compumethods/tabintpcompumethod.py index 50dfe969..2864611b 100644 --- a/odxtools/compumethods/tabintpcompumethod.py +++ b/odxtools/compumethods/tabintpcompumethod.py @@ -1,107 +1,113 @@ # SPDX-License-Identifier: MIT from dataclasses import dataclass -from typing import List, Tuple, Union +from typing import List, Union +from xml.etree import ElementTree -from ..exceptions import DecodeError, EncodeError, odxassert, odxraise +from ..exceptions import DecodeError, EncodeError, odxassert, odxraise, odxrequire +from ..odxlink import OdxDocFragment from ..odxtypes import AtomicOdxType, DataType -from .compumethod import CompuMethod, CompuMethodCategory +from ..utils import dataclass_fields_asdict +from .compumethod import CompuCategory, CompuMethod from .limit import IntervalType, Limit @dataclass class TabIntpCompuMethod(CompuMethod): - """ - A compu method of type Tab Interpolated is used for linear interpolation. - - A `TabIntpCompuMethod` is defined by a set of points. Each point is an (internal, physical) value pair. - When converting from internal to physical or vice-versa, the result is linearly interpolated. - - The function defined by a `TabIntpCompuMethod` is similar to the one of a `ScaleLinearCompuMethod` with the following differences: - - * `TabIntpCompuMethod`s are always continuous whereas `ScaleLinearCompuMethod` might have jumps - * `TabIntpCompuMethod`s are always invertible: Even if the linear interpolation is not monotonic, the first matching interval is taken. - - Refer to ASAM MCD-2 D (ODX) Specification, section 7.3.6.6.8 for details. - - Examples - -------- - - Create a TabIntpCompuMethod defined by the points (0, -1), (10, 1), (30, 2):: - - method = TabIntpCompuMethod( - internal_type=DataType.A_UINT32, - physical_type=DataType.A_UINT32, - internal_points=[0, 10, 30], - physical_points=[-1, 1, 2] - ) - - Note that the points are given as two lists. The equivalent odx definition is:: - - - TAB-INTP - - - - 0 - - -1 - - - - 10 - - 1 - - - - 30 - - 2 - - - - - + """A compu method of type Tab Interpolated is used for linear interpolation. + + A `TabIntpCompuMethod` is defined by a set of points. Each point + is an (internal, physical) value pair. When converting from + internal to physical or vice-versa, the result is linearly + interpolated. + + The function defined by a `TabIntpCompuMethod` is similar to the + one of a `ScaleLinearCompuMethod` with the following differences: + + * `TabIntpCompuMethod`s are always continuous whereas + `ScaleLinearCompuMethod` might exhibit gaps + * `TabIntpCompuMethod`s are always invertible: Even if the linear + interpolation is not monotonic, the first matching interval is + used. + + For details, refer to ASAM specification MCD-2 D (ODX), section 7.3.6.6.8. """ - internal_points: List[Union[float, int]] - physical_points: List[Union[float, int]] + @property + def internal_points(self) -> List[Union[float, int]]: + return self._internal_points + + @property + def physical_points(self) -> List[Union[float, int]]: + return self._physical_points + + @property + def internal_lower_limit(self) -> Limit: + return self._internal_lower_limit + + @property + def internal_upper_limit(self) -> Limit: + return self._internal_upper_limit + + @property + def physical_lower_limit(self) -> Limit: + return self._physical_lower_limit + + @property + def physical_upper_limit(self) -> Limit: + return self._physical_upper_limit + + @staticmethod + def compu_method_from_et(et_element: ElementTree.Element, doc_frags: List[OdxDocFragment], *, + internal_type: DataType, + physical_type: DataType) -> "TabIntpCompuMethod": + cm = CompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) + kwargs = dataclass_fields_asdict(cm) + + return TabIntpCompuMethod(**kwargs) def __post_init__(self) -> None: + odxassert(self.category == CompuCategory.TAB_INTP, + "TabIntpCompuMethod must exibit TAB-INTP category") + + self._internal_points: List[Union[int, float]] = [] + self._physical_points: List[Union[int, float]] = [] + for scale in odxrequire(self.compu_internal_to_phys).compu_scales: + internal_point = odxrequire(scale.lower_limit).value + physical_point = odxrequire(scale.compu_const).value + + if not isinstance(internal_point, (float, int)): + odxraise("The type of values of tab-intp compumethods must " + "either int or float") + if not isinstance(physical_point, (float, int)): + odxraise("The type of values of tab-intp compumethods must " + "either int or float") + + self._internal_points.append(internal_point) + self._physical_points.append(physical_point) + self._physical_lower_limit = Limit( - value_raw=str(min(self.physical_points)), + value_raw=str(min(self._physical_points)), value_type=self.physical_type, interval_type=IntervalType.CLOSED) self._physical_upper_limit = Limit( - value_raw=str(max(self.physical_points)), + value_raw=str(max(self._physical_points)), value_type=self.physical_type, interval_type=IntervalType.CLOSED) self._internal_lower_limit = Limit( - value_raw=str(min(self.internal_points)), + value_raw=str(min(self._internal_points)), value_type=self.internal_type, interval_type=IntervalType.CLOSED) self._internal_upper_limit = Limit( - value_raw=str(max(self.internal_points)), + value_raw=str(max(self._internal_points)), value_type=self.internal_type, interval_type=IntervalType.CLOSED) - self._assert_validity() - - @property - def category(self) -> CompuMethodCategory: - return "TAB-INTP" + self.__assert_validity__() - @property - def physical_lower_limit(self) -> Limit: - return self._physical_lower_limit - - @property - def physical_upper_limit(self) -> Limit: - return self._physical_upper_limit - - def _assert_validity(self) -> None: + def __assert_validity__(self) -> None: odxassert(len(self.internal_points) == len(self.physical_points)) odxassert( @@ -110,7 +116,7 @@ def _assert_validity(self) -> None: DataType.A_UINT32, DataType.A_FLOAT32, DataType.A_FLOAT64, - ], "Internal data type of tab-intp compumethod must be one of" + ], "Internal data type of TAB-INTP compumethod must be one of" " [A_INT32, A_UINT32, A_FLOAT32, A_FLOAT64]") odxassert( self.physical_type in [ @@ -118,51 +124,64 @@ def _assert_validity(self) -> None: DataType.A_UINT32, DataType.A_FLOAT32, DataType.A_FLOAT64, - ], "Physical data type of tab-intp compumethod must be one of" + ], "Physical data type of TAB-INTP compumethod must be one of" " [A_INT32, A_UINT32, A_FLOAT32, A_FLOAT64]") - def _piecewise_linear_interpolate(self, x: Union[int, float], - points: List[Tuple[Union[int, float], - Union[int, float]]]) -> Union[float, None]: - for ((x0, y0), (x1, y1)) in zip(points[:-1], points[1:]): - if x0 <= x and x <= x1: + def __piecewise_linear_interpolate__(self, x: Union[int, float], + range_samples: List[Union[int, float]], + domain_samples: List[Union[int, + float]]) -> Union[float, None]: + for i in range(0, len(range_samples) - 1): + if (x0 := range_samples[i]) <= x and x <= (x1 := range_samples[i + 1]): + y0 = domain_samples[i] + y1 = domain_samples[i + 1] return y0 + (x - x0) * (y1 - y0) / (x1 - x0) return None def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> AtomicOdxType: if not isinstance(physical_value, (int, float)): - raise EncodeError("The type of values of tab-intp compumethods must " - "either int or float") + odxraise("The type of values of tab-intp compumethods must " + "either int or float", EncodeError) + return None - reference_points = list(zip(self.physical_points, self.internal_points)) odxassert( isinstance(physical_value, (int, float)), "Only integers and floats can be piecewise linearly interpolated") - result = self._piecewise_linear_interpolate(physical_value, reference_points) + result = self.__piecewise_linear_interpolate__(physical_value, self._physical_points, + self._internal_points) if result is None: - raise EncodeError(f"Internal value {physical_value!r} must be inside the range" - f" [{min(self.physical_points)}, {max(self.physical_points)}]") + odxraise( + f"Internal value {physical_value!r} must be inside the range" + f" [{min(self.physical_points)}, {max(self.physical_points)}]", EncodeError) + res = self.internal_type.make_from(result) - if not isinstance(res, (int, float)): - odxraise() + return res def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> AtomicOdxType: if not isinstance(internal_value, (int, float)): - raise EncodeError("The internal type of values of tab-intp compumethods must " - "either int or float") + odxraise( + "The internal type of values of tab-intp compumethods must " + "either int or float", EncodeError) + return None - reference_points = list(zip(self.internal_points, self.physical_points)) - result = self._piecewise_linear_interpolate(internal_value, reference_points) + odxassert( + isinstance(internal_value, (int, float)), + "Only integers and floats can be piecewise linearly interpolated") + + result = self.__piecewise_linear_interpolate__(internal_value, self._internal_points, + self._physical_points) if result is None: - raise DecodeError(f"Internal value {internal_value!r} must be inside the range" - f" [{min(self.internal_points)}, {max(self.internal_points)}]") + odxraise( + f"Internal value {internal_value!r} must be inside the range" + f" [{min(self.internal_points)}, {max(self.internal_points)}]", DecodeError) + return None + res = self.physical_type.make_from(result) - if not isinstance(res, (int, float)): - odxraise() + return res def is_valid_physical_value(self, physical_value: AtomicOdxType) -> bool: diff --git a/odxtools/compumethods/texttablecompumethod.py b/odxtools/compumethods/texttablecompumethod.py index 781b8298..35cb4132 100644 --- a/odxtools/compumethods/texttablecompumethod.py +++ b/odxtools/compumethods/texttablecompumethod.py @@ -1,76 +1,140 @@ # SPDX-License-Identifier: MIT from dataclasses import dataclass -from typing import List, Optional, cast +from typing import List, cast +from xml.etree import ElementTree -from ..exceptions import DecodeError, EncodeError, odxassert, odxraise +from ..exceptions import DecodeError, EncodeError, odxassert, odxraise, odxrequire +from ..odxlink import OdxDocFragment from ..odxtypes import AtomicOdxType, DataType -from .compumethod import CompuMethod, CompuMethodCategory +from ..utils import dataclass_fields_asdict +from .compumethod import CompuCategory, CompuMethod from .compuscale import CompuScale @dataclass class TexttableCompuMethod(CompuMethod): + """Text table compute methods translate numbers to human readable + textual descriptions. - internal_to_phys: List[CompuScale] - # For compu_default_value, the compu_const is always defined - # the compu_inverse_value is optional - compu_default_value: Optional[CompuScale] + For details, refer to ASAM specification MCD-2 D (ODX), section 7.3.6.6.7. + + """ + + @staticmethod + def compu_method_from_et(et_element: ElementTree.Element, doc_frags: List[OdxDocFragment], *, + internal_type: DataType, + physical_type: DataType) -> "TexttableCompuMethod": + cm = CompuMethod.compu_method_from_et( + et_element, doc_frags, internal_type=internal_type, physical_type=physical_type) + kwargs = dataclass_fields_asdict(cm) + + return TexttableCompuMethod(**kwargs) def __post_init__(self) -> None: - odxassert(self.physical_type == DataType.A_UNICODE2STRING, - "TEXTTABLE must have A_UNICODE2STRING as its physical datatype.") + odxassert(self.category == CompuCategory.TEXTTABLE, + "TexttableCompuMethod must exhibit TEXTTABLE category") + + # the spec says that the physical data type shall be + # A_UNICODE2STRING, but we are a bit more lenient and allow + # any kind of string... + odxassert( + self.physical_type + in [DataType.A_UNICODE2STRING, DataType.A_UTF8STRING, DataType.A_ASCIISTRING], + "TEXTTABLE must have string type as its physical datatype.") + + if self.compu_internal_to_phys is None: + odxraise("TEXTTABLE compu methods must exhibit a COMPU-INTERNAL-TO-PHYS subtag.") + scales = [] + else: + scales = self.compu_internal_to_phys.compu_scales + odxassert( - all(scale.lower_limit is not None or scale.upper_limit is not None - for scale in self.internal_to_phys), - "Text table compu method doesn't have expected format!") + all(scale.lower_limit is not None or scale.upper_limit is not None for scale in scales), + "All scales of TEXTTABLE compu methods must provide limits!") + + self._compu_physical_default_value = None + citp = odxrequire(self.compu_internal_to_phys) + if (cdv := citp.compu_default_value) is not None: + self._compu_physical_default_value = self.physical_type.from_string(odxrequire(cdv.vt)) - @property - def category(self) -> CompuMethodCategory: - return "TEXTTABLE" + self._compu_internal_default_value = None + cpti = self.compu_phys_to_internal + if cpti is not None and (cdv := cpti.compu_default_value) is not None: + self._compu_internal_default_value = self.internal_type.from_string(odxrequire(cdv.v)) def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> AtomicOdxType: - matching_scales = [x for x in self.internal_to_phys if x.compu_const == physical_value] - for scale in matching_scales: - if scale.compu_inverse_value is not None: - return scale.compu_inverse_value - elif scale.lower_limit is not None and scale.lower_limit._value is not None: - return scale.lower_limit._value - elif scale.upper_limit is not None and scale.upper_limit._value is not None: - return scale.upper_limit._value - - if self.compu_default_value is not None and self.compu_default_value.compu_inverse_value is not None: - return self.compu_default_value.compu_inverse_value - - raise EncodeError(f"Texttable compu method could not encode '{physical_value!r}'.") - - def __is_internal_in_scale(self, internal_value: AtomicOdxType, scale: CompuScale) -> bool: - if scale == self.compu_default_value: - return True + scales = [] + if (citp := self.compu_internal_to_phys) is not None: + scales = citp.compu_scales + matching_scales = [ + x for x in scales if x.compu_const is not None and x.compu_const.value == physical_value + ] + + if len(matching_scales) == 0: + if self._compu_internal_default_value is None: + odxraise(f"Texttable could not encode {physical_value!r}.", EncodeError) + return cast(None, AtomicOdxType) + + return self._compu_internal_default_value + elif len(matching_scales) > 1: + odxraise(f"Texttable could not uniquely encode {physical_value!r}.", EncodeError) - return scale.applies(internal_value) + scale = matching_scales[0] + if scale.compu_inverse_value is not None and (civ := + scale.compu_inverse_value.value) is not None: + return civ + elif scale.lower_limit is not None and scale.lower_limit._value is not None: + return scale.lower_limit._value + elif scale.upper_limit is not None and scale.upper_limit._value is not None: + return scale.upper_limit._value + + odxraise(f"Texttable compu method could not encode '{physical_value!r}'.", EncodeError) def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> AtomicOdxType: - matching_scales = [x for x in self.internal_to_phys if x.applies(internal_value)] + scales = [] + if (citp := self.compu_internal_to_phys) is not None: + scales = citp.compu_scales + matching_scales: List[CompuScale] = [x for x in scales if x.applies(internal_value)] + if len(matching_scales) == 0: - if self.compu_default_value is None or self.compu_default_value.compu_const is None: + if self._compu_physical_default_value is None: odxraise(f"Texttable could not decode {internal_value!r}.", DecodeError) return cast(None, AtomicOdxType) - return self.compu_default_value.compu_const + return self._compu_physical_default_value - if len(matching_scales) != 1 or matching_scales[0].compu_const is None: - odxraise(f"Texttable could not decode {internal_value!r}.", DecodeError) + if len(matching_scales) > 1: + odxraise(f"Texttable could not uniquely decode {internal_value!r}.", DecodeError) - return matching_scales[0].compu_const + scale = matching_scales[0] + + if scale.compu_const is None: + odxraise(f"Encountered a COMPU-SCALE with no COMPU-CONST.") + return cast(None, AtomicOdxType) + + if scale.compu_const.value is not None: + return scale.compu_const.value + + odxraise(f"Texttable compu method could not decode '{internal_value!r}'.", EncodeError) def is_valid_physical_value(self, physical_value: AtomicOdxType) -> bool: - if self.compu_default_value is not None: + if self._compu_physical_default_value is not None: return True - return any(x.compu_const == physical_value for x in self.internal_to_phys) + scales = [] + if (cpti := self.compu_internal_to_phys) is not None: + scales = cpti.compu_scales + + return any(scale.compu_const.value == physical_value + for scale in scales + if scale.compu_const is not None) def is_valid_internal_value(self, internal_value: AtomicOdxType) -> bool: - if self.compu_default_value is not None: + if self._compu_internal_default_value is not None: return True - return any(scale.applies(internal_value) for scale in self.internal_to_phys) + scales = [] + if (citp := self.compu_internal_to_phys) is not None: + scales = citp.compu_scales + + return any(scale.applies(internal_value) for scale in scales) diff --git a/odxtools/dataobjectproperty.py b/odxtools/dataobjectproperty.py index 608f39e2..3aa3b23e 100644 --- a/odxtools/dataobjectproperty.py +++ b/odxtools/dataobjectproperty.py @@ -10,7 +10,7 @@ from .diagcodedtype import DiagCodedType from .dopbase import DopBase from .encodestate import EncodeState -from .exceptions import DecodeError, EncodeError, odxassert, odxrequire +from .exceptions import DecodeError, EncodeError, odxraise, odxrequire from .internalconstr import InternalConstr from .odxlink import OdxDocFragment, OdxLinkDatabase, OdxLinkId, OdxLinkRef from .odxtypes import AtomicOdxType, ParameterValue @@ -110,16 +110,6 @@ def unit(self) -> Optional[Unit]: def get_static_bit_length(self) -> Optional[int]: return self.diag_coded_type.get_static_bit_length() - def convert_physical_to_internal(self, physical_value: Any) -> Any: - """ - Convert a physical representation of a parameter to its internal counterpart - """ - odxassert( - self.physical_type.base_data_type.isinstance(physical_value), - f"Expected {self.physical_type.base_data_type.value}, got {type(physical_value)}") - - return self.compu_method.convert_physical_to_internal(physical_value) - def encode_into_pdu(self, physical_value: ParameterValue, encode_state: EncodeState) -> None: """ Convert a physical representation of a parameter to a string bytes that can be send over the wire @@ -129,7 +119,10 @@ def encode_into_pdu(self, physical_value: ParameterValue, encode_state: EncodeSt f"The value {repr(physical_value)} of type {type(physical_value).__name__}" f" is not a valid.") - internal_value = self.convert_physical_to_internal(physical_value) + if not isinstance(physical_value, (int, float, str, bytes, bytearray)): + odxraise(f"Invalid type '{type(physical_value).__name__}' for physical value. " + f"(Expect atomic type!)") + internal_value = self.compu_method.convert_physical_to_internal(physical_value) self.diag_coded_type.encode_into_pdu(internal_value, encode_state) def decode_from_pdu(self, decode_state: DecodeState) -> ParameterValue: diff --git a/odxtools/parameterinfo.py b/odxtools/parameterinfo.py index d22bc2be..10b80d9d 100644 --- a/odxtools/parameterinfo.py +++ b/odxtools/parameterinfo.py @@ -128,7 +128,7 @@ def parameter_info(param_list: Iterable[Parameter], quoted_names: bool = False) if isinstance(cm, TexttableCompuMethod): of.write(f": enum; choices:\n") - for scale in cm.internal_to_phys: + for scale in odxrequire(cm.compu_internal_to_phys).compu_scales: val_str = "" if scale.lower_limit is not None: val_str = f"({repr(scale.lower_limit.value)})" @@ -166,8 +166,8 @@ def parameter_info(param_list: Iterable[Parameter], quoted_names: bool = False) else: of.write(f": ") - ll = cm.physical_lower_limit - ul = cm.physical_upper_limit + ll = cm.segment.physical_lower_limit + ul = cm.segment.physical_upper_limit if ll is None or ll.interval_type == IntervalType.INFINITE: ll_str = "(-inf" else: diff --git a/odxtools/templates/macros/printCompuMethod.xml.jinja2 b/odxtools/templates/macros/printCompuMethod.xml.jinja2 new file mode 100644 index 00000000..4ab481bc --- /dev/null +++ b/odxtools/templates/macros/printCompuMethod.xml.jinja2 @@ -0,0 +1,156 @@ +{#- -*- mode: sgml; tab-width: 1; indent-tabs-mode: nil -*- + # + # SPDX-License-Identifier: MIT +-#} + +{%- import('macros/printElementId.xml.jinja2') as peid %} +{%- import('macros/printAdminData.xml.jinja2') as pad %} +{%- import('macros/printSpecialData.xml.jinja2') as psd %} + + +{%- macro printLimit(tag_name, limit_obj) -%} +{%- if limit_obj is not none %} +<{{tag_name}} +{%- if limit_obj.interval_type is not none %} + {{- make_xml_attrib("INTERVAL-TYPE", limit_obj.interval_type.value) }} + {%- endif %} +{%- if limit_obj.value_raw is none %} + {#- #}/> +{%- else %} + {#- #}> + {%- if hasattr(limit_obj._value, 'hex') -%} + {#- bytes or bytarray limit #} + {{- limit_obj._value.hex().upper() }} + {%- else -%} + {{- limit_obj._value }} + {%- endif -%} + +{%- endif -%} +{%- endif -%} +{%- endmacro -%} + +{%- macro printCompuInverseValue(civ) -%} + + {%- if civ.v is not none %} + {{civ.v}} + {%- endif %} + {%- if civ.vt is not none %} + {{civ.vt | e}} + {%- endif %} + +{%- endmacro -%} + +{%- macro printCompuDefaultValue(cdv) -%} + + {%- if cdv.v is not none %} + {{cdv.v}} + {%- endif %} + {%- if cdv.vt is not none %} + {{cdv.vt | e}} + {%- endif %} + {%- if cdv.compu_inverse_value is not none %} + {{printCompuInverseValue(cdv.compu_inverse_value)}} + {%- endif %} + +{%- endmacro -%} + +{%- macro printCompuScale(cs) -%} + + {%- if cs.short_label is not none %} + {{cs.short_label|e}} + {%- endif %} + {%- if cs.description and cs.description.strip() %} + + {{cs.description}} + + {%- endif %} + {{-printLimit("LOWER-LIMIT", cs.lower_limit)|indent(2, first=True) }} + {{-printLimit("UPPER-LIMIT", cs.upper_limit)|indent(2, first=True) }} + {%- if cs.compu_inverse_value is not none %} + {{printCompuInverseValue(cs.compu_inverse_value)}} + {%- endif %} + {%- if cs.compu_const is not none %} + + {%- if cs.compu_const.v is not none %} + {{cs.compu_const.v}} + {%- endif %} + {%- if cs.compu_const.vt is not none %} + {{cs.compu_const.vt | e}} + {%- endif %} + + {%- endif %} + {%- set crc = cs.compu_rational_coeffs %} + {%- if crc is not none %} + + + {%- for v in crc.numerators %} + {{v}} + {%- endfor %} + + {%- if crc.denominators %} + + {%- for v in crc.denominators %} + {{v}} + {%- endfor %} + + {%- endif %} + + {%- endif %} + +{%- endmacro -%} + +{%- macro printProgCode(pc) -%} + + {{pc.code_file}} + {{pc.syntax}} + {%- if pc.encryption is not none %} + {{pc.encryption}} + {%- endif %} + {%- if pc.entry_point is not none %} + {{pc.entry_point}} + {%- endif %} + {%- if pc.library_refs %} + + {%- for libref in pc.library_refs %} + + {%- endfor %} + + {%- endif %} + +{%- endmacro -%} + +{%- macro printCompuMethod(cm) -%} + + {{cm.category.value}} + {%- if cm.compu_internal_to_phys is not none %} + + + {%- for cs in cm.compu_internal_to_phys.compu_scales %} + {{ printCompuScale(cs) | indent(3) }} + {%- endfor %} + + {%- if cm.compu_internal_to_phys.prog_code is not none %} + {{ printProgCode(cm.compu_internal_to_phys.prog_code) | indent(3) }} + {%- endif %} + {%- if cm.compu_internal_to_phys.compu_default_value is not none %} + {{ printCompuDefaultValue(cm.compu_internal_to_phys.compu_default_value) | indent(3) }} + {%- endif %} + + {%- endif %} + {%- if cm.compu_phys_to_internal is not none %} + + + {%- for cs in cm.compu_phys_to_internal.compu_scales %} + {{ printCompuScale(cs) | indent(3) }} + {%- endfor %} + + {%- if cm.compu_phys_to_internal.prog_code is not none %} + {{ printProgCode(cm.compu_phys_to_internal.prog_code) | indent(3) }} + {%- endif %} + {%- if cm.compu_phys_to_internal.compu_default_value is not none %} + {{ printCompuDefaultValue(cm.compu_phys_to_internal.compu_default_value) | indent(3) }} + {%- endif %} + + {%- endif %} + +{%- endmacro -%} diff --git a/odxtools/templates/macros/printDOP.xml.jinja2 b/odxtools/templates/macros/printDOP.xml.jinja2 index a550f4a7..1df15c99 100644 --- a/odxtools/templates/macros/printDOP.xml.jinja2 +++ b/odxtools/templates/macros/printDOP.xml.jinja2 @@ -6,6 +6,8 @@ {%- import('macros/printElementId.xml.jinja2') as peid %} {%- import('macros/printAdminData.xml.jinja2') as pad %} {%- import('macros/printSpecialData.xml.jinja2') as psd %} +{%- import('macros/printCompuMethod.xml.jinja2') as pcm %} + {%- macro printDiagCodedType(dct) -%} {%- endmacro -%} - {%- macro printPhysicalType(physical_type) %} {%- if physical_type.display_radix is not none %} @@ -43,27 +44,6 @@ {%- endif %} {%- endmacro -%} -{%- macro printLimit(tag_name, limit_obj) -%} -{%- if limit_obj is not none %} -<{{tag_name}} -{%- if limit_obj.interval_type is not none %} - {{- make_xml_attrib("INTERVAL-TYPE", limit_obj.interval_type.value) }} - {%- endif %} -{%- if limit_obj.value_raw is none %} - {#- #}/> -{%- else %} - {#- #}> - {%- if hasattr(limit_obj._value, 'hex') -%} - {#- bytes or bytarray limit #} - {{- limit_obj._value.hex().upper() }} - {%- else -%} - {{- limit_obj._value }} - {%- endif -%} - -{%- endif -%} -{%- endif -%} -{%- endmacro -%} - {%- macro printScaleConstr(sc) %} {%- if sc.short_label is not none %} @@ -74,8 +54,8 @@ {{sc.description}} {%- endif %} - {{printLimit("LOWER-LIMIT", sc.lower_limit) }} - {{printLimit("UPPER-LIMIT", sc.upper_limit) }} + {{pcm.printLimit("LOWER-LIMIT", sc.lower_limit) }} + {{pcm.printLimit("UPPER-LIMIT", sc.upper_limit) }} {%- endmacro -%} @@ -85,8 +65,8 @@ {%- else %} {%- endif %} - {{printLimit("LOWER-LIMIT", ic.lower_limit) }} - {{printLimit("UPPER-LIMIT", ic.upper_limit) }} + {{pcm.printLimit("LOWER-LIMIT", ic.lower_limit) }} + {{pcm.printLimit("UPPER-LIMIT", ic.upper_limit) }} {%- if ic.scale_constrs %} {%- for sc in ic.scale_constrs %} @@ -101,104 +81,6 @@ {%- endif %} {%- endmacro -%} -{%- macro printCompuMethod(cm) -%} - - {{cm.category}} - {%- if cm.category == "TEXTTABLE" %} - - - {%- for cs in cm.internal_to_phys %} - - {%- if cs.short_label and cs.short_label.strip() %} - {{cs.short_label|e}} - {%- endif %} - {%- if cs.description and cs.description.strip() %} - - {{cs.description}} - - {%- endif %} - {{printLimit("LOWER-LIMIT", cs.lower_limit) }} - {{printLimit("UPPER-LIMIT", cs.upper_limit) }} - {%- if cs.compu_inverse_value is not none %} - - {{cs.compu_inverse_value}} - - {%- endif %} - - {{cs.compu_const | e}} - - - {%- endfor %} - - {%- if cm.compu_default_value is not none %} - - {{cm.compu_default_value.compu_const}} - {%- if cm.compu_default_value.compu_inverse_value is not none %} - - {{cm.compu_default_value.compu_inverse_value}} - - {%- endif %} - - {%- endif %} - - {%- elif cm.category == "LINEAR" %} - - - - {{printLimit("LOWER-LIMIT", cm.internal_lower_limit) }} - {{printLimit("UPPER-LIMIT", cm.internal_upper_limit) }} - - - {{cm.offset}} - {{cm.factor}} - - {%- if cm.denominator != 1 %} - - {{cm.denominator}} - - {%- endif %} - - - - - {%- elif cm.category == "SCALE-LINEAR" %} - - - {%- for lm in cm.linear_methods %} - - {{printLimit("LOWER-LIMIT", lm.internal_lower_limit) }} - {{printLimit("UPPER-LIMIT", lm.internal_upper_limit) }} - - - {{lm.offset}} - {{lm.factor}} - - {%- if lm.denominator != 1 %} - - {{lm.denominator}} - - {%- endif %} - - - {%- endfor %} - - - {%- elif cm.category == "TAB-INTP" %} - - - {%- for idx in range( cm.internal_points | length ) %} - - {{ cm.internal_points[idx] }} - - {{ cm.physical_points[idx] }} - - - {%- endfor %} - - - {%- endif %} - -{%- endmacro -%} {%- macro printDopBaseAttribs(dop) %} {{- make_xml_attrib("ID", dop.odx_id.local_id) }} @@ -225,7 +107,7 @@ {%- macro printDataObjectProp(dop) %} {{- printDopBaseSubtags(dop) }} - {{- printCompuMethod(dop.compu_method)|indent(1) }} + {{- pcm.printCompuMethod(dop.compu_method)|indent(1) }} {{- printDiagCodedType(dop.diag_coded_type)|indent(1) }} {{- printPhysicalType(dop.physical_type)|indent(1) }} {%- if dop.internal_constr %} @@ -246,7 +128,7 @@ {{- printDopBaseSubtags(dop)|indent(1) }} {{- printDiagCodedType(dop.diag_coded_type)|indent(1) }} {{- printPhysicalType(dop.physical_type)|indent(1) }} - {{- printCompuMethod(dop.compu_method)|indent(1) }} + {{- pcm.printCompuMethod(dop.compu_method)|indent(1) }} {%- for dtc in dop.dtcs_raw %} {%- if hasattr(dtc, "ref_id") %} diff --git a/odxtools/templates/macros/printMux.xml.jinja2 b/odxtools/templates/macros/printMux.xml.jinja2 index b0dec7f8..8519acd7 100644 --- a/odxtools/templates/macros/printMux.xml.jinja2 +++ b/odxtools/templates/macros/printMux.xml.jinja2 @@ -4,6 +4,7 @@ -#} {%- import('macros/printElementId.xml.jinja2') as peid %} +{%- import('macros/printCompuMethod.xml.jinja2') as pcm %} {%- import('macros/printDOP.xml.jinja2') as pdop %} {%- macro printMux(mux) %} @@ -41,8 +42,8 @@ {%- if case.structure_snref is not none %} {%- endif %} - {{ pdop.printLimit("LOWER-LIMIT", case.lower_limit) }} - {{ pdop.printLimit("UPPER-LIMIT", case.upper_limit) }} + {{ pcm.printLimit("LOWER-LIMIT", case.lower_limit) }} + {{ pcm.printLimit("UPPER-LIMIT", case.upper_limit) }} {%- endfor %} diff --git a/tests/test_compu_methods.py b/tests/test_compu_methods.py index e7632f0c..fe18dfb3 100644 --- a/tests/test_compu_methods.py +++ b/tests/test_compu_methods.py @@ -7,6 +7,11 @@ import jinja2 import odxtools +from odxtools.compumethods.compuconst import CompuConst +from odxtools.compumethods.compuinternaltophys import CompuInternalToPhys +from odxtools.compumethods.compumethod import CompuCategory +from odxtools.compumethods.compurationalcoeffs import CompuRationalCoeffs +from odxtools.compumethods.compuscale import CompuScale from odxtools.compumethods.createanycompumethod import create_any_compu_method_from_et from odxtools.compumethods.limit import IntervalType, Limit from odxtools.compumethods.linearcompumethod import LinearCompuMethod @@ -43,16 +48,31 @@ def _get_jinja_environment() -> jinja2.environment.Environment: self.jinja_env = _get_jinja_environment() self.linear_compumethod = LinearCompuMethod( - offset=0, - factor=1, - denominator=3600, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[0, 1], + denominators=[3600], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_INT32, physical_type=DataType.A_INT32, - internal_lower_limit=None, - internal_upper_limit=None, ) - self.linear_compumethod_odx = f""" + self.linear_compumethod_xml = f""" LINEAR @@ -60,11 +80,11 @@ def _get_jinja_environment() -> jinja2.environment.Environment: - {self.linear_compumethod.offset} - {self.linear_compumethod.factor} + {self.linear_compumethod.segment.offset} + {self.linear_compumethod.segment.factor} - {self.linear_compumethod.denominator} + {self.linear_compumethod.segment.denominator} @@ -77,7 +97,7 @@ def test_read_odx(self) -> None: """Test parsing of linear compumethod""" expected = self.linear_compumethod - et_element = ElementTree.fromstring(self.linear_compumethod_odx) + et_element = ElementTree.fromstring(self.linear_compumethod_xml) actual = create_any_compu_method_from_et( et_element, doc_frags, @@ -87,53 +107,83 @@ def test_read_odx(self) -> None: assert isinstance(actual, LinearCompuMethod) self.assertEqual(expected.physical_type, actual.physical_type) self.assertEqual(expected.internal_type, actual.internal_type) - self.assertEqual(expected.offset, actual.offset) - self.assertEqual(expected.factor, actual.factor) - self.assertEqual(expected.denominator, actual.denominator) + self.assertEqual(expected.segment.offset, actual.segment.offset) + self.assertEqual(expected.segment.factor, actual.segment.factor) + self.assertEqual(expected.segment.denominator, actual.segment.denominator) def test_write_odx(self) -> None: self.maxDiff = None - dlc_tpl = self.jinja_env.get_template("macros/printDOP.xml.jinja2") + dlc_tpl = self.jinja_env.get_template("macros/printCompuMethod.xml.jinja2") module = dlc_tpl.make_module() - out = module.printCompuMethod(self.linear_compumethod) # type: ignore[attr-defined] + actual_xml = module.printCompuMethod(self.linear_compumethod) # type: ignore[attr-defined] - expected_odx = self.linear_compumethod_odx + expected_xml = self.linear_compumethod_xml # We ignore spaces def remove_spaces(string: str) -> str: return "".join(string.split()) - self.assertEqual(remove_spaces(out), remove_spaces(expected_odx)) + self.assertEqual(remove_spaces(actual_xml), remove_spaces(expected_xml)) def test_linear_compu_method_type_denom_not_one(self) -> None: compu_method = LinearCompuMethod( - offset=0, - factor=1, - denominator=3600, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=Limit( + value_raw="0", + value_type=DataType.A_INT32, + interval_type=IntervalType.INFINITE), + upper_limit=Limit( + value_raw="0", + value_type=DataType.A_INT32, + interval_type=IntervalType.INFINITE), + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[0, 1], + denominators=[3600], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_INT32, - physical_type=DataType.A_INT32, - internal_lower_limit=Limit( - value_raw="0", value_type=DataType.A_INT32, interval_type=IntervalType.INFINITE), - internal_upper_limit=Limit( - value_raw="0", value_type=DataType.A_INT32, interval_type=IntervalType.INFINITE), - ) - self.assertEqual(compu_method.convert_physical_to_internal(2), 7200) + physical_type=DataType.A_INT32) + self.assertEqual(compu_method.convert_physical_to_internal(2), 7200) self.assertEqual(compu_method.convert_internal_to_physical(7200), 2) def test_linear_compu_method_type_int_int(self) -> None: compu_method = LinearCompuMethod( - offset=1, - factor=3, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[1, 3], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_INT32, - physical_type=DataType.A_INT32, - internal_lower_limit=Limit( - value_raw="0", value_type=DataType.A_INT32, interval_type=IntervalType.INFINITE), - internal_upper_limit=Limit( - value_raw="0", value_type=DataType.A_INT32, interval_type=IntervalType.INFINITE), - ) + physical_type=DataType.A_INT32) self.assertEqual(compu_method.convert_internal_to_physical(4), 13) self.assertEqual(compu_method.convert_internal_to_physical(0), 1) @@ -145,16 +195,29 @@ def test_linear_compu_method_type_int_int(self) -> None: def test_linear_compu_method_type_int_float(self) -> None: compu_method = LinearCompuMethod( - offset=1, - factor=3, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[1, 3], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_FLOAT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_INT32, - physical_type=DataType.A_FLOAT32, - internal_lower_limit=Limit( - value_raw="0", value_type=DataType.A_INT32, interval_type=IntervalType.INFINITE), - internal_upper_limit=Limit( - value_raw="0", value_type=DataType.A_INT32, interval_type=IntervalType.INFINITE), - ) + physical_type=DataType.A_FLOAT32) + self.assertTrue(compu_method.is_valid_internal_value(123)) self.assertFalse(compu_method.is_valid_internal_value("123")) self.assertFalse(compu_method.is_valid_internal_value(1.2345)) @@ -165,16 +228,28 @@ def test_linear_compu_method_type_int_float(self) -> None: def test_linear_compu_method_type_float_int(self) -> None: compu_method = LinearCompuMethod( - offset=1, - factor=3, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[1, 3], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_FLOAT32, - physical_type=DataType.A_INT32, - internal_lower_limit=Limit( - value_raw=None, value_type=DataType.A_FLOAT32, interval_type=IntervalType.INFINITE), - internal_upper_limit=Limit( - value_raw=None, value_type=DataType.A_FLOAT32, interval_type=IntervalType.INFINITE), - ) + physical_type=DataType.A_INT32) self.assertTrue(compu_method.is_valid_internal_value(1.2345)) self.assertTrue(compu_method.is_valid_internal_value(123)) self.assertFalse(compu_method.is_valid_internal_value("123")) @@ -184,36 +259,58 @@ def test_linear_compu_method_type_float_int(self) -> None: self.assertFalse(compu_method.is_valid_physical_value(1.2345)) def test_linear_compu_method_type_string(self) -> None: - self.assertRaises( - OdxError, - LinearCompuMethod, - offset=1, - factor=3, - denominator=1, - internal_type=DataType.A_ASCIISTRING, - physical_type=DataType.A_UNICODE2STRING, - internal_lower_limit=Limit( - value_raw="0", - value_type=DataType.A_ASCIISTRING, - interval_type=IntervalType.INFINITE), - internal_upper_limit=Limit( - value_raw="0", - value_type=DataType.A_ASCIISTRING, - interval_type=IntervalType.INFINITE), - ) + with self.assertRaises(OdxError): + LinearCompuMethod( + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[1, 3], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, + internal_type=DataType.A_ASCIISTRING, + physical_type=DataType.A_UNICODE2STRING) def test_linear_compu_method_limits(self) -> None: compu_method = LinearCompuMethod( - offset=1, - factor=5, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=Limit( + value_raw="2", value_type=DataType.A_INT32, interval_type=None), + upper_limit=Limit( + value_raw="15", value_type=DataType.A_INT32, interval_type=None), + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[1, 5], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_INT32, - physical_type=DataType.A_INT32, - internal_lower_limit=Limit( - value_raw="2", value_type=DataType.A_INT32, interval_type=None), - internal_upper_limit=Limit( - value_raw="15", value_type=DataType.A_INT32, interval_type=None), - ) + physical_type=DataType.A_INT32) + self.assertFalse(compu_method.is_valid_internal_value(-3)) self.assertFalse(compu_method.is_valid_internal_value(1)) self.assertFalse(compu_method.is_valid_internal_value(16)) @@ -235,44 +332,60 @@ def test_linear_compu_method_limits(self) -> None: def test_linear_compu_method_physical_limits(self) -> None: # Define decoding function: f: (2, 15] -> [-74, -14], f(x) = -5*x + 1 compu_method = LinearCompuMethod( - offset=1, - factor=-5, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=Limit( + value_raw="2", + value_type=DataType.A_INT32, + interval_type=IntervalType.OPEN), + upper_limit=Limit( + value_raw="15", value_type=DataType.A_INT32, interval_type=None), + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[1, -5], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_INT32, - physical_type=DataType.A_INT32, - internal_lower_limit=Limit( - value_raw="2", value_type=DataType.A_INT32, interval_type=IntervalType.OPEN), - internal_upper_limit=Limit( - value_raw="15", value_type=DataType.A_INT32, interval_type=None), - ) + physical_type=DataType.A_INT32) - assert compu_method.internal_lower_limit is not None - assert compu_method.internal_upper_limit is not None - assert compu_method.physical_lower_limit is not None - assert compu_method.physical_upper_limit is not None + assert compu_method.segment.internal_lower_limit is not None + assert compu_method.segment.internal_upper_limit is not None + assert compu_method.segment.physical_lower_limit is not None + assert compu_method.segment.physical_upper_limit is not None self.assertEqual( - compu_method.internal_lower_limit, + compu_method.segment.internal_lower_limit, Limit(value_raw="2", value_type=DataType.A_INT32, interval_type=IntervalType.OPEN)) - self.assertEqual(compu_method.internal_upper_limit, + self.assertEqual(compu_method.segment.internal_upper_limit, Limit(value_raw="15", value_type=DataType.A_INT32, interval_type=None)) - self.assertEqual(compu_method.internal_upper_limit.interval_type, None) + self.assertEqual(compu_method.segment.internal_upper_limit.interval_type, None) - self.assertEqual(compu_method.physical_lower_limit, + self.assertEqual(compu_method.segment.physical_lower_limit, Limit(value_raw="-74", value_type=DataType.A_INT32, interval_type=None)) self.assertEqual( - compu_method.physical_upper_limit, + compu_method.segment.physical_upper_limit, Limit(value_raw="-9", value_type=DataType.A_INT32, interval_type=IntervalType.OPEN)) - self.assertFalse(compu_method.internal_lower_limit.complies_to_lower(2)) - self.assertTrue(compu_method.internal_lower_limit.complies_to_lower(3)) - self.assertTrue(compu_method.internal_upper_limit.complies_to_upper(15)) - self.assertFalse(compu_method.internal_upper_limit.complies_to_upper(16)) + self.assertFalse(compu_method.segment.internal_lower_limit.complies_to_lower(2)) + self.assertTrue(compu_method.segment.internal_lower_limit.complies_to_lower(3)) + self.assertTrue(compu_method.segment.internal_upper_limit.complies_to_upper(15)) + self.assertFalse(compu_method.segment.internal_upper_limit.complies_to_upper(16)) - self.assertFalse(compu_method.physical_lower_limit.complies_to_lower(-75)) - self.assertTrue(compu_method.physical_lower_limit.complies_to_lower(-74)) - self.assertTrue(compu_method.physical_upper_limit.complies_to_upper(-10)) - self.assertFalse(compu_method.physical_upper_limit.complies_to_upper(-9)) + self.assertFalse(compu_method.segment.physical_lower_limit.complies_to_lower(-75)) + self.assertTrue(compu_method.segment.physical_lower_limit.complies_to_lower(-74)) + self.assertTrue(compu_method.segment.physical_upper_limit.complies_to_upper(-10)) + self.assertFalse(compu_method.segment.physical_upper_limit.complies_to_upper(-9)) self.assertTrue(compu_method.is_valid_internal_value(3)) self.assertTrue(compu_method.is_valid_internal_value(15)) @@ -308,34 +421,72 @@ def _get_jinja_environment() -> jinja2.environment.Environment: self.jinja_env = _get_jinja_environment() - self.compumethod = TabIntpCompuMethod( + self.tab_intp_compumethod = TabIntpCompuMethod( + category=CompuCategory.TAB_INTP, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=Limit( + value_raw="0", value_type=DataType.A_INT32, interval_type=None), + upper_limit=None, + compu_inverse_value=None, + compu_const=CompuConst(v="-1", vt=None, data_type=DataType.A_INT32), + compu_rational_coeffs=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + CompuScale( + short_label=None, + description=None, + lower_limit=Limit( + value_raw="10", value_type=DataType.A_INT32, interval_type=None), + upper_limit=None, + compu_inverse_value=None, + compu_const=CompuConst(v="1", vt=None, data_type=DataType.A_INT32), + compu_rational_coeffs=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + CompuScale( + short_label=None, + description=None, + lower_limit=Limit( + value_raw="30", value_type=DataType.A_INT32, interval_type=None), + upper_limit=None, + compu_inverse_value=None, + compu_const=CompuConst(v="2", vt=None, data_type=DataType.A_INT32), + compu_rational_coeffs=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_INT32, physical_type=DataType.A_FLOAT32, - internal_points=[0, 10, 30], - physical_points=[-1, 1, 2], ) - self.compumethod_odx = f""" + self.tab_intp_compumethod_xml = f""" TAB-INTP - {self.compumethod.internal_points[0]} + {self.tab_intp_compumethod.internal_points[0]} - {self.compumethod.physical_points[0]} + {self.tab_intp_compumethod.physical_points[0]} - {self.compumethod.internal_points[1]} + {self.tab_intp_compumethod.internal_points[1]} - {self.compumethod.physical_points[1]} + {self.tab_intp_compumethod.physical_points[1]} - {self.compumethod.internal_points[2]} + {self.tab_intp_compumethod.internal_points[2]} - {self.compumethod.physical_points[2]} + {self.tab_intp_compumethod.physical_points[2]} @@ -344,7 +495,7 @@ def _get_jinja_environment() -> jinja2.environment.Environment: """ def test_tabintp_convert_type_int_float(self) -> None: - method = self.compumethod + method = self.tab_intp_compumethod for internal, physical in [ (0, -1), @@ -367,9 +518,9 @@ def test_tabintp_convert_type_int_float(self) -> None: self.assertRaises(EncodeError, method.convert_physical_to_internal, 2.1) def test_read_odx(self) -> None: - expected = self.compumethod + expected = self.tab_intp_compumethod - et_element = ElementTree.fromstring(self.compumethod_odx) + et_element = ElementTree.fromstring(self.tab_intp_compumethod_xml) actual = create_any_compu_method_from_et( et_element, doc_frags, @@ -384,18 +535,19 @@ def test_read_odx(self) -> None: self.assertEqual(expected.physical_points, actual.physical_points) def test_write_odx(self) -> None: - dlc_tpl = self.jinja_env.get_template("macros/printDOP.xml.jinja2") + dlc_tpl = self.jinja_env.get_template("macros/printCompuMethod.xml.jinja2") module = dlc_tpl.make_module() - out = module.printCompuMethod(self.compumethod) # type: ignore[attr-defined] + actual_xml = module.printCompuMethod( # type: ignore[attr-defined] + self.tab_intp_compumethod) - expected_odx = self.compumethod_odx + expected_xml = self.tab_intp_compumethod_xml # We ignore spaces def remove_spaces(string: str) -> str: return "".join(string.split()) - self.assertEqual(remove_spaces(out), remove_spaces(expected_odx)) + self.assertEqual(remove_spaces(actual_xml), remove_spaces(expected_xml)) if __name__ == "__main__": diff --git a/tests/test_decoding.py b/tests/test_decoding.py index 991228d5..820a7440 100644 --- a/tests/test_decoding.py +++ b/tests/test_decoding.py @@ -2,8 +2,11 @@ import unittest from typing import cast +from odxtools.compumethods.compuinternaltophys import CompuInternalToPhys +from odxtools.compumethods.compumethod import CompuCategory +from odxtools.compumethods.compurationalcoeffs import CompuRationalCoeffs +from odxtools.compumethods.compuscale import CompuScale from odxtools.compumethods.identicalcompumethod import IdenticalCompuMethod -from odxtools.compumethods.limit import IntervalType, Limit from odxtools.compumethods.linearcompumethod import LinearCompuMethod from odxtools.dataobjectproperty import DataObjectProperty from odxtools.determinenumberofitems import DetermineNumberOfItems @@ -534,7 +537,11 @@ def test_decode_request_structure(self) -> None: ) compu_method = IdenticalCompuMethod( - internal_type=DataType.A_INT32, physical_type=DataType.A_INT32) + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32) dop = DataObjectProperty( odx_id=OdxLinkId("dop.odx_id", doc_frags), short_name="dop_sn", @@ -721,7 +728,11 @@ def test_static_field_coding(self) -> None: ) compu_method = IdenticalCompuMethod( - internal_type=DataType.A_INT32, physical_type=DataType.A_INT32) + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32) dop = DataObjectProperty( odx_id=OdxLinkId("static_field.dop.id", doc_frags), short_name="static_field_dop_sn", @@ -952,9 +963,17 @@ def test_dynamic_endmarker_field_coding(self) -> None: ) compu_method = IdenticalCompuMethod( - internal_type=DataType.A_INT32, physical_type=DataType.A_INT32) + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32) compu_method_bytefield = IdenticalCompuMethod( - internal_type=DataType.A_BYTEFIELD, physical_type=DataType.A_BYTEFIELD) + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_BYTEFIELD, + physical_type=DataType.A_BYTEFIELD) dop = DataObjectProperty( odx_id=OdxLinkId("demf.dop.id", doc_frags), @@ -1302,7 +1321,11 @@ def test_dynamic_length_field_coding(self) -> None: ) compu_method = IdenticalCompuMethod( - internal_type=DataType.A_INT32, physical_type=DataType.A_INT32) + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32) dop = DataObjectProperty( odx_id=OdxLinkId("dlf.dop.id", doc_frags), short_name="dlf_dop_sn", @@ -1530,7 +1553,11 @@ def test_decode_request_end_of_pdu_field(self) -> None: ) compu_method = IdenticalCompuMethod( - internal_type=DataType.A_INT32, physical_type=DataType.A_INT32) + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32) dop = DataObjectProperty( odx_id=OdxLinkId("dop.id", doc_frags), short_name="dop_sn", @@ -1729,15 +1756,28 @@ def test_decode_request_end_of_pdu_field(self) -> None: def test_decode_request_linear_compu_method(self) -> None: compu_method = LinearCompuMethod( - offset=1, - factor=5, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[1, 5], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_INT32, physical_type=DataType.A_INT32, - internal_lower_limit=Limit( - value_raw=None, value_type=DataType.A_INT32, interval_type=IntervalType.INFINITE), - internal_upper_limit=Limit( - value_raw=None, value_type=DataType.A_INT32, interval_type=IntervalType.INFINITE), ) diag_coded_type = StandardLengthType( base_data_type=DataType.A_UINT32, @@ -2080,7 +2120,11 @@ def test_code_dtc(self) -> None: is_highlow_byte_order_raw=None, ) compu_method = IdenticalCompuMethod( - internal_type=DataType.A_INT32, physical_type=DataType.A_INT32) + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32) dtc1 = DiagnosticTroubleCode( odx_id=OdxLinkId("dtcID1", doc_frags), @@ -2192,7 +2236,11 @@ def setUp(self) -> None: ), physical_type=PhysicalType(DataType.A_BYTEFIELD, display_radix=None, precision=None), compu_method=IdenticalCompuMethod( - internal_type=DataType.A_BYTEFIELD, physical_type=DataType.A_BYTEFIELD), + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_BYTEFIELD, + physical_type=DataType.A_BYTEFIELD), unit_ref=None, sdgs=[], internal_constr=None, @@ -2342,19 +2390,28 @@ def test_physical_constant_parameter(self) -> None: diag_coded_type=diag_coded_type, physical_type=PhysicalType(DataType.A_INT32, display_radix=None, precision=None), compu_method=LinearCompuMethod( - offset=offset, - factor=1, - denominator=1, - internal_type=DataType.A_UINT32, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[offset, 1], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, + internal_type=DataType.A_INT32, physical_type=DataType.A_INT32, - internal_lower_limit=Limit( - value_raw=None, - value_type=DataType.A_UINT32, - interval_type=IntervalType.INFINITE), - internal_upper_limit=Limit( - value_raw=None, - value_type=DataType.A_UINT32, - interval_type=IntervalType.INFINITE), ), unit_ref=None, sdgs=[], diff --git a/tests/test_diag_coded_types.py b/tests/test_diag_coded_types.py index 06da247c..f1a370f6 100644 --- a/tests/test_diag_coded_types.py +++ b/tests/test_diag_coded_types.py @@ -3,8 +3,11 @@ from xml.etree import ElementTree import odxtools.uds as uds +from odxtools.compumethods.compuinternaltophys import CompuInternalToPhys +from odxtools.compumethods.compumethod import CompuCategory +from odxtools.compumethods.compurationalcoeffs import CompuRationalCoeffs +from odxtools.compumethods.compuscale import CompuScale from odxtools.compumethods.identicalcompumethod import IdenticalCompuMethod -from odxtools.compumethods.limit import IntervalType, Limit from odxtools.compumethods.linearcompumethod import LinearCompuMethod from odxtools.createanydiagcodedtype import create_any_diag_coded_type_from_et from odxtools.dataobjectproperty import DataObjectProperty @@ -178,10 +181,18 @@ def test_end_to_end(self) -> None: compumethods = { "uint_passthrough": IdenticalCompuMethod( - internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32), + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_UINT32, + physical_type=DataType.A_UINT32), "bytes_passthrough": IdenticalCompuMethod( - internal_type=DataType.A_BYTEFIELD, physical_type=DataType.A_BYTEFIELD), + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_BYTEFIELD, + physical_type=DataType.A_BYTEFIELD), } # data object properties @@ -444,20 +455,35 @@ def test_end_to_end(self) -> None: compumethods = { "uint_passthrough": IdenticalCompuMethod( - internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32), + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_UINT32, + physical_type=DataType.A_UINT32), "multiply_with_8": LinearCompuMethod( - offset=0, - factor=8, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[0, 8], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32, - internal_lower_limit=Limit( - value_raw="0", value_type=DataType.A_UINT32, interval_type=None), - internal_upper_limit=Limit( - value_raw=None, - value_type=DataType.A_UINT32, - interval_type=IntervalType.INFINITE), ), } @@ -802,10 +828,18 @@ def test_end_to_end(self) -> None: compumethods = { "uint_passthrough": IdenticalCompuMethod( - internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32), + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_UINT32, + physical_type=DataType.A_UINT32), "bytes_passthrough": IdenticalCompuMethod( - internal_type=DataType.A_BYTEFIELD, physical_type=DataType.A_BYTEFIELD), + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_BYTEFIELD, + physical_type=DataType.A_BYTEFIELD), } # data object properties diff --git a/tests/test_diag_data_dictionary_spec.py b/tests/test_diag_data_dictionary_spec.py index a873d3f3..7899af13 100644 --- a/tests/test_diag_data_dictionary_spec.py +++ b/tests/test_diag_data_dictionary_spec.py @@ -2,6 +2,7 @@ import unittest from examples import somersaultecu +from odxtools.compumethods.compumethod import CompuCategory from odxtools.compumethods.identicalcompumethod import IdenticalCompuMethod from odxtools.compumethods.limit import Limit from odxtools.dataobjectproperty import DataObjectProperty @@ -41,7 +42,11 @@ def test_initialization(self) -> None: is_condensed_raw=None, ) ident_compu_method = IdenticalCompuMethod( - internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32) + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_UINT32, + physical_type=DataType.A_UINT32) dtc_dop = DtcDop( odx_id=OdxLinkId("DOP.dtc_dop", doc_frags), diff --git a/tests/test_encoding.py b/tests/test_encoding.py index a882342a..390870d6 100644 --- a/tests/test_encoding.py +++ b/tests/test_encoding.py @@ -2,8 +2,11 @@ import unittest from typing import List, cast +from odxtools.compumethods.compuinternaltophys import CompuInternalToPhys +from odxtools.compumethods.compumethod import CompuCategory +from odxtools.compumethods.compurationalcoeffs import CompuRationalCoeffs +from odxtools.compumethods.compuscale import CompuScale from odxtools.compumethods.identicalcompumethod import IdenticalCompuMethod -from odxtools.compumethods.limit import Limit from odxtools.compumethods.linearcompumethod import LinearCompuMethod from odxtools.dataobjectproperty import DataObjectProperty from odxtools.diaglayer import DiagLayer @@ -123,14 +126,28 @@ def test_encode_linear(self) -> None: ) # This CompuMethod represents the linear function: decode(x) = 2*x + 8 and encode(x) = (x-8)/2 compu_method = LinearCompuMethod( - offset=8, - factor=2, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[8, 2], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_UINT32, - physical_type=DataType.A_UINT32, - internal_lower_limit=Limit( - value_raw="0", value_type=DataType.A_UINT32, interval_type=None), - internal_upper_limit=None) + physical_type=DataType.A_UINT32) dop = DataObjectProperty( odx_id=OdxLinkId("dop.id", doc_frags), short_name="dop_sn", @@ -199,7 +216,11 @@ def test_encode_nrc_const(self) -> None: diag_coded_type=diag_coded_type, physical_type=PhysicalType(DataType.A_UINT32, display_radix=None, precision=None), compu_method=IdenticalCompuMethod( - internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32), + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_UINT32, + physical_type=DataType.A_UINT32), unit_ref=None, sdgs=[], internal_constr=None, @@ -353,7 +374,11 @@ def test_bit_mask(self) -> None: physical_type = PhysicalType( base_data_type=DataType.A_UINT32, display_radix=None, precision=None) compu_method = IdenticalCompuMethod( - internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32) + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_UINT32, + physical_type=DataType.A_UINT32) inner_dop = DataObjectProperty( odx_id=OdxLinkId('dop.inner', doc_frags), diff --git a/tests/test_odxtools.py b/tests/test_odxtools.py index 59a2181d..f7b62fc8 100644 --- a/tests/test_odxtools.py +++ b/tests/test_odxtools.py @@ -60,8 +60,8 @@ def test_bit_length(self) -> None: def test_convert_physical_to_internal(self) -> None: self.dop = odxdb.odxlinks.resolve(OdxLinkRef("somersault.DOP.boolean", container_doc_frags)) - self.assertEqual(self.dop.convert_physical_to_internal("false"), 0) - self.assertEqual(self.dop.convert_physical_to_internal("true"), 1) + self.assertEqual(self.dop.compu_method.convert_physical_to_internal("false"), 0) + self.assertEqual(self.dop.compu_method.convert_physical_to_internal("true"), 1) class TestComposeUDS(unittest.TestCase): diff --git a/tests/test_singleecujob.py b/tests/test_singleecujob.py index 3f19cf57..ee2ab968 100644 --- a/tests/test_singleecujob.py +++ b/tests/test_singleecujob.py @@ -10,8 +10,12 @@ import odxtools from odxtools.additionalaudience import AdditionalAudience from odxtools.audience import Audience +from odxtools.compumethods.compuconst import CompuConst +from odxtools.compumethods.compuinternaltophys import CompuInternalToPhys +from odxtools.compumethods.compumethod import CompuCategory +from odxtools.compumethods.compurationalcoeffs import CompuRationalCoeffs from odxtools.compumethods.compuscale import CompuScale -from odxtools.compumethods.limit import IntervalType, Limit +from odxtools.compumethods.limit import Limit from odxtools.compumethods.linearcompumethod import LinearCompuMethod from odxtools.compumethods.texttablecompumethod import TexttableCompuMethod from odxtools.dataobjectproperty import DataObjectProperty @@ -85,33 +89,40 @@ class Context(NamedTuple): physical_type=PhysicalType( DataType.A_UNICODE2STRING, display_radix=None, precision=None), compu_method=TexttableCompuMethod( + category=CompuCategory.TEXTTABLE, + compu_phys_to_internal=None, physical_type=DataType.A_UNICODE2STRING, - compu_default_value=None, - internal_to_phys=[ - CompuScale( - "yes", - lower_limit=Limit( - value_raw="0", value_type=DataType.A_INT32, interval_type=None), - compu_const="Yes!", - description=None, - compu_inverse_value=None, - upper_limit=None, - compu_rational_coeffs=None, - internal_type=DataType.A_INT32, - physical_type=DataType.A_UNICODE2STRING, - ), - CompuScale( - "no", - lower_limit=Limit( - value_raw="1", value_type=DataType.A_INT32, interval_type=None), - compu_const="No!", - description=None, - compu_inverse_value=None, - upper_limit=None, - compu_rational_coeffs=None, - internal_type=DataType.A_INT32, - physical_type=DataType.A_UNICODE2STRING), - ], + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + "yes", + lower_limit=Limit( + value_raw="0", value_type=DataType.A_INT32, interval_type=None), + compu_const=CompuConst( + v=None, vt="Yes!", data_type=DataType.A_UTF8STRING), + description=None, + compu_inverse_value=None, + upper_limit=None, + compu_rational_coeffs=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_UNICODE2STRING, + ), + CompuScale( + "no", + lower_limit=Limit( + value_raw="1", value_type=DataType.A_INT32, interval_type=None), + compu_const=CompuConst( + v=None, vt="No!", data_type=DataType.A_UTF8STRING), + description=None, + compu_inverse_value=None, + upper_limit=None, + compu_rational_coeffs=None, + internal_type=DataType.A_INT32, + physical_type=DataType.A_UNICODE2STRING), + ], + prog_code=None, + compu_default_value=None, + ), internal_type=DataType.A_UINT32, ), unit_ref=None, @@ -136,20 +147,28 @@ class Context(NamedTuple): physical_type=PhysicalType( DataType.A_UNICODE2STRING, display_radix=None, precision=None), compu_method=LinearCompuMethod( - offset=1, - factor=-1, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[1, -1], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_UINT32, - physical_type=DataType.A_UINT32, - internal_lower_limit=Limit( - value_raw="0", - value_type=DataType.A_INT32, - interval_type=IntervalType.INFINITE), - internal_upper_limit=Limit( - value_raw="0", - value_type=DataType.A_INT32, - interval_type=IntervalType.INFINITE), - ), + physical_type=DataType.A_UINT32), unit_ref=None, sdgs=[], internal_constr=None, @@ -172,19 +191,28 @@ class Context(NamedTuple): physical_type=PhysicalType( DataType.A_UNICODE2STRING, display_radix=None, precision=None), compu_method=LinearCompuMethod( - offset=1, - factor=-1, - denominator=1, + category=CompuCategory.LINEAR, + compu_internal_to_phys=CompuInternalToPhys( + compu_scales=[ + CompuScale( + short_label=None, + description=None, + lower_limit=None, + upper_limit=None, + compu_inverse_value=None, + compu_const=None, + compu_rational_coeffs=CompuRationalCoeffs( + numerators=[1, -1], + denominators=[1], + ), + internal_type=DataType.A_INT32, + physical_type=DataType.A_INT32), + ], + prog_code=None, + compu_default_value=None), + compu_phys_to_internal=None, internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32, - internal_lower_limit=Limit( - value_raw="0", - value_type=DataType.A_INT32, - interval_type=IntervalType.INFINITE), - internal_upper_limit=Limit( - value_raw="0", - value_type=DataType.A_INT32, - interval_type=IntervalType.INFINITE), ), unit_ref=None, sdgs=[], diff --git a/tests/test_unit_spec.py b/tests/test_unit_spec.py index a279aa56..cbe3ea8a 100644 --- a/tests/test_unit_spec.py +++ b/tests/test_unit_spec.py @@ -2,6 +2,7 @@ import unittest from xml.etree import ElementTree +from odxtools.compumethods.compumethod import CompuCategory from odxtools.compumethods.identicalcompumethod import IdenticalCompuMethod from odxtools.dataobjectproperty import DataObjectProperty from odxtools.diagdatadictionaryspec import DiagDataDictionarySpec @@ -116,7 +117,11 @@ def test_resolve_odxlinks(self) -> None: diag_coded_type=dct, physical_type=PhysicalType(DataType.A_UINT32, display_radix=None, precision=None), compu_method=IdenticalCompuMethod( - internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32), + category=CompuCategory.IDENTICAL, + compu_internal_to_phys=None, + compu_phys_to_internal=None, + internal_type=DataType.A_UINT32, + physical_type=DataType.A_UINT32), unit_ref=OdxLinkRef.from_id(unit.odx_id), sdgs=[], internal_constr=None, From cbd5a8f2232036e27181ace65dc531f67d16b0dd Mon Sep 17 00:00:00 2001 From: Andreas Lauser Date: Thu, 23 May 2024 09:56:50 +0200 Subject: [PATCH 2/7] compu methods: unify handling constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the XSD is very redundant and messy here: it defines multiple identical groups and does not use inheritance. That said, that does not prevent it to do it better... thanks to [at]kayoub5 for the nudge! Signed-off-by: Andreas Lauser Signed-off-by: Katja Köhler --- odxtools/compumethods/compuconst.py | 2 +- odxtools/compumethods/compudefaultvalue.py | 24 ++++++++++---------- odxtools/compumethods/compuinternaltophys.py | 3 ++- odxtools/compumethods/compuinversevalue.py | 21 ++++------------- odxtools/compumethods/compuphystointernal.py | 3 ++- odxtools/compumethods/compuscale.py | 8 ++++--- 6 files changed, 26 insertions(+), 35 deletions(-) diff --git a/odxtools/compumethods/compuconst.py b/odxtools/compumethods/compuconst.py index 20911d4f..b62ec995 100644 --- a/odxtools/compumethods/compuconst.py +++ b/odxtools/compumethods/compuconst.py @@ -14,7 +14,7 @@ class CompuConst: data_type: DataType @staticmethod - def compuconst_from_et(et_element: ElementTree.Element, *, data_type: DataType) -> "CompuConst": + def compuvalue_from_et(et_element: ElementTree.Element, *, data_type: DataType) -> "CompuConst": v = et_element.findtext("V") vt = et_element.findtext("VT") diff --git a/odxtools/compumethods/compudefaultvalue.py b/odxtools/compumethods/compudefaultvalue.py index 7567fd0f..733baa41 100644 --- a/odxtools/compumethods/compudefaultvalue.py +++ b/odxtools/compumethods/compudefaultvalue.py @@ -1,27 +1,27 @@ # SPDX-License-Identifier: MIT from dataclasses import dataclass -from typing import List, Optional +from typing import Optional from xml.etree import ElementTree -from ..odxlink import OdxDocFragment +from ..odxtypes import DataType +from ..utils import dataclass_fields_asdict +from .compuconst import CompuConst from .compuinversevalue import CompuInverseValue @dataclass -class CompuDefaultValue: - v: Optional[str] - vt: Optional[str] - +class CompuDefaultValue(CompuConst): compu_inverse_value: Optional[CompuInverseValue] @staticmethod - def from_et(et_element: ElementTree.Element, - doc_frags: List[OdxDocFragment]) -> "CompuDefaultValue": - v = et_element.findtext("V") - vt = et_element.findtext("VT") + def compuvalue_from_et(et_element: ElementTree.Element, *, + data_type: DataType) -> "CompuDefaultValue": + kwargs = dataclass_fields_asdict( + CompuConst.compuvalue_from_et(et_element, data_type=data_type)) compu_inverse_value = None if (civ_elem := et_element.find("COMPU-INVERSE-VALUE")) is not None: - compu_inverse_value = CompuInverseValue.from_et(civ_elem, doc_frags) + compu_inverse_value = CompuInverseValue.compuvalue_from_et( + civ_elem, data_type=data_type) - return CompuDefaultValue(v=v, vt=vt, compu_inverse_value=compu_inverse_value) + return CompuDefaultValue(**kwargs, compu_inverse_value=compu_inverse_value) diff --git a/odxtools/compumethods/compuinternaltophys.py b/odxtools/compumethods/compuinternaltophys.py index 29137c69..6e1297db 100644 --- a/odxtools/compumethods/compuinternaltophys.py +++ b/odxtools/compumethods/compuinternaltophys.py @@ -32,7 +32,8 @@ def compu_internal_to_phys_from_et(et_element: ElementTree.Element, compu_default_value = None if (cdve := et_element.find("COMPU-DEFAULT-VALUE")) is not None: - compu_default_value = CompuDefaultValue.from_et(cdve, doc_frags) + compu_default_value = CompuDefaultValue.compuvalue_from_et( + cdve, data_type=physical_type) return CompuInternalToPhys( compu_scales=compu_scales, prog_code=prog_code, compu_default_value=compu_default_value) diff --git a/odxtools/compumethods/compuinversevalue.py b/odxtools/compumethods/compuinversevalue.py index daa666a1..1572a282 100644 --- a/odxtools/compumethods/compuinversevalue.py +++ b/odxtools/compumethods/compuinversevalue.py @@ -1,20 +1,7 @@ # SPDX-License-Identifier: MIT -from dataclasses import dataclass -from typing import List, Optional -from xml.etree import ElementTree -from ..odxlink import OdxDocFragment +from .compuconst import CompuConst - -@dataclass -class CompuInverseValue: - v: Optional[str] - vt: Optional[str] - - @staticmethod - def from_et(et_element: ElementTree.Element, - doc_frags: List[OdxDocFragment]) -> "CompuInverseValue": - v = et_element.findtext("V") - vt = et_element.findtext("VT") - - return CompuInverseValue(v=v, vt=vt) +# make CompuInverseValue an alias for CompuConst. The XSD defines two +# separate but identical groups for this (why?)... +CompuInverseValue = CompuConst diff --git a/odxtools/compumethods/compuphystointernal.py b/odxtools/compumethods/compuphystointernal.py index f59034c6..43bbba46 100644 --- a/odxtools/compumethods/compuphystointernal.py +++ b/odxtools/compumethods/compuphystointernal.py @@ -32,7 +32,8 @@ def compu_phys_to_internal_from_et(et_element: ElementTree.Element, compu_default_value = None if (cdve := et_element.find("COMPU-DEFAULT-VALUE")) is not None: - compu_default_value = CompuDefaultValue.from_et(cdve, doc_frags) + compu_default_value = CompuDefaultValue.compuvalue_from_et( + cdve, data_type=internal_type) return CompuPhysToInternal( compu_scales=compu_scales, prog_code=prog_code, compu_default_value=compu_default_value) diff --git a/odxtools/compumethods/compuscale.py b/odxtools/compumethods/compuscale.py index 2dbea2d3..646a3d84 100644 --- a/odxtools/compumethods/compuscale.py +++ b/odxtools/compumethods/compuscale.py @@ -7,6 +7,7 @@ from ..odxtypes import AtomicOdxType, DataType from ..utils import create_description_from_et from .compuconst import CompuConst +from .compuinversevalue import CompuInverseValue from .compurationalcoeffs import CompuRationalCoeffs from .limit import Limit @@ -20,7 +21,7 @@ class CompuScale: description: Optional[str] lower_limit: Optional[Limit] upper_limit: Optional[Limit] - compu_inverse_value: Optional[CompuConst] + compu_inverse_value: Optional[CompuInverseValue] compu_const: Optional[CompuConst] compu_rational_coeffs: Optional[CompuRationalCoeffs] @@ -43,11 +44,12 @@ def compuscale_from_et(et_element: ElementTree.Element, doc_frags: List[OdxDocFr compu_inverse_value = None if (cive := et_element.find("COMPU-INVERSE-VALUE")) is not None: - compu_inverse_value = CompuConst.compuconst_from_et(cive, data_type=internal_type) + compu_inverse_value = CompuInverseValue.compuvalue_from_et( + cive, data_type=internal_type) compu_const = None if (cce := et_element.find("COMPU-CONST")) is not None: - compu_const = CompuConst.compuconst_from_et(cce, data_type=physical_type) + compu_const = CompuConst.compuvalue_from_et(cce, data_type=physical_type) compu_rational_coeffs: Optional[CompuRationalCoeffs] = None if (crc_elem := et_element.find("COMPU-RATIONAL-COEFFS")) is not None: From 53f28413319a7e1fb0fa773263fa4ee38632d790 Mon Sep 17 00:00:00 2001 From: Andreas Lauser Date: Thu, 23 May 2024 10:20:18 +0200 Subject: [PATCH 3/7] TabIntpCompuMethod: improve docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Andreas Lauser Signed-off-by: Katja Köhler --- odxtools/compumethods/tabintpcompumethod.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/odxtools/compumethods/tabintpcompumethod.py b/odxtools/compumethods/tabintpcompumethod.py index 2864611b..f5c77c51 100644 --- a/odxtools/compumethods/tabintpcompumethod.py +++ b/odxtools/compumethods/tabintpcompumethod.py @@ -13,7 +13,8 @@ @dataclass class TabIntpCompuMethod(CompuMethod): - """A compu method of type Tab Interpolated is used for linear interpolation. + """A table-based interpolated compu method provides a continuous + transfer function based on piecewise linear interpolation. A `TabIntpCompuMethod` is defined by a set of points. Each point is an (internal, physical) value pair. When converting from From 10bc14b68151debc7084042f17ee86f72a775416 Mon Sep 17 00:00:00 2001 From: Andreas Lauser Date: Thu, 23 May 2024 10:31:51 +0200 Subject: [PATCH 4/7] compu methods: raise En-/DecodeError when encountering incorrect runtime data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feeding incorrect data to be en-/decoded should only trigger `EncodeError` or `DecodeError`. (`OdxError` means "incorrect ODX model detected") Signed-off-by: Andreas Lauser Signed-off-by: Katja Köhler --- odxtools/compumethods/linearcompumethod.py | 10 +++++++--- odxtools/compumethods/scalelinearcompumethod.py | 10 +++++----- odxtools/compumethods/tabintpcompumethod.py | 4 ++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/odxtools/compumethods/linearcompumethod.py b/odxtools/compumethods/linearcompumethod.py index 7046f44d..a795924c 100644 --- a/odxtools/compumethods/linearcompumethod.py +++ b/odxtools/compumethods/linearcompumethod.py @@ -3,7 +3,7 @@ from typing import List, cast from xml.etree import ElementTree -from ..exceptions import odxassert, odxraise +from ..exceptions import DecodeError, EncodeError, odxassert, odxraise from ..odxlink import OdxDocFragment from ..odxtypes import AtomicOdxType, DataType from ..utils import dataclass_fields_asdict @@ -74,11 +74,15 @@ def __post_init__(self) -> None: scale, internal_type=self.internal_type, physical_type=self.physical_type) def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> AtomicOdxType: - odxassert(self._segment.internal_applies(internal_value)) + if not self._segment.internal_applies(internal_value): + odxraise(r"Cannot decode internal value {internal_value}", DecodeError) + return self._segment.convert_internal_to_physical(internal_value) def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> AtomicOdxType: - odxassert(self._segment.physical_applies(physical_value)) + if not self._segment.physical_applies(physical_value): + odxraise(r"Cannot decode physical value {physical_value}", EncodeError) + return self._segment.convert_physical_to_internal(physical_value) def is_valid_physical_value(self, physical_value: AtomicOdxType) -> bool: diff --git a/odxtools/compumethods/scalelinearcompumethod.py b/odxtools/compumethods/scalelinearcompumethod.py index 8116fadf..ee5c6be0 100644 --- a/odxtools/compumethods/scalelinearcompumethod.py +++ b/odxtools/compumethods/scalelinearcompumethod.py @@ -3,7 +3,7 @@ from typing import List, Union, cast from xml.etree import ElementTree -from ..exceptions import odxassert, odxraise +from ..exceptions import DecodeError, EncodeError, odxassert, odxraise from ..odxlink import OdxDocFragment from ..odxtypes import AtomicOdxType, DataType from ..utils import dataclass_fields_asdict @@ -67,10 +67,10 @@ def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> Union[f seg for seg in self._segments if seg.physical_applies(physical_value) ] if not applicable_segments: - odxraise(r"No applicable segment for value {physical_value} found") + odxraise(r"No applicable segment for value {physical_value} found", EncodeError) return cast(int, None) elif len(applicable_segments): - odxraise(r"Multiple applicable segments for value {physical_value} found") + odxraise(r"Multiple applicable segments for value {physical_value} found", EncodeError) seg = applicable_segments[0] return seg.convert_physical_to_internal(physical_value) @@ -80,10 +80,10 @@ def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> Union[f seg for seg in self._segments if seg.internal_applies(internal_value) ] if not applicable_segments: - odxraise(r"No applicable segment for value {internal_value} found") + odxraise(r"No applicable segment for value {internal_value} found", DecodeError) return cast(int, None) elif len(applicable_segments): - odxraise(r"Multiple applicable segments for value {internal_value} found") + odxraise(r"Multiple applicable segments for value {internal_value} found", DecodeError) seg = applicable_segments[0] return seg.convert_internal_to_physical(internal_value) diff --git a/odxtools/compumethods/tabintpcompumethod.py b/odxtools/compumethods/tabintpcompumethod.py index f5c77c51..8d061c86 100644 --- a/odxtools/compumethods/tabintpcompumethod.py +++ b/odxtools/compumethods/tabintpcompumethod.py @@ -148,7 +148,7 @@ def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> AtomicO odxassert( isinstance(physical_value, (int, float)), - "Only integers and floats can be piecewise linearly interpolated") + "Only integers and floats can be piecewise linearly interpolated", EncodeError) result = self.__piecewise_linear_interpolate__(physical_value, self._physical_points, self._internal_points) @@ -170,7 +170,7 @@ def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> AtomicO odxassert( isinstance(internal_value, (int, float)), - "Only integers and floats can be piecewise linearly interpolated") + "Only integers and floats can be piecewise linearly interpolated", DecodeError) result = self.__piecewise_linear_interpolate__(internal_value, self._internal_points, self._physical_points) From 58ea6317eeb654b607c5942462ba37bfa96ba503 Mon Sep 17 00:00:00 2001 From: Andreas Lauser Date: Thu, 23 May 2024 10:43:42 +0200 Subject: [PATCH 5/7] don't postfix private class functions with double underscores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit these are reservered for "magic" functions and according to PEP 8 should never be newly created by user code. thanks to [at]kayoub5 for the catch! Signed-off-by: Andreas Lauser Signed-off-by: Katja Köhler --- odxtools/compumethods/linearsegment.py | 4 ++-- odxtools/compumethods/tabintpcompumethod.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/odxtools/compumethods/linearsegment.py b/odxtools/compumethods/linearsegment.py index 85749774..ab78a467 100644 --- a/odxtools/compumethods/linearsegment.py +++ b/odxtools/compumethods/linearsegment.py @@ -63,7 +63,7 @@ def physical_upper_limit(self) -> Optional[Limit]: return self._physical_upper_limit def __post_init__(self) -> None: - self.__compute_physical_limits__() + self.__compute_physical_limits() def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> Union[float, int]: if not isinstance(internal_value, (int, float)): @@ -95,7 +95,7 @@ def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> Union[f return result - def __compute_physical_limits__(self) -> None: + def __compute_physical_limits(self) -> None: """Computes the physical limits and stores them in the properties self._physical_lower_limit and self._physical_upper_limit. This method is called by `__post_init__()`. diff --git a/odxtools/compumethods/tabintpcompumethod.py b/odxtools/compumethods/tabintpcompumethod.py index 8d061c86..0bc80d51 100644 --- a/odxtools/compumethods/tabintpcompumethod.py +++ b/odxtools/compumethods/tabintpcompumethod.py @@ -106,9 +106,9 @@ def __post_init__(self) -> None: value_type=self.internal_type, interval_type=IntervalType.CLOSED) - self.__assert_validity__() + self.__assert_validity() - def __assert_validity__(self) -> None: + def __assert_validity(self) -> None: odxassert(len(self.internal_points) == len(self.physical_points)) odxassert( @@ -128,10 +128,10 @@ def __assert_validity__(self) -> None: ], "Physical data type of TAB-INTP compumethod must be one of" " [A_INT32, A_UINT32, A_FLOAT32, A_FLOAT64]") - def __piecewise_linear_interpolate__(self, x: Union[int, float], - range_samples: List[Union[int, float]], - domain_samples: List[Union[int, - float]]) -> Union[float, None]: + def __piecewise_linear_interpolate(self, x: Union[int, float], + range_samples: List[Union[int, float]], + domain_samples: List[Union[int, + float]]) -> Union[float, None]: for i in range(0, len(range_samples) - 1): if (x0 := range_samples[i]) <= x and x <= (x1 := range_samples[i + 1]): y0 = domain_samples[i] @@ -149,8 +149,8 @@ def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> AtomicO odxassert( isinstance(physical_value, (int, float)), "Only integers and floats can be piecewise linearly interpolated", EncodeError) - result = self.__piecewise_linear_interpolate__(physical_value, self._physical_points, - self._internal_points) + result = self.__piecewise_linear_interpolate(physical_value, self._physical_points, + self._internal_points) if result is None: odxraise( @@ -172,8 +172,8 @@ def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> AtomicO isinstance(internal_value, (int, float)), "Only integers and floats can be piecewise linearly interpolated", DecodeError) - result = self.__piecewise_linear_interpolate__(internal_value, self._internal_points, - self._physical_points) + result = self.__piecewise_linear_interpolate(internal_value, self._internal_points, + self._physical_points) if result is None: odxraise( From eea043c19736bcf6d6bef967dba6efec06567567 Mon Sep 17 00:00:00 2001 From: Andreas Lauser Date: Thu, 23 May 2024 10:51:13 +0200 Subject: [PATCH 6/7] make the latest version of `ruff` happy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Andreas Lauser Signed-off-by: Katja Köhler --- odxtools/parameters/tablekeyparameter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/odxtools/parameters/tablekeyparameter.py b/odxtools/parameters/tablekeyparameter.py index ca2b0175..e66d32f2 100644 --- a/odxtools/parameters/tablekeyparameter.py +++ b/odxtools/parameters/tablekeyparameter.py @@ -56,8 +56,8 @@ def from_et(et_element: ElementTree.Element, **kwargs) def __post_init__(self) -> None: - self._table: "Table" - self._table_row: Optional["TableRow"] = None + self._table: Table + self._table_row: Optional[TableRow] = None if self.table_ref is None and self.table_snref is None and \ self.table_row_ref is None and self.table_row_snref is None: odxraise("Either a table or a table row must be defined.") From 7cf899349ac3241b75f4c3b7b569ed1b35de97cc Mon Sep 17 00:00:00 2001 From: Andreas Lauser Date: Thu, 23 May 2024 14:38:05 +0200 Subject: [PATCH 7/7] scale linear compu methods: improve detection of invertibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this makes the code conform to section 7.3.6.6.4 of the spec. Note that DOPs using non-invertible SCALE-LINEAR compu methods currently cannot be encoded. Signed-off-by: Andreas Lauser Signed-off-by: Katja Köhler --- odxtools/compumethods/linearsegment.py | 16 +++++ .../compumethods/scalelinearcompumethod.py | 58 +++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/odxtools/compumethods/linearsegment.py b/odxtools/compumethods/linearsegment.py index ab78a467..b17c5627 100644 --- a/odxtools/compumethods/linearsegment.py +++ b/odxtools/compumethods/linearsegment.py @@ -27,6 +27,8 @@ class LinearSegment: internal_lower_limit: Optional[Limit] internal_upper_limit: Optional[Limit] + inverse_value: Union[int, float] # value used as inverse if factor is 0 + internal_type: DataType physical_type: DataType @@ -42,6 +44,15 @@ def from_compu_scale(scale: CompuScale, *, internal_type: DataType, if len(coeffs.denominators) > 0: denominator = coeffs.denominators[0] + inverse_value: Union[int, float] = 0 + if scale.compu_inverse_value is not None: + if abs(factor) < 1e-10: + odxraise(f"COMPU-INVERSE-VALUE for non-zero slope ({factor}) defined") + x = odxrequire(scale.compu_inverse_value).value + if not isinstance(x, (int, float)): + odxraise(f"Non-numeric COMPU-INVERSE-VALUE specified ({x!r})") + inverse_value = x + internal_lower_limit = scale.lower_limit internal_upper_limit = scale.upper_limit @@ -51,6 +62,7 @@ def from_compu_scale(scale: CompuScale, *, internal_type: DataType, denominator=denominator, internal_lower_limit=internal_lower_limit, internal_upper_limit=internal_upper_limit, + inverse_value=inverse_value, internal_type=internal_type, physical_type=physical_type) @@ -85,6 +97,10 @@ def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> Union[f odxraise(f"Physical values of linear compumethods must " f"either be int or float (is: {type(physical_value).__name__})") + if abs(self.factor) < 1e-10: + # "If factor = 0 then COMPU-INVERSE-VALUE shall be specified. + return self.inverse_value + result = (physical_value * self.denominator - self.offset) / self.factor if self.internal_type in [ diff --git a/odxtools/compumethods/scalelinearcompumethod.py b/odxtools/compumethods/scalelinearcompumethod.py index ee5c6be0..48af7882 100644 --- a/odxtools/compumethods/scalelinearcompumethod.py +++ b/odxtools/compumethods/scalelinearcompumethod.py @@ -8,6 +8,7 @@ from ..odxtypes import AtomicOdxType, DataType from ..utils import dataclass_fields_asdict from .compumethod import CompuCategory, CompuMethod +from .limit import IntervalType from .linearsegment import LinearSegment @@ -62,17 +63,68 @@ def __post_init__(self) -> None: LinearSegment.from_compu_scale( scale, internal_type=self.internal_type, physical_type=self.physical_type)) + # find out if the transfer function is invertible (i.e. if it + # can be encoded by normal means). section 7.3.6.6.4 of the + # ODX specification states that the condition for + # invertibility is that adjacent COMPU-SCALES exhibit the same + # values on their common boundaries and that the slope in all + # intervals exhibit the same sign (or are 0). For segments + # with a slope of zero, COMPU-INVERSE-VALUE shall be used. + self._is_invertible = True + ref_factor = self._segments[0].factor + for i in range(0, len(self._segments) - 1): + s0 = self.segments[i] + s1 = self.segments[i + 1] + + if ref_factor * s1.factor < 0: + self._is_invertible = False + break + if s1.factor != 0: + ref_factor = s1.factor + + # both interval boundaries must not be infinite + if s0.internal_upper_limit is None or \ + s1.internal_lower_limit is None: + self._is_invertible = False + break + elif s0.internal_upper_limit.value is None or \ + s1.internal_lower_limit.value is None or \ + s0.internal_upper_limit.interval_type == IntervalType.INFINITE or \ + s1.internal_lower_limit.interval_type == IntervalType.INFINITE: + self._is_invertible = False + break + + # the intervals must use the same reference point + if (x := s0.internal_upper_limit.value) != s1.internal_lower_limit.value: + self._is_invertible = False + break + + if not isinstance(x, (int, float)): + odxraise("Linear segments must use int or float for all quantities") + + # the respective function value at the interval's + # reference point must be identical + y0 = s0.convert_internal_to_physical(x) + y1 = s1.convert_internal_to_physical(x) + if abs(y0 - y1) < 1e-10: + self._is_invertible = False + break + def convert_physical_to_internal(self, physical_value: AtomicOdxType) -> Union[float, int]: + if not self._is_invertible: + odxraise( + f"Trying to encode value {physical_value!r} using a non-invertible " + f"SCALE-LINEAR transfer function", EncodeError) + applicable_segments = [ seg for seg in self._segments if seg.physical_applies(physical_value) ] if not applicable_segments: odxraise(r"No applicable segment for value {physical_value} found", EncodeError) return cast(int, None) - elif len(applicable_segments): - odxraise(r"Multiple applicable segments for value {physical_value} found", EncodeError) seg = applicable_segments[0] + return seg.convert_physical_to_internal(physical_value) def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> Union[float, int]: @@ -82,8 +134,6 @@ def convert_internal_to_physical(self, internal_value: AtomicOdxType) -> Union[f if not applicable_segments: odxraise(r"No applicable segment for value {internal_value} found", DecodeError) return cast(int, None) - elif len(applicable_segments): - odxraise(r"Multiple applicable segments for value {internal_value} found", DecodeError) seg = applicable_segments[0] return seg.convert_internal_to_physical(internal_value)