1&&\"string\"==typeof v&&!d.checkClone&&De.test(v))return e.each((function(i){var o=e.eq(i);y&&(t[0]=v.call(this,i,o.html())),Re(o,t,n,r)}));if(p&&(a=(i=xe(t,e[0].ownerDocument,!1,e,r)).firstChild,1===i.childNodes.length&&(i=a),a||r)){for(u=(s=b.map(ve(i,\"script\"),Le)).length;f0&&ye(a,!u&&ve(e,\"script\")),s},cleanData:function(e){for(var t,n,r,i=b.event.special,o=0;void 0!==(n=e[o]);o++)if(X(n)){if(t=n[G.expando]){if(t.events)for(r in t.events)i[r]?b.event.remove(n,r):b.removeEvent(n,r,t.handle);n[G.expando]=void 0}n[Y.expando]&&(n[Y.expando]=void 0)}}}),b.fn.extend({detach:function(e){return Me(this,e,!0)},remove:function(e){return Me(this,e)},text:function(e){return B(this,(function(e){return void 0===e?b.text(this):this.empty().each((function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)}))}),null,e,arguments.length)},append:function(){return Re(this,arguments,(function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||qe(this,e).appendChild(e)}))},prepend:function(){return Re(this,arguments,(function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=qe(this,e);t.insertBefore(e,t.firstChild)}}))},before:function(){return Re(this,arguments,(function(e){this.parentNode&&this.parentNode.insertBefore(e,this)}))},after:function(){return Re(this,arguments,(function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)}))},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(b.cleanData(ve(e,!1)),e.textContent=\"\");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map((function(){return b.clone(this,e,t)}))},html:function(e){return B(this,(function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if(\"string\"==typeof e&&!Ne.test(e)&&!ge[(de.exec(e)||[\"\",\"\"])[1].toLowerCase()]){e=b.htmlPrefilter(e);try{for(;n3,ne.removeChild(t)),s}}))}();var ze=[\"Webkit\",\"Moz\",\"ms\"],Ue=v.createElement(\"div\").style,Xe={};function Ve(e){var t=b.cssProps[e]||Xe[e];return t||(e in Ue?e:Xe[e]=function(e){for(var t=e[0].toUpperCase()+e.slice(1),n=ze.length;n--;)if((e=ze[n]+t)in Ue)return e}(e)||e)}var Ge=/^(none|table(?!-c[ea]).+)/,Ye=/^--/,Qe={position:\"absolute\",visibility:\"hidden\",display:\"block\"},Je={letterSpacing:\"0\",fontWeight:\"400\"};function Ke(e,t,n){var r=ee.exec(t);return r?Math.max(0,r[2]-(n||0))+(r[3]||\"px\"):t}function Ze(e,t,n,r,i,o){var a=\"width\"===t?1:0,s=0,u=0;if(n===(r?\"border\":\"content\"))return 0;for(;a<4;a+=2)\"margin\"===n&&(u+=b.css(e,n+te[a],!0,i)),r?(\"content\"===n&&(u-=b.css(e,\"padding\"+te[a],!0,i)),\"margin\"!==n&&(u-=b.css(e,\"border\"+te[a]+\"Width\",!0,i))):(u+=b.css(e,\"padding\"+te[a],!0,i),\"padding\"!==n?u+=b.css(e,\"border\"+te[a]+\"Width\",!0,i):s+=b.css(e,\"border\"+te[a]+\"Width\",!0,i));return!r&&o>=0&&(u+=Math.max(0,Math.ceil(e[\"offset\"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))||0),u}function et(e,t,n){var r=We(e),i=(!d.boxSizingReliable()||n)&&\"border-box\"===b.css(e,\"boxSizing\",!1,r),o=i,a=$e(e,t,r),s=\"offset\"+t[0].toUpperCase()+t.slice(1);if(Ie.test(a)){if(!n)return a;a=\"auto\"}return(!d.boxSizingReliable()&&i||!d.reliableTrDimensions()&&k(e,\"tr\")||\"auto\"===a||!parseFloat(a)&&\"inline\"===b.css(e,\"display\",!1,r))&&e.getClientRects().length&&(i=\"border-box\"===b.css(e,\"boxSizing\",!1,r),(o=s in e)&&(a=e[s])),(a=parseFloat(a)||0)+Ze(e,t,n||(i?\"border\":\"content\"),o,r,a)+\"px\"}function tt(e,t,n,r,i){return new tt.prototype.init(e,t,n,r,i)}b.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=$e(e,\"opacity\");return\"\"===n?\"1\":n}}}},cssNumber:{animationIterationCount:!0,columnCount:!0,fillOpacity:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=U(t),u=Ye.test(t),l=e.style;if(u||(t=Ve(s)),a=b.cssHooks[t]||b.cssHooks[s],void 0===n)return a&&\"get\"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];\"string\"===(o=typeof n)&&(i=ee.exec(n))&&i[1]&&(n=ae(e,t,i),o=\"number\"),null!=n&&n==n&&(\"number\"!==o||u||(n+=i&&i[3]||(b.cssNumber[s]?\"\":\"px\")),d.clearCloneStyle||\"\"!==n||0!==t.indexOf(\"background\")||(l[t]=\"inherit\"),a&&\"set\"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n))}},css:function(e,t,n,r){var i,o,a,s=U(t);return Ye.test(t)||(t=Ve(s)),(a=b.cssHooks[t]||b.cssHooks[s])&&\"get\"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=$e(e,t,r)),\"normal\"===i&&t in Je&&(i=Je[t]),\"\"===n||n?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),b.each([\"height\",\"width\"],(function(e,t){b.cssHooks[t]={get:function(e,n,r){if(n)return!Ge.test(b.css(e,\"display\"))||e.getClientRects().length&&e.getBoundingClientRect().width?et(e,t,r):Fe(e,Qe,(function(){return et(e,t,r)}))},set:function(e,n,r){var i,o=We(e),a=!d.scrollboxSize()&&\"absolute\"===o.position,s=(a||r)&&\"border-box\"===b.css(e,\"boxSizing\",!1,o),u=r?Ze(e,t,r,s,o):0;return s&&a&&(u-=Math.ceil(e[\"offset\"+t[0].toUpperCase()+t.slice(1)]-parseFloat(o[t])-Ze(e,t,\"border\",!1,o)-.5)),u&&(i=ee.exec(n))&&\"px\"!==(i[3]||\"px\")&&(e.style[t]=n,n=b.css(e,t)),Ke(0,n,u)}}})),b.cssHooks.marginLeft=_e(d.reliableMarginLeft,(function(e,t){if(t)return(parseFloat($e(e,\"marginLeft\"))||e.getBoundingClientRect().left-Fe(e,{marginLeft:0},(function(){return e.getBoundingClientRect().left})))+\"px\"})),b.each({margin:\"\",padding:\"\",border:\"Width\"},(function(e,t){b.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o=\"string\"==typeof n?n.split(\" \"):[n];r<4;r++)i[e+te[r]+t]=o[r]||o[r-2]||o[0];return i}},\"margin\"!==e&&(b.cssHooks[e+t].set=Ke)})),b.fn.extend({css:function(e,t){return B(this,(function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=We(e),i=t.length;a1)}}),b.Tween=tt,tt.prototype={constructor:tt,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||b.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(b.cssNumber[n]?\"\":\"px\")},cur:function(){var e=tt.propHooks[this.prop];return e&&e.get?e.get(this):tt.propHooks._default.get(this)},run:function(e){var t,n=tt.propHooks[this.prop];return this.options.duration?this.pos=t=b.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):tt.propHooks._default.set(this),this}},tt.prototype.init.prototype=tt.prototype,tt.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=b.css(e.elem,e.prop,\"\"))&&\"auto\"!==t?t:0},set:function(e){b.fx.step[e.prop]?b.fx.step[e.prop](e):1!==e.elem.nodeType||!b.cssHooks[e.prop]&&null==e.elem.style[Ve(e.prop)]?e.elem[e.prop]=e.now:b.style(e.elem,e.prop,e.now+e.unit)}}},tt.propHooks.scrollTop=tt.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},b.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:\"swing\"},b.fx=tt.prototype.init,b.fx.step={};var nt,rt,it=/^(?:toggle|show|hide)$/,ot=/queueHooks$/;function at(){rt&&(!1===v.hidden&&e.requestAnimationFrame?e.requestAnimationFrame(at):e.setTimeout(at,b.fx.interval),b.fx.tick())}function st(){return e.setTimeout((function(){nt=void 0})),nt=Date.now()}function ut(e,t){var n,r=0,i={height:e};for(t=t?1:0;r<4;r+=2-t)i[\"margin\"+(n=te[r])]=i[\"padding\"+n]=e;return t&&(i.opacity=i.width=e),i}function lt(e,t,n){for(var r,i=(ct.tweeners[t]||[]).concat(ct.tweeners[\"*\"]),o=0,a=i.length;o1)},removeAttr:function(e){return this.each((function(){b.removeAttr(this,e)}))}}),b.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return void 0===e.getAttribute?b.prop(e,t,n):(1===o&&b.isXMLDoc(e)||(i=b.attrHooks[t.toLowerCase()]||(b.expr.match.bool.test(t)?ft:void 0)),void 0!==n?null===n?void b.removeAttr(e,t):i&&\"set\"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n+\"\"),n):i&&\"get\"in i&&null!==(r=i.get(e,t))?r:null==(r=b.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!d.radioValue&&\"radio\"===t&&k(e,\"input\")){var n=e.value;return e.setAttribute(\"type\",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,i=t&&t.match(O);if(i&&1===e.nodeType)for(;n=i[r++];)e.removeAttribute(n)}}),ft={set:function(e,t,n){return!1===t?b.removeAttr(e,n):e.setAttribute(n,n),n}},b.each(b.expr.match.bool.source.match(/\\w+/g),(function(e,t){var n=pt[t]||b.find.attr;pt[t]=function(e,t,r){var i,o,a=t.toLowerCase();return r||(o=pt[a],pt[a]=i,i=null!=n(e,t,r)?a:null,pt[a]=o),i}}));var dt=/^(?:input|select|textarea|button)$/i,ht=/^(?:a|area)$/i;function gt(e){return(e.match(O)||[]).join(\" \")}function vt(e){return e.getAttribute&&e.getAttribute(\"class\")||\"\"}function yt(e){return Array.isArray(e)?e:\"string\"==typeof e&&e.match(O)||[]}b.fn.extend({prop:function(e,t){return B(this,b.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each((function(){delete this[b.propFix[e]||e]}))}}),b.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return 1===o&&b.isXMLDoc(e)||(t=b.propFix[t]||t,i=b.propHooks[t]),void 0!==n?i&&\"set\"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&\"get\"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=b.find.attr(e,\"tabindex\");return t?parseInt(t,10):dt.test(e.nodeName)||ht.test(e.nodeName)&&e.href?0:-1}}},propFix:{for:\"htmlFor\",class:\"className\"}}),d.optSelected||(b.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),b.each([\"tabIndex\",\"readOnly\",\"maxLength\",\"cellSpacing\",\"cellPadding\",\"rowSpan\",\"colSpan\",\"useMap\",\"frameBorder\",\"contentEditable\"],(function(){b.propFix[this.toLowerCase()]=this})),b.fn.extend({addClass:function(e){var t,n,r,i,o,a,s,u=0;if(h(e))return this.each((function(t){b(this).addClass(e.call(this,t,vt(this)))}));if((t=yt(e)).length)for(;n=this[u++];)if(i=vt(n),r=1===n.nodeType&&\" \"+gt(i)+\" \"){for(a=0;o=t[a++];)r.indexOf(\" \"+o+\" \")<0&&(r+=o+\" \");i!==(s=gt(r))&&n.setAttribute(\"class\",s)}return this},removeClass:function(e){var t,n,r,i,o,a,s,u=0;if(h(e))return this.each((function(t){b(this).removeClass(e.call(this,t,vt(this)))}));if(!arguments.length)return this.attr(\"class\",\"\");if((t=yt(e)).length)for(;n=this[u++];)if(i=vt(n),r=1===n.nodeType&&\" \"+gt(i)+\" \"){for(a=0;o=t[a++];)for(;r.indexOf(\" \"+o+\" \")>-1;)r=r.replace(\" \"+o+\" \",\" \");i!==(s=gt(r))&&n.setAttribute(\"class\",s)}return this},toggleClass:function(e,t){var n=typeof e,r=\"string\"===n||Array.isArray(e);return\"boolean\"==typeof t&&r?t?this.addClass(e):this.removeClass(e):h(e)?this.each((function(n){b(this).toggleClass(e.call(this,n,vt(this),t),t)})):this.each((function(){var t,i,o,a;if(r)for(i=0,o=b(this),a=yt(e);t=a[i++];)o.hasClass(t)?o.removeClass(t):o.addClass(t);else void 0!==e&&\"boolean\"!==n||((t=vt(this))&&G.set(this,\"__className__\",t),this.setAttribute&&this.setAttribute(\"class\",t||!1===e?\"\":G.get(this,\"__className__\")||\"\"))}))},hasClass:function(e){var t,n,r=0;for(t=\" \"+e+\" \";n=this[r++];)if(1===n.nodeType&&(\" \"+gt(vt(n))+\" \").indexOf(t)>-1)return!0;return!1}});var mt=/\\r/g;b.fn.extend({val:function(e){var t,n,r,i=this[0];return arguments.length?(r=h(e),this.each((function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,b(this).val()):e)?i=\"\":\"number\"==typeof i?i+=\"\":Array.isArray(i)&&(i=b.map(i,(function(e){return null==e?\"\":e+\"\"}))),(t=b.valHooks[this.type]||b.valHooks[this.nodeName.toLowerCase()])&&\"set\"in t&&void 0!==t.set(this,i,\"value\")||(this.value=i))}))):i?(t=b.valHooks[i.type]||b.valHooks[i.nodeName.toLowerCase()])&&\"get\"in t&&void 0!==(n=t.get(i,\"value\"))?n:\"string\"==typeof(n=i.value)?n.replace(mt,\"\"):null==n?\"\":n:void 0}}),b.extend({valHooks:{option:{get:function(e){var t=b.find.attr(e,\"value\");return null!=t?t:gt(b.text(e))}},select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a=\"select-one\"===e.type,s=a?null:[],u=a?o+1:i.length;for(r=o<0?u:a?o:0;r-1)&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),b.each([\"radio\",\"checkbox\"],(function(){b.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=b.inArray(b(e).val(),t)>-1}},d.checkOn||(b.valHooks[this].get=function(e){return null===e.getAttribute(\"value\")?\"on\":e.value})})),d.focusin=\"onfocusin\"in e;var xt=/^(?:focusinfocus|focusoutblur)$/,bt=function(e){e.stopPropagation()};b.extend(b.event,{trigger:function(t,n,r,i){var o,a,s,u,l,f,p,d,y=[r||v],m=c.call(t,\"type\")?t.type:t,x=c.call(t,\"namespace\")?t.namespace.split(\".\"):[];if(a=d=s=r=r||v,3!==r.nodeType&&8!==r.nodeType&&!xt.test(m+b.event.triggered)&&(m.indexOf(\".\")>-1&&(x=m.split(\".\"),m=x.shift(),x.sort()),l=m.indexOf(\":\")<0&&\"on\"+m,(t=t[b.expando]?t:new b.Event(m,\"object\"==typeof t&&t)).isTrigger=i?2:3,t.namespace=x.join(\".\"),t.rnamespace=t.namespace?new RegExp(\"(^|\\\\.)\"+x.join(\"\\\\.(?:.*\\\\.|)\")+\"(\\\\.|$)\"):null,t.result=void 0,t.target||(t.target=r),n=null==n?[t]:b.makeArray(n,[t]),p=b.event.special[m]||{},i||!p.trigger||!1!==p.trigger.apply(r,n))){if(!i&&!p.noBubble&&!g(r)){for(u=p.delegateType||m,xt.test(u+m)||(a=a.parentNode);a;a=a.parentNode)y.push(a),s=a;s===(r.ownerDocument||v)&&y.push(s.defaultView||s.parentWindow||e)}for(o=0;(a=y[o++])&&!t.isPropagationStopped();)d=a,t.type=o>1?u:p.bindType||m,(f=(G.get(a,\"events\")||Object.create(null))[t.type]&&G.get(a,\"handle\"))&&f.apply(a,n),(f=l&&a[l])&&f.apply&&X(a)&&(t.result=f.apply(a,n),!1===t.result&&t.preventDefault());return t.type=m,i||t.isDefaultPrevented()||p._default&&!1!==p._default.apply(y.pop(),n)||!X(r)||l&&h(r[m])&&!g(r)&&((s=r[l])&&(r[l]=null),b.event.triggered=m,t.isPropagationStopped()&&d.addEventListener(m,bt),r[m](),t.isPropagationStopped()&&d.removeEventListener(m,bt),b.event.triggered=void 0,s&&(r[l]=s)),t.result}},simulate:function(e,t,n){var r=b.extend(new b.Event,n,{type:e,isSimulated:!0});b.event.trigger(r,null,t)}}),b.fn.extend({trigger:function(e,t){return this.each((function(){b.event.trigger(e,t,this)}))},triggerHandler:function(e,t){var n=this[0];if(n)return b.event.trigger(e,t,n,!0)}}),d.focusin||b.each({focus:\"focusin\",blur:\"focusout\"},(function(e,t){var n=function(e){b.event.simulate(t,e.target,b.event.fix(e))};b.event.special[t]={setup:function(){var r=this.ownerDocument||this.document||this,i=G.access(r,t);i||r.addEventListener(e,n,!0),G.access(r,t,(i||0)+1)},teardown:function(){var r=this.ownerDocument||this.document||this,i=G.access(r,t)-1;i?G.access(r,t,i):(r.removeEventListener(e,n,!0),G.remove(r,t))}}}));var wt=e.location,Tt={guid:Date.now()},Ct=/\\?/;b.parseXML=function(t){var n;if(!t||\"string\"!=typeof t)return null;try{n=(new e.DOMParser).parseFromString(t,\"text/xml\")}catch(e){n=void 0}return n&&!n.getElementsByTagName(\"parsererror\").length||b.error(\"Invalid XML: \"+t),n};var Et=/\\[\\]$/,St=/\\r?\\n/g,kt=/^(?:submit|button|image|reset|file)$/i,At=/^(?:input|select|textarea|keygen)/i;function Nt(e,t,n,r){var i;if(Array.isArray(t))b.each(t,(function(t,i){n||Et.test(e)?r(e,i):Nt(e+\"[\"+(\"object\"==typeof i&&null!=i?t:\"\")+\"]\",i,n,r)}));else if(n||\"object\"!==x(t))r(e,t);else for(i in t)Nt(e+\"[\"+i+\"]\",t[i],n,r)}b.param=function(e,t){var n,r=[],i=function(e,t){var n=h(t)?t():t;r[r.length]=encodeURIComponent(e)+\"=\"+encodeURIComponent(null==n?\"\":n)};if(null==e)return\"\";if(Array.isArray(e)||e.jquery&&!b.isPlainObject(e))b.each(e,(function(){i(this.name,this.value)}));else for(n in e)Nt(n,e[n],t,i);return r.join(\"&\")},b.fn.extend({serialize:function(){return b.param(this.serializeArray())},serializeArray:function(){return this.map((function(){var e=b.prop(this,\"elements\");return e?b.makeArray(e):this})).filter((function(){var e=this.type;return this.name&&!b(this).is(\":disabled\")&&At.test(this.nodeName)&&!kt.test(e)&&(this.checked||!pe.test(e))})).map((function(e,t){var n=b(this).val();return null==n?null:Array.isArray(n)?b.map(n,(function(e){return{name:t.name,value:e.replace(St,\"\\r\\n\")}})):{name:t.name,value:n.replace(St,\"\\r\\n\")}})).get()}});var Dt=/%20/g,jt=/#.*$/,qt=/([?&])_=[^&]*/,Lt=/^(.*?):[ \\t]*([^\\r\\n]*)$/gm,Ht=/^(?:GET|HEAD)$/,Ot=/^\\/\\//,Pt={},Rt={},Mt=\"*/\".concat(\"*\"),It=v.createElement(\"a\");function Wt(e){return function(t,n){\"string\"!=typeof t&&(n=t,t=\"*\");var r,i=0,o=t.toLowerCase().match(O)||[];if(h(n))for(;r=o[i++];)\"+\"===r[0]?(r=r.slice(1)||\"*\",(e[r]=e[r]||[]).unshift(n)):(e[r]=e[r]||[]).push(n)}}function Ft(e,t,n,r){var i={},o=e===Rt;function a(s){var u;return i[s]=!0,b.each(e[s]||[],(function(e,s){var l=s(t,n,r);return\"string\"!=typeof l||o||i[l]?o?!(u=l):void 0:(t.dataTypes.unshift(l),a(l),!1)})),u}return a(t.dataTypes[0])||!i[\"*\"]&&a(\"*\")}function Bt(e,t){var n,r,i=b.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&b.extend(!0,e,r),e}It.href=wt.href,b.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:wt.href,type:\"GET\",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(wt.protocol),global:!0,processData:!0,async:!0,contentType:\"application/x-www-form-urlencoded; charset=UTF-8\",accepts:{\"*\":Mt,text:\"text/plain\",html:\"text/html\",xml:\"application/xml, text/xml\",json:\"application/json, text/javascript\"},contents:{xml:/\\bxml\\b/,html:/\\bhtml/,json:/\\bjson\\b/},responseFields:{xml:\"responseXML\",text:\"responseText\",json:\"responseJSON\"},converters:{\"* text\":String,\"text html\":!0,\"text json\":JSON.parse,\"text xml\":b.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?Bt(Bt(e,b.ajaxSettings),t):Bt(b.ajaxSettings,e)},ajaxPrefilter:Wt(Pt),ajaxTransport:Wt(Rt),ajax:function(t,n){\"object\"==typeof t&&(n=t,t=void 0),n=n||{};var r,i,o,a,s,u,l,c,f,p,d=b.ajaxSetup({},n),h=d.context||d,g=d.context&&(h.nodeType||h.jquery)?b(h):b.event,y=b.Deferred(),m=b.Callbacks(\"once memory\"),x=d.statusCode||{},w={},T={},C=\"canceled\",E={readyState:0,getResponseHeader:function(e){var t;if(l){if(!a)for(a={};t=Lt.exec(o);)a[t[1].toLowerCase()+\" \"]=(a[t[1].toLowerCase()+\" \"]||[]).concat(t[2]);t=a[e.toLowerCase()+\" \"]}return null==t?null:t.join(\", \")},getAllResponseHeaders:function(){return l?o:null},setRequestHeader:function(e,t){return null==l&&(e=T[e.toLowerCase()]=T[e.toLowerCase()]||e,w[e]=t),this},overrideMimeType:function(e){return null==l&&(d.mimeType=e),this},statusCode:function(e){var t;if(e)if(l)E.always(e[E.status]);else for(t in e)x[t]=[x[t],e[t]];return this},abort:function(e){var t=e||C;return r&&r.abort(t),S(0,t),this}};if(y.promise(E),d.url=((t||d.url||wt.href)+\"\").replace(Ot,wt.protocol+\"//\"),d.type=n.method||n.type||d.method||d.type,d.dataTypes=(d.dataType||\"*\").toLowerCase().match(O)||[\"\"],null==d.crossDomain){u=v.createElement(\"a\");try{u.href=d.url,u.href=u.href,d.crossDomain=It.protocol+\"//\"+It.host!=u.protocol+\"//\"+u.host}catch(e){d.crossDomain=!0}}if(d.data&&d.processData&&\"string\"!=typeof d.data&&(d.data=b.param(d.data,d.traditional)),Ft(Pt,d,n,E),l)return E;for(f in(c=b.event&&d.global)&&0==b.active++&&b.event.trigger(\"ajaxStart\"),d.type=d.type.toUpperCase(),d.hasContent=!Ht.test(d.type),i=d.url.replace(jt,\"\"),d.hasContent?d.data&&d.processData&&0===(d.contentType||\"\").indexOf(\"application/x-www-form-urlencoded\")&&(d.data=d.data.replace(Dt,\"+\")):(p=d.url.slice(i.length),d.data&&(d.processData||\"string\"==typeof d.data)&&(i+=(Ct.test(i)?\"&\":\"?\")+d.data,delete d.data),!1===d.cache&&(i=i.replace(qt,\"$1\"),p=(Ct.test(i)?\"&\":\"?\")+\"_=\"+Tt.guid+++p),d.url=i+p),d.ifModified&&(b.lastModified[i]&&E.setRequestHeader(\"If-Modified-Since\",b.lastModified[i]),b.etag[i]&&E.setRequestHeader(\"If-None-Match\",b.etag[i])),(d.data&&d.hasContent&&!1!==d.contentType||n.contentType)&&E.setRequestHeader(\"Content-Type\",d.contentType),E.setRequestHeader(\"Accept\",d.dataTypes[0]&&d.accepts[d.dataTypes[0]]?d.accepts[d.dataTypes[0]]+(\"*\"!==d.dataTypes[0]?\", \"+Mt+\"; q=0.01\":\"\"):d.accepts[\"*\"]),d.headers)E.setRequestHeader(f,d.headers[f]);if(d.beforeSend&&(!1===d.beforeSend.call(h,E,d)||l))return E.abort();if(C=\"abort\",m.add(d.complete),E.done(d.success),E.fail(d.error),r=Ft(Rt,d,n,E)){if(E.readyState=1,c&&g.trigger(\"ajaxSend\",[E,d]),l)return E;d.async&&d.timeout>0&&(s=e.setTimeout((function(){E.abort(\"timeout\")}),d.timeout));try{l=!1,r.send(w,S)}catch(e){if(l)throw e;S(-1,e)}}else S(-1,\"No Transport\");function S(t,n,a,u){var f,p,v,w,T,C=n;l||(l=!0,s&&e.clearTimeout(s),r=void 0,o=u||\"\",E.readyState=t>0?4:0,f=t>=200&&t<300||304===t,a&&(w=function(e,t,n){for(var r,i,o,a,s=e.contents,u=e.dataTypes;\"*\"===u[0];)u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader(\"Content-Type\"));if(r)for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+\" \"+u[0]]){o=i;break}a||(a=i)}o=o||a}if(o)return o!==u[0]&&u.unshift(o),n[o]}(d,E,a)),!f&&b.inArray(\"script\",d.dataTypes)>-1&&(d.converters[\"text script\"]=function(){}),w=function(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];for(o=c.shift();o;)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift())if(\"*\"===o)o=u;else if(\"*\"!==u&&u!==o){if(!(a=l[u+\" \"+o]||l[\"* \"+o]))for(i in l)if((s=i.split(\" \"))[1]===o&&(a=l[u+\" \"+s[0]]||l[\"* \"+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}if(!0!==a)if(a&&e.throws)t=a(t);else try{t=a(t)}catch(e){return{state:\"parsererror\",error:a?e:\"No conversion from \"+u+\" to \"+o}}}return{state:\"success\",data:t}}(d,w,E,f),f?(d.ifModified&&((T=E.getResponseHeader(\"Last-Modified\"))&&(b.lastModified[i]=T),(T=E.getResponseHeader(\"etag\"))&&(b.etag[i]=T)),204===t||\"HEAD\"===d.type?C=\"nocontent\":304===t?C=\"notmodified\":(C=w.state,p=w.data,f=!(v=w.error))):(v=C,!t&&C||(C=\"error\",t<0&&(t=0))),E.status=t,E.statusText=(n||C)+\"\",f?y.resolveWith(h,[p,C,E]):y.rejectWith(h,[E,C,v]),E.statusCode(x),x=void 0,c&&g.trigger(f?\"ajaxSuccess\":\"ajaxError\",[E,d,f?p:v]),m.fireWith(h,[E,C]),c&&(g.trigger(\"ajaxComplete\",[E,d]),--b.active||b.event.trigger(\"ajaxStop\")))}return E},getJSON:function(e,t,n){return b.get(e,t,n,\"json\")},getScript:function(e,t){return b.get(e,void 0,t,\"script\")}}),b.each([\"get\",\"post\"],(function(e,t){b[t]=function(e,n,r,i){return h(n)&&(i=i||r,r=n,n=void 0),b.ajax(b.extend({url:e,type:t,dataType:i,data:n,success:r},b.isPlainObject(e)&&e))}})),b.ajaxPrefilter((function(e){var t;for(t in e.headers)\"content-type\"===t.toLowerCase()&&(e.contentType=e.headers[t]||\"\")})),b._evalUrl=function(e,t,n){return b.ajax({url:e,type:\"GET\",dataType:\"script\",cache:!0,async:!1,global:!1,converters:{\"text script\":function(){}},dataFilter:function(e){b.globalEval(e,t,n)}})},b.fn.extend({wrapAll:function(e){var t;return this[0]&&(h(e)&&(e=e.call(this[0])),t=b(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map((function(){for(var e=this;e.firstElementChild;)e=e.firstElementChild;return e})).append(this)),this},wrapInner:function(e){return h(e)?this.each((function(t){b(this).wrapInner(e.call(this,t))})):this.each((function(){var t=b(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)}))},wrap:function(e){var t=h(e);return this.each((function(n){b(this).wrapAll(t?e.call(this,n):e)}))},unwrap:function(e){return this.parent(e).not(\"body\").each((function(){b(this).replaceWith(this.childNodes)})),this}}),b.expr.pseudos.hidden=function(e){return!b.expr.pseudos.visible(e)},b.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},b.ajaxSettings.xhr=function(){try{return new e.XMLHttpRequest}catch(e){}};var $t={0:200,1223:204},_t=b.ajaxSettings.xhr();d.cors=!!_t&&\"withCredentials\"in _t,d.ajax=_t=!!_t,b.ajaxTransport((function(t){var n,r;if(d.cors||_t&&!t.crossDomain)return{send:function(i,o){var a,s=t.xhr();if(s.open(t.type,t.url,t.async,t.username,t.password),t.xhrFields)for(a in t.xhrFields)s[a]=t.xhrFields[a];for(a in t.mimeType&&s.overrideMimeType&&s.overrideMimeType(t.mimeType),t.crossDomain||i[\"X-Requested-With\"]||(i[\"X-Requested-With\"]=\"XMLHttpRequest\"),i)s.setRequestHeader(a,i[a]);n=function(e){return function(){n&&(n=r=s.onload=s.onerror=s.onabort=s.ontimeout=s.onreadystatechange=null,\"abort\"===e?s.abort():\"error\"===e?\"number\"!=typeof s.status?o(0,\"error\"):o(s.status,s.statusText):o($t[s.status]||s.status,s.statusText,\"text\"!==(s.responseType||\"text\")||\"string\"!=typeof s.responseText?{binary:s.response}:{text:s.responseText},s.getAllResponseHeaders()))}},s.onload=n(),r=s.onerror=s.ontimeout=n(\"error\"),void 0!==s.onabort?s.onabort=r:s.onreadystatechange=function(){4===s.readyState&&e.setTimeout((function(){n&&r()}))},n=n(\"abort\");try{s.send(t.hasContent&&t.data||null)}catch(e){if(n)throw e}},abort:function(){n&&n()}}})),b.ajaxPrefilter((function(e){e.crossDomain&&(e.contents.script=!1)})),b.ajaxSetup({accepts:{script:\"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript\"},contents:{script:/\\b(?:java|ecma)script\\b/},converters:{\"text script\":function(e){return b.globalEval(e),e}}}),b.ajaxPrefilter(\"script\",(function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type=\"GET\")})),b.ajaxTransport(\"script\",(function(e){var t,n;if(e.crossDomain||e.scriptAttrs)return{send:function(r,i){t=b(\""
- ],
- "text/plain": [
- ":Curve [qh] (energy)"
- ]
- },
- "execution_count": 8,
- "metadata": {
- "application/vnd.holoviews_exec.v0+json": {
- "id": "1001"
- }
- },
- "output_type": "execute_result"
- }
- ],
- "source": [
- "dispersion1D['energy'].hvplot()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 9,
- "metadata": {},
- "outputs": [],
- "source": [
- "dispersion1D.easyscience.add_variable('S', ['qh'], sl.structure_factor(dispersion1D['qh'].__array__()))"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 10,
- "metadata": {},
- "outputs": [
- {
- "data": {},
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.holoviews_exec.v0+json": "",
- "text/html": [
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "
\n",
- "
\n",
- ""
- ],
- "text/plain": [
- ":Curve [qh] (S)"
- ]
- },
- "execution_count": 10,
- "metadata": {
- "application/vnd.holoviews_exec.v0+json": {
- "id": "1106"
- }
- },
- "output_type": "execute_result"
- }
- ],
- "source": [
- "dispersion1D['S'].hvplot()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 12,
- "metadata": {},
- "outputs": [],
- "source": [
- "dispersion3D = xr.Dataset()\n",
- "dispersion3D.easyscience.add_coordinate('qh', np.linspace(-2, 2, 501))\n",
- "dispersion3D.easyscience.add_coordinate('qk', np.linspace(-2, 2, 51))\n",
- "dispersion3D.easyscience.add_coordinate('ql', np.linspace(-2, 2, 51))"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 13,
- "metadata": {},
- "outputs": [],
- "source": [
- "points = dispersion3D.easyscience.generate_points(['qh', 'qk', 'ql'])\n",
- "dispersion3D.easyscience.add_variable('w', ['qh', 'qk', 'ql'], sl.dispersion(points[0, :]).unstack())\n",
- "dispersion3D.easyscience.add_variable('S', ['qh', 'qk', 'ql'], sl.structure_factor_assume(points).unstack())"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 14,
- "metadata": {},
- "outputs": [
- {
- "data": {},
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.holoviews_exec.v0+json": "",
- "text/html": [
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "
\n",
- "
\n",
- ""
- ],
- "text/plain": [
- ":DynamicMap [qk,ql]\n",
- " :Curve [qh] (w)"
- ]
- },
- "execution_count": 14,
- "metadata": {
- "application/vnd.holoviews_exec.v0+json": {
- "id": "1211"
- }
- },
- "output_type": "execute_result"
- }
- ],
- "source": [
- "dispersion3D['w'].hvplot(groupby=['qk', 'ql'])"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 15,
- "metadata": {},
- "outputs": [
- {
- "data": {},
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.holoviews_exec.v0+json": "",
- "text/html": [
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "
\n",
- "
\n",
- ""
- ],
- "text/plain": [
- ":DynamicMap [qk]\n",
- " :Image [qh,ql] (S)"
- ]
- },
- "execution_count": 15,
- "metadata": {
- "application/vnd.holoviews_exec.v0+json": {
- "id": "1489"
- }
- },
- "output_type": "execute_result"
- }
- ],
- "source": [
- "dispersion3D['S'].hvplot(x='qh', y='ql', groupby=['qk'])"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# Fitting"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 17,
- "metadata": {},
- "outputs": [],
- "source": [
- "sl2 = spinLadder.from_pars(12.1, 1.4)\n",
- "sl2_disp = xr.Dataset()\n",
- "sl2_disp.easyscience.add_coordinate('qh', np.linspace(0, 1, 101))\n",
- "sl2_disp.easyscience.add_variable('w', ['qh'], sl2.dispersion(sl2_disp.qh) + 0.1*(np.random.random(sl2_disp.qh.shape) - 0.5))"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 18,
- "metadata": {},
- "outputs": [
- {
- "data": {},
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.holoviews_exec.v0+json": "",
- "text/html": [
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "
\n",
- "
\n",
- ""
- ],
- "text/plain": [
- ":Curve [qh] (w)"
- ]
- },
- "execution_count": 18,
- "metadata": {
- "application/vnd.holoviews_exec.v0+json": {
- "id": "1727"
- }
- },
- "output_type": "execute_result"
- }
- ],
- "source": [
- "sl2_disp.hvplot()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 19,
- "metadata": {},
- "outputs": [],
- "source": [
- "from easyscience.fitting import Fitter\n",
- "f = Fitter()\n",
- "f.initialize(sl, sl.dispersion)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 20,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- " \n"
- ]
- }
- ],
- "source": [
- "print(sl.Jr, sl.Jl)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 21,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- " \n"
- ]
- }
- ],
- "source": [
- "res = sl2_disp['w'].easyscience.fit(f)\n",
- "print(sl.Jr, sl.Jl)\n",
- "sl2_disp['w_fit'] = res.y_calc"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 22,
- "metadata": {},
- "outputs": [
- {
- "data": {},
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.holoviews_exec.v0+json": "",
- "text/html": [
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "
\n",
- "
\n",
- ""
- ],
- "text/plain": [
- ":NdOverlay [Variable]\n",
- " :Curve [qh] (value)"
- ]
- },
- "execution_count": 22,
- "metadata": {
- "application/vnd.holoviews_exec.v0+json": {
- "id": "1832"
- }
- },
- "output_type": "execute_result"
- }
- ],
- "source": [
- "sl2_disp.hvplot(y=['w', 'w_fit'])"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# Playing in 4D space"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 36,
- "metadata": {},
- "outputs": [],
- "source": [
- "import scipy\n",
- "def do_kdtree(combined_x_y_arrays, points):\n",
- " mytree = scipy.spatial.cKDTree(combined_x_y_arrays)\n",
- " dist, indexes = mytree.query(points)\n",
- " return indexes\n",
- "\n",
- "dispersion3D.easyscience.add_dimension('e', np.linspace(8, 20, 101))\n",
- "\n",
- "m = np.zeros(np.prod(list(dispersion3D.dims.values())))\n",
- "indices = do_kdtree(dispersion3D.easyscience.generate_points(['qh', 'qk', 'ql', 'e']).T, xr.concat([dispersion3D.easyscience.generate_points(['qh', 'qk', 'ql']).unstack()[0], dispersion3D.easyscience.generate_points(['qh', 'qk', 'ql']).unstack()[1], dispersion3D.easyscience.generate_points(['qh', 'qk', 'ql']).unstack()[2], dispersion3D['w']], dim='fit_dim').stack(all_x=['qh', 'qk', 'ql']).T)\n",
- "m[indices] = np.array(dispersion3D['S']).reshape((1, -1))"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 37,
- "metadata": {},
- "outputs": [
- {
- "data": {},
- "metadata": {},
- "output_type": "display_data"
- },
- {
- "data": {
- "application/vnd.holoviews_exec.v0+json": "",
- "text/html": [
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "\n",
- "
\n",
- "
\n",
- ""
- ],
- "text/plain": [
- ":DynamicMap [qk,ql]\n",
- " :Image [qh,e] (w_map)"
- ]
- },
- "execution_count": 37,
- "metadata": {
- "application/vnd.holoviews_exec.v0+json": {
- "id": "2590"
- }
- },
- "output_type": "execute_result"
- }
- ],
- "source": [
- "dispersion3D.easyscience.add_variable('w_map', ['qh', 'qk', 'ql', 'e'], m.reshape((dispersion3D.dims['qh'], dispersion3D.dims['qk'], dispersion3D.dims['ql'], dispersion3D.dims['e'])))\n",
- "dispersion3D['w_map'].hvplot(groupby=['qk', 'ql'], x='qh', y='e')"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": []
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "Python 3",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.8.3"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 1
-}
diff --git a/legacy/examples_old/example1.py b/legacy/examples_old/example1.py
deleted file mode 100644
index 14609f7a..00000000
--- a/legacy/examples_old/example1.py
+++ /dev/null
@@ -1,30 +0,0 @@
-__author__ = 'github.com/wardsimon'
-__version__ = '0.1.0'
-
-import numpy as np
-
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-# This is a simple example of creating an object which has fitable parameters
-
-b = BaseObj('line',
- m=Parameter('m', 1),
- c=Parameter('c', 1))
-
-
-def fit_fun(x):
- # In the real case we would gust call the evaluation fn without reference to the BaseObj
- return b.c.value + b.m.value * x
-
-
-f = Fitter()
-f.initialize(b, fit_fun)
-
-x = np.array([1, 2, 3])
-y = np.array([2, 4, 6]) - 1
-
-f_res = f.fit(x, y)
-
-print(f_res.goodness_of_fit)
diff --git a/legacy/examples_old/example1_dream.py b/legacy/examples_old/example1_dream.py
deleted file mode 100644
index 0b5621be..00000000
--- a/legacy/examples_old/example1_dream.py
+++ /dev/null
@@ -1,45 +0,0 @@
-__author__ = "github.com/wardsimon"
-__version__ = "0.1.0"
-
-import numpy as np
-
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-# This is a simple example of creating an object which has fitable parameters
-
-b = BaseObj("line", m=Parameter("m", 1), c=Parameter("c", 1))
-
-
-def fit_fun(x):
- # In the real case we would gust call the evaluation fn without reference to the BaseObj
- return b.c.value + b.m.value * x
-
-
-f = Fitter()
-f.initialize(b, fit_fun)
-f.switch_minimizer("bumps")
-
-x = np.array([1, 2, 3])
-y = np.array([2, 4, 6]) - 1
-
-method = "dream"
-dream_kwargs = {
- k: v
- for k, v in [
- ("samples", 10000),
- ("burn", 100),
- ("pop", 10),
- ("init", "eps"),
- ("thin", 1),
- ("alpha", 0.01),
- ("outliers", "none"),
- ("trim", False),
- ("steps", 0),
- ]
-}
-
-f_res = f.fit(x, y, method="dream", minimizer_kwargs=dream_kwargs)
-
-print(f_res.chi2)
diff --git a/legacy/examples_old/example2.py b/legacy/examples_old/example2.py
deleted file mode 100644
index 6e1acbd2..00000000
--- a/legacy/examples_old/example2.py
+++ /dev/null
@@ -1,53 +0,0 @@
-__author__ = "github.com/wardsimon"
-__version__ = "0.1.0"
-
-import numpy as np
-
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-# In this case we have inherited from `BaseObj` to create a class which has fitable attributes.
-
-
-class Line(BaseObj):
- """
- Simple descriptor of a line.
- """
-
- _m = 1
- _c = 0
-
- def __init__(self):
- super().__init__(self.__class__.__name__, *self._defaults)
-
- @property
- def _defaults(self):
- return [Parameter("m", self._m), Parameter("c", self._c)]
-
- @property
- def gradient(self):
- return self.m.value
-
- @property
- def intercept(self):
- return self.c.value
-
- def fit_func(self, x: np.ndarray) -> np.ndarray:
- return self.gradient * x + self.intercept
-
- def __repr__(self):
- return f"Line: m={self.m}, c={self.c}"
-
-
-l = Line()
-f = Fitter()
-f.initialize(l, l.fit_func)
-
-x = np.array([1, 2, 3])
-y = np.array([2, 4, 6]) - 1
-
-f_res = f.fit(x, y)
-
-print(f_res.chi2)
-print(l)
diff --git a/legacy/examples_old/example3.py b/legacy/examples_old/example3.py
deleted file mode 100644
index 49d6ede9..00000000
--- a/legacy/examples_old/example3.py
+++ /dev/null
@@ -1,101 +0,0 @@
-__author__ = 'github.com/wardsimon'
-__version__ = '0.1.0'
-
-from typing import Callable
-
-import numpy as np
-
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-# In this case we have inherited from `BaseObj` to create a class which has fitable attributes.
-# This class does not know about the `Calculator`, only the interface.
-
-
-class Calculator:
- def __init__(self, m=1, c=0):
- self.m = m
- self.c = c
-
- def calculate(self, x):
- return self.m * x + self.c
-
-
-class Interface:
- def __init__(self):
- self.calculator = Calculator()
-
- def get_value(self, value_label: str):
- return getattr(self.calculator, value_label, None)
-
- def set_value(self, value_label: str, value):
- print(f'Value of {value_label} set to {value}')
- setattr(self.calculator, value_label, value)
-
- def fit_func(self, x):
- return self.calculator.calculate(x)
-
-
-class Line(BaseObj):
- _defaults = [Parameter('m', 1),
- Parameter('c', 0)]
-
- def __init__(self, interface=None):
- super().__init__(self.__class__.__name__,
- *self._defaults)
- self.interface = interface
- if self.interface:
- for parameter in self.get_fit_parameters():
- name = parameter.name
- setattr(self.__class__.__dict__[name],
- '_callback',
- property(self.__gitem(name), self.__sitem(self, name)))
-
- @property
- def gradient(self):
- if self.interface:
- return self.interface.get_value('m')
- else:
- return self.m.value
-
- @property
- def intercept(self):
- if self.interface:
- return self.interface.get_value('c')
- else:
- return self.c.value
-
- def fit_func(self, x: np.ndarray) -> np.ndarray:
- if self.interface:
- return self.interface.fit_func(x)
- else:
- raise NotImplementedError
-
- def __repr__(self):
- return f'Line: m={self.m}, c={self.c}'
-
- @staticmethod
- def __gitem(key: str) -> Callable:
- def inner(obj):
- obj.interface.get_value(key)
- return lambda obj: inner(obj)
-
- @staticmethod
- def __sitem(obj, key):
- def inner(value):
- obj.interface.set_value(key, value)
- return inner
-
-
-l = Line(interface=Interface())
-l.interface
-f = Fitter(l, l.fit_func)
-
-x = np.array([1, 2, 3])
-y = np.array([2, 4, 6]) - 1
-
-f_res = f.fit(x, y)
-
-print(f_res)
-print(l)
\ No newline at end of file
diff --git a/legacy/examples_old/example4.py b/legacy/examples_old/example4.py
deleted file mode 100644
index 720ff961..00000000
--- a/legacy/examples_old/example4.py
+++ /dev/null
@@ -1,448 +0,0 @@
-__author__ = "github.com/wardsimon"
-__version__ = "0.1.0"
-
-import json
-from abc import ABCMeta
-from abc import abstractmethod
-from typing import Callable
-from typing import List
-
-import numpy as np
-
-from easyscience import global_object
-from easyscience.fitting import Fitter
-from easyscience.Objects.component_serializer import ComponentSerializer
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-# This is a much more complex case where we have calculators, interfaces, interface factory and an
-# inherited object (from `BaseObj`). In this case the Line class is available with/without an interface
-# With an interface it connects to one of the calculator interfaces. This calculator interface then translates
-# interface commands to calculator specific commands
-
-
-class Calculator1:
- """
- Generic calculator in the style of crysPy
- """
-
- def __init__(self, m: float = 1, c: float = 0):
- """
- Create a calculator object with m and c
-
- :param m: gradient
- :type m: float
- :param c: intercept
- :type c: float
- """
- self.m = m
- self.c = c
-
- def calculate(self, x_array: np.ndarray) -> np.ndarray:
- """
- For a given x calculate the corresponding y
-
- :param x_array: array of data points to be calculated
- :type x_array: np.ndarray
- :return: points calculated at `x`
- :rtype: np.ndarray
- """
- return self.m * x_array + self.c
-
-
-class Calculator2:
- """
- Isolated calculator. This calculator can't have values set, it can
- only load/save data and calculate from it. i.e in the style of crysfml
- """
-
- def __init__(self):
- """ """
- self._data = {"m": 0, "c": 0}
-
- def calculate(self, x_array: np.ndarray) -> np.ndarray:
- """
- For a given x calculate the corresponding y
-
- :param x_array: array of data points to be calculated
- :type x_array: np.ndarray
- :return: points calculated at `x`
- :rtype: np.ndarray
- """
- return self._data["m"] * x_array + self._data["c"]
-
- def export_data(self) -> str:
- return json.dumps(self._data)
-
- def import_data(self, input_str: str):
- self._data = json.loads(input_str)
-
-
-class InterfaceTemplate(ComponentSerializer, metaclass=ABCMeta):
- """
- This class is a template and defines all properties that an interface should have.
- """
-
- _interfaces = []
- _global_object = global_object
-
- def __init_subclass__(cls, is_abstract: bool = False, **kwargs):
- """
- Initialise all subclasses so that they can be created in the factory
-
- :param is_abstract: Is this a subclass which shouldn't be dded
- :type is_abstract: bool
- :param kwargs: key word arguments
- :type kwargs: dict
- :return: None
- :rtype: noneType
- """
- super().__init_subclass__(**kwargs)
- if not is_abstract:
- cls._interfaces.append(cls)
-
- @abstractmethod
- def get_value(self, value_label: str) -> float:
- """
- Method to get a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :return: associated value
- :rtype: float
- """
- pass
-
- @abstractmethod
- def set_value(self, value_label: str, value: float):
- """
- Method to set a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :param value: new numeric value
- :type value: float
- :return: None
- :rtype: noneType
- """
- pass
-
- @abstractmethod
- def fit_func(self, x_array: np.ndarray) -> np.ndarray:
- """
- Function to perform a fit
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :return: calculated points
- :rtype: np.ndarray
- """
- pass
-
-
-class Interface1(InterfaceTemplate):
- """
- A simple example interface using Calculator1
- """
-
- def __init__(self):
- # This interface will use calculator1
- self.calculator = Calculator1()
-
- def get_value(self, value_label: str) -> float:
- """
- Method to get a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :return: associated value
- :rtype: float
- """
- return getattr(self.calculator, value_label, None)
-
- def set_value(self, value_label: str, value: float):
- """
- Method to set a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :param value: new numeric value
- :type value: float
- :return: None
- :rtype: noneType
- """
- if self._global_object.debug:
- print(f"Interface1: Value of {value_label} set to {value}")
- setattr(self.calculator, value_label, value)
-
- def fit_func(self, x_array: np.ndarray) -> np.ndarray:
- """
- Function to perform a fit
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :return: calculated points
- :rtype: np.ndarray
- """
- return self.calculator.calculate(x_array)
-
-
-class Interface2(InterfaceTemplate):
- """
- This is a more complex template. Here we need to export_data data,
- transfer it to the calculator (get and calculate) and import data
- from the calculator (set)
- """
-
- def __init__(self):
- """
- Set up a calculator and a local dict
- """
- self.calculator = Calculator2()
- self._data: dict = {}
-
- def get_value(self, value_label: str) -> float:
- """
- Method to get a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :return: associated value
- :rtype: float
- """
- self._data = json.loads(self.calculator.export_data())
- return getattr(self._data, value_label, None)
-
- def set_value(self, value_label: str, value: float):
- """
- Method to set a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :param value: new numeric value
- :type value: float
- :return: None
- :rtype: noneType
- """
- if self._global_object.debug:
- print(f"Interface2: Value of {value_label} set to {value}")
- self._data = json.loads(self.calculator.export_data())
- if value_label in self._data.keys():
- self._data[value_label] = value
- self.calculator.import_data(json.dumps(self._data))
-
- def fit_func(self, x_array: np.ndarray) -> np.ndarray:
- """
- Function to perform a fit
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :return: calculated points
- :rtype: np.ndarray
- """
- return self.calculator.calculate(x_array)
-
-
-class InterfaceFactory:
- """
- This class allows for the creation and transference of interfaces.
- """
-
- def __init__(self):
- self._interfaces: List[InterfaceTemplate] = InterfaceTemplate._interfaces
- self._current_interface = None
- self.__interface_obj = None
- self.create()
-
- def create(self, interface_name: str = None):
- """
- Create an interface to a calculator from those initialized. Interfaces can be selected
- by `interface_name` where `interface_name` is one of `obj.available_interfaces`. This
- interface can now be accessed by obj()
-
- :param interface_name: name of interface to be created
- :type interface_name: str
- :return: None
- :rtype: noneType
- """
- if interface_name is None:
- interface_name = self._interfaces[0].__name__
-
- interfaces = self.available_interfaces
- if interface_name in interfaces:
- self._current_interface = self._interfaces[interfaces.index(interface_name)]
- self.__interface_obj = self._current_interface()
-
- def switch(self, new_interface: str):
- """
- Changes the current interface to a new interface. The current interface is destroyed and
- all ComponentSerializer parameters carried over to the new interface. i.e. pick up where you left off.
-
- :param new_interface: name of new interface to be created
- :type new_interface: str
- :return: None
- :rtype: noneType
- """
- serialized = self.__interface_obj.as_dict()
- interfaces = self.available_interfaces
- if new_interface in interfaces:
- self._current_interface: InterfaceTemplate = self._interfaces[
- interfaces.index(new_interface)
- ]
- self.__interface_obj = self._current_interface.from_dict(serialized)
- else:
- raise AttributeError
-
- @property
- def available_interfaces(self) -> List[str]:
- """
- Return all available interfaces.
-
- :return: List of available interface names
- :rtype: List[str]
- """
- return [this_interface.__name__ for this_interface in self._interfaces]
-
- @property
- def current_interface(self):
- """
- Returns the constructor for the currently selected interface
-
- :return: Interface constructor
- :rtype: InterfaceTemplate
- """
- return self._current_interface
-
- def fit_func(self, x_array: np.ndarray, *args, **kwargs) -> np.ndarray:
- """
- Pass through to the underlying interfaces fitting function.
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :param args: positional arguments for the fitting function
- :type args: Any
- :param kwargs: key/value pair arguments for the fitting function.
- :type kwargs: Any
- :return: points calculated at positional values `x`
- :rtype: np.ndarray
- """
- return self.__interface_obj.fit_function(x_array, *args, **kwargs)
-
- def generate_bindings(self, name):
- """
- Automatically bind a `Parameter` to the corresponding interface.
-
- :param name: parameter name
- :type name: str
- :return: binding property
- :rtype: property
- """
- return property(self.__get_item(name), self.__set_item(self, name))
-
- def __call__(self, *args, **kwargs) -> InterfaceTemplate:
- return self.__interface_obj
-
- @staticmethod
- def __get_item(key: str) -> Callable:
- """
- Access the value of a key by a callable object
-
- :param key: name of parameter to be retrieved
- :type key: str
- :return: function to get key
- :rtype: Callable
- """
-
- def inner(obj):
- obj().get_value(key)
-
- return lambda obj: inner(obj)
-
- @staticmethod
- def __set_item(obj, key):
- """
- Set the value of a key by a callable object
-
- :param obj: object to be created from
- :type obj: InterfaceFactory
- :param key: name of parameter to be set
- :type key: str
- :return: function to set key
- :rtype: Callable
- """
-
- def inner(value):
- obj().set_value(key, value)
-
- return inner
-
-
-class Line(BaseObj):
- """
- Simple descriptor of a line.
- """
-
- _defaults = [Parameter("m", 1), Parameter("c", 0)]
-
- def __init__(self, interface_factory: InterfaceFactory = None):
- """
- Create a line and add an interface if requested
-
- :param interface_factory: interface controller object
- :type interface_factory: InterfaceFactory
- """
- super().__init__(self.__class__.__name__, *self._defaults)
- self.interface = interface_factory
- self._set_interface()
-
- def _set_interface(self):
- if self.interface:
- # If an interface is given, generate bindings
- for parameter in self.get_fit_parameters():
- name = parameter.name
- self.interface.generate_bindings(name)
-
- @property
- def gradient(self):
- if self.interface:
- return self.interface().get_value("m")
- else:
- return self.m.value
-
- @property
- def intercept(self):
- if self.interface:
- return self.interface().get_value("c")
- else:
- return self.c.value
-
- def __repr__(self):
- return f"Line: m={self.m}, c={self.c}"
-
-
-interface = InterfaceFactory()
-line = Line(interface_factory=interface)
-f = Fitter(line, interface.fit_func)
-
-# y = 2x -1
-x = np.array([1, 2, 3], dtype=np.float64)
-y = np.array([2, 4, 6], dtype=np.float64) - 1
-
-f_res = f.fit(x, y)
-
-print("\n######### Interface 1 #########\n")
-print(f_res)
-print(line)
-
-# This gets the interface name `'Interface2'`
-other_interface = interface.available_interfaces[1]
-# Switch over the interfaces
-line.interface.switch(other_interface)
-# Reset the values so we don't cheat
-line.m.value = 1
-line.c.value = 0
-f_res = f.fit(x, y)
-
-print("\n######### Interface 2 #########\n")
-print(f_res)
-print(line)
diff --git a/legacy/examples_old/example5_broken.py b/legacy/examples_old/example5_broken.py
deleted file mode 100644
index a7967ee8..00000000
--- a/legacy/examples_old/example5_broken.py
+++ /dev/null
@@ -1,367 +0,0 @@
-__author__ = "github.com/wardsimon"
-__version__ = "0.1.0"
-
-import json
-from abc import ABCMeta
-from abc import abstractmethod
-from typing import Callable
-
-import numpy as np
-
-from easyscience import global_object
-from easyscience.fitting import Fitter
-from easyscience.Objects.Base import BaseObj
-from easyscience.Objects.Base import Parameter
-from easyscience.Objects.component_serializer import ComponentSerializer
-
-# from easyscience.Objects.Base import LoggedProperty
-from easyscience.Objects.Inferface import InterfaceFactoryTemplate
-
-# This is a much more complex case where we have calculators, interfaces, interface factory and an
-# inherited object (from `BaseObj`). In this case the Line class is available with/without an interface
-# With an interface it connects to one of the calculator interfaces. This calculator interface then translates
-# interface commands to calculator specific commands
-
-
-class Calculator1:
- """
- Generic calculator in the style of crysPy
- """
-
- def __init__(self, m: float = 1, c: float = 0):
- """
- Create a calculator object with m and c
-
- :param m: gradient
- :type m: float
- :param c: intercept
- :type c: float
- """
- self.m = m
- self.c = c
-
- def calculate(self, x_array: np.ndarray) -> np.ndarray:
- """
- For a given x calculate the corresponding y
-
- :param x_array: array of data points to be calculated
- :type x_array: np.ndarray
- :return: points calculated at `x`
- :rtype: np.ndarray
- """
- return self.m * x_array + self.c
-
-
-class Calculator2:
- """
- Isolated calculator. This calculator can't have values set, it can
- only load/save data and calculate from it. i.e in the style of crysfml
- """
-
- def __init__(self):
- """ """
- self._data = {"m": 0, "c": 0}
-
- def calculate(self, x_array: np.ndarray) -> np.ndarray:
- """
- For a given x calculate the corresponding y
-
- :param x_array: array of data points to be calculated
- :type x_array: np.ndarray
- :return: points calculated at `x`
- :rtype: np.ndarray
- """
- return self._data["m"] * x_array + self._data["c"]
-
- def export_data(self) -> str:
- return json.dumps(self._data)
-
- def import_data(self, input_str: str):
- self._data = json.loads(input_str)
-
-
-class InterfaceTemplate(ComponentSerializer, metaclass=ABCMeta):
- """
- This class is a template and defines all properties that an interface should have.
- """
-
- _interfaces = []
- _global_object = global_object
-
- def __init_subclass__(cls, is_abstract: bool = False, **kwargs):
- """
- Initialise all subclasses so that they can be created in the factory
-
- :param is_abstract: Is this a subclass which shouldn't be dded
- :type is_abstract: bool
- :param kwargs: key word arguments
- :type kwargs: dict
- :return: None
- :rtype: noneType
- """
- super().__init_subclass__(**kwargs)
- if not is_abstract:
- cls._interfaces.append(cls)
-
- @abstractmethod
- def get_value(self, value_label: str) -> float:
- """
- Method to get a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :return: associated value
- :rtype: float
- """
- pass
-
- @abstractmethod
- def set_value(self, value_label: str, value: float):
- """
- Method to set a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :param value: new numeric value
- :type value: float
- :return: None
- :rtype: noneType
- """
- pass
-
- @abstractmethod
- def fit_func(self, x_array: np.ndarray) -> np.ndarray:
- """
- Function to perform a fit
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :return: calculated points
- :rtype: np.ndarray
- """
- pass
-
-
-class Interface1(InterfaceTemplate):
- """
- A simple example interface using Calculator1
- """
-
- def __init__(self):
- # This interface will use calculator1
- self.calculator = Calculator1()
-
- def get_value(self, value_label: str) -> float:
- """
- Method to get a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :return: associated value
- :rtype: float
- """
- return getattr(self.calculator, value_label, None)
-
- def set_value(self, value_label: str, value: float):
- """
- Method to set a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :param value: new numeric value
- :type value: float
- :return: None
- :rtype: noneType
- """
- if self._global_object.debug:
- print(f"Interface1: Value of {value_label} set to {value}")
- setattr(self.calculator, value_label, value)
-
- def fit_func(self, x_array: np.ndarray) -> np.ndarray:
- """
- Function to perform a fit
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :return: calculated points
- :rtype: np.ndarray
- """
- return self.calculator.calculate(x_array)
-
-
-class Interface2(InterfaceTemplate):
- """
- This is a more complex template. Here we need to export_data data,
- transfer it to the calculator (get and calculate) and import data
- from the calculator (set)
- """
-
- def __init__(self):
- """
- Set up a calculator and a local dict
- """
- self.calculator = Calculator2()
- self._data: dict = {}
-
- def get_value(self, value_label: str) -> float:
- """
- Method to get a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :return: associated value
- :rtype: float
- """
- self._data = json.loads(self.calculator.export_data())
- return getattr(self._data, value_label, None)
-
- def set_value(self, value_label: str, value: float):
- """
- Method to set a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :param value: new numeric value
- :type value: float
- :return: None
- :rtype: noneType
- """
- if self._global_object.debug:
- print(f"Interface2: Value of {value_label} set to {value}")
- self._data = json.loads(self.calculator.export_data())
- if value_label in self._data.keys():
- self._data[value_label] = value
- self.calculator.import_data(json.dumps(self._data))
-
- def fit_func(self, x_array: np.ndarray) -> np.ndarray:
- """
- Function to perform a fit
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :return: calculated points
- :rtype: np.ndarray
- """
- return self.calculator.calculate(x_array)
-
-
-class InterfaceFactory(InterfaceFactoryTemplate):
- def __init__(self):
- super(InterfaceFactory, self).__init__(InterfaceTemplate._interfaces)
-
- def generate_bindings(self, name, *args, **kwargs) -> property:
- """
- Automatically bind a `Parameter` to the corresponding interface.
-
- :param name: parameter name
- :type name: str
- :return: binding property
- :rtype: property
- """
- return property(self.__get_item(name), self.__set_item(self, name))
-
- @staticmethod
- def __get_item(key: str) -> Callable:
- """
- Access the value of a key by a callable object
-
- :param key: name of parameter to be retrieved
- :type key: str
- :return: function to get key
- :rtype: Callable
- """
-
- def inner(obj):
- obj().get_value(key)
-
- return lambda obj: inner(obj)
-
- @staticmethod
- def __set_item(obj, key):
- """
- Set the value of a key by a callable object
-
- :param obj: object to be created from
- :type obj: InterfaceFactory
- :param key: name of parameter to be set
- :type key: str
- :return: function to set key
- :rtype: Callable
- """
-
- def inner(value):
- obj().set_value(key, value)
-
- return inner
-
-
-class Line(BaseObj):
- """
- Simple descriptor of a line.
- """
-
- _defaults = [Parameter("m", 1), Parameter("c", 0)]
-
- def __init__(self, interface_factory: InterfaceFactory = None):
- """
- Create a line and add an interface if requested.
-
- :param interface_factory: interface controller object
- :type interface_factory: InterfaceFactory
- """
- self.interface = interface_factory
- super().__init__(self.__class__.__name__, *self._defaults)
- self._set_interface()
-
- def _set_interface(self):
- if self.interface:
- # If an interface is given, generate bindings
- for parameter in self.get_fit_parameters():
- name = parameter.name
- self.set_binding(name, self.interface.generate_bindings)
-
- @property
- def gradient(self):
- # if self.interface:
- # return self.interface().get_value('m')
- # else:
- return self.m.value
-
- @property
- def intercept(self):
- # if self.interface:
- # return self.interface().get_value('c')
- # else:
- return self.c.value
-
- def __repr__(self):
- return f"Line: m={self.m}, c={self.c}"
-
-
-global_object.debug = True
-
-interface = InterfaceFactory()
-line = Line(interface_factory=interface)
-f = Fitter(line, interface.fit_func)
-
-# y = 2x -1
-x = np.array([1, 2, 3], dtype=np.float64)
-y = 2 * x - 1
-
-f_res = f.fit(x, y)
-
-print("\n######### Interface 1 #########\n")
-print(f_res)
-print(line)
-
-a = line.c
-
-# Now lets change fitting engine
-f.switch_minimizer("bumps")
-# Reset the values so we don't cheat
-line.m = 1
-line.c = 0
-f_res = f.fit(x, y, weights=0.1 * np.ones_like(x))
-print("\n######### bumps fitting #########\n")
-print(f_res)
-print(line)
diff --git a/legacy/examples_old/example6_broken.py b/legacy/examples_old/example6_broken.py
deleted file mode 100644
index fe2dbbf2..00000000
--- a/legacy/examples_old/example6_broken.py
+++ /dev/null
@@ -1,446 +0,0 @@
-__author__ = "github.com/wardsimon"
-__version__ = "0.1.0"
-
-import json
-from abc import ABCMeta
-from abc import abstractmethod
-from typing import Callable
-
-import numpy as np
-
-from easyscience import global_object
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.Variable import Parameter
-from easyscience.Objects.component_serializer import ComponentSerializer
-from easyscience.Objects.Inferface import InterfaceFactoryTemplate
-
-# This is a much more complex case where we have calculators, interfaces, interface factory and an
-# inherited object (from `BaseObj`). In this case the Line class is available with/without an interface
-# With an interface it connects to one of the calculator interfaces. This calculator interface then translates
-# interface commands to calculator specific commands
-
-
-class Calculator1:
- """
- Generic calculator in the style of crysPy
- """
-
- class calcLine:
- def __init__(self, m: float = 1, c: float = 0):
- self.m = m
- self.c = c
-
- def __call__(self, *args, **kwargs):
- return self.m * args[0] + self.c
-
- class calcCurve:
- def __init__(self, A=0, p=0, x_shift=0):
- self.A = A
- self.p = p
- self.x_shift = x_shift
-
- def __call__(self, *args, **kwargs):
- return self.A * np.sin((2 * np.pi / self.p) * (x + self.x_shift))
-
- def __init__(self):
- """
- Create a calculator object with m and c
-
- :param m: gradient
- :type m: float
- :param c: intercept
- :type c: float
- """
- self._line = []
- self._curve = []
-
- def add_line(self, m: float = 1, c: float = 0):
- self._line.append(self.calcLine(m, c))
-
- def add_curve(self, A=0, p=0, x_shift=0):
- self._curve.append(self.calcCurve(A, p, x_shift))
-
- @property
- def lines(self):
- return self._line
-
- @property
- def curves(self):
- return self._curve
-
- def calculate(self, x_array: np.ndarray) -> np.ndarray:
- """
- For a given x calculate the corresponding y
-
- :param x_array: array of data points to be calculated
- :type x_array: np.ndarray
- :return: points calculated at `x`
- :rtype: np.ndarray
- """
- y = np.zeros_like(x_array)
- for line in self._line:
- y += line(x_array)
- for curve in self._curve:
- y += curve(x_array)
- return y
-
-
-class InterfaceTemplate(ComponentSerializer, metaclass=ABCMeta):
- """
- This class is a template and defines all properties that an interface should have.
- """
-
- _interfaces = []
- _global_object = global_object
-
- def __init_subclass__(cls, is_abstract: bool = False, **kwargs):
- """
- Initialise all subclasses so that they can be created in the factory
-
- :param is_abstract: Is this a subclass which shouldn't be dded
- :type is_abstract: bool
- :param kwargs: key word arguments
- :type kwargs: dict
- :return: None
- :rtype: noneType
- """
- super().__init_subclass__(**kwargs)
- if not is_abstract:
- cls._interfaces.append(cls)
-
- @abstractmethod
- def create_curve(self, A, p, x_shift):
- pass
-
- @abstractmethod
- def get_curve_value(self, curve, par, value_label: str) -> float:
- """
- Method to get a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :return: associated value
- :rtype: float
- """
- pass
-
- @abstractmethod
- def set_curve_value(self, curve, par, value_label: str, value: float):
- """
- Method to set a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :param value: new numeric value
- :type value: float
- :return: None
- :rtype: noneType
- """
- pass
-
- @abstractmethod
- def fit_func(self, x_array: np.ndarray) -> np.ndarray:
- """
- Function to perform a fit
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :return: calculated points
- :rtype: np.ndarray
- """
- pass
-
-
-class Interface1(InterfaceTemplate):
- """
- A simple example interface using Calculator1
- """
-
- def __init__(self):
- # This interface will use calculator1
- self.calculator = Calculator1()
-
- def get_value(self, value_label: str) -> float:
- """
- Method to get a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :return: associated value
- :rtype: float
- """
- return getattr(self.calculator, value_label, None)
-
- def set_value(self, value_label: str, value: float):
- """
- Method to set a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :param value: new numeric value
- :type value: float
- :return: None
- :rtype: noneType
- """
- if self._global_object.debug:
- print(f"Interface1: Value of {value_label} set to {value}")
- setattr(self.calculator, value_label, value)
-
- def fit_func(self, x_array: np.ndarray) -> np.ndarray:
- """
- Function to perform a fit
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :return: calculated points
- :rtype: np.ndarray
- """
- return self.calculator.calculate(x_array)
-
-
-class Calculator2:
- """
- Isolated calculator. This calculator can't have values set, it can
- only load/save data and calculate from it. i.e in the style of crysfml
- """
-
- def __init__(self):
- """ """
- self._data = {"m": 0, "c": 0}
-
- def calculate(self, x_array: np.ndarray) -> np.ndarray:
- """
- For a given x calculate the corresponding y
-
- :param x_array: array of data points to be calculated
- :type x_array: np.ndarray
- :return: points calculated at `x`
- :rtype: np.ndarray
- """
- return self._data["m"] * x_array + self._data["c"]
-
- def export_data(self) -> str:
- return json.dumps(self._data)
-
- def import_data(self, input_str: str):
- self._data = json.loads(input_str)
-
-
-class Interface2(InterfaceTemplate):
- """
- This is a more complex template. Here we need to export_data data,
- transfer it to the calculator (get and calculate) and import data
- from the calculator (set)
- """
-
- def __init__(self):
- """
- Set up a calculator and a local dict
- """
- self.calculator = Calculator2()
- self._data: dict = {}
-
- def get_value(self, value_label: str) -> float:
- """
- Method to get a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :return: associated value
- :rtype: float
- """
- self._data = json.loads(self.calculator.export_data())
- return getattr(self._data, value_label, None)
-
- def set_value(self, value_label: str, value: float):
- """
- Method to set a value from the calculator
-
- :param value_label: parameter name to get
- :type value_label: str
- :param value: new numeric value
- :type value: float
- :return: None
- :rtype: noneType
- """
- if self._global_object.debug:
- print(f"Interface2: Value of {value_label} set to {value}")
- self._data = json.loads(self.calculator.export_data())
- if value_label in self._data.keys():
- self._data[value_label] = value
- self.calculator.import_data(json.dumps(self._data))
-
- def fit_func(self, x_array: np.ndarray) -> np.ndarray:
- """
- Function to perform a fit
-
- :param x_array: points to be calculated at
- :type x_array: np.ndarray
- :return: calculated points
- :rtype: np.ndarray
- """
- return self.calculator.calculate(x_array)
-
-
-class InterfaceFactory(InterfaceFactoryTemplate):
- def __init__(self):
- super(InterfaceFactory, self).__init__(InterfaceTemplate._interfaces)
-
- def generate_bindings(self, name, *args, **kwargs) -> property:
- """
- Automatically bind a `Parameter` to the corresponding interface.
-
- :param name: parameter name
- :type name: str
- :return: binding property
- :rtype: property
- """
- return property(self.__get_item(name), self.__set_item(self, name))
-
- @staticmethod
- def __get_item(key: str) -> Callable:
- """
- Access the value of a key by a callable object
-
- :param key: name of parameter to be retrieved
- :type key: str
- :return: function to get key
- :rtype: Callable
- """
-
- def inner(obj):
- obj().get_value(key)
-
- return lambda obj: inner(obj)
-
- @staticmethod
- def __set_item(obj, key):
- """
- Set the value of a key by a callable object
-
- :param obj: object to be created from
- :type obj: InterfaceFactory
- :param key: name of parameter to be set
- :type key: str
- :return: function to set key
- :rtype: Callable
- """
-
- def inner(value):
- obj().set_value(key, value)
-
- return inner
-
-
-class Line(BaseObj):
- """
- Simple descriptor of a line.
- """
-
- _defaults = [Parameter("m", 1), Parameter("c", 0)]
-
- def __init__(self, interface_factory: InterfaceFactory = None):
- """
- Create a line and add an interface if requested
-
- :param interface_factory: interface controller object
- :type interface_factory: InterfaceFactory
- """
- self.interface = interface_factory
- super().__init__(self.__class__.__name__, *self._defaults)
- self._set_interface()
-
- def _set_interface(self):
- if self.interface:
- # If an interface is given, generate bindings
- for parameter in self.get_fit_parameters():
- name = parameter.name
- self.set_binding(name, self.interface.generate_bindings)
-
- def __repr__(self):
- return f"Line: m={self.m}, c={self.c}"
-
-
-class Curve(BaseObj):
- """
- Simple descriptor of a line.
- """
-
- _defaults = [Parameter("A", 1.0), Parameter("p", np.pi), Parameter("x_shift", 0.0)]
-
- def __init__(self, interface_factory: InterfaceFactory = None):
- """
- Create a line and add an interface if requested
-
- :param interface_factory: interface controller object
- :type interface_factory: InterfaceFactory
- """
- self.interface = interface_factory
- super().__init__(self.__class__.__name__, *self._defaults)
- self._set_interface()
-
- def _set_interface(self):
- if self.interface:
- # If an interface is given, generate bindings
- for parameter in self.get_fit_parameters():
- name = parameter.name
- self.set_binding(name, self.interface.generate_bindings)
-
- def __repr__(self):
- return f"Curve: A={self.A}, p={self.p}, x_shift={self.x_shift}"
-
-
-class Model(BaseObj):
- """
- Simple descriptor of a line.
- """
-
- _defaults = [Line, Curve]
-
- def __init__(self, interface_factory: InterfaceFactory = None):
- """
- Create a line and add an interface if requested
-
- :param interface_factory: interface controller object
- :type interface_factory: InterfaceFactory
- """
- self.interface = interface_factory
- super().__init__(
- self.__class__.__name__, *[default(interface) for default in self._defaults]
- )
-
- def __repr__(self):
- this_str = "Hybrid Model:\n"
- for name, item in self._kwargs.items():
- this_str += f"{name}: "
- for par in item._defaults:
- this_str += f"{par.name} = {par.value}, "
- this_str = this_str[:-2] + "\n"
- return this_str
-
-
-interface = InterfaceFactory()
-hybrid = Model(interface_factory=interface)
-f = Fitter(hybrid, interface.fit_func)
-print(hybrid)
-
-# y = 2x -1
-x = np.array([1, 2, 3])
-y = 2 * x - 1
-
-f_res = f.fit(x, y)
-
-print("\n######### Interface 1 #########\n")
-print(f_res)
-print(hybrid)
-
-# Now lets change fitting engine
-f.switch_minimizer("bumps")
-# Reset the values so we don't cheat
-hybrid.m = 1
-hybrid.c = 0
-f_res = f.fit(x, y, weights=0.1 * np.ones_like(x))
-print("\n######### bumps fitting #########\n")
-print(f_res)
-print(hybrid)
diff --git a/legacy/examples_old/example_dataset.py b/legacy/examples_old/example_dataset.py
deleted file mode 100644
index 4a656802..00000000
--- a/legacy/examples_old/example_dataset.py
+++ /dev/null
@@ -1,56 +0,0 @@
-__author__ = 'github.com/wardsimon'
-__version__ = '0.1.0'
-
-import time
-
-import matplotlib.pyplot as plt
-
-import numpy as np
-from easyscience.Datasets.xarray import xr
-
-d = xr.Dataset()
-
-nx = 5E3
-x_min = 0
-x_max = 3*np.pi
-
-x = np.linspace(x_min, x_max, num=int(nx))
-
-d.easyscience.add_coordinate('x', x)
-d.easyscience.add_variable('y', ['x'], np.sin(x), auto_sigma=False)
-
-d['y'].plot()
-plt.show()
-
-
-d.easyscience.remove_variable('y')
-d.easyscience.add_coordinate('y', x + np.pi)
-d.easyscience.add_variable('z', ['x', 'y'], np.sin(x).reshape((-1, 1))*np.cos(x).reshape((1, -1)) + (0.5 - np.random.random(size=(int(nx), int(nx)))))
-
-def func(x, *args, **kwargs):
- return np.sin(x[:, 0]) * np.cos(x[:, 1])
-
-bdims, f = d['z'].easyscience.fit_prep(func)
-d['x_broadcast'], d['y_broadcast'] = bdims
-
-print('Applying func - No dask')
-t = time.time()
-d['computed_no_dask'] = xr.apply_ufunc(f, d['x_broadcast'], d['y_broadcast'])
-temp = d['z'] - d['computed_no_dask']
-print(f'Time taken: {time.time() - t}')
-temp.plot()
-plt.show()
-
-print('Applying func - Dask')
-t = time.time()
-to_chunk = ['x_broadcast', 'y_broadcast', 'z']
-for name in to_chunk:
- d[name] = d[name].chunk({'x': 4000, 'y': 4000})
-d['computed_dask'] = xr.apply_ufunc(f, d['x_broadcast'], d['y_broadcast'], dask='parallelized')
-temp = d['z'] - d['computed_dask']
-temp.compute()
-print(f'Time taken: {time.time() - t}')
-temp.plot()
-plt.show()
-
-print('All done :-)')
diff --git a/legacy/examples_old/example_dataset2.py b/legacy/examples_old/example_dataset2.py
deleted file mode 100644
index b235bc4f..00000000
--- a/legacy/examples_old/example_dataset2.py
+++ /dev/null
@@ -1,50 +0,0 @@
-__author__ = "github.com/wardsimon"
-__version__ = "0.1.0"
-
-import matplotlib.pyplot as plt
-
-import numpy as np
-from easyscience.Datasets.xarray import xr
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-d = xr.Dataset()
-
-b = BaseObj("line", m=Parameter("m", 1), c=Parameter("c", 1))
-
-
-def fit_fun(x, *args, **kwargs):
- # In the real case we would gust call the evaluation fn without reference to the BaseObj
- return b.c.value + b.m.value * x
-
-
-f = Fitter()
-f.initialize(b, fit_fun)
-
-nx = 1e3
-x_min = 0
-x_max = 100
-
-x = np.linspace(x_min, x_max, num=int(nx))
-y = 2 * x - 1 + 5 * (np.random.random(size=x.shape) - 0.5)
-
-d.easyscience.add_coordinate("x", x)
-d.easyscience.add_variable("y", ["x"], y, auto_sigma=True)
-
-
-def post(result, addition=10):
- return result + addition
-
-
-d["y"].easyscience.postcompute_func = post
-
-# d['y'] = d['y'].chunk({'x': 1000})
-f_res = d["y"].easyscience.fit(f, dask="parallelized")
-
-print(f_res.chi2)
-
-d["y"].plot()
-d["computed"] = f_res.y_calc
-d["computed"].plot()
-plt.show()
diff --git a/legacy/examples_old/example_dataset2pt2.py b/legacy/examples_old/example_dataset2pt2.py
deleted file mode 100644
index 83c9cbe0..00000000
--- a/legacy/examples_old/example_dataset2pt2.py
+++ /dev/null
@@ -1,57 +0,0 @@
-__author__ = 'github.com/wardsimon'
-__version__ = '0.1.0'
-
-import matplotlib.pyplot as plt
-
-import numpy as np
-from easyscience.Datasets.xarray import xr
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-d = xr.Dataset()
-
-m_starting_point = 1
-c_starting_point = 1
-
-
-b = BaseObj('line',
- m=Parameter('m', m_starting_point),
- c=Parameter('c', c_starting_point))
-
-
-def fit_fun(x, *args, **kwargs):
- # In the real case we would gust call the evaluation fn without reference to the BaseObj
- return b.c.raw_value + b.m.raw_value * x
-
-
-nx = 1E3
-x_min = 0
-x_max = 100
-
-x = np.linspace(x_min, x_max, num=int(nx))
-y = 2*x - 1 + 5*(np.random.random(size=x.shape) - 0.5)
-
-d.easyscience.add_coordinate('x', x)
-d.easyscience.add_variable('y', ['x'], y, auto_sigma=False)
-
-f = Fitter()
-f.initialize(b, fit_fun)
-
-fig, ax = plt.subplots(2, 3, sharey='row')
-for idx, minimizer in enumerate(['lmfit', 'bumps', 'dfo_ls']):
-
- b.m = m_starting_point
- b.c = c_starting_point
- f.switch_minimizer(minimizer)
-
- f_res = d['y'].easyscience.fit(f, vectorize=True)
- print(f_res.p)
-
- d['y'].plot(ax=ax[0, idx])
- f_res.y_calc.unstack().plot(ax=ax[0, idx])
- temp = d['y'] - f_res.y_calc
- temp.plot(ax=ax[1, idx])
- ax[0, idx].set_title(f'Minimizer - {minimizer}')
-ax[1, 0].set_ylabel('Difference')
-plt.show()
diff --git a/legacy/examples_old/example_dataset2pt2_broken.py b/legacy/examples_old/example_dataset2pt2_broken.py
deleted file mode 100644
index ef4dcbab..00000000
--- a/legacy/examples_old/example_dataset2pt2_broken.py
+++ /dev/null
@@ -1,57 +0,0 @@
-__author__ = 'github.com/wardsimon'
-__version__ = '0.1.0'
-
-import matplotlib.pyplot as plt
-
-import numpy as np
-from easyscience.Datasets.xarray import xr
-from easyscience.fitting import Fitter
-from easyscience.Objects.Base import BaseObj
-from easyscience.Objects.Base import Parameter
-
-d = xr.Dataset()
-
-m_starting_point = 1
-c_starting_point = 1
-
-
-b = BaseObj('line',
- m=Parameter('m', m_starting_point),
- c=Parameter('c', c_starting_point))
-
-
-def fit_fun(x, *args, **kwargs):
- # In the real case we would gust call the evaluation fn without reference to the BaseObj
- return b.c.value + b.m.value * x
-
-
-nx = 1E3
-x_min = 0
-x_max = 100
-
-x = np.linspace(x_min, x_max, num=int(nx))
-y = 2*x - 1 + 5*(np.random.random(size=x.shape) - 0.5)
-
-d.easyscience.add_coordinate('x', x)
-d.easyscience.add_variable('y', ['x'], y, auto_sigma=False)
-
-f = Fitter()
-f.initialize(b, fit_fun)
-
-fig, ax = plt.subplots(2, 3, sharey='row')
-for idx, minimizer in enumerate(['lmfit', 'bumps', 'dfo_ls']):
-
- b.m = m_starting_point
- b.c = c_starting_point
- f.switch_minimizer(minimizer)
-
- f_res = d['y'].easyscience.fit(f, vectorize=True)
- print(f_res.p)
-
- d['y'].plot(ax=ax[0, idx])
- f_res.y_calc.unstack().plot(ax=ax[0, idx])
- temp = d['y'] - f_res.y_calc
- temp.plot(ax=ax[1, idx])
- ax[0, idx].set_title(f'Minimizer - {minimizer}')
-ax[1, 0].set_ylabel('Difference')
-plt.show()
diff --git a/legacy/examples_old/example_dataset3.py b/legacy/examples_old/example_dataset3.py
deleted file mode 100644
index 4164dc5e..00000000
--- a/legacy/examples_old/example_dataset3.py
+++ /dev/null
@@ -1,92 +0,0 @@
-__author__ = 'github.com/wardsimon'
-__version__ = '0.1.0'
-
-import time
-
-import matplotlib.pyplot as plt
-
-import numpy as np
-from easyscience.Datasets.xarray import xr
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-d = xr.Dataset()
-
-nx = 5E2
-x_min = 0
-x_max = 3
-
-sin_offest = - 0.35
-cos_offset = 0.14
-
-x = np.linspace(x_min, x_max, num=int(nx))
-
-d.easyscience.add_coordinate('x', x)
-d.easyscience.add_coordinate('y', x + 1)
-d.easyscience.add_variable('z', ['x', 'y'], np.sin(2*np.pi*(x + sin_offest)).reshape((-1, 1))*np.cos(2*np.pi*(x + cos_offset)).reshape((1, -1)) + 2*(0.5 - np.random.random(size=(int(nx), int(nx)))))
-
-s_off_start_point = 0
-c_off_start_point = 0
-
-b = BaseObj('line',
- s_off=Parameter('s_off', s_off_start_point),
- c_off=Parameter('c_off', c_off_start_point))
-
-
-def fit_fun(x, *args, **kwargs):
- # In the real case we would gust call the evaluation fn without reference to the BaseObj
- return np.sin(2*np.pi*(x[:, 0] + b.s_off.value)) * np.cos(2*np.pi*(x[:, 1] + b.c_off.value))
-
-
-f = Fitter()
-f.initialize(b, fit_fun)
-
-print('Performing fit - No dask')
-t = time.time()
-f_res = d['z'].easyscience.fit(f)
-d['computed_no_dask'] = f_res.y_calc
-temp = d['z'] - d['computed_no_dask']
-print(f'Time taken: {time.time() - t}')
-
-print(f_res.p0)
-print(f_res.p)
-
-fig, axes = plt.subplots(ncols=2, nrows=2)
-d['z'].plot(ax=axes[0, 0])
-d['computed_no_dask'].plot(ax=axes[0, 1])
-temp.plot(ax=axes[1, 0])
-plt.tight_layout()
-plt.show()
-
-
-
-b.s_off.value = s_off_start_point
-b.c_off.value = c_off_start_point
-
-f = Fitter()
-f.initialize(b, fit_fun)
-
-print('Performing fit - Dask')
-t = time.time()
-to_chunk = ['z']
-print('Chunking -->')
-for name in to_chunk:
- d[name] = d[name].chunk({'x': 100, 'y': 100})
-print('Fitting -->')
-f_res = d['z'].easyscience.fit(f, dask='parallelized')
-d['computed_dask'] = f_res.y_calc
-temp = d['z'] - d['computed_dask']
-print(f'Time taken: {time.time() - t}')
-
-print(f_res.p0)
-print(f_res.p)
-
-fig, axes = plt.subplots(ncols=2, nrows=2)
-d['z'].plot(ax=axes[0, 0])
-d['computed_dask'].plot(ax=axes[0, 1])
-temp.plot(ax=axes[1, 0])
-plt.tight_layout()
-plt.show()
-
-print('All done :-)')
diff --git a/legacy/examples_old/example_dataset3pt2.py b/legacy/examples_old/example_dataset3pt2.py
deleted file mode 100644
index 187306dd..00000000
--- a/legacy/examples_old/example_dataset3pt2.py
+++ /dev/null
@@ -1,62 +0,0 @@
-__author__ = 'github.com/wardsimon'
-__version__ = '0.1.0'
-
-import matplotlib.pyplot as plt
-
-import numpy as np
-from easyscience.Datasets.xarray import xr
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-d = xr.Dataset()
-
-nx = 5E2
-x_min = 0
-x_max = 3
-
-sin_offest = - 0.35
-cos_offset = 0.14
-
-x = np.linspace(x_min, x_max, num=int(nx))
-
-d.easyscience.add_coordinate('x', x)
-d.easyscience.add_coordinate('y', x + 1)
-d.easyscience.add_variable('z', ['x', 'y'], np.sin(2*np.pi*(x + sin_offest)).reshape((-1, 1))*np.cos(2*np.pi*(x + cos_offset)).reshape((1, -1)) + 2*(0.5 - np.random.random(size=(int(nx), int(nx)))))
-
-s_off_start_point = 0
-c_off_start_point = 0
-
-b = BaseObj('line',
- s_off=Parameter('s_off', s_off_start_point),
- c_off=Parameter('c_off', c_off_start_point))
-
-
-def fit_fun(x, *args, **kwargs):
- # In the real case we would gust call the evaluation fn without reference to the BaseObj
- return np.sin(2*np.pi*(x[:, 0] + b.s_off.value)) * np.cos(2*np.pi*(x[:, 1] + b.c_off.value))
-
-
-fig, ax = plt.subplots(2, 3, sharey=True, sharex=True)
-cbar_ax1 = fig.add_axes([0.85, 0.15, 0.05, 0.3])
-cbar_ax2 = fig.add_axes([0.85, 0.60, 0.05, 0.3])
-
-for idx, minimizer in enumerate(['lmfit', 'bumps', 'dfo_ls']):
- b.s_off = s_off_start_point
- b.c_off = c_off_start_point
-
- f = Fitter()
- f.initialize(b, fit_fun)
-
- f_res = d['z'].easyscience.fit(f)
- d[f'computed_{minimizer}'] = f_res.y_calc
- d[f'dz_{minimizer}'] = d['z'] - d[f'computed_{minimizer}']
-
- p1 = d[f'computed_{minimizer}'].plot(ax=ax[0, idx], cbar_kwargs={'cax': cbar_ax1})
- p2 = d[f'dz_{minimizer}'].plot(ax=ax[1, idx], cbar_kwargs={'cax': cbar_ax2})
- ax[0, idx].set_title(f'{minimizer}')
- ax[1, idx].set_title('s_off - {:0.03f}\nc_off - {:0.03f}'.format(b.s_off.value, b.c_off.value))
- ax[0, idx].set_aspect('equal', 'box')
- ax[1, idx].set_aspect('equal', 'box')
-fig.subplots_adjust(right=0.8)
-plt.show()
diff --git a/legacy/examples_old/example_dataset4.py b/legacy/examples_old/example_dataset4.py
deleted file mode 100644
index 1e975d4e..00000000
--- a/legacy/examples_old/example_dataset4.py
+++ /dev/null
@@ -1,49 +0,0 @@
-__author__ = 'github.com/wardsimon'
-__version__ = '0.1.0'
-
-import matplotlib.pyplot as plt
-
-import numpy as np
-from easyscience.Datasets.xarray import xr
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-d = xr.Dataset()
-
-b = BaseObj('line',
- m=Parameter('m', 1),
- c=Parameter('c', 1))
-
-
-def fit_fun(x, *args, **kwargs):
- # In the real case we would gust call the evaluation fn without reference to the BaseObj
- return b.c.value + b.m.value * x
-
-
-f = Fitter()
-f.initialize(b, fit_fun)
-
-nx = 1E3
-x_min = 0
-x_max = 100
-
-x = np.linspace(x_min, x_max, num=int(nx))
-y1 = 2*x - 1 + 5*(np.random.random(size=x.shape) - 0.5)
-x2 = x + 20
-y2 = 2*x2 - 1 + 5*(np.random.random(size=x2.shape) - 0.5)
-
-d.easyscience.add_coordinate('x1', x)
-d.easyscience.add_variable('y1', ['x1'], y1, auto_sigma=True)
-d.easyscience.add_coordinate('x2', x2)
-d.easyscience.add_variable('y2', ['x2'], y2, auto_sigma=True)
-
-res = d.easyscience.fit(f, ['y1', 'y2'])
-
-fig, axs = plt.subplots(1, len(res), sharey=True)
-for idx, r in enumerate(res):
- r.y_obs.plot(ax=axs[idx])
- r.y_calc.plot(ax=axs[idx])
- axs[idx].set_title(f'Dataset {idx}')
-plt.show()
-
diff --git a/legacy/examples_old/example_dataset4_2.py b/legacy/examples_old/example_dataset4_2.py
deleted file mode 100644
index 9bba2ffd..00000000
--- a/legacy/examples_old/example_dataset4_2.py
+++ /dev/null
@@ -1,67 +0,0 @@
-__author__ = 'github.com/wardsimon'
-__version__ = '0.1.0'
-
-import matplotlib.pyplot as plt
-
-import numpy as np
-from easyscience.Datasets.xarray import xr
-from easyscience.fitting import Fitter
-from easyscience.Objects.ObjectClasses import BaseObj
-from easyscience.Objects.ObjectClasses import Parameter
-
-d = xr.Dataset()
-
-
-class Wavey(BaseObj):
- def __init__(self,
- amplitude: Parameter,
- phase: Parameter,
- period: Parameter):
- super(Wavey, self).__init__('SimpleWave',
- amplitude=amplitude, phase=phase,
- period=period)
-
- def __repr__(self):
- return (f'{self.name} - A={self.amplitude}, ph={self.phase}, period={self.period}')
-
- @classmethod
- def from_params(cls, amplitude: float = 1, phase: float = 0, period: float = 2*np.pi):
- amplitude = Parameter('amplitude', amplitude, min=0)
- phase = Parameter('phase', phase)
- period = Parameter('period', period, min=0, max=2*np.pi)
- return cls(amplitude, phase, period)
-
- def fit_fun(self, x, *args, **kwargs):
- # In the real case we would gust call the evaluation fn without reference to the BaseObj
- return self.amplitude.value * np.sin((x + self.phase.value)/self.period.value)
-
-b = Wavey.from_params()
-bb = Wavey.from_params(1.1, 0.1, 1.9*np.pi)
-f = Fitter()
-
-f.initialize(b, b.fit_fun)
-
-nx = 1E3
-x_min = 0
-x_max = 100
-
-x = np.linspace(x_min, x_max, num=int(nx))
-y1 = bb.fit_fun(x) + .75*(0.5 - np.random.random(size=x.shape))
-x2 = x + 20
-y2 = bb.fit_fun(x2) + .75*(0.5 - np.random.random(size=x2.shape))
-
-d.easyscience.add_coordinate('x1', x)
-d.easyscience.add_variable('y1', ['x1'], y1, auto_sigma=True)
-d.easyscience.add_coordinate('x2', x2)
-d.easyscience.add_variable('y2', ['x2'], y2, auto_sigma=True)
-
-res = d.easyscience.fit(f, ['y1', 'y2'])
-
-fig, axs = plt.subplots(1, len(res), sharey=True)
-for idx, r in enumerate(res):
- r.y_obs.plot(ax=axs[idx])
- r.y_calc.plot(ax=axs[idx])
- axs[idx].set_title(f'Dataset {idx}')
-plt.show()
-print(f'Actual = {bb}')
-print(f'Fitted = {b}')
diff --git a/pixi.toml b/pixi.toml
index a3237349..bbbca890 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -3,14 +3,13 @@
###########
[workspace]
-
# Supported platforms for the lock file (pixi.lock)
platforms = [
'win-64',
# Set minimum supported version for glibc to be 2.35 to ensure packages
# like `crysfml` that only have wheels for glibc 2.35+
# (manylinux_2_35_x86_64) are used.
- #libc = { family = 'glibc', version = '2.35' }
+ # libc = { family = 'glibc', version = '2.35' }
{ platform = 'linux-64', glibc = '2.35' },
# Set minimum supported version for macOS to be 14.0 to ensure packages
# like `scipp` that only have wheels for macOS 14.0+ (macosx_14_0_arm64)
@@ -44,8 +43,8 @@ python = '3.14.*'
# editable installations.
[feature.dev.dependencies]
-nodejs = '*' # Required for Prettier (non-Python formatting)
-pixi-kernel = '*' # Pixi Jupyter kernel
+nodejs = '*' # Required for Prettier (non-Python formatting)
+pixi-kernel = '*' # Pixi Jupyter kernel
[feature.dev.pypi-dependencies]
pip = '*'
@@ -55,7 +54,7 @@ easyscience = { path = '.', editable = true, extras = ['dev'] }
# of editable installations.
[feature.user.dependencies]
-pixi-kernel = '*' # Pixi Jupyter kernel
+pixi-kernel = '*' # Pixi Jupyter kernel
[feature.user.pypi-dependencies]
pip = '*'
@@ -66,7 +65,6 @@ easyscience = '*'
##############
[environments]
-
# The `default` feature is always included in all environments.
# Additional features can be specified per environment.
@@ -87,13 +85,12 @@ user = { features = ['py-max', 'user'] }
#######
[tasks]
-
##################
# π§ͺ Testing Tasks
##################
unit-tests = 'python -m pytest tests/unit/ --color=yes -v'
-functional-tests = 'python -m pytest tests/functional/ --color=yes -v'
+# functional-tests = 'python -m pytest tests/functional/ --color=yes -v'
integration-tests = 'python -m pytest tests/integration/ --color=yes -n auto -v'
notebook-tests = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --color=yes -n auto -v'
@@ -152,7 +149,7 @@ raw-metrics-json = 'radon raw -s -j src/'
#############
unit-tests-coverage = 'pixi run unit-tests --cov=src/easyscience --cov-report=term-missing'
-functional-tests-coverage = 'pixi run functional-tests --cov=src/easyscience --cov-report=term-missing'
+# functional-tests-coverage = 'pixi run functional-tests --cov=src/easyscience --cov-report=term-missing'
integration-tests-coverage = 'pixi run integration-tests --cov=src/easyscience --cov-report=term-missing'
docstring-coverage = 'interrogate -c pyproject.toml src/easyscience'
@@ -172,9 +169,9 @@ notebook-tweak = 'python tools/tweak_notebooks.py docs/docs/tutorials/'
notebook-exec = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --overwrite --color=yes -n auto -v'
notebook-prepare = { depends-on = [
- #'notebook-convert',
+ # 'notebook-convert',
'notebook-strip',
- #'notebook-tweak',
+ # 'notebook-tweak',
] }
########################
@@ -266,7 +263,7 @@ clean-pycache = "find . -type d -name '__pycache__' -prune -exec rm -rf '{}' +"
post-install = { depends-on = [
'npm-config',
'prettier-install',
- #'pre-commit-setup',
+ # 'pre-commit-setup',
] }
##########################
diff --git a/prettierrc.toml b/prettierrc.toml
index b98c86eb..d8a956f5 100644
--- a/prettierrc.toml
+++ b/prettierrc.toml
@@ -1,22 +1,24 @@
plugins = [
- "prettier-plugin-toml", # use the TOML plugin
+ 'prettier-plugin-toml', # use the TOML plugin
]
-endOfLine = 'lf' # change line endings to LF
-proseWrap = 'always' # change wrapping in Markdown files
-semi = false # remove semicolons
-singleQuote = true # use single quotes instead of double quotes
-tabWidth = 2 # change tab width to 2 spaces
-useTabs = false # use spaces instead of tabs
+endOfLine = 'lf' # change line endings to LF
+proseWrap = 'always' # change wrapping in Markdown files
+semi = false # remove semicolons
+singleQuote = true # use single quotes instead of double quotes
+tabWidth = 2 # change tab width to 2 spaces
+useTabs = false # use spaces instead of tabs
-printWidth = 79 # wrap lines at 79 characters
+printWidth = 79 # wrap lines at 79 characters
[[overrides]]
-files = ["*.md"]
+files = ['*.md']
+
[overrides.options]
-printWidth = 72 # wrap Markdown files at 72 characters
+printWidth = 72 # wrap Markdown files at 72 characters
[[overrides]]
-files = ["*.yml", "*.yaml"]
+files = ['*.yml', '*.yaml']
+
[overrides.options]
-printWidth = 88 # wrap YAML files at 88 characters
+printWidth = 88 # wrap YAML files at 88 characters
diff --git a/pyproject.toml b/pyproject.toml
index 89d28050..58e9ed21 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@
[project]
name = 'easyscience'
-dynamic = ['version'] # Use versioningit to manage the version
+dynamic = ['version'] # Use versioningit to manage the version
description = 'Core building blocks for EasyScience'
authors = [{ name = 'EasyScience contributors' }]
readme = 'README.md'
@@ -24,54 +24,54 @@ classifiers = [
]
requires-python = '>=3.11'
dependencies = [
- 'asteval', # Safely evaluate Python expressions from strings
- 'lmfit', # Non-linear least squares fitting
- 'bumps>=1.0.4', # Bayesian uncertainty estimation
- 'dfo-ls', # Derivative-free optimization
- 'numpy', # Numerical computing
- 'scipp', # Handling and analysis of scientific data
+ 'asteval', # Safely evaluate Python expressions from strings
+ 'lmfit', # Non-linear least squares fitting
+ 'bumps>=1.0.4', # Bayesian uncertainty estimation
+ 'dfo-ls', # Derivative-free optimization
+ 'numpy', # Numerical computing
+ 'scipp', # Handling and analysis of scientific data
]
[project.optional-dependencies]
dev = [
- 'pooch', # Data downloader
- 'matplotlib', # Plotting library
- 'jupyterlab', # Jupyter notebooks
- 'ipykernel', # Jupyter kernel (required for running notebooks)
- 'ipywidgets', # Widgets (needed for interactive matplotlib backends)
- 'ipympl', # Matplotlib Jupyter widget backend (%matplotlib widget)
- 'IPython', # Interactive Python shell
- 'GitPython', # Interact with Git repositories
- 'build', # Building the package
- 'pre-commit', # Pre-commit hooks
- 'jinja2', # Templating
- 'nbmake', # Building notebooks
- 'nbstripout', # Strip output from notebooks
- 'nbqa', # Linting and formatting notebooks
- 'pytest', # Testing
- 'pytest-cov', # Test coverage
- 'pytest-xdist', # Enable parallel testing
- 'ruff', # Linting and formatting code
- 'radon', # Code complexity and maintainability
- 'validate-pyproject[all]', # Validate pyproject.toml
- 'versioningit', # Automatic versioning from git tags
- 'jupytext', # Jupyter notebook text format support
- 'jupyterquiz', # Quizzes in Jupyter notebooks
- 'pydoclint', # Docstring linter
- 'format-docstring', # Docstring formatter
- 'docstripy', # Convert docstrings to other formats
- 'interrogate', # Docstring coverage checker
- 'copier', # Template management
- 'mike', # MkDocs: Versioned documentation support
- 'mkdocs', # Static site generator
- 'mkdocs-material', # Documentation framework on top of MkDocs
- 'mkdocs-autorefs', # MkDocs: Auto-references support
- 'mkdocs-jupyter', # MkDocs: Jupyter notebook support
- 'mkdocs-plugin-inline-svg', # MkDocs: Inline SVG support
- 'mkdocs-markdownextradata-plugin', # MkDocs: Markdown extra data support, such as global variables
- 'mkdocstrings-python', # MkDocs: Python docstring support
- 'pyyaml', # YAML parser
- 'spdx-headers', # SPDX license header validation
+ 'pooch', # Data downloader
+ 'matplotlib', # Plotting library
+ 'jupyterlab', # Jupyter notebooks
+ 'ipykernel', # Jupyter kernel (required for running notebooks)
+ 'ipywidgets', # Widgets (needed for interactive matplotlib backends)
+ 'ipympl', # Matplotlib Jupyter widget backend (%matplotlib widget)
+ 'IPython', # Interactive Python shell
+ 'GitPython', # Interact with Git repositories
+ 'build', # Building the package
+ 'pre-commit', # Pre-commit hooks
+ 'jinja2', # Templating
+ 'nbmake', # Building notebooks
+ 'nbstripout', # Strip output from notebooks
+ 'nbqa', # Linting and formatting notebooks
+ 'pytest', # Testing
+ 'pytest-cov', # Test coverage
+ 'pytest-xdist', # Enable parallel testing
+ 'ruff', # Linting and formatting code
+ 'radon', # Code complexity and maintainability
+ 'validate-pyproject[all]', # Validate pyproject.toml
+ 'versioningit', # Automatic versioning from git tags
+ 'jupytext', # Jupyter notebook text format support
+ 'jupyterquiz', # Quizzes in Jupyter notebooks
+ 'pydoclint', # Docstring linter
+ 'format-docstring', # Docstring formatter
+ 'docstripy', # Convert docstrings to other formats
+ 'interrogate', # Docstring coverage checker
+ 'copier', # Template management
+ 'mike', # MkDocs: Versioned documentation support
+ 'mkdocs', # Static site generator
+ 'mkdocs-material', # Documentation framework on top of MkDocs
+ 'mkdocs-autorefs', # MkDocs: Auto-references support
+ 'mkdocs-jupyter', # MkDocs: Jupyter notebook support
+ 'mkdocs-plugin-inline-svg', # MkDocs: Inline SVG support
+ 'mkdocs-markdownextradata-plugin', # MkDocs: Markdown extra data support, such as global variables
+ 'mkdocstrings-python', # MkDocs: Python docstring support
+ 'pyyaml', # YAML parser
+ 'spdx-headers', # SPDX license header validation
]
[project.urls]
@@ -102,7 +102,7 @@ packages = ['src/easyscience']
allow-direct-references = true
[tool.hatch.version]
-source = 'versioningit' # Use versioningit to manage the version
+source = 'versioningit' # Use versioningit to manage the version
################################
# Configuration for versioningit
@@ -118,9 +118,9 @@ source = 'versioningit' # Use versioningit to manage the version
# pixi.lock update without any changes to the source code.
[tool.versioningit.format]
-distance = '{base_version}+dev{distance}' # example: 1.2.3.post4+dev3
-dirty = '{base_version}+dirty{distance}' # example: 0.5.8+dirty3
-distance-dirty = '{base_version}+devdirty{distance}' # example: 0.5.8+devdirty3
+distance = '{base_version}+dev{distance}' # example: 1.2.3.post4+dev3
+dirty = '{base_version}+dirty{distance}' # example: 0.5.8+dirty3
+distance-dirty = '{base_version}+devdirty{distance}' # example: 0.5.8+devdirty3
# Configure how versioningit detects versions from Git
# - 'match' ensures it only considers tags starting with 'v'
@@ -138,9 +138,8 @@ default-tag = 'v999.0.0'
# https://interrogate.readthedocs.io/en/latest/
[tool.interrogate]
-fail-under = 45 # Minimum docstring coverage percentage to pass
+fail-under = 45 # Minimum docstring coverage percentage to pass
verbose = 1
-exclude = ['src/easyscience/legacy']
#######################################
# Configuration for coverage/pytest-cov
@@ -150,13 +149,13 @@ exclude = ['src/easyscience/legacy']
# https://coverage.readthedocs.io/en/latest/
[tool.coverage.run]
-branch = true # Measure branch coverage as well
-source = ['src'] # Limit coverage to the source code directory
+branch = true # Measure branch coverage as well
+source = ['src'] # Limit coverage to the source code directory
[tool.coverage.report]
show_missing = true # Show missing lines
-skip_covered = false # Skip files with 100% coverage in the report
-fail_under = 80 # Minimum coverage percentage to pass
+skip_covered = false # Skip files with 100% coverage in the report
+fail_under = 80 # Minimum coverage percentage to pass
##########################
# Configuration for pytest
@@ -178,33 +177,33 @@ testpaths = ['tests']
# https://docs.astral.sh/ruff/rules/
[tool.ruff]
-exclude = ['tests', 'legacy', 'src/easyscience/legacy']
+exclude = ['tests']
indent-width = 4
-line-length = 99 # See also `max-line-length` in [tool.ruff.lint.pycodestyle]
-preview = true # Enable new rules that are not yet stable, like DOC
+line-length = 99 # See also `max-line-length` in [tool.ruff.lint.pycodestyle]
+preview = true # Enable new rules that are not yet stable, like DOC
builtins = [
'display',
-] # Clutch-fix/patch to https://github.com/nbQA-dev/nbQA/issues/882
+] # Clutch-fix/patch to https://github.com/nbQA-dev/nbQA/issues/882
# Formatting options for Ruff
[tool.ruff.format]
-docstring-code-format = true # Whether to format code snippets in docstrings
-docstring-code-line-length = 72 # Line length for code snippets in docstrings
-indent-style = 'space' # PEP 8 recommends using spaces over tabs
-quote-style = 'single' # But double quotes in docstrings (PEP 8, PEP 257)
+docstring-code-format = true # Whether to format code snippets in docstrings
+docstring-code-line-length = 72 # Line length for code snippets in docstrings
+indent-style = 'space' # PEP 8 recommends using spaces over tabs
+quote-style = 'single' # But double quotes in docstrings (PEP 8, PEP 257)
# Linting rules to use with Ruff
[tool.ruff.lint]
select = [
- # 'E', # General PEP 8 style errors
- 'E9', # Runtime errors (e.g., syntax errors, undefined names)
- # 'F', # Pyflakes-specific checks (e.g., unused variables, imports)
- 'F63', # Issues related to invalid escape sequences in strings
+ # 'E', # General PEP 8 style errors
+ 'E9', # Runtime errors (e.g., syntax errors, undefined names)
+ # 'F', # Pyflakes-specific checks (e.g., unused variables, imports)
+ 'F63', # Issues related to invalid escape sequences in strings
'F7', # Type-related errors (e.g., mismatched argument types, unsupported operations)
- 'F82', # Import-related errors (e.g., unresolved imports, reimported modules)
- 'I', # Import sorting issues (e.g., unsorted imports)
- # 'S', # Security-related issues (e.g., use of insecure functions or libraries)
+ 'F82', # Import-related errors (e.g., unresolved imports, reimported modules)
+ 'I', # Import sorting issues (e.g., unsorted imports)
+ # 'S', # Security-related issues (e.g., use of insecure functions or libraries)
# 'W', # General PEP 8 warnings (e.g., lines too long, trailing whitespace)
]
@@ -212,31 +211,31 @@ select = [
# Ignore specific rules globally
ignore = [
- 'COM812', # https://docs.astral.sh/ruff/rules/missing-trailing-comma/
+ 'COM812', # https://docs.astral.sh/ruff/rules/missing-trailing-comma/
# Replaced by 'D' plus pydocstyle and pydoclint
- 'DOC', # https://docs.astral.sh/ruff/rules/#pydoclint-doc
+ 'DOC', # https://docs.astral.sh/ruff/rules/#pydoclint-doc
# Disable, as [tool.format_docstring] split one-line docstrings into the canonical multi-line layout
- 'D200', # https://docs.astral.sh/ruff/rules/unnecessary-multiline-docstring/
+ 'D200', # https://docs.astral.sh/ruff/rules/unnecessary-multiline-docstring/
]
# Ignore specific rules in certain files or directories
[tool.ruff.lint.per-file-ignores]
'*/__init__.py' = [
- 'F401', # re-exports are intentional in __init__.py
+ 'F401', # re-exports are intentional in __init__.py
]
'tests/**' = [
- 'ANN', # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann
- 'D', # https://docs.astral.sh/ruff/rules/#pydocstyle-d
- 'DOC', # https://docs.astral.sh/ruff/rules/#pydoclint-doc
- 'INP001', # https://docs.astral.sh/ruff/rules/implicit-namespace-package/
- 'S101', # https://docs.astral.sh/ruff/rules/assert/
+ 'ANN', # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann
+ 'D', # https://docs.astral.sh/ruff/rules/#pydocstyle-d
+ 'DOC', # https://docs.astral.sh/ruff/rules/#pydoclint-doc
+ 'INP001', # https://docs.astral.sh/ruff/rules/implicit-namespace-package/
+ 'S101', # https://docs.astral.sh/ruff/rules/assert/
]
'docs/**' = [
- 'INP001', # https://docs.astral.sh/ruff/rules/implicit-namespace-package/
- 'T201', # https://docs.astral.sh/ruff/rules/print/
+ 'INP001', # https://docs.astral.sh/ruff/rules/implicit-namespace-package/
+ 'T201', # https://docs.astral.sh/ruff/rules/print/
]
'docs/docs/tutorials/**' = [
- 'E402', # https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/
+ 'E402', # https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/
]
# Specific options for certain rules
@@ -258,7 +257,7 @@ max-complexity = 10
# https://peps.python.org/pep-0008/#maximum-line-length
# Use 99 characters as the project-wide maximum for regular code lines.
# Use 72 characters for docstrings.
-max-line-length = 99 # See also `line-length` in [tool.ruff]
+max-line-length = 99 # See also `line-length` in [tool.ruff]
max-doc-length = 72
[tool.ruff.lint.pydocstyle]
@@ -285,7 +284,6 @@ max-positional-args = 6
# the parameter declarations in the code (in function's signature).
[tool.pydoclint]
-exclude = 'src/easyscience/legacy'
style = 'numpy'
check-style-mismatch = true
check-arg-defaults = true
@@ -299,7 +297,6 @@ allow-init-docstring = true
# https://github.com/jsh9/format-docstring
[tool.format_docstring]
-exclude = 'src/easyscience/legacy'
docstring_style = 'numpy'
line_length = 72
fix_rst_backticks = true
diff --git a/src/easyscience/__init__.py b/src/easyscience/__init__.py
index ae709682..2e27db53 100644
--- a/src/easyscience/__init__.py
+++ b/src/easyscience/__init__.py
@@ -7,14 +7,12 @@
# Must be executed before any other imports
global_object = GlobalObject()
-global_object.instantiate_stack()
-global_object.stack.enabled = False
-from .base_classes import ObjBase # noqa: E402
-from .fitting import AvailableMinimizers # noqa: E402
+from .base_classes import EasyList # noqa: E402
+from .base_classes import ModelBase # noqa: E402
from .fitting import Fitter # noqa: E402
-from .legacy import CollectionBase # noqa: E402
+from .fitting.minimizers import Minimizers # noqa: E402
from .variable import DescriptorNumber # noqa: E402
from .variable import Parameter # noqa: E402
@@ -23,9 +21,9 @@
__all__ = [
__version__,
global_object,
- ObjBase,
- CollectionBase,
- AvailableMinimizers,
+ ModelBase,
+ EasyList,
+ Minimizers,
Fitter,
DescriptorNumber,
Parameter,
diff --git a/src/easyscience/base_classes/__init__.py b/src/easyscience/base_classes/__init__.py
index 6c465346..500ae1a8 100644
--- a/src/easyscience/base_classes/__init__.py
+++ b/src/easyscience/base_classes/__init__.py
@@ -1,11 +1,7 @@
# SPDX-FileCopyrightText: 2025 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-from ..legacy.collection_base import CollectionBase
-from ..legacy.obj_base import ObjBase
-from .based_base import BasedBase
+from .easy_base import EasyBase
from .easy_list import EasyList
from .model_base import ModelBase
-from .new_base import NewBase
-
-__all__ = [BasedBase, CollectionBase, ObjBase, ModelBase, NewBase, EasyList]
+from .serializer_base import SerializerBase
diff --git a/src/easyscience/base_classes/based_base.py b/src/easyscience/base_classes/based_base.py
deleted file mode 100644
index 4044fb7b..00000000
--- a/src/easyscience/base_classes/based_base.py
+++ /dev/null
@@ -1,269 +0,0 @@
-# SPDX-FileCopyrightText: 2025 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from inspect import signature
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Dict
-from typing import Iterable
-from typing import List
-from typing import Optional
-from typing import Set
-
-from easyscience import global_object
-
-from ..io import SerializerComponent
-from ..variable import Parameter
-from ..variable.descriptor_base import DescriptorBase
-
-if TYPE_CHECKING:
- from ..fitting.calculators import InterfaceFactoryTemplate
-
-
-class BasedBase(SerializerComponent):
- __slots__ = ['_name', '_global_object', 'user_data', '_kwargs']
-
- _REDIRECT = {}
-
- def __init__(
- self,
- name: str,
- interface: Optional[InterfaceFactoryTemplate] = None,
- unique_name: Optional[str] = None,
- ):
- self._global_object = global_object
- if unique_name is None:
- unique_name = self._global_object.generate_unique_name(self.__class__.__name__)
- self._unique_name = unique_name
- self._name = name
- self._global_object.map.add_vertex(self, obj_type='created')
- self.interface = interface
- self.user_data: dict = {}
-
- @property
- def _arg_spec(self) -> Set[str]:
- base_cls = getattr(self, '__old_class__', self.__class__)
- sign = signature(base_cls.__init__)
- names = [
- param.name
- for param in sign.parameters.values()
- if param.kind == param.POSITIONAL_OR_KEYWORD
- ]
- return set(names[1:])
-
- def __reduce__(self) -> tuple:
- """
- Make the class picklable.
-
- Due to the nature of the dynamic class definitions special
- measures need to be taken.
-
- Returns
- -------
- tuple
- Tuple consisting of how to make the object.
- """
- state = self.encode()
- cls = getattr(self, '__old_class__', self.__class__)
- return cls.from_dict, (state,)
-
- @property
- def unique_name(self) -> str:
- """Get the unique name of the object."""
- return self._unique_name
-
- @unique_name.setter
- def unique_name(self, new_unique_name: str):
- """
- Set a new unique name for the object.
-
- The old name is still kept in the map.
-
- Parameters
- ----------
- new_unique_name : str
- New unique name for the object.
-
- Raises
- ------
- TypeError
- If ``new_unique_name`` is not a string.
- """
- if not isinstance(new_unique_name, str):
- raise TypeError('Unique name has to be a string.')
- self._unique_name = new_unique_name
- self._global_object.map.add_vertex(self)
-
- @property
- def name(self) -> str:
- """
- Get the common name of the object.
-
- Returns
- -------
- str
- Common name of the object.
- """
- return self._name
-
- @name.setter
- def name(self, new_name: str):
- """
- Set a new common name for the object.
-
- Parameters
- ----------
- new_name : str
- New name for the object.
- """
- self._name = new_name
-
- @property
- def interface(self) -> InterfaceFactoryTemplate:
- """Get the current interface of the object."""
- return self._interface
-
- @interface.setter
- def interface(self, new_interface: InterfaceFactoryTemplate):
- """
- Set the current interface to the object and generate bindings if
- possible.
-
- iF.e. ``` def __init__(self, bar, interface=None, **kwargs):
- super().__init__(self, **kwargs) self.foo = bar
- self.interface = interface # As final step after initialization
- to set correct bindings. ```
- """
- self._interface = new_interface
- if new_interface is not None:
- self.generate_bindings()
-
- def generate_bindings(self):
- """
- Generate or re-generate bindings to an interface (if exists).
-
- Raises
- ------
- AttributeError
- If ``interface`` has not been set.
- """
- if self.interface is None:
- raise AttributeError(
- 'Interface error for generating bindings. `interface` has to be set.'
- )
- interfaceable_children = [
- key
- for key in self._global_object.map.get_edges(self)
- if issubclass(type(self._global_object.map.get_item_by_key(key)), BasedBase)
- ]
- for child_key in interfaceable_children:
- child = self._global_object.map.get_item_by_key(child_key)
- child.interface = self.interface
- self.interface.generate_bindings(self)
-
- def switch_interface(self, new_interface_name: str):
- """Switch or create a new interface."""
- if self.interface is None:
- raise AttributeError(
- 'Interface error for generating bindings. `interface` has to be set.'
- )
- self.interface.switch(new_interface_name)
- self.generate_bindings()
-
- def get_parameters(self) -> List[Parameter]:
- """
- Get all parameter objects as a list.
-
- Returns
- -------
- List[Parameter]
- List of ``Parameter`` objects.
- """
- par_list = []
- for key, item in self._kwargs.items():
- if hasattr(item, 'get_parameters'):
- par_list = [*par_list, *item.get_parameters()]
- elif isinstance(item, Parameter):
- par_list.append(item)
- return par_list
-
- def _get_linkable_attributes(self) -> List[DescriptorBase]:
- """
- Get all objects which can be linked against as a list.
-
- Returns
- -------
- List[DescriptorBase]
- List of ``Descriptor``/`Parameter` objects.
- """
- item_list = []
- for key, item in self._kwargs.items():
- if hasattr(item, '_get_linkable_attributes'):
- item_list = [*item_list, *item._get_linkable_attributes()]
- elif issubclass(type(item), (DescriptorBase)):
- item_list.append(item)
- return item_list
-
- def get_fit_parameters(self) -> List[Parameter]:
- """
- Get all objects which can be fitted (and are not fixed) as a
- list.
-
- Returns
- -------
- List[Parameter]
- List of ``Parameter`` objects which can be used in fitting.
- """
- fit_list = []
- for key, item in self._kwargs.items():
- if hasattr(item, 'get_fit_parameters'):
- fit_list = [*fit_list, *item.get_fit_parameters()]
- elif isinstance(item, Parameter):
- if item.independent and not item.fixed:
- fit_list.append(item)
- return fit_list
-
- def __dir__(self) -> Iterable[str]:
- """
- This creates auto-completion and helps out in iPython notebooks.
-
- Returns
- -------
- Iterable[str]
- List of function and parameter names for auto- completion.
- """
- new_class_objs = list(k for k in dir(self.__class__) if not k.startswith('_'))
- return sorted(new_class_objs)
-
- def __copy__(self) -> BasedBase:
- """Return a copy of the object."""
- temp = self.as_dict(skip=['unique_name'])
- new_obj = self.__class__.from_dict(temp)
- return new_obj
-
- def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
- """
- Convert an object into a full dictionary using
- ``SerializerDict``. This is a shortcut for
- ```obj.encode(encoder=SerializerDict)```
-
- Parameters
- ----------
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming
- the dictionary. By default, None.
-
- Returns
- -------
- Dict[str, Any]
- Encoded object containing all information to reform an
- EasyScience object.
- """
- # extend skip to include unique_name by default
- if skip is None:
- skip = []
- if 'unique_name' not in skip:
- skip.append('unique_name')
- return super().as_dict(skip=skip)
diff --git a/src/easyscience/base_classes/collection_base.py b/src/easyscience/base_classes/collection_base.py
deleted file mode 100644
index c62e9382..00000000
--- a/src/easyscience/base_classes/collection_base.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-"""
-.. deprecated::
- This module has been moved to ``easyscience.legacy.collection_base``.
- Please update your imports.
-"""
-
-from easyscience import global_object
-
-from ..legacy.collection_base import CollectionBase # noqa: F401
-
-global_object.log.warning(
- 'easyscience.base_classes.collection_base is deprecated. '
- 'Please import from easyscience.legacy.collection_base instead.',
- stacklevel=2,
-)
diff --git a/src/easyscience/base_classes/new_base.py b/src/easyscience/base_classes/easy_base.py
similarity index 74%
rename from src/easyscience/base_classes/new_base.py
rename to src/easyscience/base_classes/easy_base.py
index e1797166..927f7813 100644
--- a/src/easyscience/base_classes/new_base.py
+++ b/src/easyscience/base_classes/easy_base.py
@@ -3,7 +3,6 @@
from __future__ import annotations
-from inspect import signature
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -12,17 +11,15 @@
from typing import Iterable
from typing import List
from typing import Optional
- from typing import Set
from easyscience import global_object
-from ..global_object.undo_redo import property_stack
-from ..io.serializer_base import SerializerBase
+from .serializer_base import SerializerBase
-class NewBase:
+class EasyBase:
"""
- This is the new base class for easyscience objects.
+ This is the base class for easyscience objects.
It provides serialization capabilities as well as unique naming and
display naming.
@@ -38,26 +35,11 @@ def __init__(self, unique_name: Optional[str] = None, display_name: Optional[str
if not isinstance(unique_name, str):
raise TypeError('Unique name has to be a string.')
self._unique_name = unique_name
- self._global_object.map.add_vertex(self, obj_type='created')
+ self._global_object.registry.add(self)
if display_name is not None and not isinstance(display_name, str):
raise TypeError('Display name must be a string or None')
self._display_name = display_name
- @property
- def _arg_spec(self) -> Set[str]:
- """
- This method is used by the serializer to determine which
- arguments are needed by the constructor to deserialize the
- object.
- """
- sign = signature(self.__class__.__init__)
- names = [
- param.name
- for param in sign.parameters.values()
- if param.kind == param.POSITIONAL_OR_KEYWORD
- ]
- return set(names[1:])
-
@property
def unique_name(self) -> str:
"""Get the unique name of the object."""
@@ -68,7 +50,7 @@ def unique_name(self, new_unique_name: str):
"""
Set a new unique name for the object.
- The old name is still kept in the map.
+ The old name is removed from the registry.
Parameters
----------
@@ -79,11 +61,27 @@ def unique_name(self, new_unique_name: str):
------
TypeError
If ``new_unique_name`` is not a string.
+ ValueError
+ If ``new_unique_name`` is already registered to another object.
"""
if not isinstance(new_unique_name, str):
raise TypeError('Unique name has to be a string.')
- self._unique_name = new_unique_name
- self._global_object.map.add_vertex(self)
+ registry = self._global_object.registry
+ old_unique_name = self._unique_name
+ if new_unique_name != old_unique_name:
+ self._unique_name = new_unique_name
+ try:
+ registry.add(self)
+ except ValueError:
+ # Keep the old name if the new one is taken
+ self._unique_name = old_unique_name
+ raise
+ if old_unique_name in registry and registry[old_unique_name] is self:
+ # Only drop the old name if it is still ours. If it was
+ # discarded while this object stayed alive (e.g. in
+ # ModelBase.from_dict or registry.clear()), it may since
+ # have been given to another object.
+ registry.discard(old_unique_name)
self._default_unique_name = False
@property
@@ -102,7 +100,6 @@ def display_name(self) -> str:
return display_name
@display_name.setter
- @property_stack
def display_name(self, name: str | None) -> None:
"""
Set the pretty display name.
@@ -137,19 +134,21 @@ def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
Dict[str, Any]
Encoded object containing all information to reform an
EasyScience object.
+
+ Notes
+ -----
+ A ``unique_name`` that was generated automatically is not
+ written; only an explicitly supplied one is, so that a
+ deserialized object gets a fresh name instead of clashing with
+ the original. Likewise ``display_name`` is omitted when it is
+ ``None``. Pass ``skip`` to drop further fields.
"""
serializer = SerializerBase()
- temp_dict = serializer._convert_to_dict(self, skip=skip, full_encode=False)
- if skip is None:
- skip = []
- if self._default_unique_name or 'unique_name' in skip:
- temp_dict.pop('unique_name', None)
- if self._display_name is None or 'display_name' in skip:
- temp_dict.pop('display_name', None)
- return temp_dict
+ skip = list(skip) if skip else []
+ return serializer._convert_to_dict(self, skip=skip, full_encode=False)
@classmethod
- def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase:
+ def from_dict(cls, obj_dict: Dict[str, Any]) -> EasyBase:
"""
Re-create an EasyScience object from a full encoded dictionary.
@@ -157,11 +156,11 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase:
----------
obj_dict : Dict[str, Any]
Dictionary containing the serialized contents (from
- ``SerializerDict``) of an EasyScience object.
+ ``to_dict``) of an EasyScience object.
Returns
-------
- NewBase
+ EasyBase
Reformed EasyScience object.
Raises
@@ -192,7 +191,7 @@ def __dir__(self) -> Iterable[str]:
new_class_objs = list(k for k in dir(self.__class__) if not k.startswith('_'))
return sorted(new_class_objs)
- def __copy__(self) -> NewBase:
+ def __copy__(self) -> EasyBase:
"""Return a copy of the object."""
temp = self.to_dict(skip=['unique_name'])
new_obj = self.__class__.from_dict(temp)
diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py
index 2effca51..7bc3d825 100644
--- a/src/easyscience/base_classes/easy_list.py
+++ b/src/easyscience/base_classes/easy_list.py
@@ -16,13 +16,13 @@
from typing import overload
from easyscience import global_object
-from easyscience.io.serializer_base import SerializerBase
+from easyscience.base_classes.serializer_base import SerializerBase
from easyscience.variable.descriptor_base import DescriptorBase
+from .easy_base import EasyBase
from .model_base import ModelBase
-from .new_base import NewBase
-ProtectedType_ = TypeVar('ProtectedType', bound=NewBase)
+ProtectedType_ = TypeVar('ProtectedType', bound=EasyBase)
class EasyList(ModelBase, MutableSequence[ProtectedType_]):
@@ -30,8 +30,8 @@ class EasyList(ModelBase, MutableSequence[ProtectedType_]):
# we would have to overwrite "extend", "remove", "__iadd__", "count", "append", "__iter__" and "clear"
def __init__(
self,
- *args: ProtectedType_ | list[ProtectedType_],
- protected_types: list[Type[NewBase]] | Type[NewBase] | None = None,
+ *args: ProtectedType_ | Iterable[ProtectedType_],
+ protected_types: list[Type[EasyBase]] | Type[EasyBase] | None = None,
unique_name: Optional[str] = None,
display_name: Optional[str] = None,
**kwargs: Any,
@@ -41,11 +41,15 @@ def __init__(
Parameters
----------
- *args : ProtectedType_ | list[ProtectedType_]
+ *args : ProtectedType_ | Iterable[ProtectedType_]
Initial items to add to the list.
- protected_types : list[Type[NewBase]] | Type[NewBase] | None, default=None
- Types that are allowed in the list. Can be a single NewBase
- subclass or a list of them. If None,. By default, None.
+ protected_types : list[Type[EasyBase]] | Type[EasyBase] | None, default=None
+ Types that are allowed in the list. Can be a single EasyBase
+ subclass or a list of them. If None, any ``EasyBase`` object
+ is accepted, including descriptors and parameters. Both bare
+ descriptors and ``ModelBase`` items contribute to
+ ``get_all_variables`` and hence to fitting. By default,
+ None.
unique_name : Optional[str], default=None
Optional unique name for the list. By default, None.
display_name : Optional[str], default=None
@@ -56,27 +60,27 @@ def __init__(
Raises
------
TypeError
- If ``protected_types`` is not a supported NewBase type or
+ If ``protected_types`` is not a supported EasyBase type or
iterable of supported types.
"""
super().__init__(unique_name=unique_name, display_name=display_name)
if protected_types is None:
- self._protected_types = [NewBase]
- elif isinstance(protected_types, type) and issubclass(protected_types, NewBase):
+ self._protected_types = [EasyBase]
+ elif isinstance(protected_types, type) and issubclass(protected_types, EasyBase):
self._protected_types = [protected_types]
elif isinstance(protected_types, Iterable) and all(
- issubclass(t, NewBase) for t in protected_types
+ issubclass(t, EasyBase) for t in protected_types
):
self._protected_types = list(protected_types)
else:
raise TypeError(
- 'protected_types must be a NewBase subclass or an iterable of NewBase subclasses'
+ 'protected_types must be an EasyBase subclass or an iterable of EasyBase subclasses'
)
self._data: List[ProtectedType_] = []
# Add initial items
for item in args:
- if isinstance(item, list):
+ if isinstance(item, (list, tuple)):
for sub_item in item:
self.append(sub_item)
else:
@@ -266,12 +270,14 @@ def _get_key(self, obj: ProtectedType_) -> str:
def get_all_variables(self) -> List[DescriptorBase]:
"""
- Get all ``Descriptor`` and ``Parameter`` objects from all
- elements that are derived from ``ModelBase``.
+ Get all ``Descriptor`` and ``Parameter`` objects held by this
+ list.
- For each element that is a ``ModelBase`` instance, the element's
- own ``get_all_variables()`` method is called and the results are
- collected into a single flat list.
+ Elements that are ``DescriptorBase`` instances (e.g. a bare
+ ``Parameter``) are collected directly, while elements derived
+ from ``ModelBase`` contribute the result of their own
+ ``get_all_variables()`` call. Everything is collected into a
+ single flat list.
Returns
-------
@@ -281,7 +287,9 @@ def get_all_variables(self) -> List[DescriptorBase]:
"""
all_vars: List[DescriptorBase] = []
for item in self._data:
- if isinstance(item, ModelBase):
+ if isinstance(item, DescriptorBase):
+ all_vars.append(item)
+ elif isinstance(item, ModelBase):
all_vars.extend(item.get_all_variables())
return all_vars
@@ -360,11 +368,13 @@ def to_dict(self, skip: list[str] | None = None) -> dict:
"""
Convert the EasyList to a dictionary for serialization.
+ The serializer calls ``to_dict`` on nested EasyScience objects.
+
Parameters
----------
skip : list[str] | None, default=None
List of field names as strings to skip when forming the
- dictionary.
+ dictionary. By default, None.
Returns
-------
@@ -375,7 +385,7 @@ def to_dict(self, skip: list[str] | None = None) -> dict:
# protected_types holds classes, which the generic encoder
# cannot handle; they are encoded explicitly below.
dict_repr = super().to_dict(skip=[*skip, 'protected_types'])
- if self._protected_types != [NewBase]:
+ if self._protected_types != [EasyBase]:
dict_repr['protected_types'] = [
{'@module': cls_.__module__, '@class': cls_.__name__}
for cls_ in self._protected_types
@@ -384,19 +394,19 @@ def to_dict(self, skip: list[str] | None = None) -> dict:
return dict_repr
@classmethod
- def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase:
+ def from_dict(cls, obj_dict: Dict[str, Any]) -> EasyBase:
"""
Re-create an EasyScience object from a full encoded dictionary.
Parameters
----------
obj_dict : Dict[str, Any]
- Dictionary containing the serialized contents (from
- ``SerializerDict``) of an EasyScience object.
+ Dictionary containing the serialized contents
+ of an EasyScience object.
Returns
-------
- NewBase
+ EasyBase
Reformed EasyScience object.
Raises
diff --git a/src/easyscience/base_classes/model_base.py b/src/easyscience/base_classes/model_base.py
index dee45ba5..f8d472e1 100644
--- a/src/easyscience/base_classes/model_base.py
+++ b/src/easyscience/base_classes/model_base.py
@@ -13,13 +13,13 @@
from typing import List
from typing import Optional
-from ..io import SerializerBase
from ..variable import Parameter
from ..variable.descriptor_base import DescriptorBase
-from .new_base import NewBase
+from .easy_base import EasyBase
+from .serializer_base import SerializerBase
-class ModelBase(NewBase):
+class ModelBase(EasyBase):
"""
This is the base class for all model classes in EasyScience. It
provides methods to get parameters for fitting and analysis as well
@@ -98,15 +98,6 @@ def get_free_parameters(self) -> List[Parameter]:
"""
return [param for param in self.get_fittable_parameters() if not param.fixed]
- def get_fit_parameters(self) -> List[Parameter]:
- """
- This is an alias for ``get_free_parameters``.
-
- To be removed when fully moved to new base classes and minimizer
- can be changed.
- """
- return self.get_free_parameters()
-
@classmethod
def from_dict(cls, obj_dict: Dict[str, Any]) -> ModelBase:
"""
@@ -117,7 +108,7 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> ModelBase:
----------
obj_dict : Dict[str, Any]
Dictionary containing the serialized contents (from
- ``SerializerDict``) of an EasyScience object.
+ ``to_dict``) of an EasyScience object.
Returns
-------
@@ -147,7 +138,7 @@ class definition.
try:
temp_param = getattr(cls_instance, key)
setattr(cls_instance, '_' + key, value)
- cls_instance._global_object.map.prune(temp_param.unique_name)
+ cls_instance._global_object.registry.discard(temp_param.unique_name)
except Exception as e:
raise SyntaxError(f"""Could not set parameter {key} during `from_dict` with full deserialized variable. \n'
This should be fixed in the class definition. Error: {e}""") from e
diff --git a/src/easyscience/base_classes/obj_base.py b/src/easyscience/base_classes/obj_base.py
deleted file mode 100644
index 8d44b193..00000000
--- a/src/easyscience/base_classes/obj_base.py
+++ /dev/null
@@ -1,18 +0,0 @@
-# SPDX-FileCopyrightText: 2025 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-"""
-.. deprecated::
- This module has been moved to ``easyscience.legacy.obj_base``.
- Please update your imports.
-"""
-
-from easyscience import global_object
-
-from ..legacy.obj_base import ObjBase # noqa: F401
-
-global_object.log.warning(
- 'easyscience.base_classes.obj_base is deprecated. '
- 'Please import from easyscience.legacy.obj_base instead.',
- stacklevel=2,
-)
diff --git a/src/easyscience/io/serializer_base.py b/src/easyscience/base_classes/serializer_base.py
similarity index 73%
rename from src/easyscience/io/serializer_base.py
rename to src/easyscience/base_classes/serializer_base.py
index 3fc22d06..c5c2c089 100644
--- a/src/easyscience/io/serializer_base.py
+++ b/src/easyscience/base_classes/serializer_base.py
@@ -9,20 +9,15 @@
from enum import Enum
from importlib import import_module
from inspect import getfullargspec
-from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import MutableSequence
-from typing import Optional
from typing import Tuple
import numpy as np
-if TYPE_CHECKING:
- from .serializer_component import SerializerComponent
-
_e = json.JSONEncoder()
@@ -33,20 +28,19 @@ class SerializerBase:
``encode`` and ``decode`` are abstract methods to be implemented for
each serializer. It is expected that the helper function
- `_convert_to_dict` will be used as a base for encoding (or the
- ``SerializerDict`` as it's more flexible).
+ `_convert_to_dict` will be used as a base for encoding.
"""
@abstractmethod
- def encode(self, obj: SerializerComponent, skip: Optional[List[str]] = None, **kwargs) -> any:
+ def encode(self, obj: Any, skip: List[str] | None = None, **kwargs) -> any:
"""
Abstract implementation of an encoder.
Parameters
----------
- obj : SerializerComponent
+ obj : Any
Object to be encoded.
- skip : Optional[List[str]], default=None
+ skip : List[str] | None, default=None
List of field names as strings to skip when forming the
encoded object. By default, None.
**kwargs :
@@ -67,7 +61,6 @@ def encode(self, obj: SerializerComponent, skip: Optional[List[str]] = None, **k
def decode(cls, obj: Any) -> Any:
"""
Re-create an EasyScience object from the output of an encoder.
- The default decoder is ``SerializerDict``.
Parameters
----------
@@ -87,6 +80,10 @@ def get_arg_spec(func: Callable) -> Tuple[Any, List[str]]:
Get the full argument specification of a function (typically
``__init__``)
+ Both positional-or-keyword and keyword-only arguments are
+ returned, so constructors that use ``*`` to force keyword-only
+ arguments are serialized correctly.
+
Parameters
----------
func : Callable
@@ -99,7 +96,7 @@ def get_arg_spec(func: Callable) -> Tuple[Any, List[str]]:
"""
spec = getfullargspec(func)
- args = spec.args[1:]
+ args = spec.args[1:] + spec.kwonlyargs
return spec, args
@staticmethod
@@ -148,14 +145,19 @@ def _encode_objs(obj: Any) -> Dict[str, Any]:
def _convert_to_dict(
self,
- obj: SerializerComponent,
- skip: Optional[List[str]] = None,
+ obj: Any,
+ skip: List[str] | None = None,
full_encode: bool = False,
**kwargs,
) -> dict:
"""A JSON serializable dict representation of an object."""
if skip is None:
skip = []
+ own_skip = list(skip)
+ if getattr(obj, '_default_unique_name', False) and 'unique_name' not in own_skip:
+ own_skip.append('unique_name')
+ if hasattr(obj, '_display_name') and obj._display_name is None:
+ own_skip.append('display_name')
if full_encode:
new_obj = SerializerBase._encode_objs(obj)
@@ -175,8 +177,6 @@ def _convert_to_dict(
if hasattr(obj, '_arg_spec'):
args = obj._arg_spec
- redirect = getattr(obj, '_REDIRECT', {})
-
def runner(o):
if full_encode:
return SerializerBase._encode_objs(o)
@@ -184,76 +184,36 @@ def runner(o):
return o
for c in args:
- if c not in skip:
- if c in redirect.keys():
- if redirect[c] is None:
- continue
- a = runner(redirect[c](obj))
- else:
- try:
- a = runner(obj.__getattribute__(c))
- except AttributeError:
- try:
- a = runner(obj.__getattribute__('_' + c))
- except AttributeError:
- err = True
- if hasattr(obj, 'kwargs'):
- # type: ignore
- option = getattr(obj, 'kwargs')
- if hasattr(option, c):
- v = getattr(option, c)
- delattr(option, c)
- d.update(runner(v)) # pylint: disable=E1101
- err = False
- if hasattr(obj, '_kwargs'):
- # type: ignore
- option = getattr(obj, '_kwargs')
- if hasattr(option, c):
- v = getattr(option, c)
- delattr(option, c)
- d.update(runner(v)) # pylint: disable=E1101
- err = False
- if err:
- raise NotImplementedError(
- 'Unable to automatically determine as_dict '
- 'format from class. MSONAble requires all '
- 'args to be present as either self.argname or '
- 'self._argname, and kwargs to be present under'
- 'a self.kwargs variable to automatically '
- 'determine the dict format. Alternatively, '
- 'you can implement both as_dict and from_dict.'
- )
- d[c] = self._recursive_encoder(
- a, skip=skip, encoder=self, full_encode=full_encode, **kwargs
- )
+ if c in own_skip:
+ continue
+ try:
+ a = runner(obj.__getattribute__(c))
+ except AttributeError:
+ try:
+ a = runner(obj.__getattribute__('_' + c))
+ except AttributeError:
+ raise NotImplementedError(
+ 'Unable to automatically determine to_dict '
+ 'format from class. All args must be present '
+ 'as either self.argname or self._argname to '
+ 'automatically determine the dict format. '
+ 'Alternatively, you can implement both to_dict '
+ 'and from_dict.'
+ ) from None
+ d[c] = self._recursive_encoder(
+ a, skip=skip, encoder=self, full_encode=full_encode, **kwargs
+ )
if spec.varargs is not None and getattr(obj, spec.varargs, None) is not None:
d.update({spec.varargs: getattr(obj, spec.varargs)})
- if hasattr(obj, '_kwargs'):
- if not issubclass(type(obj), MutableSequence):
- d_k = list(d.keys())
- for k, v in getattr(obj, '_kwargs').items():
- # We should have already obtained `key` and `_key`
- if k not in skip and k not in d_k:
- if k[0] == '_' and k[1:] in d_k:
- continue
- vv = v
- if k in redirect.keys():
- if redirect[k] is None:
- continue
- vv = redirect[k](obj)
- v_ = runner(vv)
- d[k] = self._recursive_encoder(
- v_,
- skip=skip,
- encoder=self,
- full_encode=full_encode,
- **kwargs,
- )
if isinstance(obj, Enum):
d.update({'value': runner(obj.value)}) # pylint: disable=E1101
if hasattr(obj, '_convert_to_dict'):
d = obj._convert_to_dict(d, self, skip=skip, **kwargs)
- if hasattr(obj, '_global_object') and 'unique_name' not in d and 'unique_name' not in skip:
+ if (
+ hasattr(obj, '_global_object')
+ and 'unique_name' not in d
+ and 'unique_name' not in own_skip
+ ):
d['unique_name'] = obj.unique_name
return d
@@ -304,7 +264,9 @@ def _convert_from_dict(d: Any) -> Any:
return np.array(d['data'], dtype=d['dtype'])
if issubclass(T_, (list, MutableSequence)):
- return [SerializerBase._convert_from_dict(x) for x in d]
+ # Elements that are serialized EasyScience objects are rebuilt
+ # by their own ``from_dict``; anything else is decoded here.
+ return [SerializerBase._deserialize_value(x) for x in d]
return d
@staticmethod
@@ -434,7 +396,10 @@ def _recursive_encoder(
): # strings have encode
return encoder._convert_to_dict(obj, skip, full_encode, **kwargs)
elif hasattr(obj, 'to_dict') and obj.__class__.__module__.startswith('easy'):
- return obj.to_dict(skip=skip)
+ # EasyScience objects serialize themselves, so that
+ # ``to_dict`` overrides (e.g. EasyList, Parameter) apply
+ # when the object is nested as well as at the top level.
+ return obj.to_dict(skip=list(skip))
else:
return [
self._recursive_encoder(it, skip, encoder, full_encode, **kwargs) for it in obj
@@ -449,5 +414,5 @@ def _recursive_encoder(
): # strings have encode
return encoder._convert_to_dict(obj, skip, full_encode, **kwargs)
elif hasattr(obj, 'to_dict') and obj.__class__.__module__.startswith('easy'):
- return obj.to_dict(skip=skip)
+ return obj.to_dict(skip=list(skip))
return obj
diff --git a/src/easyscience/fitting/__init__.py b/src/easyscience/fitting/__init__.py
index 29e6ddef..a3956527 100644
--- a/src/easyscience/fitting/__init__.py
+++ b/src/easyscience/fitting/__init__.py
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-from .available_minimizers import AvailableMinimizers
from .fitter import Fitter
from .minimizers.utils import FitResults
from .sampler import Sampler
diff --git a/src/easyscience/fitting/calculators/__init__.py b/src/easyscience/fitting/calculators/__init__.py
deleted file mode 100644
index 4104ddae..00000000
--- a/src/easyscience/fitting/calculators/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-# SPDX-FileCopyrightText: 2025 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from .interface_factory import InterfaceFactoryTemplate
-
-__all__ = [InterfaceFactoryTemplate]
diff --git a/src/easyscience/fitting/calculators/interface_factory.py b/src/easyscience/fitting/calculators/interface_factory.py
deleted file mode 100644
index ad5fbc24..00000000
--- a/src/easyscience/fitting/calculators/interface_factory.py
+++ /dev/null
@@ -1,268 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Callable
-from typing import List
-from typing import NamedTuple
-from typing import Optional
-from typing import Type
-
-from easyscience import global_object
-
-if TYPE_CHECKING:
- from abc import ABCMeta
-
- from .. import Fitter
-
-
-class InterfaceFactoryTemplate:
- """
- This class allows for the creation and transference of interfaces.
- """
-
- def __init__(self, interface_list: List[ABCMeta], *args, **kwargs):
- self._interfaces: List[ABCMeta] = interface_list
- self._current_interface: ABCMeta
- self.__interface_obj: None = None
- self.create(*args, **kwargs)
-
- def create(self, *args: Any, interface_name: str | None = None, **kwargs: Any) -> None:
- """
- Create an interface to a calculator from those initialized.
-
- Interfaces can be selected by ``interface_name`` where
- ``interface_name`` is one of ``obj.available_interfaces``. This
- interface can now be accessed by obj().
-
- Parameters
- ----------
- *args : Any
- Positional arguments forwarded to the interface constructor.
- interface_name : str | None, default=None
- Name of interface to be created.
-
- **kwargs : Any
- Keyword arguments forwarded to the interface constructor.
-
- Raises
- ------
- NotImplementedError
- If no interfaces are available to instantiate.
- """
- if interface_name is None:
- if len(self._interfaces) > 0:
- # Fallback name
- interface_name = self.return_name(self._interfaces[0])
- else:
- raise NotImplementedError
- interfaces = self.available_interfaces
- if interface_name in interfaces:
- self._current_interface = self._interfaces[interfaces.index(interface_name)]
- self.__interface_obj = self._current_interface(*args, **kwargs)
-
- def switch(self, new_interface: str, fitter: Optional[Type[Fitter]] = None) -> None:
- """
- Changes the current interface to a new interface.
-
- The current interface is destroyed and all SerializerComponent
- parameters carried over to the new interface. i.e. pick up where
- you left off.
-
- Parameters
- ----------
- new_interface : str
- Name of new interface to be created.
- fitter : Optional[Type[Fitter]], default=None
- Fitting interface which contains the fitting object which
- may have bindings which will be updated. By default, None.
-
- Raises
- ------
- AttributeError
- If ``new_interface`` is not a valid interface name.
- """
- interfaces = self.available_interfaces
- if new_interface in interfaces:
- self._current_interface = self._interfaces[interfaces.index(new_interface)]
- self.__interface_obj = self._current_interface()
- else:
- raise AttributeError('The user supplied interface is not valid.')
- if fitter is not None:
- if hasattr(fitter, '_fit_object'):
- obj = getattr(fitter, '_fit_object')
- try:
- if hasattr(obj, 'update_bindings'):
- obj.update_bindings()
- except Exception as e:
- global_object.log.getLogger('fitting.calculators').warning(
- 'Unable to auto generate bindings.\n%s', e
- )
- elif hasattr(fitter, 'generate_bindings'):
- try:
- fitter.generate_bindings()
- except Exception as e:
- global_object.log.getLogger('fitting.calculators').warning(
- 'Unable to auto generate bindings.\n%s', e
- )
-
- @property
- def available_interfaces(self) -> List[str]:
- """
- Return all available interfaces.
-
- Returns
- -------
- List[str]
- List of available interface names.
- """
- return [self.return_name(this_interface) for this_interface in self._interfaces]
-
- @property
- def current_interface(self) -> ABCMeta:
- """
- Returns the constructor for the currently selected interface.
-
- Returns
- -------
- ABCMeta
- Interface constructor.
- """
- return self._current_interface
-
- @property
- def current_interface_name(self) -> str:
- """
- Returns the constructor name for the currently selected
- interface.
-
- Returns
- -------
- str
- Interface constructor name.
- """
- return self.return_name(self._current_interface)
-
- @property
- def fit_func(
- self,
- ) -> Callable: # , x_array: np.ndarray, *args, **kwargs) -> np.ndarray:
- """
- Pass through to the underlying interfaces fitting function.
-
- Returns
- -------
- Callable
- Callable proxy to the underlying interface fit function.
- """
-
- def __fit_func(*args, **kwargs):
- return self.__interface_obj.fit_func(*args, **kwargs)
-
- return __fit_func
-
- def call(self, *args, **kwargs):
- return self.fit_func(*args, **kwargs)
-
- def generate_bindings(self, model: Any, *args: Any, ifun: Any = None, **kwargs: Any) -> None:
- """
- Automatically bind a ``Parameter`` to the corresponding
- interface.
-
- Parameters
- ----------
- model : Any
- Model whose linkable attributes should be bound.
- *args : Any
- Positional arguments reserved for interface-specific binding
- hooks.
- ifun : Any, default=None
- Optional interface hook. By default, None.
- **kwargs : Any
- Keyword arguments reserved for interface-specific binding
- hooks.
- """
-
- class_links = self.__interface_obj.create(model)
- props = model._get_linkable_attributes()
- props_names = [prop.name for prop in props]
- for item in class_links:
- for item_key in item.name_conversion.keys():
- if item_key not in props_names:
- continue
- idx = props_names.index(item_key)
- prop = props[idx]
-
- # Should be fetched this way to ensure we don't get value from callback
- if hasattr(prop, 'value_no_call_back'):
- # Property object
- prop_value = prop.value_no_call_back
- else:
- # Descriptor object
- prop_value = prop.value
-
- prop._callback = item.make_prop(item_key)
- prop._callback.fset(prop_value)
-
- def __call__(self, *args, **kwargs) -> None:
- return self.__interface_obj
-
- def __reduce__(self):
- return (
- self.__state_restore__,
- (
- self.__class__,
- self.current_interface_name,
- ),
- )
-
- @staticmethod
- def __state_restore__(cls, interface_str):
- obj = cls()
- if interface_str in obj.available_interfaces:
- obj.switch(interface_str)
- return obj
-
- @staticmethod
- def return_name(this_interface) -> str:
- """Return an interfaces name."""
- interface_name = this_interface.__name__
- if hasattr(this_interface, 'name'):
- interface_name = getattr(this_interface, 'name')
- return interface_name
-
-
-class ItemContainer(NamedTuple):
- link_name: str
- name_conversion: dict
- getter_fn: Callable
- setter_fn: Callable
-
- def make_prop(self, parameter_name) -> property:
- return property(
- fget=self.__make_getter(parameter_name),
- fset=self.__make_setter(parameter_name),
- )
-
- def convert_key(self, lookup_key: str) -> str:
- key = self.name_conversion.get(lookup_key, None)
- return key
-
- def __make_getter(self, get_name: str) -> Callable:
-
- def get_value():
- inner_key = self.name_conversion.get(get_name, None)
- return self.getter_fn(self.link_name, inner_key)
-
- return get_value
-
- def __make_setter(self, get_name: str) -> Callable:
-
- def set_value(value):
- inner_key = self.name_conversion.get(get_name, None)
- self.setter_fn(self.link_name, **{inner_key: value})
-
- return set_value
diff --git a/src/easyscience/fitting/engine_base.py b/src/easyscience/fitting/engine_base.py
new file mode 100644
index 00000000..f72643a4
--- /dev/null
+++ b/src/easyscience/fitting/engine_base.py
@@ -0,0 +1,241 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+from abc import ABCMeta
+from inspect import Parameter as InspectParameter
+from inspect import Signature
+from inspect import _empty
+from typing import Callable
+from typing import Dict
+from typing import Tuple
+
+import numpy as np
+
+from easyscience.variable import Parameter
+
+PARAMETER_PREFIX = 'p'
+
+
+class EngineBase(metaclass=ABCMeta):
+ """
+ Base for all evaluation engines: minimizers and samplers.
+
+ An engine binds an EasyScience object and a fit function, and
+ repeatedly evaluates the function while writing values back into the
+ object's ``Parameter`` instances. ``EngineBase`` owns this shared
+ system: the parameter cache, the ``Parameter``-writing wrapped
+ fit function, and value restore on failure. It deliberately declares
+ no abstract methods: the interfaces are defined on its subclasses
+ (``MinimizerBase.fit``, ``DreamSampler.run``).
+ """
+
+ package: str = None
+
+ def __init__(
+ self,
+ obj,
+ fit_function: Callable,
+ ):
+ self._object = obj
+ self._original_fit_function = fit_function
+ self._cached_pars: Dict[str, Parameter] = {}
+ self._cached_pars_vals: Dict[str, Tuple[float, float]] = {}
+ self._fit_function = None
+
+ def _restore_parameter_values(self) -> None:
+ for key in self._cached_pars.keys():
+ self._cached_pars[key].value = self._cached_pars_vals[key][0]
+ self._cached_pars[key].error = self._cached_pars_vals[key][1]
+
+ @staticmethod
+ def validate_arrays(x: np.ndarray, y: np.ndarray, weights: np.ndarray) -> None:
+ """Validate the (x, y, weights) arrays handed to an engine.
+
+ Parameters
+ ----------
+ x : np.ndarray
+ Independent variable array.
+ y : np.ndarray
+ Dependent variable array.
+ weights : np.ndarray
+ Weight array.
+
+ Raises
+ ------
+ ValueError
+ If any array is non-numeric, the shapes disagree, x or y contain
+ NaN or infinite values, or the weights are non-finite or
+ non-positive.
+ """
+ # A non-numeric dtype (an object array holding None, or a string
+ # array) would otherwise reach np.isfinite and raise an opaque numpy
+ # TypeError instead of the ValueError documented here.
+ for name, arr in (('x', x), ('y', y), ('weights', weights)):
+ if not np.issubdtype(arr.dtype, np.number):
+ raise ValueError(f'{name} must hold numeric values, got dtype {arr.dtype}.')
+
+ if y.shape != x.shape:
+ raise ValueError('x and y must have the same shape.')
+
+ if not np.isfinite(x).all():
+ raise ValueError('x cannot contain NaN or infinite values.')
+ if not np.isfinite(y).all():
+ raise ValueError('y cannot contain NaN or infinite values.')
+
+ if weights.shape != x.shape:
+ raise ValueError('Weights must have the same shape as x and y.')
+
+ if not np.isfinite(weights).all():
+ raise ValueError('Weights cannot be NaN or infinite.')
+
+ if (weights <= 0).any():
+ raise ValueError('Weights must be strictly positive and non-zero.')
+
+ def evaluate(
+ self, x: np.ndarray, parameters: dict[str, float] | None = None, **kwargs
+ ) -> np.ndarray:
+ """
+ Evaluate the fit function for values of x.
+
+ Parameters used are either the latest or user supplied. If the
+ parameters are user supplied, it must be in a dictionary of
+ {'parameter_name': parameter_value,...}.
+
+ Parameters
+ ----------
+ x : np.ndarray
+ X values for which the fit function will be evaluated.
+ parameters : dict[str, float] | None, default=None
+ Dictionary of parameters which will be used in the fit
+ function. They must be in a dictionary of {'parameter_name':
+ parameter_value,...}. By default, None.
+ **kwargs :
+ Additional arguments.
+
+ Returns
+ -------
+ np.ndarray
+ Y values calculated at points x for a set of parameters.
+
+ Raises
+ ------
+ TypeError
+ If ``parameters`` is not a dictionary.
+ """
+ if parameters is None:
+ parameters = {}
+ if not isinstance(parameters, dict):
+ raise TypeError('parameters must be a dictionary')
+
+ if self._fit_function is None:
+ # This will also generate self._cached_pars
+ self._fit_function = self._generate_fit_function()
+
+ parameters = self._prepare_parameters(parameters)
+
+ return self._fit_function(x, **parameters, **kwargs)
+
+ def _prepare_parameters(self, parameters: dict[str, float]) -> dict[str, float]:
+ """
+ Prepare the parameters for the engine.
+
+ Parameters
+ ----------
+ parameters : dict[str, float]
+ Dict of parameters for the engine with names as keys.
+
+ Returns
+ -------
+ dict[str, float]
+ Completed parameter dictionary for the engine.
+ """
+ pars = self._cached_pars
+
+ for name, item in pars.items():
+ parameter_name = PARAMETER_PREFIX + str(name)
+ if parameter_name not in parameters.keys():
+ parameters[parameter_name] = item.value
+ return parameters
+
+ def _generate_fit_function(self) -> Callable:
+ """
+ Using the user supplied ``fit_function``, wrap it in such a way
+ we can update ``Parameter`` on iterations.
+
+ Returns
+ -------
+ Callable
+ A fit function which is compatible with bumps models.
+ """
+ # Original fit function
+ func = self._original_fit_function
+ # Get a list of `Parameters`
+ self._cached_pars = {}
+ self._cached_pars_vals = {}
+ for parameter in self._object.get_free_parameters():
+ key = parameter.unique_name
+ self._cached_pars[key] = parameter
+ self._cached_pars_vals[key] = (parameter.value, parameter.error)
+
+ # Make a new fit function
+ def _fit_function(x: np.ndarray, **kwargs) -> np.ndarray:
+ """
+ Wrapped fit function which now has an EasyScience compatible
+ form.
+
+ Parameters
+ ----------
+ x : np.ndarray
+ Array of data points to be calculated.
+ **kwargs :
+ Key word arguments.
+
+ Returns
+ -------
+ np.ndarray
+ Points calculated at ``x``.
+ """
+ # Update the `Parameter` values
+ # TODO THIS IS NOT THREAD SAFE :-(
+
+ for name, value in kwargs.items():
+ par_name = name[1:]
+ if par_name in self._cached_pars.keys():
+ # This will take into account constraints
+ if self._cached_pars[par_name].value != value:
+ self._cached_pars[par_name].value = value
+
+ # TODO Pre processing here
+ return_data = func(x)
+ # TODO Loading or manipulating data here
+ return return_data
+
+ _fit_function.__signature__ = self._create_signature(self._cached_pars)
+ return _fit_function
+
+ @staticmethod
+ def _create_signature(parameters: Dict[int, Parameter]) -> Signature:
+ """
+ Wrap the function signature.
+
+ This is done as lmfit wants the function to be in the form: f =
+ (x, a=1, b=2)... Where we need to be generic. Note that this
+ won't hold for much outside of this scope.
+ """
+ wrapped_parameters = []
+ wrapped_parameters.append(
+ InspectParameter('x', InspectParameter.POSITIONAL_OR_KEYWORD, annotation=_empty)
+ )
+
+ for name, parameter in parameters.items():
+ default_value = parameter.value
+
+ wrapped_parameters.append(
+ InspectParameter(
+ PARAMETER_PREFIX + str(name),
+ InspectParameter.POSITIONAL_OR_KEYWORD,
+ annotation=_empty,
+ default=default_value,
+ )
+ )
+ return Signature(wrapped_parameters)
diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py
index 7cb948e0..7643b62e 100644
--- a/src/easyscience/fitting/fitter.py
+++ b/src/easyscience/fitting/fitter.py
@@ -4,20 +4,17 @@
import functools
from typing import Callable
from typing import List
-from typing import Optional
-from typing import Union
import numpy as np
-from easyscience import global_object
-
-from .available_minimizers import AvailableMinimizers
-from .available_minimizers import from_string_to_enum
from .minimizers import FitResults
from .minimizers import MinimizerBase
+from .minimizers import Minimizers
from .minimizers.factory import factory
+from .reshaping import inject_x
+from .reshaping import reshape_dataset
-DEFAULT_MINIMIZER = AvailableMinimizers.LMFit_leastsq
+DEFAULT_MINIMIZER = Minimizers.LMFit_leastsq
class Fitter:
@@ -34,7 +31,7 @@ def __init__(self, fit_object, fit_function: Callable):
self._max_evaluations: int = None
self._minimizer: MinimizerBase = None # set in _update_minimizer
- self._enum_current_minimizer: AvailableMinimizers = None # set in _update_minimizer
+ self._enum_current_minimizer: Minimizers = None # set in _update_minimizer
self._update_minimizer(DEFAULT_MINIMIZER)
def make_model(self, pars=None) -> Callable:
@@ -43,13 +40,10 @@ def make_model(self, pars=None) -> Callable:
def evaluate(self, pars=None) -> np.ndarray:
return self._minimizer.evaluate(pars)
- def convert_to_pars_obj(self, pars) -> object:
- return self._minimizer.convert_to_pars_obj(pars)
-
# TODO: remove this method when we are ready to adjust the dependent products
def initialize(self, fit_object: object, fit_function: Callable) -> None:
"""
- Set the model and callable in the calculator interface.
+ Set the model and callable.
Parameters
----------
@@ -62,42 +56,29 @@ def initialize(self, fit_object: object, fit_function: Callable) -> None:
self._fit_function = fit_function
self._update_minimizer(DEFAULT_MINIMIZER)
- # TODO: remove this method when we are ready to adjust the dependent products
- def create(self, minimizer_enum: Union[AvailableMinimizers, str] = DEFAULT_MINIMIZER) -> None:
- """
- Create the required minimizer.
-
- Parameters
- ----------
- minimizer_enum : Union[AvailableMinimizers, str], default=DEFAULT_MINIMIZER
- The enum of the minimization engine to create. By default,
- DEFAULT_MINIMIZER.
- """
- if isinstance(minimizer_enum, str):
- global_object.log.getLogger('fitting').warning(
- 'minimizer should be set with enum %s', minimizer_enum
- )
- minimizer_enum = from_string_to_enum(minimizer_enum)
- self._update_minimizer(minimizer_enum)
-
- def switch_minimizer(self, minimizer_enum: Union[AvailableMinimizers, str]) -> None:
+ def switch_minimizer(self, minimizer_enum: Minimizers) -> None:
"""
Switch minimizer and initialize.
Parameters
----------
- minimizer_enum : Union[AvailableMinimizers, str]
+ minimizer_enum : Minimizers
The enum of the minimizer to create and instantiate.
- """
- if isinstance(minimizer_enum, str):
- global_object.log.getLogger('fitting').warning(
- 'minimizer should be set with enum %s', minimizer_enum
- )
- minimizer_enum = from_string_to_enum(minimizer_enum)
+ Raises
+ ------
+ TypeError
+ If ``minimizer_enum`` is not a ``Minimizers``
+ member.
+ """
+ if not isinstance(minimizer_enum, Minimizers):
+ raise TypeError(
+ f'minimizer must be a Minimizers member, got {minimizer_enum!r}; '
+ f'use Minimizers[name] to convert a name'
+ )
self._update_minimizer(minimizer_enum)
- def _update_minimizer(self, minimizer_enum: AvailableMinimizers) -> None:
+ def _update_minimizer(self, minimizer_enum: Minimizers) -> None:
self._minimizer = factory(
minimizer_enum=minimizer_enum,
fit_object=self._fit_object,
@@ -106,16 +87,16 @@ def _update_minimizer(self, minimizer_enum: AvailableMinimizers) -> None:
self._enum_current_minimizer = minimizer_enum
@property
- def available_minimizers(self) -> List[str]:
+ def available_minimizers(self) -> List[Minimizers]:
"""
- Get a list of the names of available fitting minimizers.
+ Get a list of the available fitting minimizers.
Returns
-------
- List[str]
+ List[Minimizers]
List of available fitting minimizers.
"""
- return [minimize.name for minimize in AvailableMinimizers]
+ return list(Minimizers)
@property
def minimizer(self) -> MinimizerBase:
@@ -211,6 +192,9 @@ def fit_object(self) -> object:
"""
Get the EasyScience object used as a model.
+ For a ``Fitter`` this is not one of the supplied fit
+ objects but a read-only, indexable and iterable aggregate.
+
Returns
-------
object
@@ -232,17 +216,17 @@ def fit_object(self, fit_object: object) -> None:
self._update_minimizer(self._enum_current_minimizer)
def _fit_function_wrapper(
- self, real_x: Optional[np.ndarray] = None, flatten: bool = True
+ self,
+ real_x: np.ndarray | None = None,
+ flatten: bool = True,
) -> Callable:
"""
- Simple fit function which injects the real X (independent)
- values into the optimizer function.
-
- This will also flatten the results if needed.
+ Wrap the fit function so it evaluates on the real X (independent)
+ values instead of the optimizer's dummy x, flattening if needed.
Parameters
----------
- real_x : Optional[np.ndarray], default=None
+ real_x : np.ndarray | None, default=None
Independent x parameters to be injected. By default, None.
flatten : bool, default=True
Should the result be a flat 1D array? By default, True.
@@ -252,18 +236,7 @@ def _fit_function_wrapper(
Callable
Wrapped optimizer function.
"""
- fun = self._fit_function
-
- @functools.wraps(fun)
- def wrapped_fit_function(x, **kwargs):
- if real_x is not None:
- x = real_x
- dependent = fun(x, **kwargs)
- if flatten:
- dependent = dependent.flatten()
- return dependent
-
- return wrapped_fit_function
+ return inject_x(self._fit_function, real_x, flatten=flatten)
@property
def fit(self) -> Callable:
@@ -281,9 +254,9 @@ def fit(self) -> Callable:
def inner_fit_callable(
x: np.ndarray,
y: np.ndarray,
- weights: Optional[np.ndarray] = None,
+ weights: np.ndarray | None = None,
vectorized: bool = False,
- progress_callback: Callable[[dict], bool | None] | None = None,
+ progress_callback: Callable[[dict], None] | None = None,
**kwargs,
) -> FitResults:
"""
@@ -325,72 +298,7 @@ def inner_fit_callable(
return inner_fit_callable
- @staticmethod
- def _precompute_reshaping(
- x: np.ndarray,
- y: np.ndarray,
- weights: Optional[np.ndarray],
- vectorized: bool,
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], tuple[int, ...]]:
- """
- Check the dimensions of the inputs and reshape if necessary.
-
- Parameters
- ----------
- x : np.ndarray
- ND matrix of dependent points.
- y : np.ndarray
- N-1D matrix of independent points.
- weights : Optional[np.ndarray]
- Optional weights for the fit.
- vectorized : bool
- Whether ``x`` already stores vectorized coordinates.
-
- Returns
- -------
- tuple[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], tuple[int, ...]]
- Reshaped x values, reshaped input data, flattened y values,
- flattened weights, and the original x shape.
-
- Raises
- ------
- ValueError
- If the shapes of ``x`` and ``y`` are incompatible.
- """
- # Make sure that they are np arrays
- x_new = np.array(x)
- y_new = np.array(y)
- # Get the shape
- x_shape = x_new.shape
- # Check if the x data is 1D
- if len(x_shape) > 1:
- # It is ND data
- # Check if the data is vectorized. i.e. should x be [NxMx...x Ndims]
- if vectorized:
- # Assert that the shapes are the same
- if np.all(x_shape[:-1] != y_new.shape):
- raise ValueError('The shape of the x and y data must be the same')
- # If so do nothing but note that the data is vectorized
- # x_shape = (-1,) # Should this be done?
- else:
- # Assert that the shapes are the same
- if np.prod(x_new.shape[:-1]) != y_new.size:
- raise ValueError('The number of elements in x and y data must be the same')
- # Reshape the data to be [len(NxMx..), Ndims] i.e. flatten to columns
- x_new = x_new.reshape(-1, x_shape[-1], order='F')
- else:
- # Assert that the shapes are the same
- if np.all(x_shape != y_new.shape):
- raise ValueError('The shape of the x and y data must be the same')
- # It is 1D data
- x_new = x.flatten()
- # The optimizer needs a 1D array, flatten the y data
- y_new = y_new.flatten()
- if weights is not None:
- weights = np.array(weights).flatten()
- # Make a 'dummy' x array for the fit function
- x_for_fit = np.array(range(y_new.size))
- return x_for_fit, x_new, y_new, weights, x_shape
+ _precompute_reshaping = staticmethod(reshape_dataset)
@staticmethod
def _post_compute_reshaping(
@@ -418,112 +326,3 @@ def _post_compute_reshaping(
fit_result.y_calc = np.reshape(fit_result.y_calc, y.shape)
fit_result.y_err = np.reshape(fit_result.y_err, y.shape)
return fit_result
-
- def mcmc_sample(
- self,
- x: np.ndarray,
- y: np.ndarray,
- weights: np.ndarray,
- samples: int = 10000,
- burn: int = 2000,
- thin: int = 10,
- population: Optional[int] = None,
- vectorized: bool = False,
- sampler_kwargs: Optional[dict] = None,
- progress_callback: Optional[Callable[[dict], Optional[bool]]] = None,
- abort_test: Optional[Callable[[], bool]] = None,
- ) -> dict:
- """
- Run Bayesian MCMC sampling using the BUMPS DREAM sampler.
-
- Works with both a plain ``Fitter`` (single dataset) and a
- ``MultiFitter`` (multiple datasets) via polymorphic dispatch:
- ``_precompute_reshaping`` and ``_fit_function_wrapper`` are
- resolved on the concrete subclass at call time, so multi-dataset
- flattening is handled automatically when called on a
- ``MultiFitter`` instance.
-
- Parameters
- ----------
- x : np.ndarray
- Independent variable array (or list of arrays for
- ``MultiFitter``).
- y : np.ndarray
- Dependent variable array (or list of arrays for
- ``MultiFitter``).
- weights : np.ndarray
- Weight array (or list of arrays for ``MultiFitter``).
- samples : int, default=10000
- Number of retained DREAM samples requested from BUMPS.
- burn : int, default=2000
- Burn-in steps to discard before collecting samples.
- thin : int, default=10
- Thinning interval β only every ``thin``-th sample is kept,
- which reduces autocorrelation between consecutive draws.
- population : Optional[int], default=None
- BUMPS DREAM population count (number of parallel chains).
- vectorized : bool, default=False
- When ``True``, each x array may be multi-dimensional (e.g.
- an ``(N, M, 2)`` grid for a 2D model) and is left as-is.
- When ``False`` (default), each x array is expected to be
- 1-D.
- sampler_kwargs : Optional[dict], default=None
- Additional keyword arguments forwarded to the BUMPS DREAM
- sampler.
- progress_callback : Optional[Callable[[dict], Optional[bool]]], default=None
- Optional callback invoked at each DREAM generation. The
- payload dict includes ``iteration`` and ``sampling: True``.
- abort_test : Optional[Callable[[], bool]], default=None
- Optional callable that returns ``True`` to abort sampling
- early.
-
- Returns
- -------
- dict
- Dictionary with keys ``'draws'``, ``'param_names'``,
- ``'internal_bumps_object'``, and ``'logp'``.
-
- Raises
- ------
- ValueError
- If ``samples``, ``burn``, or ``thin`` are invalid.
- RuntimeError
- If the active minimizer is not a BUMPS instance.
- """
- if not isinstance(samples, int) or samples <= 0:
- raise ValueError('samples must be a positive integer.')
- if not isinstance(burn, int) or burn < 0:
- raise ValueError('burn must be a non-negative integer.')
- if not isinstance(thin, int) or thin < 1:
- raise ValueError('thin must be a positive integer.')
-
- x_fit, x_new, y_new, w_new, dims = self._precompute_reshaping(x, y, weights, vectorized)
- self._dependent_dims = dims
-
- original_fit_func = self._fit_function
- self.fit_function = self._fit_function_wrapper(x_new, flatten=True)
-
- try:
- minimizer = self.minimizer
- if not (hasattr(minimizer, 'package') and minimizer.package == 'bumps'):
- raise RuntimeError(
- 'Bayesian sampling requires a BUMPS minimizer. '
- 'Use ``fitter.switch_minimizer(AvailableMinimizers.Bumps)`` first.'
- )
-
- result = minimizer.mcmc_sample(
- x=x_fit,
- y=y_new,
- weights=w_new,
- samples=samples,
- burn=burn,
- thin=thin,
- population=population,
- sampler_kwargs=sampler_kwargs,
- progress_callback=progress_callback,
- abort_test=abort_test,
- )
- finally:
- self.fit_function = original_fit_func
-
- return result
diff --git a/src/easyscience/fitting/minimizers/__init__.py b/src/easyscience/fitting/minimizers/__init__.py
index 0ee6cc6b..40006813 100644
--- a/src/easyscience/fitting/minimizers/__init__.py
+++ b/src/easyscience/fitting/minimizers/__init__.py
@@ -5,5 +5,6 @@
from .minimizer_bumps import Bumps
from .minimizer_dfo import DFO
from .minimizer_lmfit import LMFit
+from .minimizers import Minimizers
from .utils import FitError
from .utils import FitResults
diff --git a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py
index 35255615..82b5dfc6 100644
--- a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py
+++ b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py
@@ -2,6 +2,8 @@
# SPDX-License-Identifier: BSD-3-Clause
from .eval_counter import EvalCounter
+from .problem import build_curve_problem
+from .problem import parameter_names
+from .problem import parameter_snapshot
+from .problem import to_bumps_parameter
from .progress_monitor import BumpsProgressMonitor
-
-__all__ = ['BumpsProgressMonitor', 'EvalCounter']
diff --git a/src/easyscience/fitting/minimizers/bumps_utils/problem.py b/src/easyscience/fitting/minimizers/bumps_utils/problem.py
new file mode 100644
index 00000000..df471cfc
--- /dev/null
+++ b/src/easyscience/fitting/minimizers/bumps_utils/problem.py
@@ -0,0 +1,134 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""BUMPS problem construction shared by the ``Bumps`` minimizer and
+``DreamSampler``.
+
+These are free functions rather than ``Bumps`` methods so that any
+:class:`~easyscience.fitting.engine_base.EngineBase`: a minimizer or a
+sampler, can build a BUMPS ``Curve``/``FitProblem`` without inheriting
+from the minimizer.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+from bumps.names import Curve
+from bumps.names import FitProblem
+from bumps.parameter import Parameter as BumpsParameter
+
+from easyscience.variable import Parameter
+
+from ...engine_base import PARAMETER_PREFIX
+from .eval_counter import EvalCounter
+
+if TYPE_CHECKING:
+ from ...engine_base import EngineBase
+
+
+def to_bumps_parameter(par: Parameter) -> BumpsParameter:
+ """Convert an EasyScience ``Parameter`` to a prefixed ``BumpsParameter``.
+
+ Parameters
+ ----------
+ par : Parameter
+ EasyScience parameter to convert.
+
+ Returns
+ -------
+ BumpsParameter
+ Bumps Parameter compatible object, named
+ ``PARAMETER_PREFIX + par.unique_name``.
+ """
+ return BumpsParameter(
+ name=PARAMETER_PREFIX + par.unique_name,
+ value=par.value,
+ bounds=[par.min, par.max],
+ fixed=par.fixed,
+ )
+
+
+def build_curve_problem(
+ engine: 'EngineBase',
+ x: np.ndarray,
+ y: np.ndarray,
+ weights: np.ndarray,
+) -> tuple[FitProblem, EvalCounter, Curve]:
+ """Build a BUMPS ``FitProblem`` around an engine's wrapped fit function.
+
+ Wraps ``engine._generate_fit_function()`` in an :class:`EvalCounter`,
+ converts the engine's cached parameters via
+ :func:`to_bumps_parameter`, and assembles
+ ``Curve(fit_func, x, y, dy=1/weights, **bumps_pars)`` into a
+ ``FitProblem``.
+
+ Parameters
+ ----------
+ engine : EngineBase
+ The engine (minimizer or sampler) supplying the fit function and
+ parameter cache.
+ x : np.ndarray
+ Independent variable array.
+ y : np.ndarray
+ Dependent variable array.
+ weights : np.ndarray
+ Weight array; converted to ``dy = 1 / weights``.
+
+ Returns
+ -------
+ tuple[FitProblem, EvalCounter, Curve]
+ The assembled problem, the evaluation counter wrapping the fit
+ function, and the ``Curve`` model itself.
+ The ``Curve`` is returned because ``FitProblem.fitness`` is
+ deprecated in current BUMPS.
+ """
+ fit_func = EvalCounter(engine._generate_fit_function())
+
+ bumps_pars = {
+ PARAMETER_PREFIX + str(name): to_bumps_parameter(par)
+ for name, par in engine._cached_pars.items()
+ }
+
+ curve = Curve(fit_func, x, y, dy=1 / weights, **bumps_pars)
+ return FitProblem(curve), fit_func, curve
+
+
+def parameter_names(problem: FitProblem) -> list[str]:
+ """Return the problem's parameter names with the prefix stripped.
+
+ Parameters
+ ----------
+ problem : FitProblem
+ A BUMPS problem built by :func:`build_curve_problem`.
+
+ Returns
+ -------
+ list[str]
+ Parameter names in problem order, without ``PARAMETER_PREFIX``.
+ """
+ return [(p.name or '')[len(PARAMETER_PREFIX) :] for p in problem._parameters]
+
+
+def parameter_snapshot(problem: FitProblem, point: np.ndarray | None) -> dict:
+ """Snapshot the problem's parameter values as ``{name: value}``.
+
+ Parameters
+ ----------
+ problem : FitProblem
+ A BUMPS problem built by :func:`build_curve_problem`.
+ point : np.ndarray | None
+ Parameter values to report; when ``None`` the problem's current
+ values (``problem.getp()``) are used.
+
+ Returns
+ -------
+ dict
+ Mapping of prefix-stripped parameter names to ``float`` values.
+ """
+ labels = problem.labels()
+ values = problem.getp() if point is None else point
+ snapshot = {}
+ for label, value in zip(labels, values):
+ snapshot[label[len(PARAMETER_PREFIX) :]] = float(value)
+ return snapshot
diff --git a/src/easyscience/fitting/minimizers/factory.py b/src/easyscience/fitting/minimizers/factory.py
index 335d288c..f4cca004 100644
--- a/src/easyscience/fitting/minimizers/factory.py
+++ b/src/easyscience/fitting/minimizers/factory.py
@@ -3,21 +3,19 @@
from typing import Callable
-from .. import available_minimizers
-from ..available_minimizers import AvailableMinimizers
+from . import minimizers
from .minimizer_base import MinimizerBase
+from .minimizers import Minimizers
-if available_minimizers.lmfit_engine_available:
+if minimizers.lmfit_engine_available:
from .minimizer_lmfit import LMFit
-if available_minimizers.dfo_engine_available:
+if minimizers.dfo_engine_available:
from .minimizer_dfo import DFO
-if available_minimizers.bumps_engine_available:
+if minimizers.bumps_engine_available:
from .minimizer_bumps import Bumps
-def factory(
- minimizer_enum: AvailableMinimizers, fit_object, fit_function: Callable
-) -> MinimizerBase:
+def factory(minimizer_enum: Minimizers, fit_object, fit_function: Callable) -> MinimizerBase:
if minimizer_enum.package == 'lm':
minimizer = LMFit(obj=fit_object, fit_function=fit_function, minimizer_enum=minimizer_enum)
diff --git a/src/easyscience/fitting/minimizers/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py
index 48eef544..ca5e1647 100644
--- a/src/easyscience/fitting/minimizers/minimizer_base.py
+++ b/src/easyscience/fitting/minimizers/minimizer_base.py
@@ -1,81 +1,58 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-from abc import ABCMeta
from abc import abstractmethod
-from inspect import Parameter as InspectParameter
-from inspect import Signature
-from inspect import _empty
from typing import Any
from typing import Callable
-from typing import Dict
from typing import List
-from typing import Tuple
-from typing import Union
import numpy as np
-# causes circular import when Parameter is imported
-# from easyscience.base_classes import ObjBase
from easyscience.variable import Parameter
-from ..available_minimizers import AvailableMinimizers
+from ..engine_base import EngineBase
+from .minimizers import Minimizers
from .utils import FitError
from .utils import FitResults
-MINIMIZER_PARAMETER_PREFIX = 'p'
-
-class MinimizerBase(metaclass=ABCMeta):
+class MinimizerBase(EngineBase):
"""
This template class is the basis for all minimizer engines in
``EasyScience``.
"""
- package: str = None
-
def __init__(
self,
- obj, #: ObjBase,
+ obj, #: ModelBase,
fit_function: Callable,
- minimizer_enum: AvailableMinimizers,
- ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501
+ minimizer_enum: Minimizers,
+ ): # todo after constraint changes, add type hint: obj: ModelBase # noqa: E501
if minimizer_enum.method not in self.supported_methods():
raise FitError(f'Method {minimizer_enum.method} not available in {self.__class__}')
- self._object = obj
- self._original_fit_function = fit_function
+ super().__init__(obj=obj, fit_function=fit_function)
self._minimizer_enum = minimizer_enum
self._method = minimizer_enum.method
- self._cached_pars: Dict[str, Parameter] = {}
- self._cached_pars_vals: Dict[str, Tuple[float]] = {}
self._cached_model = None
- self._fit_function = None
@property
- def enum(self) -> AvailableMinimizers:
+ def enum(self) -> Minimizers:
return self._minimizer_enum
@property
def name(self) -> str:
return self._minimizer_enum.name
- def _restore_parameter_values(self) -> None:
- for key in self._cached_pars.keys():
- self._cached_pars[key].value = self._cached_pars_vals[key][0]
- self._cached_pars[key].error = self._cached_pars_vals[key][1]
-
@abstractmethod
def fit(
self,
x: np.ndarray,
y: np.ndarray,
weights: np.ndarray,
- model: Callable | None = None,
- parameters: List[Parameter] | None = None,
method: str | None = None,
tolerance: float | None = None,
max_evaluations: int | None = None,
- progress_callback: Callable[[dict], bool | None] | None = None,
+ progress_callback: Callable[[dict], None] | None = None,
**kwargs,
) -> FitResults:
"""
@@ -89,18 +66,16 @@ def fit(
Measured points.
weights : np.ndarray
Weights for supplied measured points.
- model : Callable | None, default=None
- Optional Model which is being fitted to. By default, None.
- parameters : List[Parameter] | None, default=None
- Optional parameters for the fit. By default, None.
method : str | None, default=None
Method for the minimizer to use. By default, None.
tolerance : float | None, default=None
Requested convergence tolerance. By default, None.
max_evaluations : int | None, default=None
Maximum number of objective evaluations. By default, None.
- progress_callback : Callable[[dict], bool | None] | None, default=None
- Optional progress callback. By default, None.
+ progress_callback : Callable[[dict], None] | None, default=None
+ Optional progress callback. Its return value is ignored by
+ every backend; use ``abort_test`` to stop a running fit. By
+ default, None.
**kwargs :
Additional arguments for the fitting function.
@@ -110,50 +85,6 @@ def fit(
Fit results.
"""
- def evaluate(
- self, x: np.ndarray, minimizer_parameters: dict[str, float] | None = None, **kwargs
- ) -> np.ndarray:
- """
- Evaluate the fit function for values of x.
-
- Parameters used are either the latest or user supplied. If the
- parameters are user supplied, it must be in a dictionary of
- {'parameter_name': parameter_value,...}.
-
- Parameters
- ----------
- x : np.ndarray
- X values for which the fit function will be evaluated.
- minimizer_parameters : dict[str, float] | None, default=None
- Dictionary of parameters which will be used in the fit
- function. They must be in a dictionary of {'parameter_name':
- parameter_value,...}. By default, None.
- **kwargs :
- Additional arguments.
-
- Returns
- -------
- np.ndarray
- Y values calculated at points x for a set of parameters.
-
- Raises
- ------
- TypeError
- If ``minimizer_parameters`` is not a dictionary.
- """
- if minimizer_parameters is None:
- minimizer_parameters = {}
- if not isinstance(minimizer_parameters, dict):
- raise TypeError('minimizer_parameters must be a dictionary')
-
- if self._fit_function is None:
- # This will also generate self._cached_pars
- self._fit_function = self._generate_fit_function()
-
- minimizer_parameters = self._prepare_parameters(minimizer_parameters)
-
- return self._fit_function(x, **minimizer_parameters, **kwargs)
-
def _get_method_kwargs(self, passed_method: str | None = None) -> dict[str, str]:
if passed_method is not None:
if passed_method not in self.supported_methods():
@@ -165,24 +96,6 @@ def _get_method_kwargs(self, passed_method: str | None = None) -> dict[str, str]
return {}
- @abstractmethod
- def convert_to_pars_obj(self, par_list: List[Parameter] | None = None) -> Any:
- """
- Create an engine compatible container with the ``Parameters``
- converted from the base object.
-
- Parameters
- ----------
- par_list : List[Parameter] | None, default=None
- If only a single/selection of parameter is required. Specify
- as a list. By default, None.
-
- Returns
- -------
- Any
- Engine Parameters compatible object.
- """
-
@staticmethod
@abstractmethod
def supported_methods() -> List[str]:
@@ -207,120 +120,6 @@ def all_methods() -> List[str]:
List of all available methods.
"""
- @staticmethod
- @abstractmethod
- def convert_to_par_object(obj): # todo after constraint changes, add type hint: obj: ObjBase
- """
- Convert an ``EasyScience.variable.Parameter`` object to an
- engine Parameter object.
- """
-
- def _prepare_parameters(self, parameters: dict[str, float]) -> dict[str, float]:
- """
- Prepare the parameters for the minimizer.
-
- Parameters
- ----------
- parameters : dict[str, float]
- Dict of parameters for the minimizer with names as keys.
-
- Returns
- -------
- dict[str, float]
- Completed parameter dictionary for the minimizer.
- """
- pars = self._cached_pars
-
- for name, item in pars.items():
- parameter_name = MINIMIZER_PARAMETER_PREFIX + str(name)
- if parameter_name not in parameters.keys():
- parameters[parameter_name] = item.value
- return parameters
-
- def _generate_fit_function(self) -> Callable:
- """
- Using the user supplied ``fit_function``, wrap it in such a way
- we can update ``Parameter`` on iterations.
-
- Returns
- -------
- Callable
- A fit function which is compatible with bumps models.
- """
- # Original fit function
- func = self._original_fit_function
- # Get a list of `Parameters`
- self._cached_pars = {}
- self._cached_pars_vals = {}
- for parameter in self._object.get_fit_parameters():
- key = parameter.unique_name
- self._cached_pars[key] = parameter
- self._cached_pars_vals[key] = (parameter.value, parameter.error)
-
- # Make a new fit function
- def _fit_function(x: np.ndarray, **kwargs) -> np.ndarray:
- """
- Wrapped fit function which now has an EasyScience compatible
- form.
-
- Parameters
- ----------
- x : np.ndarray
- Array of data points to be calculated.
- **kwargs :
- Key word arguments.
-
- Returns
- -------
- np.ndarray
- Points calculated at ``x``.
- """
- # Update the `Parameter` values and the callback if needed
- # TODO THIS IS NOT THREAD SAFE :-(
-
- for name, value in kwargs.items():
- par_name = name[1:]
- if par_name in self._cached_pars.keys():
- # This will take into account constraints
- if self._cached_pars[par_name].value != value:
- self._cached_pars[par_name].value = value
-
- # Since we are calling the parameter fset will be called.
- # TODO Pre processing here
- return_data = func(x)
- # TODO Loading or manipulating data here
- return return_data
-
- _fit_function.__signature__ = self._create_signature(self._cached_pars)
- return _fit_function
-
- @staticmethod
- def _create_signature(parameters: Dict[int, Parameter]) -> Signature:
- """
- Wrap the function signature.
-
- This is done as lmfit wants the function to be in the form: f =
- (x, a=1, b=2)... Where we need to be generic. Note that this
- won't hold for much outside of this scope.
- """
- wrapped_parameters = []
- wrapped_parameters.append(
- InspectParameter('x', InspectParameter.POSITIONAL_OR_KEYWORD, annotation=_empty)
- )
-
- for name, parameter in parameters.items():
- default_value = parameter.value
-
- wrapped_parameters.append(
- InspectParameter(
- MINIMIZER_PARAMETER_PREFIX + str(name),
- InspectParameter.POSITIONAL_OR_KEYWORD,
- annotation=_empty,
- default=default_value,
- )
- )
- return Signature(wrapped_parameters)
-
@staticmethod
def _error_from_jacobian(
jacobian: np.ndarray, residuals: np.ndarray, confidence: float = 0.95
diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py
index 315d856c..44c05a20 100644
--- a/src/easyscience/fitting/minimizers/minimizer_bumps.py
+++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py
@@ -3,8 +3,6 @@
from __future__ import annotations
-import copy
-import math
from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
@@ -13,45 +11,46 @@
from bumps.fitters import FIT_AVAILABLE_IDS
from bumps.fitters import FITTERS
from bumps.fitters import FitDriver
-from bumps.names import Curve
from bumps.names import FitProblem
from bumps.parameter import Parameter as BumpsParameter
+from scipy.optimize import OptimizeResult
-# causes circular import when Parameter is imported
-# from easyscience.base_classes import ObjBase
from easyscience.variable import Parameter
-from ..available_minimizers import AvailableMinimizers
+from ..engine_base import PARAMETER_PREFIX
from .bumps_utils import BumpsProgressMonitor
from .bumps_utils import EvalCounter
-from .minimizer_base import MINIMIZER_PARAMETER_PREFIX
+from .bumps_utils import build_curve_problem
+from .bumps_utils import parameter_names
+from .bumps_utils import parameter_snapshot
+from .bumps_utils import to_bumps_parameter
from .minimizer_base import MinimizerBase
+from .minimizers import Minimizers
from .utils import FitError
from .utils import FitResults
if TYPE_CHECKING:
- from bumps.dream.state import MCMCDraw
+ from bumps.fitters import FitBase
-FIT_AVAILABLE_IDS_FILTERED = copy.copy(FIT_AVAILABLE_IDS)
-# Considered experimental
-FIT_AVAILABLE_IDS_FILTERED.remove('pt')
+# 'pt' (parallel tempering) is considered experimental and is not exposed.
+FIT_AVAILABLE_IDS_FILTERED = [fit_id for fit_id in FIT_AVAILABLE_IDS if fit_id != 'pt']
class Bumps(MinimizerBase):
"""
This is a wrapper to Bumps: https://bumps.readthedocs.io/ It allows
for the Bumps fitting engine to use parameters declared in an
- ``EasyScience.base_classes.ObjBase``.
+ ``easyscience.base_classes.ModelBase``.
"""
package = 'bumps'
def __init__(
self,
- obj: object, #: ObjBase,
+ obj: object,
fit_function: Callable,
- minimizer_enum: AvailableMinimizers | None = None,
- ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501
+ minimizer_enum: Minimizers | None = None,
+ ):
"""
Initialize the fitting engine.
@@ -61,7 +60,7 @@ def __init__(
Object containing the ``Parameter`` instances to fit.
fit_function : Callable
Callable returning model y values for the supplied x values.
- minimizer_enum : AvailableMinimizers | None, default=None
+ minimizer_enum : Minimizers | None, default=None
Selected BUMPS minimizer configuration. By default, None.
"""
super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum)
@@ -70,7 +69,8 @@ def __init__(
@staticmethod
def all_methods() -> list[str]:
- return FIT_AVAILABLE_IDS_FILTERED
+ # Copy so callers cannot mutate the module-level list in place.
+ return list(FIT_AVAILABLE_IDS_FILTERED)
@staticmethod
def supported_methods() -> list[str]:
@@ -83,12 +83,10 @@ def fit(
x: np.ndarray,
y: np.ndarray,
weights: np.ndarray,
- model: Callable | None = None,
- parameters: list[Parameter] | None = None,
method: str | None = None,
tolerance: float | None = None,
max_evaluations: int | None = None,
- progress_callback: Callable[[dict], bool | None] | None = None,
+ progress_callback: Callable[[dict], None] | None = None,
abort_test: Callable[[], bool] | None = None,
minimizer_kwargs: dict | None = None,
engine_kwargs: dict | None = None,
@@ -105,10 +103,6 @@ def fit(
Measured points.
weights : np.ndarray
Weights for supplied measured points.
- model : Callable | None, default=None
- Optional Model which is being fitted to. By default, None.
- parameters : list[Parameter] | None, default=None
- Optional parameters for the fit. By default, None.
method : str | None, default=None
Method for minimization. By default, None.
tolerance : float | None, default=None
@@ -118,17 +112,22 @@ def fit(
``steps`` parameter. If ``None``, the default value defined
by the selected BUMPS fitter (``fitclass.settings``) is
used. By default, None.
- progress_callback : Callable[[dict], bool | None] | None, default=None
- Optional callback for progress updates. The payload field
- ``iteration`` carries the BUMPS optimizer step index. By
- default, None.
+ progress_callback : Callable[[dict], None] | None, default=None
+ Optional callback for progress updates. The field
+ ``iteration`` carries the BUMPS optimizer step index. The
+ return value is ignored: use ``abort_test`` to stop a
+ running fit. By default, None.
abort_test : Callable[[], bool] | None, default=None
Optional callback that returns ``True`` to signal that the
fit should be aborted. Called periodically during the BUMPS
- optimizer iteration loop.
+ optimizer iteration loop, and once more after the optimizer
+ returns in order to distinguish an aborted fit from a
+ converged one, so it must be side-effect free. An aborted fit
+ returns ``FitResults(success=False)`` rather than raising.
minimizer_kwargs : dict | None, default=None
Additional keyword arguments passed to the BUMPS minimizer.
- By default, None.
+ The mapping is copied before use, so it is never mutated. By
+ default, None.
engine_kwargs : dict | None, default=None
Additional engine keyword arguments. By default, None.
**kwargs : Any
@@ -137,74 +136,74 @@ def fit(
Returns
-------
FitResults
- Fit results.
+ Fit results. ``FitResults.iterations`` is the number of BUMPS
+ *optimizer steps* consumed (the last reported step index plus
+ one), which is what ``max_evaluations`` tests against; it is
+ not comparable to LMFit's ``nfev`` or DFO-LS' ``nf``. The
+ objective-call count is reported separately as
+ ``FitResults.n_evaluations``, which is the cross-backend
+ consistent figure. Note that BUMPS derives the step index from
+ whatever the selected fitter reports to its monitors, so the
+ granularity of a "step" varies between fitters.
Raises
------
FitError
- If the BUMPS fit fails.
+ If the BUMPS fit raises. A fit that merely fails to converge
+ is reported as ``FitResults(success=False)`` instead.
ValueError
- If the input shapes or weights are invalid.
+ If the input shapes or weights are invalid, or if
+ ``progress_callback`` is not callable.
"""
method_dict = self._get_method_kwargs(method)
x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights)
- if y.shape != x.shape:
- raise ValueError('x and y must have the same shape.')
-
- if weights.shape != x.shape:
- raise ValueError('Weights must have the same shape as x and y.')
+ self.validate_arrays(x, y, weights)
- if not np.isfinite(weights).all():
- raise ValueError('Weights cannot be NaN or infinite.')
-
- if (weights <= 0).any():
- raise ValueError('Weights must be strictly positive and non-zero.')
+ if progress_callback is not None and not callable(progress_callback):
+ raise ValueError('progress_callback must be callable')
if engine_kwargs is None:
engine_kwargs = {}
- if minimizer_kwargs is None:
- minimizer_kwargs = {}
+ minimizer_kwargs = {} if minimizer_kwargs is None else dict(minimizer_kwargs)
minimizer_kwargs.update(engine_kwargs)
method_str = method_dict.get('method', self._method)
fitclass = self._resolve_fitclass(method_str)
- # Resolve BUMPS-native defaults so the budget reported back to the caller (and
- # used by the budget-exhaustion check in `_gen_fit_results`) reflects the values
- # actually consumed by the fitter, even when the caller passes None.
+ # Only values the caller supplied explicitly are pushed back into
+ # `minimizer_kwargs`. BUMPS pairs an independent `ftol`/`xtol` default per
+ # fitter (`newton` combines ftol=1e-6 with xtol=1e-12, `amoeba` ftol=1e-8 with
+ # xtol=1e-6).
fitter_settings = dict(fitclass.settings)
- if max_evaluations is None:
+
+ if max_evaluations is not None:
+ minimizer_kwargs['steps'] = max_evaluations
+ else:
max_evaluations = fitter_settings.get('steps')
- if tolerance is None:
- ftol = fitter_settings.get('ftol')
- xtol = fitter_settings.get('xtol')
- tols = [t for t in (ftol, xtol) if t is not None]
- tolerance = min(tols) if tols else None
if tolerance is not None:
minimizer_kwargs['ftol'] = tolerance # tolerance for change in function value
minimizer_kwargs['xtol'] = (
tolerance # tolerance for change in parameter value, could be an independent value
)
- if max_evaluations is not None:
- minimizer_kwargs['steps'] = max_evaluations
+ else:
+ # Report the stricter of the two BUMPS defaults; nothing is written back.
+ ftol = fitter_settings.get('ftol')
+ xtol = fitter_settings.get('xtol')
+ tols = [t for t in (ftol, xtol) if t is not None]
+ tolerance = min(tols) if tols else None
- if model is None:
- model_function = self._make_model(parameters=parameters)
- model = model_function(x, y, weights)
+ # The Curve comes back directly from the helper.
+ problem, self._eval_counter, model = build_curve_problem(self, x, y, weights)
self._cached_model = model
self._p_0 = {f'p{key}': self._cached_pars[key].value for key in self._cached_pars.keys()}
- problem = FitProblem(model)
-
monitors = []
if progress_callback is not None:
- if not callable(progress_callback):
- raise ValueError('progress_callback must be callable')
monitors.append(
BumpsProgressMonitor(problem, progress_callback, self._build_progress_payload)
)
@@ -219,35 +218,54 @@ def fit(
)
driver.clip()
- # Why do we do this? Because a fitting template has to have global_object instantiated outside pre-runtime
- from easyscience import global_object
-
- stack_status = global_object.stack.enabled
- global_object.stack.enabled = False
-
try:
# Drive the fit through the local FitDriver instance so the supplied
# `monitors` (including the optional progress callback monitor) are
# invoked. `bumps.fitters.fit` constructs its own driver.
- x, fx = driver.fit()
- from scipy.optimize import OptimizeResult
+ best_x, fx = driver.fit()
+
+ # BUMPS signals a failed optimization by returning `None` in place of a
+ # parameter vector (e.g. Levenberg-Marquardt landing on non-finite
+ # values); `FitDriver.fit` skips `problem.setp` in that case. Poll
+ # `abort_test` once more to tell a user-cancelled run apart from a
+ # converged one, since BUMPS stops quietly either way.
+ if best_x is None:
+ success = False
+ message = 'BUMPS returned no solution; the fit did not converge'
+ elif abort_test is not None and abort_test():
+ success = False
+ message = 'Fit aborted before convergence'
+ else:
+ success = True
+ message = 'Fit converged'
# BUMPS' `MonitorRunner.history.step` is populated by the driver itself
# (independently of any user-supplied monitors) and exposes the canonical
- # last-step index reached by the fitter, so we use it as `nit`.
- history_step = getattr(getattr(driver, 'monitor_runner', None), 'history', None)
- nit_value = int(history_step.step[0]) if history_step is not None else None
+ # last-step index reached by the fitter, so we use it as `nit`. `Trace`
+ # indexes into an internal list, so an empty trace raises `IndexError`
+ # rather than returning a default β that happens when the fit is aborted
+ # before the fitter reports its first step.
+ history = getattr(getattr(driver, 'monitor_runner', None), 'history', None)
+ step_trace = getattr(history, 'step', None)
+ nit_value = int(step_trace[0]) if step_trace is not None and len(step_trace) else None
+
model_results = OptimizeResult(
- x=x,
- dx=driver.stderr(),
+ # `driver.stderr()` derives the errors from the covariance at the
+ # solution, so it cannot be evaluated without one.
+ x=best_x,
+ dx=driver.stderr() if best_x is not None else None,
fun=fx,
- success=True,
- status=0,
- message='successful termination',
+ success=success,
+ status=0 if success else 1,
+ message=message,
nit=nit_value,
)
model_results.state = driver.fitter.state
- self._set_parameter_fit_result(model_results, stack_status, problem._parameters)
+
+ if best_x is None:
+ self._restore_parameter_values()
+ else:
+ self._set_parameter_fit_result(model_results, parameter_names(problem))
results = self._gen_fit_results(
model_results,
max_evaluations=max_evaluations,
@@ -255,27 +273,43 @@ def fit(
)
except Exception as e:
self._restore_parameter_values()
- raise FitError(e)
- finally:
- global_object.stack.enabled = stack_status
+ raise FitError(e) from e
return results
@staticmethod
- def _resolve_fitclass(method: str):
+ def _resolve_fitclass(method: str) -> type[FitBase]:
+ """
+ Look up the BUMPS fitter class registered under ``method``.
+
+ Parameters
+ ----------
+ method : str
+ A BUMPS fitter id, e.g. ``'amoeba'``.
+
+ Returns
+ -------
+ type[FitBase]
+ The matching BUMPS fitter class.
+
+ Raises
+ ------
+ FitError
+ If no registered fitter carries that id.
+ """
for fitclass in FITTERS:
if fitclass.id == method:
return fitclass
raise FitError(f'Unknown BUMPS fitting method: {method}')
def _build_progress_payload(
- self, problem, iteration: int, point: np.ndarray, nllf: float
+ self, problem: FitProblem, iteration: int, point: np.ndarray | None, nllf: float
) -> dict:
# Use the nllf already computed by the fitter to avoid a costly
# model re-evaluation, and let BUMPS apply its own chisq scaling.
chi2 = float(problem.chisq(nllf=nllf, norm=False))
reduced_chi2 = float(problem.chisq(nllf=nllf, norm=True))
- parameter_values = self._current_parameter_snapshot(problem, point)
+ parameter_values = parameter_snapshot(problem, point)
return {
'iteration': iteration,
@@ -286,434 +320,10 @@ def _build_progress_payload(
'finished': False,
}
- def _current_parameter_snapshot(self, problem, point: np.ndarray) -> dict:
- labels = problem.labels()
- values = problem.getp() if point is None else point
- snapshot = {}
- for label, value in zip(labels, values):
- dict_name = label[len(MINIMIZER_PARAMETER_PREFIX) :]
- snapshot[dict_name] = float(value)
- return snapshot
-
- def convert_to_pars_obj(self, par_list: list[Parameter] | None = None) -> list[BumpsParameter]:
- """
- Create a container with the ``Parameters`` converted from the
- base object.
-
- Parameters
- ----------
- par_list : list[Parameter] | None, default=None
- If only a single/selection of parameter is required. Specify
- as a list. By default, None.
-
- Returns
- -------
- list[BumpsParameter]
- Bumps Parameters list.
- """
- if par_list is None:
- # Assume that we have a ObjBase for which we can obtain a list
- par_list = self._object.get_fit_parameters()
- pars_obj = [self.__class__.convert_to_par_object(obj) for obj in par_list]
- return pars_obj
-
- # For some reason I have to double staticmethod :-/
- @staticmethod
- def convert_to_par_object(obj: Parameter) -> BumpsParameter:
- """
- Convert an ``EasyScience.variable.Parameter`` object to a bumps
- Parameter object.
-
- Parameters
- ----------
- obj : Parameter
- EasyScience parameter to convert.
-
- Returns
- -------
- BumpsParameter
- Bumps Parameter compatible object.
- """
-
- value = obj.value
-
- return BumpsParameter(
- name=MINIMIZER_PARAMETER_PREFIX + obj.unique_name,
- value=value,
- bounds=[obj.min, obj.max],
- fixed=obj.fixed,
- )
-
- def _make_model(self, parameters: list[BumpsParameter] | None = None) -> Callable:
- """
- Generate a bumps model from the supplied ``fit_function`` and
- parameters in the base object. Note that this makes a callable
- as it needs to be initialized with *x*, *y*, *weights*
-
- Weights are converted to dy (standard deviation of y).
-
- Parameters
- ----------
- parameters : list[BumpsParameter] | None, default=None
- Optional BUMPS parameters to bind into the model.
-
- Returns
- -------
- Callable
- Callable to make a bumps Curve model.
- """
- fit_func = EvalCounter(self._generate_fit_function())
- self._eval_counter = fit_func
-
- def _outer(obj):
-
- def _make_func(x, y, weights):
- bumps_pars = {}
- if not parameters:
- for name, par in obj._cached_pars.items():
- bumps_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = (
- obj.convert_to_par_object(par)
- )
- else:
- for par in parameters:
- bumps_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = (
- obj.convert_to_par_object(par)
- )
- return Curve(fit_func, x, y, dy=1 / weights, **bumps_pars)
-
- return _make_func
-
- return _outer(self)
-
- def mcmc_sample(
- self,
- x: np.ndarray,
- y: np.ndarray,
- weights: np.ndarray,
- samples: int = 10000,
- burn: int = 2000,
- thin: int = 10,
- population: int | None = None,
- resume_state: MCMCDraw | None = None,
- sampler_kwargs: dict | None = None,
- progress_callback: Callable[[dict], bool | None] | None = None,
- abort_test: Callable[[], bool] | None = None,
- ) -> dict:
- """
- Run Bayesian MCMC sampling using the BUMPS DREAM sampler.
-
- Builds a BUMPS ``FitProblem`` from the current model and runs
- the DREAM sampler. This is the public minimizer-level entry
- point for Bayesian sampling; the higher-level
- ``MultiFitter.mcmc_sample`` delegates to this method after
- flattening multi-dataset arrays.
-
- Parameters
- ----------
- x : np.ndarray
- Flattened independent variable array.
- y : np.ndarray
- Flattened dependent variable array.
- weights : np.ndarray
- Flattened weight array.
- samples : int, default=10000
- Number of raw samples to draw across all chains, before thinning.
- A guaranteed minimum, not an exact count: DREAM advances in
- blocks of 10 generations (one generation = one draw per chain)
- and stops at the first block boundary at or past ``samples``.
- burn : int, default=2000
- Burn-in generations to discard. BUMPS counts ``burn`` in
- generations while ``samples`` counts raw draws, so ``burn=500``
- discards ``500 * n_chains`` raw samples.
- thin : int, default=10
- Thinning interval β only every ``thin``-th generation is stored.
- population : int | None, default=None
- BUMPS DREAM population count per parameter (number of parallel
- chains): BUMPS creates ``ceil(population * n_parameters)`` chains.
- resume_state : MCMCDraw | None, default=None
- A BUMPS ``MCMCDraw`` state object from a previous
- ``mcmc_sample()`` call (e.g. ``PosteriorResults.sampler_state``).
- When provided, DREAM **continues** the saved chain instead of
- starting cold. The population, parameter count, and parameter
- names must match the current model β a ``ValueError`` is raised
- otherwise.
-
- ``samples`` must be the **total** number of raw samples, not an
- increment: to extend an existing chain of ``N`` raw samples by
- ``M``, pass ``samples=N + M`` (DREAM keeps only the last
- ``samples`` draws in its buffer). The `Sampler.extend` helper
- computes this for you.
-
- ``burn`` is forced to 0 on resume: a previously-converged chain is
- never re-burned.
-
- The ``population`` and ``initializer`` parameters
- have **no effect** when ``resume_state`` is provided β they
- are determined by the saved state.
-
- Resuming against *different* data is undefined behaviour (the
- chain's likelihood changes underneath it).
- sampler_kwargs : dict | None, default=None
- Additional keyword arguments forwarded to
- ``bumps.fitters.fit``.
- progress_callback : Callable[[dict], bool | None] | None, default=None
- Optional callback for progress updates during sampling. The
- payload dict includes ``iteration`` (DREAM generation
- number) and ``sampling: True``.
- abort_test : Callable[[], bool] | None, default=None
- Optional callback that returns ``True`` to signal that
- sampling should be aborted. Called periodically during the
- DREAM sampling loop.
-
- Returns
- -------
- dict
- Dictionary with keys ``'draws'``, ``'param_names'``,
- ``'internal_bumps_object'``, and ``'logp'``.
-
- Raises
- ------
- ValueError
- If the input shapes or weights are invalid, if
- ``progress_callback`` is not callable, or if ``resume_state``
- is incompatible with the current model (parameter count,
- names/order, or population mismatch).
- FitError
- If DREAM sampling was aborted by the user (via
- ``abort_test``).
- Exception
- Re-raised from DREAM fitting if any unexpected error occurs
- (parameter values are restored beforehand).
- """
- from bumps.fitters import DreamFit
- from bumps.names import FitProblem
-
- x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights)
-
- if not isinstance(samples, int) or samples <= 0:
- raise ValueError('samples must be a positive integer.')
- if not isinstance(burn, int) or burn < 0:
- raise ValueError('burn must be a non-negative integer.')
- if not isinstance(thin, int) or thin < 1:
- raise ValueError('thin must be a positive integer.')
-
- if y.shape != x.shape:
- raise ValueError('x and y must have the same shape.')
-
- if not np.isfinite(x).all():
- raise ValueError('x cannot contain NaN or infinite values.')
- if not np.isfinite(y).all():
- raise ValueError('y cannot contain NaN or infinite values.')
-
- if weights.shape != x.shape:
- raise ValueError('Weights must have the same shape as x and y.')
-
- if not np.isfinite(weights).all():
- raise ValueError('Weights cannot be NaN or infinite.')
-
- if (weights <= 0).any():
- raise ValueError('Weights must be strictly positive and non-zero.')
-
- # Build the BUMPS Curve model using the minimizer's existing machinery
- model_func = self._make_model()
- curve = model_func(x, y, weights)
- problem = FitProblem(curve)
-
- pop = population
- if resume_state is not None:
- pop, burn = self._validate_resume_state(problem, resume_state, population, burn)
-
- # Build DREAM kwargs. Use the resolved ``pop``, not the raw
- # ``population`` argument β on resume ``pop`` is the negative
- # absolute chain count that reproduces the saved state's
- # population, which BUMPS requires to match.
- dream_kwargs: dict = {'samples': samples, 'burn': burn, 'thin': thin}
- if pop is not None:
- dream_kwargs['pop'] = pop
- if sampler_kwargs:
- dream_kwargs.update(sampler_kwargs)
-
- # Build monitors (same pattern as classical Bumps.fit())
- monitors = []
- if progress_callback is not None:
- if not callable(progress_callback):
- raise ValueError('progress_callback must be callable')
- # Compute total DREAM steps for progress display (burn + sampling generations).
- # BUMPS DREAM default population count is 10 when not specified by the user.
- # A negative ``pop`` (resume) is an absolute chain count.
- _dream_default_pop = 10
- pop_val = abs(pop) if pop is not None else _dream_default_pop
- _total_steps = burn + (samples + pop_val - 1) // pop_val
- monitors.append(
- BumpsProgressMonitor(
- problem,
- progress_callback,
- lambda problem, iteration, point, nllf: {
- **self._build_sample_progress_payload(problem, iteration, point, nllf),
- 'total_steps': _total_steps,
- },
- )
- )
-
- driver = FitDriver(
- fitclass=DreamFit,
- problem=problem,
- monitors=monitors,
- abort_test=abort_test if abort_test is not None else (lambda: False),
- **dream_kwargs,
- )
- driver.clip()
-
- from easyscience import global_object
-
- stack_status = global_object.stack.enabled
- global_object.stack.enabled = False
-
- try:
- fit_kwargs = {}
- if resume_state is not None:
- # Defensive copy: BUMPS mutates the state object in-place
- # (via MCMCDraw.resize() β see bumps/dream/core.py allocate_state)
- # during resume. Without a copy, the caller's original state
- # object is silently altered, making it impossible to compare
- # pre- and post-resume state (shape mismatch). See
- # https://github.com/easyscience/core/pull/257
- fit_kwargs['fit_state'] = copy.deepcopy(resume_state)
- x_opt, fx = driver.fit(**fit_kwargs)
- result_state = getattr(driver.fitter, 'state', None)
- if result_state is None:
- raise FitError('Sampling aborted by user')
- except Exception:
- self._restore_parameter_values()
- raise
- finally:
- global_object.stack.enabled = stack_status
-
- _draw = result_state.draw()
- draws = _draw.points
- param_names = [p.name[len(MINIMIZER_PARAMETER_PREFIX) :] for p in problem._parameters]
- logp = _draw.logp
-
- return {
- 'draws': draws,
- 'param_names': param_names,
- 'internal_bumps_object': result_state,
- 'logp': logp,
- }
-
- def _validate_resume_state(
- self,
- problem: FitProblem,
- resume_state: MCMCDraw,
- population: int | None,
- burn: int,
- ) -> tuple[int, int]:
- """Check that ``resume_state`` is compatible with ``problem`` and
- resolve the population and burn values to use when resuming.
-
- Parameters
- ----------
- problem : FitProblem
- The freshly built BUMPS ``FitProblem`` for the current model.
- resume_state : MCMCDraw
- The saved chain state to resume from.
- population : int | None
- The caller-supplied population scale factor, or ``None``.
- burn : int
- The caller-supplied burn-in, ignored (with a warning) on resume.
-
- Returns
- -------
- tuple[int, int]
- ``(population, burn)`` to pass to DREAM. The population is
- returned as a **negative** number, which BUMPS'
- ``initpop.generate`` reads as an absolute chain count, exactly
- reproducing the saved state's population. ``burn`` is always 0:
- a previously converged chain is never re-burned.
-
- Raises
- ------
- ValueError
- If ``resume_state`` is incompatible with the current model
- (parameter count, names/order, or population mismatch).
- """
- from easyscience import global_object
-
- logger = global_object.log.getLogger('fitting.bumps')
-
- # Parameter count
- n_params = len(problem._parameters)
- if n_params != resume_state.Nvar:
- raise ValueError(
- f'resume_state has {resume_state.Nvar} parameters but the current '
- f'model has {n_params}. The model must have the same '
- f'number of fitted parameters as when the saved chain was created.'
- )
-
- prefix = MINIMIZER_PARAMETER_PREFIX
- fresh_names = [(p.name or '')[len(prefix) :] for p in problem._parameters]
- state_labels = list(resume_state.labels)
- if state_labels and all(lbl.startswith(prefix) for lbl in state_labels):
- state_names = [lbl[len(prefix) :] for lbl in state_labels]
- if fresh_names != state_names:
- raise ValueError(
- f'Parameter names/order mismatch between the current model '
- f'and resume_state.\n'
- f' Current model : {fresh_names}\n'
- f' resume_state : {state_names}'
- )
- else:
- logger.warning(
- 'resume_state does not carry parameter names (it was most '
- 'likely reloaded from disk, where BUMPS does not preserve '
- 'labels). Parameter-name validation is skipped; the saved '
- 'chain is matched to the current model by parameter order. '
- 'Ensure this is the same model, with parameters in the same '
- 'order, used to create the chain.'
- )
-
- # Population. BUMPS creates ``ceil(population * n_params)`` chains
- # and requires the resumed state's chain count to match.
- if population is not None:
- expected_npop = math.ceil(population * n_params)
- if expected_npop != resume_state.Npop:
- raise ValueError(
- f'Requested population ({population}) would produce '
- f'{expected_npop} chains but the saved state has '
- f'{resume_state.Npop} chains. The population cannot '
- f'be changed on resume.'
- )
- if burn > 0:
- logger.warning(
- f'burn={burn} ignored on resume: a previously converged '
- f'chain is not re-burned. Forcing burn=0.'
- )
-
- # A negative ``pop`` is read by ``bumps.initpop.generate`` as an
- # absolute chain count, exactly reproducing the saved population
- # without having to recover the original scale factor.
- return -int(resume_state.Npop), 0
-
- def _build_sample_progress_payload(
- self, problem, iteration: int, point: np.ndarray, nllf: float
- ) -> dict:
- """
- Build a progress payload for Bayesian DREAM sampling steps.
-
- Called by :class:`BumpsProgressMonitor` at each DREAM
- generation. The payload includes ``sampling: True`` so
- downstream consumers can distinguish sampling progress from
- classical fitting progress.
- """
- payload = self._build_progress_payload(problem, iteration, point, nllf)
- payload['sampling'] = True
- return payload
-
def _set_parameter_fit_result(
self,
fit_result: Any,
- stack_status: bool,
- par_list: list[BumpsParameter],
+ par_names: list[str],
) -> None:
"""
Update parameters to their final values and assign a std error
@@ -723,28 +333,18 @@ def _set_parameter_fit_result(
----------
fit_result : Any
BUMPS OptimizeResult containing best-fit values and errors.
- stack_status : bool
- Whether the undo stack was enabled.
- par_list : list[BumpsParameter]
- List of BUMPS parameter objects.
+ par_names : list[str]
+ Cached-parameter names in BUMPS problem order, already
+ stripped of ``PARAMETER_PREFIX``. As seen in
+ :func:`~easyscience.fitting.minimizers.bumps_utils.parameter_names`.
"""
- from easyscience import global_object
-
pars = self._cached_pars
x_result = np.asarray(fit_result.x)
- stderr = np.asarray(fit_result.dx)
+ stderr = None if fit_result.dx is None else np.asarray(fit_result.dx)
- if stack_status:
- self._restore_parameter_values()
- global_object.stack.enabled = True
- global_object.stack.beginMacro('Fitting routine')
-
- for index, name in enumerate([par.name for par in par_list]):
- dict_name = name[len(MINIMIZER_PARAMETER_PREFIX) :]
- pars[dict_name].value = x_result[index]
- pars[dict_name].error = stderr[index]
- if stack_status:
- global_object.stack.endMacro()
+ for index, name in enumerate(par_names):
+ pars[name].value = x_result[index]
+ pars[name].error = None if stderr is None else stderr[index]
def _gen_fit_results(
self,
@@ -774,8 +374,10 @@ def _gen_fit_results(
"""
results = FitResults()
+ # `hasattr`, not a truthiness test: every `FitResults` field starts out
+ # falsy, so testing the current value would silently discard every kwarg.
for name, value in kwargs.items():
- if getattr(results, name, False):
+ if hasattr(results, name):
setattr(results, name, value)
n_evaluations = None if self._eval_counter is None else self._eval_counter.count
# BUMPS exposes `nit` as the last reported optimizer step index rather than the
@@ -801,41 +403,36 @@ def _gen_fit_results(
pars = self._cached_pars
item = {}
for index, name in enumerate(self._cached_model.pars.keys()):
- dict_name = name[len(MINIMIZER_PARAMETER_PREFIX) :]
+ dict_name = name[len(PARAMETER_PREFIX) :]
item[name] = pars[dict_name].value
results.p0 = self._p_0
results.p = item
results.x = self._cached_model.x
results.y_obs = self._cached_model.y
- results.y_calc = self.evaluate(results.x, minimizer_parameters=results.p)
+ results.y_calc = self.evaluate(results.x, parameters=results.p)
results.y_err = self._cached_model.dy
results.n_evaluations = n_evaluations
results.iterations = n_steps_used
- results.message = ''
+ results.message = '' if fit_results.success else fit_results.message
+
if stopped_on_budget:
+ from easyscience import global_object
+
results.message = (
f'Fit stopped: reached maximum optimizer steps ({max_evaluations}); '
f'objective evaluated {n_evaluations} times'
)
- if stopped_on_budget:
- from easyscience import global_object
-
if tolerance is None:
- global_object.log.getLogger('fitting.bumps').warning(
- f'Fit did not converge within the maximum optimizer steps of {max_evaluations} '
- f'({n_evaluations} objective evaluations). '
- 'Consider increasing the maximum number of evaluations or adjusting the tolerance.'
- )
+ reason = 'Fit did not converge within'
else:
- global_object.log.getLogger('fitting.bumps').warning(
- f'Fit did not reach the desired tolerance of {tolerance} within the maximum optimizer steps of {max_evaluations} '
- f'({n_evaluations} objective evaluations). '
- 'Consider increasing the maximum number of evaluations or adjusting the tolerance.'
- )
-
- # results.residual = results.y_obs - results.y_calc
- # results.goodness_of_fit = np.sum(results.residual**2)
+ reason = f'Fit did not reach the desired tolerance of {tolerance} within'
+ global_object.log.getLogger('fitting.bumps').warning(
+ f'{reason} the maximum optimizer steps of {max_evaluations} '
+ f'({n_evaluations} objective evaluations). '
+ 'Consider increasing the maximum number of evaluations or adjusting the tolerance.'
+ )
+
results.minimizer_engine = self.__class__
results.fit_args = None
results.engine_result = fit_results
diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py
index 319db849..98299d02 100644
--- a/src/easyscience/fitting/minimizers/minimizer_dfo.py
+++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py
@@ -10,13 +10,11 @@
import dfols
import numpy as np
-# causes circular import when Parameter is imported
-# from easyscience.base_classes import ObjBase
from easyscience.variable import Parameter
-from ..available_minimizers import AvailableMinimizers
-from .minimizer_base import MINIMIZER_PARAMETER_PREFIX
+from ..engine_base import PARAMETER_PREFIX
from .minimizer_base import MinimizerBase
+from .minimizers import Minimizers
from .utils import FitError
from .utils import FitResults
@@ -46,10 +44,10 @@ class DFO(MinimizerBase):
def __init__(
self,
- obj: object, #: ObjBase,
+ obj: object, #: ModelBase,
fit_function: Callable,
- minimizer_enum: AvailableMinimizers | None = None,
- ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501
+ minimizer_enum: Minimizers | None = None,
+ ): # todo after constraint changes, add type hint: obj: ModelBase # noqa: E501
"""
Initialize the fitting engine.
@@ -59,7 +57,7 @@ def __init__(
Object containing the ``Parameter`` instances to fit.
fit_function : Callable
Callable returning model y values for the supplied x values.
- minimizer_enum : AvailableMinimizers | None, default=None
+ minimizer_enum : Minimizers | None, default=None
Selected DFO minimizer configuration. By default, None.
"""
super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum)
@@ -78,12 +76,10 @@ def fit(
x: np.ndarray,
y: np.ndarray,
weights: np.ndarray,
- model: Callable | None = None,
- parameters: List[Parameter] | None = None,
method: str | None = None,
tolerance: float | None = None,
max_evaluations: int | None = None,
- progress_callback: Callable[[dict], bool | None] | None = None,
+ progress_callback: Callable[[dict], None] | None = None,
callback: Callable[[DFOCallbackState], None] | None = None,
**kwargs,
) -> FitResults:
@@ -98,18 +94,15 @@ def fit(
Measured points.
weights : np.ndarray
Weights for supplied measured points.
- model : Callable | None, default=None
- Optional Model which is being fitted to. By default, None.
- parameters : List[Parameter] | None, default=None
- Optional parameters for the fit. By default, None.
method : str | None, default=None
Method for minimization. By default, None.
tolerance : float | None, default=None
Requested optimizer tolerance. By default, None.
max_evaluations : int | None, default=None
Maximum number of evaluations. By default, None.
- progress_callback : Callable[[dict], bool | None] | None, default=None
- Optional callback receiving normalized progress payloads.
+ progress_callback : Callable[[dict], None] | None, default=None
+ Optional callback receiving normalized progress payloads. Its
+ return value is ignored.
callback : Callable[[DFOCallbackState], None] | None, default=None
Optional native DFO callback.
**kwargs :
@@ -126,56 +119,28 @@ def fit(
------
FitError
If the DFO fit fails.
- ValueError
- If the input shapes, weights, or tolerance are invalid.
"""
x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights)
- if y.shape != x.shape:
- raise ValueError('x and y must have the same shape.')
-
- if weights.shape != x.shape:
- raise ValueError('Weights must have the same shape as x and y.')
-
- if not np.isfinite(weights).all():
- raise ValueError('Weights cannot be NaN or infinite.')
-
- if (weights <= 0).any():
- raise ValueError('Weights must be strictly positive and non-zero.')
+ self.validate_arrays(x, y, weights)
# Bridge progress_callback into the DFO callback mechanism
if progress_callback is not None and callback is None:
callback = self._make_progress_adapter(progress_callback)
- if model is None:
- model_function = self._make_model(
- parameters=parameters,
- callback=callback,
- )
- model = model_function(x, y, weights)
- elif callback is not None:
- model = self._wrap_model_with_callback(
- model,
- self._get_callback_parameter_names(parameters),
- callback,
- )
+ model_function = self._make_model(callback=callback)
+ model = model_function(x, y, weights)
self._cached_model = model
self._cached_model.x = x
self._cached_model.y = y
self._p_0 = {f'p{key}': self._cached_pars[key].value for key in self._cached_pars.keys()}
- # Why do we do this? Because a fitting template has to have global_object instantiated outside pre-runtime
- from easyscience import global_object
-
- stack_status = global_object.stack.enabled
- global_object.stack.enabled = False
-
kwargs = self._prepare_kwargs(tolerance, max_evaluations, **kwargs)
try:
model_results = self._dfo_fit(self._cached_pars, model, **kwargs)
- self._set_parameter_fit_result(model_results, stack_status)
+ self._set_parameter_fit_result(model_results)
results = self._gen_fit_results(model_results, weights)
except FitError:
self._restore_parameter_values()
@@ -183,22 +148,10 @@ def fit(
except Exception as e:
self._restore_parameter_values()
raise FitError(e)
- finally:
- global_object.stack.enabled = stack_status
return results
- def convert_to_pars_obj(self, par_list: List[Parameter] | None = None):
- """Required by interface but not needed for DFO-LS."""
- pass
-
- @staticmethod
- def convert_to_par_object(obj) -> None:
- """Required by interface but not needed for DFO-LS."""
- pass
-
def _make_model(
self,
- parameters: List[Parameter] | None = None,
callback: Callable[[DFOCallbackState], None] | None = None,
) -> Callable:
"""
@@ -208,8 +161,6 @@ def _make_model(
Parameters
----------
- parameters : List[Parameter] | None, default=None
- Optional parameter subset to include in the model.
callback : Callable[[DFOCallbackState], None] | None, default=None
Optional callback invoked on each objective evaluation.
@@ -223,13 +174,10 @@ def _make_model(
def _outer(obj: DFO):
def _make_func(x, y, weights):
- dfo_pars = {}
- if not parameters:
- for name, par in obj._cached_pars.items():
- dfo_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = par.value
- else:
- for par in parameters:
- dfo_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = par.value
+ dfo_pars = {
+ PARAMETER_PREFIX + str(name): par.value
+ for name, par in obj._cached_pars.items()
+ }
def _residuals(pars_values: List[float]) -> np.ndarray:
for idx, par_name in enumerate(dfo_pars.keys()):
@@ -246,13 +194,6 @@ def _residuals(pars_values: List[float]) -> np.ndarray:
return _outer(self)
- def _get_callback_parameter_names(
- self, parameters: List[Parameter] | None = None
- ) -> list[str]:
- if parameters is not None:
- return [MINIMIZER_PARAMETER_PREFIX + parameter.unique_name for parameter in parameters]
- return [MINIMIZER_PARAMETER_PREFIX + name for name in self._cached_pars.keys()]
-
@staticmethod
def _wrap_model_with_callback(
model: Callable,
@@ -302,7 +243,7 @@ def wrapped_model(pars_values: List[float]) -> np.ndarray:
@staticmethod
def _make_progress_adapter(
- progress_callback: Callable[[dict], bool | None],
+ progress_callback: Callable[[dict], None],
) -> Callable[['DFOCallbackState'], None]:
"""
Create a DFO callback that translates DFOCallbackState into the
@@ -310,8 +251,8 @@ def _make_progress_adapter(
Parameters
----------
- progress_callback : Callable[[dict], bool | None]
- Standard progress callback (dict -> bool|None).
+ progress_callback : Callable[[dict], None]
+ Standard progress callback (dict -> None).
Returns
-------
@@ -324,7 +265,7 @@ def adapter(state: 'DFOCallbackState') -> None:
dof = max(np.asarray(state.residuals).size - len(state.best_parameters), 1)
reduced_chi2 = chi2 / dof if dof > 0 else chi2
param_snapshot = {
- name[len(MINIMIZER_PARAMETER_PREFIX) :]: float(val)
+ name[len(PARAMETER_PREFIX) :]: float(val)
for name, val in state.best_parameters.items()
}
payload = {
@@ -339,9 +280,7 @@ def adapter(state: 'DFOCallbackState') -> None:
return adapter
- def _set_parameter_fit_result(
- self, fit_result: Any, stack_status: bool, ci: float = 0.95
- ) -> None:
+ def _set_parameter_fit_result(self, fit_result: Any, ci: float = 0.95) -> None:
"""
Update parameters to their final values and assign a std error
to them.
@@ -350,28 +289,17 @@ def _set_parameter_fit_result(
----------
fit_result : Any
Fit object which contains info on the fit.
- stack_status : bool
- Whether the undo stack was enabled.
ci : float, default=0.95
Confidence interval for calculating errors. Default 95%. By
default, 0.95.
"""
- from easyscience import global_object
-
pars = self._cached_pars
- if stack_status:
- self._restore_parameter_values()
- global_object.stack.enabled = True
- global_object.stack.beginMacro('Fitting routine')
error_matrix = self._error_from_jacobian(fit_result.jacobian, fit_result.resid, ci)
for idx, par in enumerate(pars.values()):
par.value = fit_result.x[idx]
par.error = error_matrix[idx, idx]
- if stack_status:
- global_object.stack.endMacro()
-
def _gen_fit_results(self, fit_results: Any, weights: np.ndarray, **kwargs: Any) -> FitResults:
"""
Convert fit results into the unified ``FitResults`` format.
@@ -411,7 +339,7 @@ def _gen_fit_results(self, fit_results: Any, weights: np.ndarray, **kwargs: Any)
results.p0 = self._p_0
results.x = self._cached_model.x
results.y_obs = self._cached_model.y
- results.y_calc = self.evaluate(results.x, minimizer_parameters=results.p)
+ results.y_calc = self.evaluate(results.x, parameters=results.p)
# `weights` here are 1/sigma (residuals are multiplied by them in `_make_model`).
# `FitResults.chi2` divides residuals by `y_err`, so `y_err` must be sigma, not the weight.
results.y_err = 1 / np.asarray(weights)
diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py
index 31fb543f..b9f47db0 100644
--- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py
+++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py
@@ -11,13 +11,11 @@
from lmfit import Parameters as LMParameters
from lmfit.model import ModelResult
-# causes circular import when Parameter is imported
-# from easyscience.base_classes import ObjBase
from easyscience.variable import Parameter
-from ..available_minimizers import AvailableMinimizers
-from .minimizer_base import MINIMIZER_PARAMETER_PREFIX
+from ..engine_base import PARAMETER_PREFIX
from .minimizer_base import MinimizerBase
+from .minimizers import Minimizers
from .utils import FitError
from .utils import FitResults
@@ -27,17 +25,17 @@ class LMFit(MinimizerBase): # noqa: S101
This is a wrapper to the extended Levenberg-Marquardt Fit:
https://lmfit.github.io/lmfit-py/ It allows for the lmfit fitting
engine to use parameters declared in an
- ``EasyScience.base_classes.ObjBase``.
+ ``easyscience.base_classes.ModelBase``.
"""
package = 'lmfit'
def __init__(
self,
- obj: object, #: ObjBase,
+ obj: object, #: ModelBase,
fit_function: Callable,
- minimizer_enum: AvailableMinimizers | None = None,
- ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501
+ minimizer_enum: Minimizers | None = None,
+ ): # todo after constraint changes, add type hint: obj: ModelBase # noqa: E501
"""
Initialize the minimizer.
@@ -47,7 +45,7 @@ def __init__(
Object containing the ``Parameter`` instances to fit.
fit_function : Callable
Callable returning model y values for the supplied x values.
- minimizer_enum : AvailableMinimizers | None, default=None
+ minimizer_enum : Minimizers | None, default=None
Selected LMFit minimizer configuration. By default, None.
"""
super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum)
@@ -85,12 +83,10 @@ def fit(
x: np.ndarray,
y: np.ndarray,
weights: np.ndarray = None,
- model: LMModel | None = None,
- parameters: LMParameters | None = None,
method: str | None = None,
tolerance: float | None = None,
max_evaluations: int | None = None,
- progress_callback: Callable[[dict], bool | None] | None = None,
+ progress_callback: Callable[[dict], None] | None = None,
minimizer_kwargs: dict | None = None,
engine_kwargs: dict | None = None,
**kwargs,
@@ -106,18 +102,15 @@ def fit(
Measured points.
weights : np.ndarray, default=None
Weights for supplied measured points. By default, None.
- model : LMModel | None, default=None
- Optional Model which is being fitted to. By default, None.
- parameters : LMParameters | None, default=None
- Optional parameters for the fit. By default, None.
method : str | None, default=None
Minimizer method. By default, None.
tolerance : float | None, default=None
Requested optimizer tolerance. By default, None.
max_evaluations : int | None, default=None
Maximum number of function evaluations. By default, None.
- progress_callback : Callable[[dict], bool | None] | None, default=None
- Optional callback receiving normalized progress payloads.
+ progress_callback : Callable[[dict], None] | None, default=None
+ Optional callback receiving normalized progress payloads. Its
+ return value is ignored.
minimizer_kwargs : dict | None, default=None
Additional keyword arguments passed to LMFit's minimizer. By
default, None.
@@ -137,22 +130,10 @@ def fit(
------
FitError
If the LMFit optimization fails.
- ValueError
- If the input shapes or weights are invalid.
"""
x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights)
- if y.shape != x.shape:
- raise ValueError('x and y must have the same shape.')
-
- if weights.shape != x.shape:
- raise ValueError('Weights must have the same shape as x and y.')
-
- if not np.isfinite(weights).all():
- raise ValueError('Weights cannot be NaN or infinite.')
-
- if (weights <= 0).any():
- raise ValueError('Weights must be strictly positive and non-zero.')
+ self.validate_arrays(x, y, weights)
if engine_kwargs is None:
engine_kwargs = {}
@@ -160,15 +141,8 @@ def fit(
method_kwargs = self._get_method_kwargs(method)
fit_kws_dict = self._get_fit_kws(method, tolerance, minimizer_kwargs)
- # Why do we do this? Because a fitting template has to have global_object instantiated outside pre-runtime
- from easyscience import global_object
-
- stack_status = global_object.stack.enabled
- global_object.stack.enabled = False
-
try:
- if model is None:
- model = self._make_model()
+ model = self._make_model()
self._last_iteration = None
iter_cb = self._create_iter_callback(progress_callback)
@@ -183,18 +157,16 @@ def fit(
**engine_kwargs,
**kwargs,
)
- self._set_parameter_fit_result(model_results, stack_status)
+ self._set_parameter_fit_result(model_results)
results = self._gen_fit_results(model_results, iterations=self._last_iteration)
except Exception as e:
self._restore_parameter_values()
raise FitError(e)
- finally:
- global_object.stack.enabled = stack_status
return results
def _create_iter_callback(
self,
- progress_callback: Callable[[dict], bool | None] | None,
+ progress_callback: Callable[[dict], None] | None,
) -> Callable | None:
def iter_cb(params, iteration: int, residuals: np.ndarray, *args, **kwargs) -> bool:
@@ -218,12 +190,12 @@ def _build_progress_payload(self, params, iteration: int, residuals: np.ndarray)
reduced_chi2 = chi2 / degrees_of_freedom if degrees_of_freedom > 0 else chi2
parameter_values = {
- parameter_name[len(MINIMIZER_PARAMETER_PREFIX) :]: float(parameter.value)
+ parameter_name[len(PARAMETER_PREFIX) :]: float(parameter.value)
for parameter_name, parameter in params.items()
- if parameter_name.startswith(MINIMIZER_PARAMETER_PREFIX)
+ if parameter_name.startswith(PARAMETER_PREFIX)
}
for parameter_name, parameter in self._cached_pars.items():
- lmfit_parameter_name = f'{MINIMIZER_PARAMETER_PREFIX}{parameter_name}'
+ lmfit_parameter_name = f'{PARAMETER_PREFIX}{parameter_name}'
if lmfit_parameter_name not in params:
parameter_values[parameter_name] = float(parameter.value)
@@ -241,65 +213,17 @@ def _get_fit_kws(
) -> dict[str:str]:
if minimizer_kwargs is None:
minimizer_kwargs = {}
+ # `method` is usually None, because `Fitter.fit`
+ # does not pass one; the minimizer's own method is what actually runs,
+ # so it decides which tolerance keyword the backend accepts.
+ effective_method = method if method is not None else self._method
if tolerance is not None:
- if method in [None, 'least_squares', 'leastsq']:
+ if effective_method in ['least_squares', 'leastsq']:
minimizer_kwargs['ftol'] = tolerance
- if method in ['differential_evolution', 'powell', 'cobyla']:
+ if effective_method in ['differential_evolution', 'powell', 'cobyla']:
minimizer_kwargs['tol'] = tolerance
return minimizer_kwargs
- def convert_to_pars_obj(self, parameters: List[Parameter] | None = None) -> LMParameters:
- """
- Create an lmfit compatible container with the ``Parameters``
- converted from the base object.
-
- Parameters
- ----------
- parameters : List[Parameter] | None, default=None
- If only a single/selection of parameter is required. Specify
- as a list. By default, None.
-
- Returns
- -------
- LMParameters
- Lmfit Parameters compatible object.
- """
- if parameters is None:
- # Assume that we have a ObjBase for which we can obtain a list
- parameters = self._object.get_fit_parameters()
- lm_parameters = LMParameters().add_many([
- self.convert_to_par_object(parameter) for parameter in parameters
- ])
- return lm_parameters
-
- @staticmethod
- def convert_to_par_object(parameter: Parameter) -> LMParameter:
- """
- Convert an EasyScience Parameter object to a lmfit Parameter
- object.
-
- Parameters
- ----------
- parameter : Parameter
- EasyScience parameter to convert.
-
- Returns
- -------
- LMParameter
- Lmfit Parameter compatible object.
- """
- value = parameter.value
-
- return LMParameter(
- MINIMIZER_PARAMETER_PREFIX + parameter.unique_name,
- value=value,
- vary=not parameter.fixed,
- min=parameter.min,
- max=parameter.max,
- expr=None,
- brute_step=None,
- )
-
def _make_model(self, pars: LMParameters | None = None) -> LMModel:
"""
Generate a lmfit model from the supplied ``fit_function`` and
@@ -326,7 +250,7 @@ def _make_model(self, pars: LMParameters | None = None) -> LMModel:
model = LMModel(
fit_func,
independent_vars=['x'],
- param_names=[MINIMIZER_PARAMETER_PREFIX + str(key) for key in pars.keys()],
+ param_names=[PARAMETER_PREFIX + str(key) for key in pars.keys()],
)
# Assign values from the `Parameter` to the model
for name, item in pars.items():
@@ -336,7 +260,7 @@ def _make_model(self, pars: LMParameters | None = None) -> LMModel:
value = item.value
model.set_param_hint(
- MINIMIZER_PARAMETER_PREFIX + str(name),
+ PARAMETER_PREFIX + str(name),
value=value,
min=item.min,
max=item.max,
@@ -346,7 +270,7 @@ def _make_model(self, pars: LMParameters | None = None) -> LMModel:
self._cached_model = model
return model
- def _set_parameter_fit_result(self, fit_result: ModelResult, stack_status: bool) -> None:
+ def _set_parameter_fit_result(self, fit_result: ModelResult) -> None:
"""
Update parameters to their final values and assign a std error
to them.
@@ -355,24 +279,18 @@ def _set_parameter_fit_result(self, fit_result: ModelResult, stack_status: bool)
----------
fit_result : ModelResult
Fit object which contains info on the fit.
- stack_status : bool
- Whether the undo stack was enabled.
"""
- from easyscience import global_object
-
pars = self._cached_pars
- if stack_status:
- self._restore_parameter_values()
- global_object.stack.enabled = True
- global_object.stack.beginMacro('Fitting routine')
for name in pars.keys():
- pars[name].value = fit_result.params[MINIMIZER_PARAMETER_PREFIX + str(name)].value
+ pars[name].value = fit_result.params[PARAMETER_PREFIX + str(name)].value
if fit_result.errorbars:
- pars[name].error = fit_result.params[MINIMIZER_PARAMETER_PREFIX + str(name)].stderr
+ pars[name].error = fit_result.params[PARAMETER_PREFIX + str(name)].stderr
else:
- pars[name].error = 0.0
- if stack_status:
- global_object.stack.endMacro()
+ # No covariance available (gradient-free method, aborted fit, or a
+ # parameter at a bound). None keeps that distinguishable from a
+ # genuine zero uncertainty and clears any stale error from a
+ # previous fit.
+ pars[name].error = None
def _gen_fit_results(self, fit_results: ModelResult, **kwargs: Any) -> FitResults:
"""
diff --git a/src/easyscience/fitting/available_minimizers.py b/src/easyscience/fitting/minimizers/minimizers.py
similarity index 54%
rename from src/easyscience/fitting/available_minimizers.py
rename to src/easyscience/fitting/minimizers/minimizers.py
index 311bf35e..b8593c1b 100644
--- a/src/easyscience/fitting/available_minimizers.py
+++ b/src/easyscience/fitting/minimizers/minimizers.py
@@ -3,6 +3,7 @@
from dataclasses import dataclass
from enum import Enum
+from enum import unique
from easyscience import global_object
@@ -48,7 +49,8 @@ class AvailableMinimizer:
enum_id: int
-class AvailableMinimizers(AvailableMinimizer, Enum):
+@unique
+class Minimizers(AvailableMinimizer, Enum):
if lmfit_engine_available:
LMFit = 'lm', 'leastsq', 11
LMFit_leastsq = 'lm', 'leastsq', 12
@@ -66,39 +68,3 @@ class AvailableMinimizers(AvailableMinimizer, Enum):
if dfo_engine_available:
DFO = 'dfo', 'leastsq', 31
DFO_leastsq = 'dfo', 'leastsq', 32
-
-
-# Temporary solution to convert string to enum
-def from_string_to_enum(minimizer_name: str) -> AvailableMinimizers:
- if minimizer_name == 'LMFit':
- minmizer_enum = AvailableMinimizers.LMFit
- elif minimizer_name == 'LMFit_leastsq':
- minmizer_enum = AvailableMinimizers.LMFit_leastsq
- elif minimizer_name == 'LMFit_powell':
- minmizer_enum = AvailableMinimizers.LMFit_powell
- elif minimizer_name == 'LMFit_cobyla':
- minmizer_enum = AvailableMinimizers.LMFit_cobyla
- elif minimizer_name == 'LMFit_differential_evolution':
- minmizer_enum = AvailableMinimizers.LMFit_differential_evolution
- elif minimizer_name == 'LMFit_scipy_least_squares':
- minmizer_enum = AvailableMinimizers.LMFit_scipy_least_squares
-
- elif minimizer_name == 'Bumps':
- minmizer_enum = AvailableMinimizers.Bumps
- elif minimizer_name == 'Bumps_simplex':
- minmizer_enum = AvailableMinimizers.Bumps_simplex
- elif minimizer_name == 'Bumps_newton':
- minmizer_enum = AvailableMinimizers.Bumps_newton
- elif minimizer_name == 'Bumps_lm':
- minmizer_enum = AvailableMinimizers.Bumps_lm
-
- elif minimizer_name == 'DFO':
- minmizer_enum = AvailableMinimizers.DFO
- elif minimizer_name == 'DFO_leastsq':
- minmizer_enum = AvailableMinimizers.DFO_leastsq
- else:
- raise ValueError(
- f'Invalid minimizer name: {minimizer_name}. The following minimizers are available: {[minimize.name for minimize in AvailableMinimizers]}' # noqa: E501
- )
-
- return minmizer_enum
diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py
index e2d88943..b90bd607 100644
--- a/src/easyscience/fitting/multi_fitter.py
+++ b/src/easyscience/fitting/multi_fitter.py
@@ -1,13 +1,17 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
+from collections.abc import Sequence
from typing import Callable
import numpy as np
-from ..base_classes import CollectionBase
+from ..base_classes import EasyList
+from ..base_classes import ModelBase
from .fitter import Fitter
from .minimizers import FitResults
+from .reshaping import inject_x_multi
+from .reshaping import reshape_datasets
class MultiFitter(Fitter):
@@ -20,28 +24,53 @@ class MultiFitter(Fitter):
The inherited ``fit`` wrapper from ``Fitter`` is used unchanged,
including support for forwarding progress callbacks to the active
minimizer.
+
"""
def __init__(
self,
- fit_objects: list | None = None,
- fit_functions: list[Callable] | None = None,
+ fit_objects: Sequence | None = None,
+ fit_functions: Sequence[Callable] | None = None,
):
- # Create a dummy core object to hold all the fit objects.
- self._fit_objects = CollectionBase('multi', *fit_objects)
- self._fit_functions = fit_functions
+ # Both arguments default to None so the constructor can be called
+ # empty; normalise to empty sequences so nothing below has to
+ # special-case None.
+ if fit_objects is None:
+ fit_objects = []
+ if fit_functions is None:
+ fit_functions = []
+ # Aggregate the fit objects so a single object can be sent to Fitter.
+ # *-unpacking keeps any sequence (list, tuple, etc) working.
+ self._fit_objects = EasyList(*fit_objects, protected_types=ModelBase)
+ self._fit_functions = list(fit_functions)
# Initialize with the first of the fit_functions, without this it is
- # not possible to change the fitting engine.
- super().__init__(self._fit_objects, self._fit_functions[0])
+ # not possible to change the fitting engine. With no functions given
+ # the Fitter is created with ``None``; the minimizer only stores the
+ # callable, so this is harmless until a fit is attempted.
+ first_fit_function = self._fit_functions[0] if self._fit_functions else None
+ super().__init__(self._fit_objects, first_fit_function)
+
+ @property
+ def fit_functions(self) -> list[Callable]:
+ """
+ Get the per-dataset fit functions, in dataset order.
+
+ Returns
+ -------
+ list[Callable]
+ One fit function per dataset.
+ """
+ return list(self._fit_functions)
def _fit_function_wrapper(
- self, real_x: list[np.ndarray] | None = None, flatten: bool = True
+ self,
+ real_x: list[np.ndarray] | None = None,
+ flatten: bool = True,
) -> Callable:
"""
- Simple fit function which injects the N real X (independent)
- values into the optimizer function.
-
- This will also flatten the results if needed.
+ Wrap the per-dataset fit functions into one function that
+ evaluates each on its real X (independent) values and
+ concatenates the results, flattening if needed.
Parameters
----------
@@ -56,81 +85,9 @@ def _fit_function_wrapper(
Callable
Wrapped optimizer function.
"""
- # Extract of a list of callable functions
- wrapped_fns = []
- for this_x, this_fun in zip(real_x, self._fit_functions):
- self._fit_function = this_fun
- wrapped_fns.append(Fitter._fit_function_wrapper(self, this_x, flatten=flatten))
-
- def wrapped_fun(x, **kwargs):
- # Generate an empty Y based on x
- y = np.zeros_like(x)
- i = 0
- # Iterate through wrapped functions, passing the WRONG x, the correct
- # x was injected in the step above.
- for idx, dim in enumerate(self._dependent_dims):
- ep = i + np.prod(dim)
- y[i:ep] = wrapped_fns[idx](x, **kwargs)
- i = ep
- return y
-
- return wrapped_fun
-
- @staticmethod
- def _precompute_reshaping(
- x: list[np.ndarray],
- y: list[np.ndarray],
- weights: list[np.ndarray] | None,
- vectorized: bool,
- ) -> tuple[np.ndarray, list[np.ndarray], np.ndarray, np.ndarray | None, list[tuple[int, ...]]]:
- """
- Convert an array of X's and Y's to an acceptable shape for
- fitting.
-
- Parameters
- ----------
- x : list[np.ndarray]
- List of independent variables.
- y : list[np.ndarray]
- List of dependent variables.
- weights : list[np.ndarray] | None
- Optional weights for each dataset.
- vectorized : bool
- When ``True``, each x array may be multi-dimensional (e.g.
- an ``(N, M, 2)`` grid for a 2D model) and is left as-is.
- When ``False`` (default), each x array is expected to be
- 1-D.
+ return inject_x_multi(self._fit_functions, real_x, self._dependent_dims, flatten=flatten)
- Returns
- -------
- tuple[np.ndarray, list[np.ndarray], np.ndarray, np.ndarray | None, list[tuple[int, ...]]]
- Reshaped x values, reshaped input data, flattened y values,
- flattened weights, and stored dependent dimensions.
- """
- if weights is None:
- weights = [None] * len(x)
- _, _x_new, _y_new, _weights, _dims = Fitter._precompute_reshaping(
- x[0], y[0], weights[0], vectorized
- )
- x_new = [_x_new]
- y_new = [_y_new]
- w_new = [_weights]
- dims = [_dims]
- for _x, _y, _w in zip(x[1::], y[1::], weights[1::]):
- _, _x_new, _y_new, _weights, _dims = Fitter._precompute_reshaping(
- _x, _y, _w, vectorized
- )
- x_new.append(_x_new)
- y_new.append(_y_new)
- w_new.append(_weights)
- dims.append(_dims)
- y_new = np.hstack(y_new)
- if w_new[0] is None:
- w_new = None
- else:
- w_new = np.hstack(w_new)
- x_fit = np.linspace(0, y_new.size - 1, y_new.size)
- return x_fit, x_new, y_new, w_new, dims
+ _precompute_reshaping = staticmethod(reshape_datasets)
def _post_compute_reshaping(
self,
diff --git a/src/easyscience/fitting/reshaping.py b/src/easyscience/fitting/reshaping.py
new file mode 100644
index 00000000..c68a7739
--- /dev/null
+++ b/src/easyscience/fitting/reshaping.py
@@ -0,0 +1,216 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Data reshaping and fit-function wrapping shared by ``Fitter`` and ``Sampler``.
+
+Both fitting and sampling provide the engine witha flat 1-D ``y`` array
+and a dummy 1-D ``x`` index array, while the user's fit function is called
+with the real ``x``. The helpers here do the translation in one place:
+``reshape_dataset``/``inject_x`` do one dataset,
+``reshape_datasets``/``inject_x_multi`` do a list of datasets.
+"""
+
+from __future__ import annotations
+
+import functools
+from typing import Callable
+
+import numpy as np
+
+
+def reshape_dataset(
+ x: np.ndarray,
+ y: np.ndarray,
+ weights: np.ndarray | None,
+ vectorized: bool,
+) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None, tuple[int, ...]]:
+ """
+ Check the dimensions of the inputs and reshape if necessary.
+
+ Parameters
+ ----------
+ x : np.ndarray
+ Independent points; 1-D, or ND with the coordinate components on
+ the last axis.
+ y : np.ndarray
+ Dependent points, one per observation.
+ weights : np.ndarray | None
+ Optional weights for the fit.
+ vectorized : bool
+ Whether ``x`` already stores vectorized coordinates.
+
+ Returns
+ -------
+ tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None, tuple[int, ...]]
+ Dummy x index array for the engine, reshaped x values, flattened
+ y values, flattened weights, and the shape of ``y`` (the dependent
+ dimensions; its product is the number of observations, which the
+ multi-dataset helpers use to slice the combined output).
+
+ Raises
+ ------
+ ValueError
+ If the shapes of ``x`` and ``y`` are incompatible.
+ """
+ # Make sure that they are np arrays
+ x_new = np.array(x)
+ y_new = np.array(y)
+ # Get the shapes
+ x_shape = x_new.shape
+ y_shape = y_new.shape
+ # Check if the x data is 1D
+ if len(x_shape) > 1:
+ # It is ND data
+ # Check if the data is vectorized. i.e. should x be [NxMx...x Ndims]
+ if vectorized:
+ # Assert that the shapes are the same
+ if np.all(x_shape[:-1] != y_new.shape):
+ raise ValueError('The shape of the x and y data must be the same')
+ # If so do nothing but note that the data is vectorized
+ # x_shape = (-1,) # Should this be done?
+ else:
+ # Assert that the shapes are the same
+ if np.prod(x_new.shape[:-1]) != y_new.size:
+ raise ValueError('The number of elements in x and y data must be the same')
+ # Reshape the data to be [len(NxMx..), Ndims] i.e. flatten to columns
+ x_new = x_new.reshape(-1, x_shape[-1], order='F')
+ else:
+ # Assert that the shapes are the same
+ if np.all(x_shape != y_new.shape):
+ raise ValueError('The shape of the x and y data must be the same')
+ # It is 1D data
+ x_new = x.flatten()
+ # The optimizer needs a 1D array, flatten the y data
+ y_new = y_new.flatten()
+ if weights is not None:
+ weights = np.array(weights).flatten()
+ # Make a 'dummy' x array for the fit function
+ x_for_fit = np.array(range(y_new.size))
+ return x_for_fit, x_new, y_new, weights, y_shape
+
+
+def reshape_datasets(
+ x: list[np.ndarray],
+ y: list[np.ndarray],
+ weights: list[np.ndarray] | None,
+ vectorized: bool,
+) -> tuple[np.ndarray, list[np.ndarray], np.ndarray, np.ndarray | None, list[tuple[int, ...]]]:
+ """
+ Convert a list of X's and Y's to an acceptable shape for fitting.
+
+ Parameters
+ ----------
+ x : list[np.ndarray]
+ List of independent variables.
+ y : list[np.ndarray]
+ List of dependent variables.
+ weights : list[np.ndarray] | None
+ Optional weights for each dataset.
+ vectorized : bool
+ When ``True``, each x array may be multi-dimensional (e.g. an
+ ``(N, M, 2)`` grid for a 2D model) and is left as-is. When
+ ``False`` (default), each x array is expected to be 1-D.
+
+ Returns
+ -------
+ tuple[np.ndarray, list[np.ndarray], np.ndarray, np.ndarray | None, list[tuple[int, ...]]]
+ Dummy x index array for the engine, per-dataset reshaped x values,
+ concatenated y values, concatenated weights, and the per-dataset
+ dependent dimensions.
+ """
+ if weights is None:
+ weights = [None] * len(x)
+ x_new, y_new, w_new, dims = [], [], [], []
+ for _x, _y, _w in zip(x, y, weights):
+ _, _x_new, _y_new, _w_new, _dims = reshape_dataset(_x, _y, _w, vectorized)
+ x_new.append(_x_new)
+ y_new.append(_y_new)
+ w_new.append(_w_new)
+ dims.append(_dims)
+ y_new = np.hstack(y_new)
+ w_new = None if w_new[0] is None else np.hstack(w_new)
+ x_fit = np.linspace(0, y_new.size - 1, y_new.size)
+ return x_fit, x_new, y_new, w_new, dims
+
+
+def inject_x(
+ fit_function: Callable,
+ real_x: np.ndarray | None = None,
+ flatten: bool = True,
+) -> Callable:
+ """
+ Wrap a fit function so it ignores the engine's dummy x and evaluates
+ on ``real_x`` instead, flattening the result if needed.
+
+ Parameters
+ ----------
+ fit_function : Callable
+ The user's fit function.
+ real_x : np.ndarray | None, default=None
+ Independent x values to be injected. By default, None.
+ flatten : bool, default=True
+ Should the result be a flat 1D array? By default, True.
+
+ Returns
+ -------
+ Callable
+ Wrapped optimizer function.
+ """
+
+ @functools.wraps(fit_function)
+ def wrapped_fit_function(x, **kwargs):
+ if real_x is not None:
+ x = real_x
+ dependent = fit_function(x, **kwargs)
+ if flatten:
+ dependent = dependent.flatten()
+ return dependent
+
+ return wrapped_fit_function
+
+
+def inject_x_multi(
+ fit_functions: list[Callable],
+ real_x: list[np.ndarray],
+ dims: list[tuple[int, ...]],
+ flatten: bool = True,
+) -> Callable:
+ """
+ Wrap one fit function per dataset into a single function whose output
+ is the concatenation of the per-dataset outputs.
+
+ Parameters
+ ----------
+ fit_functions : list[Callable]
+ One fit function per dataset.
+ real_x : list[np.ndarray]
+ One independent x array per dataset, injected into the matching
+ fit function.
+ dims : list[tuple[int, ...]]
+ Per-dataset dependent (``y``) shapes used to slice the combined
+ output, as returned by ``reshape_datasets``.
+ flatten : bool, default=True
+ Should each per-dataset result be flattened? By default, True.
+
+ Returns
+ -------
+ Callable
+ Wrapped optimizer function.
+ """
+ wrapped_fns = [
+ inject_x(this_fun, this_x, flatten=flatten)
+ for this_x, this_fun in zip(real_x, fit_functions)
+ ]
+
+ def wrapped_fun(x, **kwargs):
+ # Generate an empty Y based on x
+ y = np.zeros_like(x)
+ i = 0
+ # Iterate through wrapped functions, passing the WRONG x, the correct
+ # x was injected in the step above.
+ for wrapped, dim in zip(wrapped_fns, dims):
+ ep = i + np.prod(dim)
+ y[i:ep] = wrapped(x, **kwargs)
+ i = ep
+ return y
+
+ return wrapped_fun
diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py
index d3915115..298e36f7 100644
--- a/src/easyscience/fitting/sampler.py
+++ b/src/easyscience/fitting/sampler.py
@@ -15,7 +15,9 @@
from easyscience import global_object
-from .minimizers.minimizer_base import MINIMIZER_PARAMETER_PREFIX
+from .engine_base import PARAMETER_PREFIX
+from .reshaping import inject_x_multi
+from .reshaping import reshape_datasets
if TYPE_CHECKING: # avoid import cycles; only needed for type hints
from bumps.dream.state import MCMCDraw
@@ -51,7 +53,6 @@ def _data_fingerprint(
def _validate_dataset_arrays(
name: str,
data: np.ndarray | list | tuple,
- allow_none_entries: bool = False,
) -> None:
"""Check that ``data`` (an array or list of arrays) holds numeric,
at-least-1-D, non-empty arrays.
@@ -67,9 +68,6 @@ def _validate_dataset_arrays(
``'weights'``).
data : np.ndarray | list | tuple
A single dataset array, or a list/tuple of dataset arrays.
- allow_none_entries : bool, default=False
- Accept ``None`` entries inside a list (used for per-dataset
- optional weights).
Raises
------
@@ -81,8 +79,6 @@ def _validate_dataset_arrays(
is_multi = isinstance(data, (list, tuple))
for i, entry in enumerate(data if is_multi else [data]):
label = f'{name}[{i}]' if is_multi else name
- if entry is None and allow_none_entries:
- continue
try:
arr = np.asarray(entry)
except Exception as exc:
@@ -108,8 +104,6 @@ def _copy_data(data):
in-place mutation of the copies, so the chain and the ``save()``
fingerprint always describe the data actually sampled.
"""
- if data is None:
- return None
if isinstance(data, (list, tuple)):
return [_copy_data(d) for d in data]
arr = np.array(data, copy=True)
@@ -173,7 +167,7 @@ def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[s
"""Reload a DREAM chain state saved by ``Sampler.save``.
This is the standalone reader: unlike ``Sampler.load_state`` it needs no
- fitter, model or data, so a saved chain can be inspected or post-processed
+ model or data, so a saved chain can be inspected or post-processed
on a machine that does not have the model. Parameter names are restored
from the sidecar when available (schema versions 1 and 2), falling back to
the state's labels with the minimizer prefix stripped.
@@ -218,9 +212,7 @@ def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[s
# save_state/load_state does not preserve labels, so a reloaded state
# typically carries default labels like ['P0', 'P1', ...].
param_names = [
- lbl[len(MINIMIZER_PARAMETER_PREFIX) :]
- if lbl.startswith(MINIMIZER_PARAMETER_PREFIX)
- else lbl
+ lbl[len(PARAMETER_PREFIX) :] if lbl.startswith(PARAMETER_PREFIX) else lbl
for lbl in state.labels
]
@@ -251,19 +243,9 @@ class SamplingResults:
logp: np.ndarray
state: MCMCDraw
- def to_legacy_dict(self) -> dict:
- """Return the legacy dict shape produced by the deprecated
- ``mcmc_sample()`` APIs."""
- return {
- 'draws': self.draws,
- 'param_names': self.param_names,
- 'internal_bumps_object': self.state,
- 'logp': self.logp,
- }
-
class Sampler:
- """Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS minimizer.
+ """Bayesian MCMC sampler for one dataset, backed by the BUMPS DREAM engine.
One ``Sampler`` instance represents one chain over one ``(x, y, weights)``
dataset. The data is bound at construction; ``sample()`` and ``extend()``
@@ -274,32 +256,27 @@ class Sampler:
effect on the sampler, and there are deliberately no setters: to sample
different data, create a new ``Sampler``.
- Construct directly with a configured ``Fitter`` (or ``MultiFitter``) whose
- minimizer has been switched to ``AvailableMinimizers.Bumps``. **Running a
- fit first is not required** β the ``Fitter`` supplies the model and the
- minimizer, not a fit result, and sampling from the initial parameter values
- works fine.
-
- It is often worth fitting first anyway. DREAM seeds its whole starting
- population inside a tiny ball around the parameters' *current* values
- (BUMPS' default ``init='eps'``), so sampling from fitted values starts the
- chain in the right region and shortens the burn-in needed to reach the
- typical set. From a poor initial guess, expect to burn for longer.
-
- The sampler is BUMPS/DREAM-specific for now: the BUMPS check in ``_run()``
- is the seam where another backend would plug in.
+ ``Sampler`` is a parallel entry point to ``Fitter``: it takes the same
+ ``(fit_object, fit_function)`` pair plus the data.
Parameters
----------
- fitter : Fitter
- A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has been
- switched to ``AvailableMinimizers.Bumps``.
+ fit_object : object
+ The EasyScience model object holding the ``Parameter`` instances to
+ sample. For multiple datasets this is one object exposing all the
+ parameters (for example an ``EasyList`` of models, or the
+ ``fit_object`` of a ``MultiFitter``).
+ fit_function : Callable | list[Callable]
+ The model function, or one per dataset when ``x``, ``y`` and
+ ``weights`` are lists of arrays.
x : np.ndarray | list[np.ndarray]
- Independent variable array (or list of arrays for ``MultiFitter``).
+ Independent variable array (or list of arrays, one per dataset).
y : np.ndarray | list[np.ndarray]
- Dependent variable array (or list of arrays for ``MultiFitter``).
- weights : np.ndarray | list[np.ndarray | None] | None, default=None
- Weight array (or list of arrays for ``MultiFitter``).
+ Dependent variable array (or list of arrays, one per dataset).
+ weights : np.ndarray | list[np.ndarray]
+ Weight array (or list of arrays, one per dataset). Required:
+ sampling has no default weighting, so a missing weight array is
+ rejected here rather than deep inside the sampling engine.
vectorized : bool, default=False
When ``True``, each x array may be multi-dimensional (e.g. an
``(N, M, 2)`` grid for a 2D model) and is left as-is.
@@ -311,14 +288,14 @@ class Sampler:
Raises
------
TypeError
- If ``fitter`` is not Fitter-shaped (no ``minimizer``/``fit_function``),
- if any dataset in ``x``/``y``/``weights`` is not a numeric array
- (e.g. a string), or ``vectorized``/``sampler_kwargs`` have the wrong
- type.
+ If ``fit_object`` has no ``get_free_parameters``, ``fit_function``
+ is not callable, any dataset in ``x``/``y``/``weights`` is not a
+ numeric array (e.g. a string), or ``vectorized``/``sampler_kwargs``
+ have the wrong type.
ValueError
- If ``x``, ``y`` and ``weights`` do not hold matching structures
- (all arrays, or lists of the same length), or any dataset is a
- scalar or empty array.
+ If ``fit_function``, ``x``, ``y`` and ``weights`` do not hold
+ matching structures (all single, or lists of the same length), or
+ any dataset is a scalar or empty array.
Notes
-----
@@ -351,7 +328,12 @@ class Sampler:
the whole chain::
sampler = Sampler(
- fitter, x, y, weights=w, sampler_kwargs={'trim': False}
+ parameter_container,
+ model_function,
+ x,
+ y,
+ weights=w,
+ sampler_kwargs={'trim': False},
)
Note also that trimming does not survive a ``save()``/``load_state()``
@@ -362,76 +344,149 @@ class Sampler:
def __init__(
self,
- fitter: 'Fitter',
+ fit_object: object,
+ fit_function: Callable | list[Callable],
x: np.ndarray | list[np.ndarray],
y: np.ndarray | list[np.ndarray],
- weights: np.ndarray | list[np.ndarray | None] | None = None,
+ weights: np.ndarray | list[np.ndarray],
vectorized: bool = False,
sampler_kwargs: dict | None = None,
):
- if not (hasattr(fitter, 'minimizer') and hasattr(fitter, 'fit_function')):
- raise TypeError(
- f'fitter must be a configured Fitter or MultiFitter, got {type(fitter).__name__}.'
- )
- x_is_multi = isinstance(x, (list, tuple))
- if x_is_multi != isinstance(y, (list, tuple)):
+ is_multi = isinstance(x, (list, tuple))
+ if is_multi != isinstance(y, (list, tuple)):
raise ValueError('x and y must either both be arrays or both be lists of arrays.')
- if x_is_multi and len(x) != len(y):
+ if is_multi and len(x) != len(y):
raise ValueError(
f'x and y must hold the same number of datasets, got {len(x)} and {len(y)}.'
)
- if weights is not None:
- if isinstance(weights, (list, tuple)) != x_is_multi:
- raise ValueError(
- 'weights must match the structure of x and y (array or list of arrays).'
- )
- if x_is_multi and len(weights) != len(x):
- raise ValueError(
- f'weights must hold the same number of datasets as x and y, '
- f'got {len(weights)} and {len(x)}.'
- )
+ if isinstance(weights, (list, tuple)) != is_multi:
+ raise ValueError(
+ 'weights must match the structure of x and y (array or list of arrays).'
+ )
+ if is_multi and len(weights) != len(x):
+ raise ValueError(
+ f'weights must hold the same number of datasets as x and y, '
+ f'got {len(weights)} and {len(x)}.'
+ )
_validate_dataset_arrays('x', x)
_validate_dataset_arrays('y', y)
- if weights is not None:
- _validate_dataset_arrays('weights', weights, allow_none_entries=True)
+ _validate_dataset_arrays('weights', weights)
+ if isinstance(fit_function, (list, tuple)) != is_multi:
+ raise ValueError(
+ 'fit_function must be a list of callables when x, y and weights are '
+ 'lists of arrays, and a single callable otherwise.'
+ )
+ if is_multi and len(fit_function) != len(x):
+ raise ValueError(
+ f'fit_function must hold one callable per dataset, '
+ f'got {len(fit_function)} for {len(x)} datasets.'
+ )
+ fit_functions = list(fit_function) if is_multi else [fit_function]
+ if not all(callable(f) for f in fit_functions):
+ raise TypeError('fit_function must be callable (or a list of callables).')
+ if not hasattr(fit_object, 'get_free_parameters'):
+ raise TypeError(
+ f'fit_object must be an EasyScience model object exposing the parameters '
+ f'to sample, got {type(fit_object).__name__}.'
+ )
if not isinstance(vectorized, bool):
raise TypeError(f'vectorized must be a bool, got {type(vectorized).__name__}.')
if sampler_kwargs is not None and not isinstance(sampler_kwargs, dict):
raise TypeError(
f'sampler_kwargs must be a dict or None, got {type(sampler_kwargs).__name__}.'
)
- self._fitter = fitter
- # Defensive copies, exposed read-only: mutating the caller's arrays
- # (or the properties) cannot desynchronise the chain and the save()
- # fingerprint from the data actually sampled. To sample different
- # data, create a new Sampler.
- self._x = _copy_data(x)
- self._y = _copy_data(y)
- self._weights = _copy_data(weights)
+ self._is_multi = is_multi
+ self._fit_object = fit_object
+ self._fit_functions = fit_functions
+
+ self._x = _copy_data(x if is_multi else [x])
+ self._y = _copy_data(y if is_multi else [y])
+ self._weights = _copy_data(weights if is_multi else [weights])
self._vectorized = vectorized
self._default_sampler_kwargs = dict(sampler_kwargs or {})
self._state: MCMCDraw | None = None # current chain state
self._results: SamplingResults | None = None
+ @classmethod
+ def from_fitter(
+ cls,
+ fitter: Fitter,
+ x: np.ndarray | list[np.ndarray],
+ y: np.ndarray | list[np.ndarray],
+ weights: np.ndarray | list[np.ndarray],
+ vectorized: bool = False,
+ sampler_kwargs: dict | None = None,
+ ) -> Sampler:
+ """Build a ``Sampler`` from the model bound to an existing ``Fitter``.
+
+ A convenience for the common fit-then-sample workflow: the sampler
+ takes the fitter's ``fit_object`` and fit function(s) and is
+ otherwise identical to one constructed directly. The fitter is not
+ retained or modified. A ``MultiFitter`` yields a multi-dataset
+ sampler, so ``x``, ``y`` and ``weights`` must then be lists of arrays.
+
+ Parameters
+ ----------
+ fitter : Fitter
+ A configured ``Fitter`` or ``MultiFitter``. It does not need to
+ have been fitted.
+ x : np.ndarray | list[np.ndarray]
+ Independent variable array (or list of arrays, one per dataset).
+ y : np.ndarray | list[np.ndarray]
+ Dependent variable array (or list of arrays, one per dataset).
+ weights : np.ndarray | list[np.ndarray]
+ Weight array (or list of arrays, one per dataset).
+ vectorized : bool, default=False
+ See ``Sampler``.
+ sampler_kwargs : dict | None, default=None
+ See ``Sampler``.
+
+ Returns
+ -------
+ Sampler
+ A sampler bound to the fitter's model and the given data.
+ """
+ # A MultiFitter exposes one function per dataset as ``fit_functions``;
+ # a plain Fitter has a single ``fit_function``.
+ fit_function = getattr(fitter, 'fit_functions', None) or fitter.fit_function
+ return cls(
+ fitter.fit_object,
+ fit_function,
+ x,
+ y,
+ weights,
+ vectorized=vectorized,
+ sampler_kwargs=sampler_kwargs,
+ )
+
+ def _single_or_list(self, data: list):
+ """Return bound data the way it was passed in: one item or a list copy."""
+ return list(data) if self._is_multi else data[0]
+
@property
- def fitter(self) -> Fitter:
- """The Fitter supplying the model and minimizer (read-only)."""
- return self._fitter
+ def fit_object(self) -> object:
+ """The EasyScience model object holding the sampled parameters (read-only)."""
+ return self._fit_object
+
+ @property
+ def fit_function(self) -> Callable | list[Callable]:
+ """The model function, or list of them for multiple datasets (read-only)."""
+ return self._single_or_list(self._fit_functions)
@property
def x(self) -> np.ndarray | list[np.ndarray]:
"""The bound independent variable data (read-only copy)."""
- return list(self._x) if isinstance(self._x, list) else self._x
+ return self._single_or_list(self._x)
@property
def y(self) -> np.ndarray | list[np.ndarray]:
"""The bound dependent variable data (read-only copy)."""
- return list(self._y) if isinstance(self._y, list) else self._y
+ return self._single_or_list(self._y)
@property
- def weights(self) -> np.ndarray | list[np.ndarray | None] | None:
- """The bound weight data (read-only copy, or None)."""
- return list(self._weights) if isinstance(self._weights, list) else self._weights
+ def weights(self) -> np.ndarray | list[np.ndarray]:
+ """The bound weight data (read-only copy)."""
+ return self._single_or_list(self._weights)
@property
def state(self) -> MCMCDraw | None:
@@ -460,15 +515,7 @@ def logp(self) -> np.ndarray | None:
def _fingerprint(self) -> str | None:
"""SHA-256 fingerprint of the bound (x, y, weights) data, or None."""
- x_list = list(self._x) if isinstance(self._x, (list, tuple)) else [self._x]
- y_list = list(self._y) if isinstance(self._y, (list, tuple)) else [self._y]
- if self._weights is None:
- w_list = []
- elif isinstance(self._weights, (list, tuple)):
- w_list = [w for w in self._weights if w is not None]
- else:
- w_list = [self._weights]
- return _data_fingerprint(x_list, y_list, w_list)
+ return _data_fingerprint(self._x, self._y, self._weights)
def _run(
self,
@@ -478,52 +525,46 @@ def _run(
population: int | None,
resume_state: MCMCDraw | None,
sampler_kwargs: dict | None,
- progress_callback: Callable[[dict], bool | None] | None,
+ progress_callback: Callable[[dict], None] | None,
abort_test: Callable[[], bool] | None,
) -> SamplingResults:
"""Shared sampling engine for ``sample()`` and ``extend()``.
Argument validation for ``samples``/``burn``/``thin`` lives in
- ``Bumps.mcmc_sample`` (single source of truth).
+ ``DreamSampler.run``.
"""
- # Check the minimizer is BUMPS *before* mutating the fitter β a
- # non-BUMPS fitter must not be needlessly rebuilt.
- minimizer = self._fitter.minimizer
- if not (hasattr(minimizer, 'package') and minimizer.package == 'bumps'):
+ from .minimizers.minimizers import bumps_engine_available
+
+ if not bumps_engine_available:
raise RuntimeError(
- 'Bayesian sampling requires a BUMPS minimizer. '
- 'Use ``fitter.switch_minimizer(AvailableMinimizers.Bumps)`` first.'
+ 'Bayesian sampling requires the bumps package. '
+ 'Install it with ``pip install bumps``.'
)
+ from .samplers.sampler_bumps import DreamSampler
- x_fit, x_new, y_new, w_new, dims = self._fitter._precompute_reshaping(
+ x_fit, x_new, y_new, w_new, dims = reshape_datasets(
self._x, self._y, self._weights, self._vectorized
)
- self._fitter._dependent_dims = dims
- wrapped = self._fitter._fit_function_wrapper(x_new, flatten=True)
+ wrapped = inject_x_multi(self._fit_functions, x_new, dims)
merged_kwargs = {**self._default_sampler_kwargs, **(sampler_kwargs or {})}
- original_fit_func = self._fitter.fit_function
- # Assigning fit_function triggers _update_minimizer() and *rebuilds*
- # the minimizer object β it must be re-fetched after this assignment.
- self._fitter.fit_function = wrapped
- try:
- minimizer = self._fitter.minimizer
- result = minimizer.mcmc_sample(
- x=x_fit,
- y=y_new,
- weights=w_new,
- samples=samples,
- burn=burn,
- thin=thin,
- population=population,
- resume_state=resume_state,
- sampler_kwargs=merged_kwargs or None,
- progress_callback=progress_callback,
- abort_test=abort_test,
- )
- finally:
- self._fitter.fit_function = original_fit_func
+ # This is where a sampler factory would plug in once there
+ # is more than one backend.
+ engine = DreamSampler(obj=self._fit_object, fit_function=wrapped)
+ result = engine.run(
+ x=x_fit,
+ y=y_new,
+ weights=w_new,
+ samples=samples,
+ burn=burn,
+ thin=thin,
+ population=population,
+ resume_state=resume_state,
+ sampler_kwargs=merged_kwargs or None,
+ progress_callback=progress_callback,
+ abort_test=abort_test,
+ )
results = SamplingResults(
draws=result['draws'],
@@ -542,7 +583,7 @@ def sample(
thin: int = 10,
population: int | None = None,
sampler_kwargs: dict | None = None,
- progress_callback: Callable[[dict], bool | None] | None = None,
+ progress_callback: Callable[[dict], None] | None = None,
abort_test: Callable[[], bool] | None = None,
) -> SamplingResults:
"""Run fresh Bayesian MCMC sampling on the bound data.
@@ -571,9 +612,10 @@ def sample(
sampler_kwargs : dict | None, default=None
Additional keyword arguments forwarded to the BUMPS DREAM
sampler (merged over the instance defaults).
- progress_callback : Callable[[dict], bool | None] | None, default=None
+ progress_callback : Callable[[dict], None] | None, default=None
Optional callback invoked at each DREAM generation. The payload
- dict includes ``iteration`` and ``sampling: True``.
+ dict includes ``iteration`` and ``sampling: True``. Any return
+ value is ignored.
abort_test : Callable[[], bool] | None, default=None
Optional callable that returns ``True`` to abort sampling early.
@@ -591,7 +633,7 @@ def sample(
Exceptions propagate from the sampling engine: ``ValueError`` if
``samples``, ``burn``, or ``thin`` are invalid, and ``RuntimeError``
- if the active minimizer is not a BUMPS instance.
+ if the ``bumps`` package is not installed.
"""
if self._state is not None:
global_object.log.getLogger('fitting').warning(
@@ -615,7 +657,7 @@ def extend(
thin: int = 10,
total_samples: int | None = None,
sampler_kwargs: dict | None = None,
- progress_callback: Callable[[dict], bool | None] | None = None,
+ progress_callback: Callable[[dict], None] | None = None,
abort_test: Callable[[], bool] | None = None,
) -> SamplingResults:
"""Continue the existing chain with additional samples.
@@ -646,8 +688,9 @@ def extend(
sampler_kwargs : dict | None, default=None
Additional keyword arguments forwarded to the BUMPS DREAM
sampler (merged over the instance defaults).
- progress_callback : Callable[[dict], bool | None] | None, default=None
- Optional callback invoked at each DREAM generation.
+ progress_callback : Callable[[dict], None] | None, default=None
+ Optional callback invoked at each DREAM generation. Any return
+ value is ignored.
abort_test : Callable[[], bool] | None, default=None
Optional callable that returns ``True`` to abort sampling early.
@@ -660,7 +703,8 @@ def extend(
------
RuntimeError
If there is no chain to extend (call ``sample()`` or
- ``load_state()`` first), or the minimizer is not BUMPS.
+ ``load_state()`` first), or the ``bumps`` package is not
+ installed.
Notes
-----
@@ -705,7 +749,7 @@ def save(self, path: str | os.PathLike) -> None:
``.params.json`` sidecar with the parameter names, the
easyscience version, and a fingerprint of the bound data (verified
with a warning on ``load_state()``). Use ``load_chain`` to read the
- files back without a fitter.
+ files back without the model.
Parameters
----------
@@ -748,7 +792,7 @@ def save(self, path: str | os.PathLike) -> None:
def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults:
"""Load a previously saved chain into this sampler.
- The sampler must be constructed with the same fitter and data used to
+ The sampler must be constructed with the same model and data used to
create the chain β ``extend()`` then continues the saved chain. If the
sidecar carries a data fingerprint and it does not match this
sampler's bound data, a warning is logged (extending a chain against
diff --git a/tests/unit/fitting/calculators/__init__.py b/src/easyscience/fitting/samplers/__init__.py
similarity index 60%
rename from tests/unit/fitting/calculators/__init__.py
rename to src/easyscience/fitting/samplers/__init__.py
index b1990ec8..a617b861 100644
--- a/tests/unit/fitting/calculators/__init__.py
+++ b/src/easyscience/fitting/samplers/__init__.py
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
+from .sampler_bumps import DreamSampler
+from .validation import validate_run_settings
diff --git a/src/easyscience/fitting/samplers/sampler_bumps.py b/src/easyscience/fitting/samplers/sampler_bumps.py
new file mode 100644
index 00000000..7190c552
--- /dev/null
+++ b/src/easyscience/fitting/samplers/sampler_bumps.py
@@ -0,0 +1,367 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""The BUMPS DREAM MCMC engine β ``DreamSampler``."""
+
+from __future__ import annotations
+
+import copy
+import math
+from functools import partial
+from typing import TYPE_CHECKING
+from typing import Callable
+
+import numpy as np
+from bumps.fitters import FitDriver
+
+from ..engine_base import PARAMETER_PREFIX
+from ..engine_base import EngineBase
+from ..minimizers.bumps_utils import BumpsProgressMonitor
+from ..minimizers.bumps_utils import build_curve_problem
+from ..minimizers.bumps_utils import parameter_names
+from ..minimizers.bumps_utils import parameter_snapshot
+from ..minimizers.utils import FitError
+from .validation import validate_run_settings
+
+if TYPE_CHECKING:
+ from bumps.dream.state import MCMCDraw
+ from bumps.names import FitProblem
+
+
+class DreamSampler(EngineBase):
+ """
+ BUMPS DREAM MCMC engine. Runs and resumes chains for one
+ ``(obj, fit_function)`` binding.
+
+ This is the minimizer-independent location of Bayesian sampling: it
+ builds its own BUMPS ``FitProblem`` via the shared ``bumps_utils``
+ helpers, so sampling no longer requires the ``Fitter``'s active
+ minimizer to be BUMPS, only an installed ``bumps`` package.
+
+ ``DreamSampler`` is internal. The public entry point is
+ :class:`easyscience.fitting.Sampler`.
+ """
+
+ package = 'bumps'
+
+ def __init__(
+ self,
+ obj: object, #: ModelBase,
+ fit_function: Callable,
+ ): # todo after constraint changes, add type hint: obj: ModelBase # noqa: E501
+ """
+ Initialize the sampling engine.
+
+ Parameters
+ ----------
+ obj : object
+ Object containing the ``Parameter`` instances to sample.
+ fit_function : Callable
+ Callable returning model y values for the supplied x values.
+ """
+ super().__init__(obj=obj, fit_function=fit_function)
+
+ def run(
+ self,
+ x: np.ndarray,
+ y: np.ndarray,
+ weights: np.ndarray,
+ samples: int = 10000,
+ burn: int = 2000,
+ thin: int = 10,
+ population: int | None = None,
+ resume_state: MCMCDraw | None = None,
+ sampler_kwargs: dict | None = None,
+ progress_callback: Callable[[dict], None] | None = None,
+ abort_test: Callable[[], bool] | None = None,
+ ) -> dict:
+ """
+ Run Bayesian MCMC sampling using the BUMPS DREAM sampler.
+
+ Builds a BUMPS ``FitProblem`` from the bound object and fit
+ function and runs the DREAM sampler. This is the engine-level
+ entry point for Bayesian sampling; the public
+ :class:`~easyscience.fitting.Sampler` delegates to this method
+ after flattening its bound data.
+
+ Parameters
+ ----------
+ x : np.ndarray
+ Flattened independent variable array.
+ y : np.ndarray
+ Flattened dependent variable array.
+ weights : np.ndarray
+ Flattened weight array. Sampling has no default weighting,
+ so weights are required; ``Sampler`` enforces this at
+ construction.
+ samples : int, default=10000
+ Number of raw samples to draw across all chains, before thinning.
+ A guaranteed minimum, not an exact count: DREAM advances in
+ blocks of 10 generations (one generation = one draw per chain)
+ and stops at the first block boundary at or past ``samples``.
+ burn : int, default=2000
+ Burn-in generations to discard. BUMPS counts ``burn`` in
+ generations while ``samples`` counts raw draws, so ``burn=500``
+ discards ``500 * n_chains`` raw samples.
+ thin : int, default=10
+ Thinning interval: only every ``thin``-th generation is stored.
+ population : int | None, default=None
+ BUMPS DREAM population count per parameter (number of parallel
+ chains): BUMPS creates ``ceil(population * n_parameters)`` chains.
+ resume_state : MCMCDraw | None, default=None
+ A BUMPS ``MCMCDraw`` state object from a previous ``run()``
+ call. When provided, DREAM **continues** the saved chain
+ instead of starting cold. The population, parameter count,
+ and parameter names must match the current model. Otherwise, a
+ ``ValueError`` is raised.
+
+ ``samples`` must be the **total** number of raw samples, not an
+ increment: to extend an existing chain of ``N`` raw samples by
+ ``M``, pass ``samples=N + M`` (DREAM keeps only the last
+ ``samples`` draws in its buffer). The `Sampler.extend` helper
+ computes this for you.
+
+ ``burn`` is forced to 0 on resume: a previously-converged chain is
+ never re-burned.
+
+ The ``population`` and ``initializer`` parameters
+ have **no effect** when ``resume_state`` is provided. They
+ are determined by the saved state.
+
+ Resuming against *different* data is undefined behaviour (the
+ chain's likelihood changes underneath it).
+ sampler_kwargs : dict | None, default=None
+ Additional keyword arguments forwarded to
+ ``bumps.fitters.fit``.
+ progress_callback : Callable[[dict], None] | None, default=None
+ Optional callback for progress updates during sampling. The
+ payload dict includes ``iteration`` (DREAM generation
+ number) and ``sampling: True``. Any return value is ignored.
+ abort_test : Callable[[], bool] | None, default=None
+ Optional callback that returns ``True`` to signal that
+ sampling should be aborted. Called periodically during the
+ DREAM sampling loop.
+
+ Returns
+ -------
+ dict
+ Dictionary with keys ``'draws'``, ``'param_names'``,
+ ``'internal_bumps_object'``, and ``'logp'``.
+
+ Raises
+ ------
+ ValueError
+ If the input shapes or weights are invalid, if
+ ``progress_callback`` is not callable, or if ``resume_state``
+ is incompatible with the current model (parameter count,
+ names/order, or population mismatch).
+ FitError
+ If DREAM sampling was aborted by the user (via
+ ``abort_test``).
+ Exception
+ Re-raised from DREAM fitting if any unexpected error occurs
+ (parameter values are restored beforehand).
+ """
+ from bumps.fitters import DreamFit
+
+ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights)
+
+ validate_run_settings(samples, burn, thin)
+ self.validate_arrays(x, y, weights)
+
+ # Build the BUMPS Curve model around the engine's wrapped fit function
+ problem, _, _ = build_curve_problem(self, x, y, weights)
+
+ pop = population
+ if resume_state is not None:
+ pop, burn = self._validate_resume_state(problem, resume_state, population, burn)
+
+ # Build DREAM kwargs. Use the resolved ``pop``, not the raw
+ # ``population`` argument. On resume ``pop`` is the negative
+ # absolute chain count that reproduces the saved state's
+ # population, which BUMPS requires to match.
+ dream_kwargs: dict = {'samples': samples, 'burn': burn, 'thin': thin}
+ if pop is not None:
+ dream_kwargs['pop'] = pop
+ if sampler_kwargs:
+ dream_kwargs.update(sampler_kwargs)
+
+ # Build monitors (same pattern as classical Bumps.fit())
+ monitors = []
+ if progress_callback is not None:
+ if not callable(progress_callback):
+ raise ValueError('progress_callback must be callable')
+ # Compute total DREAM steps for progress display (burn + sampling generations).
+ # BUMPS DREAM default population count is 10 when not specified by the user.
+ # A negative ``pop`` (resume) is an absolute chain count.
+ _dream_default_pop = 10
+ pop_val = abs(pop) if pop is not None else _dream_default_pop
+ _total_steps = burn + (samples + pop_val - 1) // pop_val
+ monitors.append(
+ BumpsProgressMonitor(
+ problem,
+ progress_callback,
+ partial(self._build_sample_progress_payload, total_steps=_total_steps),
+ )
+ )
+
+ driver = FitDriver(
+ fitclass=DreamFit,
+ problem=problem,
+ monitors=monitors,
+ abort_test=abort_test if abort_test is not None else (lambda: False),
+ **dream_kwargs,
+ )
+ driver.clip()
+
+ try:
+ fit_kwargs = {}
+ if resume_state is not None:
+ # Defensive copy: BUMPS mutates the state object in-place
+ # (via MCMCDraw.resize(): see bumps/dream/core.py allocate_state)
+ # during resume. Without a copy, the caller's original state
+ # object is silently altered, making it impossible to compare
+ # pre- and post-resume state (shape mismatch).
+ fit_kwargs['fit_state'] = copy.deepcopy(resume_state)
+ x_opt, fx = driver.fit(**fit_kwargs)
+ result_state = getattr(driver.fitter, 'state', None)
+ if result_state is None:
+ raise FitError('Sampling aborted by user')
+ except Exception:
+ self._restore_parameter_values()
+ raise
+
+ _draw = result_state.draw()
+
+ return {
+ 'draws': _draw.points,
+ 'param_names': parameter_names(problem),
+ 'internal_bumps_object': result_state,
+ 'logp': _draw.logp,
+ }
+
+ def _validate_resume_state(
+ self,
+ problem: FitProblem,
+ resume_state: MCMCDraw,
+ population: int | None,
+ burn: int,
+ ) -> tuple[int, int]:
+ """Check that ``resume_state`` is compatible with ``problem`` and
+ resolve the population and burn values to use when resuming.
+
+ Parameters
+ ----------
+ problem : FitProblem
+ The freshly built BUMPS ``FitProblem`` for the current model.
+ resume_state : MCMCDraw
+ The saved chain state to resume from.
+ population : int | None
+ The caller-supplied population scale factor, or ``None``.
+ burn : int
+ The caller-supplied burn-in, ignored (with a warning) on resume.
+
+ Returns
+ -------
+ tuple[int, int]
+ ``(population, burn)`` to pass to DREAM. The population is
+ returned as a **negative** number, which BUMPS'
+ ``initpop.generate`` reads as an absolute chain count, exactly
+ reproducing the saved state's population. ``burn`` is always 0:
+ a previously converged chain is never re-burned.
+
+ Raises
+ ------
+ ValueError
+ If ``resume_state`` is incompatible with the current model
+ (parameter count, names/order, or population mismatch).
+ """
+ from easyscience import global_object
+
+ logger = global_object.log.getLogger('fitting.bumps')
+
+ # Parameter count
+ n_params = len(problem._parameters)
+ if n_params != resume_state.Nvar:
+ raise ValueError(
+ f'resume_state has {resume_state.Nvar} parameters but the current '
+ f'model has {n_params}. The model must have the same '
+ f'number of fitted parameters as when the saved chain was created.'
+ )
+
+ prefix = PARAMETER_PREFIX
+ fresh_names = [(p.name or '')[len(prefix) :] for p in problem._parameters]
+ state_labels = list(resume_state.labels)
+ if state_labels and all(lbl.startswith(prefix) for lbl in state_labels):
+ state_names = [lbl[len(prefix) :] for lbl in state_labels]
+ if fresh_names != state_names:
+ raise ValueError(
+ f'Parameter names/order mismatch between the current model '
+ f'and resume_state.\n'
+ f' Current model : {fresh_names}\n'
+ f' resume_state : {state_names}'
+ )
+ else:
+ logger.warning(
+ 'resume_state does not carry parameter names (it was most '
+ 'likely reloaded from disk, where BUMPS does not preserve '
+ 'labels). Parameter-name validation is skipped; the saved '
+ 'chain is matched to the current model by parameter order. '
+ 'Ensure this is the same model, with parameters in the same '
+ 'order, used to create the chain.'
+ )
+
+ # Population. BUMPS creates ``ceil(population * n_params)`` chains
+ # and requires the resumed state's chain count to match.
+ if population is not None:
+ expected_npop = math.ceil(population * n_params)
+ if expected_npop != resume_state.Npop:
+ raise ValueError(
+ f'Requested population ({population}) would produce '
+ f'{expected_npop} chains but the saved state has '
+ f'{resume_state.Npop} chains. The population cannot '
+ f'be changed on resume.'
+ )
+ if burn > 0:
+ logger.warning(
+ f'burn={burn} ignored on resume: a previously converged '
+ f'chain is not re-burned. Forcing burn=0.'
+ )
+
+ # A negative ``pop`` is read by ``bumps.initpop.generate`` as an
+ # absolute chain count, exactly reproducing the saved population
+ # without having to recover the original scale factor.
+ return -int(resume_state.Npop), 0
+
+ def _build_sample_progress_payload(
+ self,
+ problem: FitProblem,
+ iteration: int,
+ point: np.ndarray | None,
+ nllf: float,
+ total_steps: int,
+ ) -> dict:
+ """
+ Build a progress payload for Bayesian DREAM sampling steps.
+
+ Called by :class:`BumpsProgressMonitor` at each DREAM
+ generation, with ``total_steps`` bound up front by the caller.
+ The payload includes ``sampling: True`` so downstream consumers
+ can distinguish sampling progress from classical fitting
+ progress; the remaining keys match the classical-fit payload
+ built by the minimizers.
+ """
+ # Use the nllf already computed by the sampler to avoid a costly
+ # model re-evaluation, and let BUMPS apply its own chisq scaling.
+ chi2 = float(problem.chisq(nllf=nllf, norm=False))
+ reduced_chi2 = float(problem.chisq(nllf=nllf, norm=True))
+
+ return {
+ 'iteration': iteration,
+ 'chi2': chi2,
+ 'reduced_chi2': reduced_chi2,
+ 'parameter_values': parameter_snapshot(problem, point),
+ 'refresh_plots': False,
+ 'finished': False,
+ 'sampling': True,
+ 'total_steps': total_steps,
+ }
diff --git a/src/easyscience/fitting/samplers/validation.py b/src/easyscience/fitting/samplers/validation.py
new file mode 100644
index 00000000..00fae214
--- /dev/null
+++ b/src/easyscience/fitting/samplers/validation.py
@@ -0,0 +1,33 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Run-settings validation for the DREAM sampler."""
+
+from __future__ import annotations
+
+
+def validate_run_settings(samples: int, burn: int, thin: int) -> None:
+ """Validate the DREAM run settings.
+
+ Parameters
+ ----------
+ samples : int
+ Number of raw samples to draw; must be a positive integer.
+ burn : int
+ Burn-in generations to discard; must be a non-negative integer.
+ thin : int
+ Thinning interval; must be a positive integer.
+
+ Raises
+ ------
+ ValueError
+ If any value is out of range or not an integer.
+ """
+ # bool is a subclass of int, so ``samples=True`` would otherwise pass as
+ # ``samples=1``; these checks are strict (``10.0`` is rejected), so
+ # booleans must be rejected too.
+ if not isinstance(samples, int) or isinstance(samples, bool) or samples <= 0:
+ raise ValueError('samples must be a positive integer.')
+ if not isinstance(burn, int) or isinstance(burn, bool) or burn < 0:
+ raise ValueError('burn must be a non-negative integer.')
+ if not isinstance(thin, int) or isinstance(thin, bool) or thin < 1:
+ raise ValueError('thin must be a positive integer.')
diff --git a/src/easyscience/global_object/__init__.py b/src/easyscience/global_object/__init__.py
index c0934dde..53b783aa 100644
--- a/src/easyscience/global_object/__init__.py
+++ b/src/easyscience/global_object/__init__.py
@@ -2,7 +2,5 @@
# SPDX-License-Identifier: BSD-3-Clause
from .global_object import GlobalObject # noqa: F401
-from .hugger.hugger import ScriptManager # noqa: F401
from .logger import Logger # noqa: F401
-from .map import Map # noqa: F401
-from .undo_redo import UndoStack # noqa: F401
+from .object_registry import ObjectRegistry # noqa: F401
diff --git a/src/easyscience/global_object/global_object.py b/src/easyscience/global_object/global_object.py
index d128fe38..457cbfd1 100644
--- a/src/easyscience/global_object/global_object.py
+++ b/src/easyscience/global_object/global_object.py
@@ -2,9 +2,8 @@
# SPDX-License-Identifier: BSD-3-Clause
from ..utils.classUtils import singleton
-from .hugger.hugger import ScriptManager
from .logger import Logger
-from .map import Map
+from .object_registry import ObjectRegistry
@singleton
@@ -17,8 +16,7 @@ class GlobalObject:
"""
__log = Logger()
- __map = Map()
- __stack = None
+ __registry = ObjectRegistry()
__debug = False
def __init__(self):
@@ -26,22 +24,8 @@ def __init__(self):
self.log: Logger = self.__log
# Debug. Global debugging level
self.debug: bool = self.__debug
- # Stack. This is where the undo/redo operations are stored.
- self.stack = self.__stack
- #
- self.script: ScriptManager = ScriptManager()
- # Map. This is the conduit database between all global object species
- self.map: Map = self.__map
-
- def instantiate_stack(self):
- """
- The undo/redo stack references the collective.
-
- Hence it has to be imported after initialization.
- """
- from easyscience.global_object.undo_redo import UndoStack
-
- self.stack = UndoStack()
+ # Registry. All live EasyScience objects, looked up by unique name
+ self.registry: ObjectRegistry = self.__registry
def generate_unique_name(self, name_prefix: str) -> str:
"""
@@ -59,9 +43,7 @@ def generate_unique_name(self, name_prefix: str) -> str:
str
Generated unique name.
"""
- names_with_prefix = [
- name for name in self.map.vertices() if name.startswith(name_prefix + '_')
- ]
+ names_with_prefix = [name for name in self.registry if name.startswith(name_prefix + '_')]
if names_with_prefix:
name_with_prefix_count = [0]
for name in names_with_prefix:
diff --git a/src/easyscience/global_object/hugger/hugger.py b/src/easyscience/global_object/hugger/hugger.py
deleted file mode 100644
index f4f405a0..00000000
--- a/src/easyscience/global_object/hugger/hugger.py
+++ /dev/null
@@ -1,221 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import inspect
-import sys
-from abc import ABCMeta
-from abc import abstractmethod
-from typing import List
-from typing import Tuple
-
-from ...utils.classUtils import singleton
-
-
-@singleton
-class Store:
- __log = []
- __var_ident = 'var_'
- __ret_ident = 'ret_'
-
- def __init__(self):
- self.log = self.__log # TODO Async problem?
- self.var_ident = self.__var_ident
- self.ret_ident = self.__ret_ident
-
- @staticmethod
- def get_defaults() -> dict:
- return {
- 'log': Store.__log,
- 'create_list': Store.__create_list,
- 'unique_args': Store.__unique_args,
- 'unique_rets': Store.__unique_rets,
- 'var_ident': Store.__var_ident,
- 'ret_ident': Store.__ret_ident,
- }
-
- def append_log(self, log_entry: str):
- self.log.append(log_entry)
-
-
-class ScriptManager:
- def __init__(self, enabled=True):
- self._store = Store()
- self._enabled = enabled
-
- @property
- def enabled(self) -> bool:
- return self._enabled
-
- @enabled.setter
- def enabled(self, value: bool):
- self._enabled = value
-
- def history(self) -> List[str]:
- return self._store.log
-
- def reset_history(self):
- defaults = Store.get_defaults()
- for key, item in defaults.items():
- setattr(self._store, key, item)
-
- def append_log(self, log_entry: str):
- self._store.log.append(log_entry)
-
-
-class Hugger(metaclass=ABCMeta):
- def __init__(self):
- self._store = Store()
-
- @property
- def log(self) -> list:
- return self._store.log
-
- @abstractmethod
- def patch(self):
- pass
-
- @abstractmethod
- def restore(self):
- pass
-
-
-class PatcherFactory(Hugger, metaclass=ABCMeta):
- def __init__(self):
- super().__init__()
-
- @staticmethod
- def is_mutable(arg) -> bool:
- ret = True
- if isinstance(arg, (int, float, complex, str, tuple, frozenset, bytes, property)):
- ret = False
- return ret
-
- @staticmethod
- def _caller_name(skip: int = 2):
- """
- Get a name of a caller in the format module.class.method
- ``skip`` specifies how many levels of stack to skip while
- getting caller name.
-
- skip=1 means "who calls me", skip=2 "who calls my caller" etc.
- An empty string is returned if skipped levels exceed stack
- height
- https://gist.github.com/techtonik/2151727#gistcomment-2333747
- """
-
- def stack_(frame):
- framelist = []
- while frame:
- framelist.append(frame)
- frame = frame.f_back
- return framelist
-
- stack = stack_(sys._getframe(1))
- start = 0 + skip
- if len(stack) < start + 1:
- return ''
- parentframe = stack[start]
-
- name = []
- module = inspect.getmodule(parentframe)
- # `modname` can be None when frame is executed directly in console
- # TODO(techtonik): consider using __main__
- if module:
- name.append(module.__name__)
- # detect classname
- if 'self' in parentframe.f_locals:
- # I don't know any way to detect call from the object method
- # XXX: there seems to be no way to detect static method call - it will
- # be just a function call
- name.append(parentframe.f_locals['self'].__class__.__name__)
- codename = parentframe.f_code.co_name
- if codename != '': # top level usually
- name.append(codename) # function or a method
- del parentframe
- return '.'.join(name)
-
- def _append_args(self, *args, **kwargs):
-
- def check(res):
- return (
- id(res) not in self._store.unique_rets
- and id(res) not in self._store.create_list
- and id(res) not in self._store.unique_args
- )
-
- for arg in args:
- if self.is_mutable(arg) and check(arg):
- self._store.unique_args.append(id(arg))
- for item in kwargs.values():
- if self.is_mutable(item) and check(item):
- self._store.unique_args.append(id(item))
-
- def _append_create(self, obj):
- this_id = id(obj)
- if this_id not in self._store.create_list:
- self._store.create_list.append(this_id)
-
- def _append_result(self, result) -> int:
- ret = 0
-
- def check(res):
- return (
- id(res) not in self._store.unique_rets
- and id(res) not in self._store.create_list
- and id(res) not in self._store.unique_args
- )
-
- if isinstance(result, type(None)):
- return ret
- elif isinstance(result, tuple):
- for res in result:
- # if self.is_mutable(res) and check(res):
- if check(res):
- self._store.unique_rets.append(id(res))
- ret = len(result)
- else:
- # if self.is_mutable(result) and check(result):
- if check(result):
- self._store.unique_rets.append(id(result))
- ret = 1
- return ret
-
- def _append_log(self, log_entry: str):
- self._store.log.append(log_entry)
-
- def __options(self, item) -> Tuple[int, dict]:
- this_id = id(item)
- option = {
- 'create_list': self._store.create_list,
- 'return_list': self._store.unique_rets,
- 'input_list': self._store.unique_args,
- }
- return this_id, option
-
- def _get_position(self, query: str, item) -> int:
- this_id, option = self.__options(item)
- in_list = self._in_list(query, item)
- index = None
- if in_list:
- index = option.get(query).index(this_id)
- return index
-
- def _in_list(self, query: str, item) -> bool:
- this_id, option = self.__options(item)
- return this_id in option.get(query, [])
-
- @staticmethod
- def _get_class_that_defined_method(method_in) -> classmethod:
- if inspect.ismethod(method_in):
- for cls in inspect.getmro(method_in.__self__.__class__):
- if cls.__dict__.get(method_in.__name__) is method_in:
- return cls
- method_in = method_in.__func__ # fallback to __qualname__ parsing
- if inspect.isfunction(method_in):
- class_name = method_in.__qualname__.split('.', 1)[0].rsplit('.', 1)[0]
- try:
- cls = getattr(inspect.getmodule(method_in), class_name)
- except AttributeError:
- cls = method_in.__globals__.get(class_name)
- if isinstance(cls, type):
- return cls
diff --git a/src/easyscience/global_object/hugger/property.py b/src/easyscience/global_object/hugger/property.py
deleted file mode 100644
index e36c52e5..00000000
--- a/src/easyscience/global_object/hugger/property.py
+++ /dev/null
@@ -1,281 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import logging
-import sys
-from functools import wraps
-from typing import Any
-from typing import Callable
-from typing import List
-
-from easyscience import global_object
-
-from .hugger import PatcherFactory
-from .hugger import Store
-
-
-class LoggedProperty(property):
- """
- Pump up python properties.
-
- In this case we can see who has called this property and then do
- something if a criteria is met. In this case if the caller is not a
- member of the ``ObjBase`` class. Note that all high level
- ``EasyScience`` objects should be built from ``ObjBase``.
- """
-
- _global_object = global_object
-
- def __init__(self, *args, get_id=None, my_self=None, test_class=None, **kwargs):
- super(LoggedProperty, self).__init__(*args, **kwargs)
- self._get_id = get_id
- self._my_self = my_self
- self.test_class = test_class
-
- @staticmethod
- def _caller_class(test_class, skip: int = 1):
-
- def stack_(frame):
- frame_list = []
- while frame:
- frame_list.append(frame)
- frame = frame.f_back
- return frame_list
-
- stack: List[Any] = stack_(sys._getframe(1))
- start = 0 + skip
- if len(stack) < start + 1:
- return ''
- parent_frame = stack[start]
- test = False
- if 'self' in parent_frame.f_locals:
- test = issubclass(parent_frame.f_locals['self'].__class__, test_class)
- return test
-
- def __get__(self, instance, owner=None):
- if not global_object.script.enabled:
- return super(LoggedProperty, self).__get__(instance, owner)
- test = self._caller_class(self.test_class)
- res = super(LoggedProperty, self).__get__(instance, owner)
-
- def result_item(item_to_be_resulted):
- if item_to_be_resulted is None:
- return None
- if global_object.map.is_known(item_to_be_resulted):
- global_object.map.change_type(item_to_be_resulted, 'returned')
- else:
- global_object.map.add_vertex(item_to_be_resulted, obj_type='returned')
-
- if not test and self._get_id is not None and self._my_self is not None:
- if not isinstance(res, list):
- result_item(res)
- else:
- for item in res:
- result_item(item)
- Store().append_log(self.makeEntry('get', res))
- if global_object.debug: # noqa: S1006
- logging.getLogger('easyscience.global_object.hugger').debug(
- "I'm %s and %s has been called from the outside!", self._my_self, self._get_id
- )
- return res
-
- def __set__(self, instance, value):
- if not global_object.script.enabled:
- return super().__set__(instance, value)
- test = self._caller_class(self.test_class)
- if not test and self._get_id is not None and self._my_self is not None:
- Store().append_log(self.makeEntry('set', value))
- if global_object.debug: # noqa: S1006
- logging.getLogger('easyscience.global_object.hugger').debug(
- "I'm %s and %s has been set to %s from the outside!",
- self._my_self,
- self._get_id,
- value,
- )
- return super().__set__(instance, value)
-
- def makeEntry(self, log_type, returns, *args, **kwargs) -> str:
- temp = ''
- if returns is None:
- returns = []
- if not isinstance(returns, list):
- returns = [returns]
- if log_type == 'get':
- for var in returns:
- if var.unique_name in global_object.map.returned_objs:
- index = global_object.map.returned_objs.index(var.unique_name)
- temp += f'{Store().var_ident}{index}, '
- if len(returns) > 0:
- temp = temp[:-2]
- temp += ' = '
- if self._my_self.unique_name in global_object.map.created_objs:
- # for edge in route[::-1]:
- index = global_object.map.created_objs.index(self._my_self.unique_name)
- temp += f'{self._my_self.__class__.__name__.lower()}_{index}.{self._get_id}'
- if self._my_self.unique_name in global_object.map.created_internal:
- # We now have to trace....
- route = global_object.map.reverse_route(self._my_self) # noqa: F841
- index = global_object.map.created_internal.index(self._my_self.unique_name)
- temp += f'{self._my_self.__class__.__name__.lower()}_{index}.{self._get_id}'
- elif log_type == 'set':
- if self._my_self.unique_name in global_object.map.created_objs:
- index = global_object.map.created_objs.index(self._my_self.unique_name)
- temp += f'{self._my_self.__class__.__name__.lower()}_{index}.{self._get_id} = '
- args = args[1:]
- for var in args:
- if var.unique_name in global_object.map.argument_objs:
- index = global_object.map.argument_objs.index(var.unique_name)
- temp += f'{Store().var_ident}{index}'
- elif var.unique_name in global_object.map.returned_objs:
- index = global_object.map.returned_objs.index(var.unique_name)
- temp += f'{Store().var_ident}{index}'
- elif var.unique_name in global_object.map.created_objs:
- index = global_object.map.created_objs.index(var.unique_name)
- temp += f'{self._my_self.__class__.__name__.lower()}_{index}'
- else:
- if isinstance(var, str):
- var = '"' + var + '"'
- temp += f'{var}'
- else:
- logging.getLogger('easyscience.global_object.hugger').debug(
- '%s is not implemented yet. Sorry', log_type
- )
- temp += '\n'
- return temp
-
-
-class PropertyHugger(PatcherFactory):
- # Properties are immutable, so need to be set at the parent level. However unlike `FunctionHugger` we can't traverse
- # the stack to get the parent. So, it and it's name has to be set at initialization. Boo!
-
- _global_object = global_object
-
- def __init__(self, klass, prop_name):
- super().__init__()
- self.klass = klass
- if isinstance(prop_name, tuple):
- self.prop_name = prop_name[0]
- self.property = prop_name[1]
- else:
- self.prop_name = prop_name
- self.property = klass.__dict__.get(prop_name)
- self.__patch_ref = {
- 'fget': {'old': self.property.fget, 'patcher': self.patch_get},
- 'fset': {'old': self.property.fset, 'patcher': self.patch_set},
- 'fdel': {'old': self.property.fdel, 'patcher': self.patch_del},
- }
-
- def patch(self):
- option = {}
- for key, item in self.__patch_ref.items():
- func = getattr(self.property, key)
- if func is not None:
- if global_object.debug:
- logging.getLogger('easyscience.global_object.hugger').debug(
- 'Patching property %s.%s', self.klass.__name__, self.prop_name
- )
- patch_function: Callable = item.get('patcher')
- new_func = patch_function(func)
- option[key] = new_func
- setattr(self.klass, self.prop_name, property(**option))
-
- def restore(self):
- if global_object.debug:
- logging.getLogger('easyscience.global_object.hugger').debug(
- 'Restoring property %s.%s', self.klass.__name__, self.prop_name
- )
- setattr(self.klass, self.prop_name, self.property)
-
- def patch_get(self, func: Callable) -> Callable:
-
- @wraps(func)
- def inner(*args, **kwargs):
- if global_object.debug:
- logging.getLogger('easyscience.global_object.hugger').debug(
- '%s.%s has been called with %s, %s',
- self.klass.__name__,
- self.prop_name,
- args[1:],
- kwargs,
- )
- res = func(*args, **kwargs)
- self._append_args(*args, **kwargs)
- self._append_result(res)
- self._append_log(self.makeEntry('get', res, *args, **kwargs))
- return res
-
- return inner
-
- def patch_set(self, func: Callable) -> Callable:
-
- @wraps(func)
- def inner(*args, **kwargs):
- if global_object.debug:
- logging.getLogger('easyscience.global_object.hugger').debug(
- '%s.%s has been set with %s, %s',
- self.klass.__name__,
- self.prop_name,
- args[1:],
- kwargs,
- )
- self._append_args(*args, **kwargs)
- self._append_log(self.makeEntry('set', None, *args, **kwargs))
- return func(*args, **kwargs)
-
- return inner
-
- def patch_del(self, func: Callable) -> Callable:
-
- @wraps(func)
- def inner(*args, **kwargs):
- if global_object.debug:
- logging.getLogger('easyscience.global_object.hugger').debug(
- '%s.%s has been deleted.', self.klass.__name__, self.prop_name
- )
- self._append_log(self.makeEntry('del', None, *args, **kwargs))
- return func(*args, **kwargs)
-
- return inner
-
- def makeEntry(self, log_type, returns, *args, **kwargs) -> str:
- temp = ''
- if returns is None:
- returns = []
- if not isinstance(returns, list):
- returns = [returns]
- if log_type == 'get':
- for var in returns:
- if self._in_list('return_list', var):
- index = self._get_position('return_list', var)
- temp += f'{self._store.var_ident}{index}, '
- if len(returns) > 0:
- temp = temp[:-2]
- temp += ' = '
- if self._in_list('create_list', args[0]):
- index = self._get_position('create_list', args[0])
- temp += f'{self.klass.__name__.lower()}_{index}.{self.prop_name}'
- elif log_type == 'set':
- if self._in_list('create_list', args[0]):
- index = self._get_position('create_list', args[0])
- temp += f'{self.klass.__name__.lower()}_{index}.{self.prop_name} = '
- args = args[1:]
- for var in args:
- if self._in_list('input_list', var):
- index = self._get_position('input_list', var)
- temp += f'{self._store.var_ident}{index}'
- elif self._in_list('return_list', var):
- index = self._get_position('return_list', var)
- temp += f'{self._store.var_ident}{index}'
- elif self._in_list('create_list', var):
- index = self._get_position('create_list', var)
- temp += f'{self.klass.__name__.lower()}_{index}'
- else:
- if isinstance(var, str):
- var = '"' + var + '"'
- temp += f'{var}'
- else:
- logging.getLogger('easyscience.global_object.hugger').debug(
- '%s is not implemented yet. Sorry', log_type
- )
- temp += '\n'
- return temp
diff --git a/src/easyscience/global_object/map.py b/src/easyscience/global_object/map.py
deleted file mode 100644
index c79fb9ac..00000000
--- a/src/easyscience/global_object/map.py
+++ /dev/null
@@ -1,337 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import gc
-import sys
-import weakref
-from typing import List
-from typing import Optional
-
-
-class _EntryList(list):
- def __init__(self, *args, my_type=None, **kwargs):
- super(_EntryList, self).__init__(*args, **kwargs)
- self.__known_types = {'argument', 'created', 'created_internal', 'returned'}
- self.finalizer = None
- self._type = []
- if my_type in self.__known_types:
- self._type.append(my_type)
-
- def __repr__(self) -> str:
- s = 'Map entry of type: '
- if self._type:
- s += ', '.join(self._type)
- else:
- s += 'Undefined'
- if self.finalizer is None:
- s += '. Without a finalizer.'
- else:
- s += '. With a finalizer.'
- return s
-
- def __delitem__(self, key):
- super(_EntryList, self).__delitem__(key)
-
- def remove_type(self, old_type: str):
- if old_type in self.__known_types and old_type in self._type:
- self._type.remove(old_type)
-
- def reset_type(self, default_type: str = None):
- self._type = []
- self.type = default_type
-
- @property
- def type(self) -> List[str]:
- return self._type
-
- @type.setter
- def type(self, value: str):
- if value in self.__known_types and value not in self._type:
- self._type.append(value)
-
- @property
- def is_argument(self) -> bool:
- return 'argument' in self._type
-
- @property
- def is_created(self) -> bool:
- return 'created' in self._type
-
- @property
- def is_created_internal(self) -> bool:
- return 'created_internal' in self._type
-
- @property
- def is_returned(self) -> bool:
- return 'returned' in self._type
-
-
-class Map:
- def __init__(self):
- # A dictionary of object names and their corresponding objects
- self._store = weakref.WeakValueDictionary()
- # A dict with object names as keys and a list of their object types as values, with weak references
- self.__type_dict = {}
-
- def _snapshot_items(self):
- """
- Return a stable snapshot of __type_dict items.
-
- Some callers iterate over __type_dict while other threads or
- weakref finalizers may modify it. Creating a list snapshot (with
- a retry loop) prevents RuntimeError: dictionary changed size
- during iteration.
- """
- while True:
- try:
- return list(self.__type_dict.items())
- except RuntimeError:
- # Dict changed during snapshot creation, retry
- continue
-
- def vertices(self) -> List[str]:
- """
- Returns the vertices of a map.
-
- Uses a retry loop to handle RuntimeError that can occur when the
- WeakValueDictionary is modified during iteration (e.g., by
- garbage collection).
- """
- while True:
- try:
- return list(self._store)
- except RuntimeError:
- # Dictionary changed size during iteration, retry
- continue
-
- def edges(self):
- """Returns the edges of a map."""
- return self.__generate_edges()
-
- @property
- def argument_objs(self) -> List[str]:
- return self._nested_get('argument')
-
- @property
- def created_objs(self) -> List[str]:
- return self._nested_get('created')
-
- @property
- def created_internal(self) -> List[str]:
- return self._nested_get('created_internal')
-
- @property
- def returned_objs(self) -> List[str]:
- return self._nested_get('returned')
-
- def _nested_get(self, obj_type: str) -> List[str]:
- """Access a nested object in root by key sequence."""
- # Create a stable snapshot of the dict items to avoid RuntimeError
- # when the dict is modified during iteration (e.g., by finalizers).
- while True:
- try:
- items = self._snapshot_items()
- return [key for key, item in items if obj_type in item.type]
- except RuntimeError:
- # In case the snapshot itself raises (very rare), retry
- continue
-
- def get_item_by_key(self, item_id: str) -> object:
- if item_id in self._store:
- return self._store[item_id]
- raise ValueError('Item not in map.')
-
- def is_known(self, vertex: object) -> bool:
- """
- Check if a vertex is known in the map.
-
- All objects should have a 'unique_name' attribute.
- """
- return vertex.unique_name in self._store
-
- def find_type(self, vertex: object) -> List[str]:
- if self.is_known(vertex):
- return self.__type_dict[vertex.unique_name].type
-
- def reset_type(self, obj, default_type: str):
- if obj.unique_name in self.__type_dict:
- self.__type_dict[obj.unique_name].reset_type(default_type)
-
- def change_type(self, obj, new_type: str):
- if obj.unique_name in self.__type_dict:
- self.__type_dict[obj.unique_name].type = new_type
-
- def add_vertex(self, obj: object, obj_type: str = None):
- name = obj.unique_name
- if name in self._store:
- raise ValueError(f'Object name {name} already exists in the graph.')
- # Clean up stale entry in __type_dict if the weak reference was collected
- # but the finalizer hasn't run yet
- if name in self.__type_dict:
- del self.__type_dict[name]
-
- self._store[name] = obj
-
- entry_list = _EntryList()
- entry_list.finalizer = weakref.finalize(obj, self.prune_type_dict, name)
- entry_list.type = obj_type
- self.__type_dict[name] = entry_list # Add objects type to the list of types
-
- def add_edge(self, start_obj: object, end_obj: object):
- if start_obj.unique_name in self.__type_dict:
- self.__type_dict[start_obj.unique_name].append(end_obj.unique_name)
- else:
- raise AttributeError('Start object not in map.')
-
- def get_edges(self, start_obj) -> List[str]:
- if start_obj.unique_name in self.__type_dict:
- return list(self.__type_dict[start_obj.unique_name])
- else:
- raise AttributeError
-
- def __generate_edges(self) -> list:
- """
- A static method generating the edges of the map.
-
- Edges are represented as sets with one (a loop back to the
- vertex) or two vertices
- """
- edges = []
- # Iterate over a snapshot of items and snapshot neighbour lists to
- # avoid concurrent modification issues.
- for vertex, neighbours in self._snapshot_items():
- neighbours_snapshot = list(neighbours)
- for neighbour in neighbours_snapshot:
- if {neighbour, vertex} not in edges:
- edges.append({vertex, neighbour})
- return edges
-
- def prune_vertex_from_edge(self, parent_obj, child_obj):
- vertex1 = parent_obj.unique_name
- if child_obj is None:
- return
- vertex2 = child_obj.unique_name
-
- if vertex1 in self.__type_dict and vertex2 in self.__type_dict[vertex1]:
- del self.__type_dict[vertex1][self.__type_dict[vertex1].index(vertex2)]
-
- def prune_type_dict(self, key: str):
- if key in self.__type_dict:
- del self.__type_dict[key]
-
- def prune(self, key: str):
- if key in self.__type_dict:
- del self.__type_dict[key]
- if key in self._store:
- del self._store[key]
-
- def find_isolated_vertices(self) -> list:
- """Returns a list of isolated vertices."""
- isolated = []
- for vertex, neighbours in self._snapshot_items():
- if not list(neighbours):
- isolated.append(vertex)
- return isolated
-
- def find_path(self, start_vertex: str, end_vertex: str, path=[]) -> list:
- """Find a path from start_vertex to end_vertex in map."""
-
- graph = self.__type_dict
- path = path + [start_vertex]
- if start_vertex == end_vertex:
- return path
- if start_vertex not in graph:
- return []
- for vertex in graph[start_vertex]:
- if vertex not in path:
- extended_path = self.find_path(vertex, end_vertex, path)
- if extended_path:
- return extended_path
- return []
-
- def find_all_paths(self, start_vertex: str, end_vertex: str, path=[]) -> list:
- """Find all paths from start_vertex to end_vertex in map."""
-
- graph = self.__type_dict
- path = path + [start_vertex]
- if start_vertex == end_vertex:
- return [path]
- if start_vertex not in graph:
- return []
- paths = []
- for vertex in graph[start_vertex]:
- if vertex not in path:
- extended_paths = self.find_all_paths(vertex, end_vertex, path)
- for p in extended_paths:
- paths.append(p)
- return paths
-
- def reverse_route(self, end_vertex: str, start_vertex: Optional[str] = None) -> List:
- """
- In this case we have an object and want to know the connections
- to get to another in reverse.
-
- We might not know the start_object. In which case we follow the
- shortest path to a base vertex.
-
- Parameters
- ----------
- end_vertex : str
- Final vertex in the path.
- start_vertex : Optional[str], default=None
- Starting vertex. If omitted, the shortest reverse route is
- chosen automatically.
-
- Returns
- -------
- List
- Reverse path from ``end_vertex`` back to ``start_vertex``.
- """
- path_length = sys.maxsize
- optimum_path = []
- if start_vertex is None:
- # We now have to find where to begin..... Iterate over a snapshot
- for possible_start, vertices in self._snapshot_items():
- vertices_snapshot = list(vertices)
- if end_vertex in vertices_snapshot:
- temp_path = self.find_path(possible_start, end_vertex)
- if len(temp_path) < path_length:
- path_length = len(temp_path)
- optimum_path = temp_path
- else:
- optimum_path = self.find_path(start_vertex, end_vertex)
- optimum_path.reverse()
- return optimum_path
-
- def is_connected(self, vertices_encountered=None, start_vertex=None) -> bool:
- """Determines if the map is connected."""
- if vertices_encountered is None:
- vertices_encountered = set()
- graph = self.__type_dict
- vertices = list(graph)
- if not start_vertex:
- # chose a vertex from graph as a starting point
- start_vertex = vertices[0]
- vertices_encountered.add(start_vertex)
- if len(vertices_encountered) != len(vertices):
- for vertex in list(graph[start_vertex]):
- if vertex not in vertices_encountered and self.is_connected(
- vertices_encountered, vertex
- ):
- return True
- else:
- return True
- return False
-
- def _clear(self):
- """
- Reset the map to an empty state.
-
- Only to be used for testing
- """
- self._store.clear()
- self.__type_dict.clear()
- gc.collect()
-
- def __repr__(self) -> str:
- return f'Map object of {len(self._store)} vertices.'
diff --git a/src/easyscience/global_object/object_registry.py b/src/easyscience/global_object/object_registry.py
new file mode 100644
index 00000000..1b94d3d3
--- /dev/null
+++ b/src/easyscience/global_object/object_registry.py
@@ -0,0 +1,77 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import weakref
+from typing import Iterator
+
+
+class ObjectRegistry:
+ """Global registry of EasyScience objects, indexed by ``unique_name``.
+
+ Registered objects must have a ``unique_name`` attribute. Values are
+ held weakly, so an entry disappears once nothing else references the
+ object.
+
+ Supports ``name in registry``, ``registry[name]``, ``len(registry)``
+ and iteration over the registered names.
+ """
+
+ def __init__(self):
+ # A dict with object names as keys and a list of their object
+ # types as values, with weak references
+ self._store = weakref.WeakValueDictionary()
+
+ def __getitem__(self, name: str) -> object:
+ return self._store[name]
+
+ def __contains__(self, name: object) -> bool:
+ return name in self._store
+
+ def __iter__(self) -> Iterator[str]:
+ # Iterate over a snapshot, so objects collected mid-loop are safe
+ return iter(list(self._store))
+
+ def __len__(self) -> int:
+ return len(self._store)
+
+ def names(self) -> list[str]:
+ """All registered unique names."""
+ return list(self._store)
+
+ def objects(self) -> list[object]:
+ """All registered objects that are still alive.
+
+ The list holds strong references, so it keeps those objects alive
+ until it is released.
+ """
+ return list(self._store.values())
+
+ def add(self, obj: object) -> None:
+ """Register ``obj`` under its ``unique_name``.
+
+ Parameters
+ ----------
+ obj : object
+ The object to register. It must have a ``unique_name``
+ attribute.
+
+ Raises
+ ------
+ ValueError
+ If the name is already registered.
+ """
+ name = obj.unique_name
+ if name in self._store:
+ raise ValueError(f'Object name {name} already exists in the registry.')
+ self._store[name] = obj
+
+ def discard(self, name: str) -> None:
+ """Remove ``name`` from the registry, if present."""
+ self._store.pop(name, None)
+
+ def clear(self) -> None:
+ """Empty the registry."""
+ self._store.clear()
+
+ def __repr__(self) -> str:
+ return f'ObjectRegistry object of {len(self._store)} entries.'
diff --git a/src/easyscience/global_object/undo_redo.py b/src/easyscience/global_object/undo_redo.py
deleted file mode 100644
index 141fc5e7..00000000
--- a/src/easyscience/global_object/undo_redo.py
+++ /dev/null
@@ -1,472 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import abc
-import functools
-import logging
-from collections import UserDict
-from collections import deque
-from typing import Any
-from typing import Callable
-from typing import Iterable
-from typing import NoReturn
-from typing import TypeVar
-from typing import Union
-
-import numpy as np
-
-# Removed: from easyscience import global_object # Avoid circular import
-
-
-class UndoCommand(metaclass=abc.ABCMeta):
- """The Command interface pattern."""
-
- def __init__(self, obj) -> None:
- self._obj = obj
- self._text = None
-
- @abc.abstractmethod
- def undo(self) -> NoReturn:
- """Undo implementation which should be overwritten."""
-
- @abc.abstractmethod
- def redo(self) -> NoReturn:
- """Redo implementation which should be overwritten."""
-
- @property
- def text(self) -> str:
- return self._text
-
- @text.setter
- def text(self, text: str) -> NoReturn:
- self._text = text
-
-
-T_ = TypeVar('T_', bound=UndoCommand)
-
-
-def dict_stack_deco(func: Callable) -> Callable:
-
- def inner(obj, *args, **kwargs):
- from easyscience import global_object # Local import to avoid circular dependency
-
- # Only do the work to a NotarizedDict.
- if hasattr(obj, '_stack_enabled') and obj._stack_enabled:
- if not kwargs:
- global_object.stack.push(DictStack(obj, *args))
- else:
- global_object.stack.push(DictStackReCreate(obj, **kwargs))
- else:
- func(obj, *args, **kwargs)
-
- return inner
-
-
-class NotarizedDict(UserDict):
- """
- A simple dict drop in for EasyScience group classes.
-
- This is used as it wraps the get/set methods
- """
-
- def __init__(self, **kwargs):
- from easyscience import global_object # Local import to avoid circular dependency
-
- super().__init__(**kwargs)
- self._global_object = global_object
- self._stack_enabled = False
-
- @classmethod
- def _classname(cls):
- # This method just returns the name of the class
- return cls.__name__
-
- @dict_stack_deco
- def __setitem__(self, key, value):
- super(NotarizedDict, self).__setitem__(key, value)
-
- @dict_stack_deco
- def __delitem__(self, key):
- super(NotarizedDict, self).__delitem__(key)
-
- def __repr__(self):
- return f'{self._classname()}({self.data})'
-
- @dict_stack_deco
- def reorder(self, **kwargs):
- self.data = kwargs.copy()
-
-
-class CommandHolder:
- """
- A holder for one or more commands which are added to the stack.
- """
-
- def __init__(self, text: str = None):
- self._commands = deque()
- self._text = text
- self.__index = 0
-
- def append(self, command: T_):
- self._commands.appendleft(command)
-
- def pop(self):
- return self._commands.popleft()
-
- def __iter__(self) -> T_:
- while self.__index < len(self):
- index = self.__index
- self.__index += 1
- yield self._commands[index]
- self.__index = 0
-
- def __len__(self) -> int:
- return len(self._commands)
-
- @property
- def is_macro(self) -> bool:
- return len(self) > 1
-
- @property
- def current(self) -> T_:
- return self._commands[0]
-
- @property
- def text(self) -> str:
- text = ''
- if self._commands:
- text = self._commands[-1].text
- if self._text is not None:
- text = self._text
- return text
-
- @text.setter
- def text(self, text: str):
- self._text = text
-
-
-class UndoStack:
- """Implement a version of QUndoStack without the QT."""
-
- def __init__(self, max_history: Union[int, type(None)] = None):
- self._history = deque(maxlen=max_history)
- self._future = deque(maxlen=max_history)
- self._macro_running = False
- self._command_running = False
- self._max_history = max_history
- self._enabled = False
-
- @property
- def enabled(self) -> bool:
- return self._enabled
-
- @enabled.setter
- def enabled(self, state: bool):
- if self.enabled and self._macro_running:
- self.endMacro()
- self._enabled = state
-
- def force_state(self, state: bool):
- self._enabled = state
-
- @property
- def history(self) -> deque:
- return self._history
-
- @property
- def future(self) -> deque:
- return self._future
-
- def push(self, command: T_) -> NoReturn:
- """Add a command to the history stack."""
- # If we're not enabled, then what are we doing!
- if not self.enabled or self._command_running:
- # Do the command and leave.
- command.redo()
- return
- # If there's a macro add the command to the command holder
- if self._macro_running:
- self.history[0].append(command)
- else:
- # Else create the command holder and add it to the stack
- com = CommandHolder()
- com.append(command)
- self.history.appendleft(com)
- # Actually do the command
- command.redo()
- # Reset the future
- self._future = deque(maxlen=self._max_history)
-
- def pop(self) -> T_:
- """
- !! WARNING - TO BE USED WITH EXTREME CAUTION !! !! THIS IS
- PROBABLY NOT THE FN YOU'RE LOOKING FOR, IT CAN BREAK A LOT OF
- STUFF !! Sometimes you really don't want the last command.
- Remove it from the stack
-
- Returns
- -------
- T_
- None.
- """
- pop_it = self._history.popleft()
- popped = pop_it.pop()
- if len(pop_it) > 0:
- self.history.appendleft(pop_it)
- return popped
-
- def clear(self) -> None: # NoReturn:
- """Remove any commands on the stack and reset the state."""
- self._history = deque(maxlen=self._max_history)
- self._future = deque(maxlen=self._max_history)
- self._macro_running = False
-
- def undo(self) -> NoReturn:
- """Undo the last change to the stack."""
- if self.canUndo():
- # Move the command from the past to the future
- this_command_stack = self._history.popleft()
- self._future.appendleft(this_command_stack)
-
- # Execute all undo commands
- for command in this_command_stack:
- try:
- self._command_running = True
- command.undo()
- except Exception as e:
- logging.getLogger('easyscience.global_object.undo_redo').warning(str(e))
- finally:
- self._command_running = False
-
- def redo(self) -> NoReturn:
- """Redo the last ``undo`` command on the stack."""
- if self.canRedo():
- # Move from the future to the past
- this_command_stack = self._future.popleft()
- self._history.appendleft(this_command_stack)
- # Need to go from right to left
- this_command_stack = list(this_command_stack)
- this_command_stack.reverse()
- for command in this_command_stack:
- try:
- self._command_running = True
- command.redo()
- except Exception as e:
- logging.getLogger('easyscience.global_object.undo_redo').warning(str(e))
- finally:
- self._command_running = False
-
- def beginMacro(self, text: str) -> NoReturn:
- """
- Start a bulk update i.e. multiple commands under one undo/redo
- command.
- """
- if self._macro_running:
- raise AssertionError('Cannot start a macro when one is already running')
- com = CommandHolder(text)
- self.history.appendleft(com)
- self._macro_running = True
-
- def endMacro(self) -> NoReturn:
- """
- End a bulk update i.e. multiple commands under one undo/redo
- command.
- """
- if not self._macro_running:
- raise AssertionError('Cannot end a macro when one is not running')
- self._macro_running = False
-
- def canUndo(self) -> bool:
- """Can the last command be undone?"""
- return len(self._history) > 0 and not self._macro_running
-
- def canRedo(self) -> bool:
- """Can we redo a command?"""
- return len(self._future) > 0 and not self._macro_running
-
- def redoText(self) -> str:
- """Text associated with a redo item."""
- text = ''
- if self.canRedo():
- text = self.future[0].text
- return text
-
- def undoText(self) -> str:
- """Text associated with a undo item."""
- text = ''
- if self.canUndo():
- text = self.history[0].text
- return text
-
-
-class PropertyStack(UndoCommand):
- """Stack operator for when a property setter is wrapped."""
-
- def __init__(self, parent, func: Callable, old_value: Any, new_value: Any, text: str = None):
- # self.setText("Setting {} to {}".format(func.__name__, new_value))
- super().__init__(self)
- self._parent = parent
- self._old_value = old_value
- self._new_value = new_value
- self._set_func = func
- self.text = f'{parent} value changed from {old_value} to {new_value}'
- if text is not None:
- self.text = text
-
- def undo(self) -> NoReturn:
- self._set_func(self._parent, self._old_value)
-
- def redo(self) -> NoReturn:
- self._set_func(self._parent, self._new_value)
-
-
-class FunctionStack(UndoCommand):
- def __init__(self, parent, set_func: Callable, unset_func: Callable, text: str = None):
- super().__init__(self)
- self._parent = parent
- self._old_fn = set_func
- self._new_fn = unset_func
- self.text = f'{parent} called {set_func}'
- if text is not None:
- self.text = text
-
- def undo(self):
- self._new_fn()
-
- def redo(self):
- self._old_fn()
-
-
-class DictStack(UndoCommand):
- def __init__(self, in_dict: NotarizedDict, *args):
- super().__init__(self)
- self._parent = in_dict
-
- self._deletion = False
- self._creation = False
-
- self._key = None
- self._index = None
- self._old_value = None
- self._new_value = None
- self.text = ''
-
- if len(args) == 1:
- # We are deleting
- self._deletion = True
- self._index = list(self._parent.keys()).index(args[0])
- self._old_value = self._parent[args[0]]
- self._key = args[0]
- self.text = f'Deleting {args[0]} from {self._parent}'
- elif len(args) == 2:
- # We are either creating or setting
- self._key = args[0]
- self._new_value = args[1]
- if self._key in self._parent.keys():
- # We are modifying
- self._old_value = self._parent[self._key]
- self.text = f'Setting {self._parent}[{self._key}] from {self._old_value} to {self._new_value}'
- else:
- self._creation = True
- self.text = f'Creating {self._parent}[{self._key}] with value {self._new_value}'
- else:
- raise ValueError
-
- def undo(self) -> NoReturn:
- if self._creation:
- # Now we delete
- self._parent.data.__delitem__(self._key)
- else:
- # Now we create/change value
- if self._index is None:
- self._parent.data.__setitem__(self._key, self._old_value)
- else:
- # This deals with placing an item in a place
- keys = list(self._parent.keys())
- values = list(self._parent.values())
- keys.insert(self._index, self._key)
- values.insert(self._index, self._old_value)
- self._parent.reorder(**{k: v for k, v in zip(keys, values)})
-
- def redo(self) -> NoReturn:
- if self._deletion:
- # Now we delete
- self._parent.data.__delitem__(self._key)
- else:
- self._parent.data.__setitem__(self._key, self._new_value)
-
-
-class DictStackReCreate(UndoCommand):
- def __init__(self, in_dict: NotarizedDict, **kwargs):
- super().__init__(self)
- self._parent = in_dict
- self._old_value = in_dict.data.copy()
- self._new_value = kwargs
- self.text = 'Updating dictionary'
-
- def undo(self) -> NoReturn:
- self._parent.data = self._old_value
-
- def redo(self) -> NoReturn:
- self._parent.data = self._new_value
-
-
-def property_stack(arg: Union[str, Callable], begin_macro: bool = False) -> Callable:
- """
- Decorate a ``property`` setter with undo/redo functionality This
- decorator can be used as:
-
- @property_stack def func() ....
-
- or
-
- @property_stack("This is the undo/redo text) def func() ....
-
- In the latter case the argument is a string which might be
- evaluated. The possible markups for this string are;
-
- ``obj`` - The thing being operated on ``func`` - The function being
- called ``name`` - The name of the function being called.
- ``old_value`` - The pre-set value ``new_value`` - The post-set value
-
- An example would be ``Function {name}: Set from {old_value} to
- {new_value}``
- """
-
- def make_wrapper(func: Callable, name: str, **kwargs) -> Callable:
- def wrapper(obj, *args) -> NoReturn:
- from easyscience import global_object # Local import to avoid circular dependency
-
- old_value = getattr(obj, name)
- new_value = args[0]
- if issubclass(type(old_value), Iterable) or issubclass(type(new_value), Iterable):
- ret = np.all(old_value == new_value)
- else:
- ret = old_value == new_value
- if ret:
- return
-
- if global_object.debug:
- logging.getLogger('easyscience.global_object.undo_redo').debug(
- "I'm %s and have been set from %s to %s!", obj, old_value, new_value
- )
-
- global_object.stack.push(PropertyStack(obj, func, old_value, new_value, **kwargs))
-
- return functools.update_wrapper(wrapper, func)
-
- if isinstance(arg, Callable):
- func = arg
- name = func.__name__
- wrapper = make_wrapper(func, name)
- setattr(wrapper, 'func', func)
- else:
- txt = arg
-
- def wrapper(func: Callable) -> Callable:
- name = func.__name__
- inner_wrapper = make_wrapper(func, name, text=txt.format(**locals()))
- setattr(inner_wrapper, 'func', func)
- return inner_wrapper
-
- return wrapper
diff --git a/src/easyscience/io/__init__.py b/src/easyscience/io/__init__.py
deleted file mode 100644
index 6a53db96..00000000
--- a/src/easyscience/io/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-# SPDX-FileCopyrightText: 2025 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from .serializer_base import SerializerBase
-from .serializer_component import SerializerComponent
-from .serializer_dict import SerializerDict
-
-__all__ = [
- SerializerBase,
- SerializerComponent,
- SerializerDict,
-]
diff --git a/src/easyscience/io/serializer_component.py b/src/easyscience/io/serializer_component.py
deleted file mode 100644
index 10c5f7aa..00000000
--- a/src/easyscience/io/serializer_component.py
+++ /dev/null
@@ -1,123 +0,0 @@
-# SPDX-FileCopyrightText: 2025 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Dict
-from typing import List
-from typing import Optional
-
-from .serializer_dict import SerializerDict
-
-if TYPE_CHECKING:
- from .serializer_base import SerializerBase
-
-
-class SerializerComponent:
- """
- This base class adds the capability of saving and loading
- (encoding/decoding, serializing/deserializing) easyscience objects
- via the ``encode`` and ``decode`` methods. The default encoder is
- ``SerializerDict``, which converts the object to a dictionary.
-
- Shortcuts for dictionary and encoding is also present.
- """
-
- def __deepcopy__(self, memo):
- return self.from_dict(self.as_dict())
-
- def encode(
- self, skip: Optional[List[str]] = None, encoder: Optional[SerializerBase] = None, **kwargs
- ) -> Any:
- """
- Use an encoder to covert an EasyScience object into another
- format. Default is to a dictionary using ``SerializerDict``.
-
- Parameters
- ----------
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming the
- encoded object. By default, None.
- encoder : Optional[SerializerBase], default=None
- The encoder to be used for encoding the data. By default,
- None.
- **kwargs :
- Any additional key word arguments to be passed to the
- encoder.
-
- Returns
- -------
- Any
- Encoded object containing all information to reform an
- EasyScience object.
- """
- if encoder is None:
- encoder = SerializerDict
- encoder_obj = encoder()
- return encoder_obj.encode(self, skip=skip, **kwargs)
-
- @classmethod
- def decode(cls, obj: Any, decoder: Optional[SerializerBase] = None) -> Any:
- """
- Re-create an EasyScience object from the output of an encoder.
- The default decoder is ``SerializerDict``.
-
- Parameters
- ----------
- obj : Any
- Encoded EasyScience object.
- decoder : Optional[SerializerBase], default=None
- Decoder to be used to reform the EasyScience object. By
- default, None.
-
- Returns
- -------
- Any
- Reformed EasyScience object.
- """
-
- if decoder is None:
- decoder = SerializerDict
- return decoder.decode(obj)
-
- def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
- """
- Convert an EasyScience object into a full dictionary using
- ``SerializerDict``. This is a shortcut for
- ```obj.encode(encoder=SerializerDict)```
-
- Parameters
- ----------
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming
- the dictionary. By default, None.
-
- Returns
- -------
- Dict[str, Any]
- Encoded object containing all information to reform an
- EasyScience object.
- """
-
- return self.encode(skip=skip, encoder=SerializerDict)
-
- @classmethod
- def from_dict(cls, obj_dict: Dict[str, Any]) -> Any:
- """
- Re-create an EasyScience object from a full encoded dictionary.
-
- Parameters
- ----------
- obj_dict : Dict[str, Any]
- Dictionary containing the serialized contents (from
- ``SerializerDict``) of an EasyScience object.
-
- Returns
- -------
- Any
- Reformed EasyScience object.
- """
-
- return cls.decode(obj_dict, decoder=SerializerDict)
diff --git a/src/easyscience/io/serializer_dict.py b/src/easyscience/io/serializer_dict.py
deleted file mode 100644
index af6c1e9d..00000000
--- a/src/easyscience/io/serializer_dict.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# SPDX-FileCopyrightText: 2025 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-__author__ = 'https://github.com/materialsvirtuallab/monty/blob/master/monty/json.py'
-__version__ = '3.0.0'
-
-
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Dict
-from typing import List
-from typing import Optional
-
-from .serializer_base import SerializerBase
-
-if TYPE_CHECKING:
- from .serializer_component import SerializerComponent
-
-
-class SerializerDict(SerializerBase):
- """
- This is a serializer that can encode and decode EasyScience objects
- to and from a dictionary.
- """
-
- def encode(
- self,
- obj: SerializerComponent,
- skip: Optional[List[str]] = None,
- full_encode: bool = False,
- **kwargs: Any,
- ) -> Dict[str, Any]:
- """
- Convert an EasyScience object to a dictionary.
-
- Parameters
- ----------
- obj : SerializerComponent
- Object to be encoded.
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming the
- encoded object. By default, None.
- full_encode : bool, default=False
- Should the data also be encoded (default False). By default,
- False.
- **kwargs : Any
- Any additional key word arguments to be passed to the
- encoder.
-
- Returns
- -------
- Dict[str, Any]
- Object encoded to dictionary containing all information to
- reform an EasyScience object.
- """
-
- return self._convert_to_dict(obj, skip=skip, full_encode=full_encode, **kwargs)
-
- @classmethod
- def decode(cls, d: Dict[str, Any]) -> SerializerComponent:
- """
- Re-create an EasyScience object from the dictionary
- representation.
-
- Parameters
- ----------
- d : Dict[str, Any]
- Dict representation of an EasyScience object.
-
- Returns
- -------
- SerializerComponent
- EasyScience object.
- """
-
- return SerializerBase._convert_from_dict(d)
diff --git a/src/easyscience/job/analysis.py b/src/easyscience/job/analysis.py
index 03686714..06defcf2 100644
--- a/src/easyscience/job/analysis.py
+++ b/src/easyscience/job/analysis.py
@@ -3,26 +3,29 @@
from abc import ABCMeta
from abc import abstractmethod
+from typing import Any
+from typing import Optional
import numpy as np
+from ..base_classes.model_base import ModelBase
from ..fitting.minimizers import MinimizerBase
-from ..legacy.obj_base import ObjBase
-class AnalysisBase(ObjBase, metaclass=ABCMeta):
+class AnalysisBase(ModelBase, metaclass=ABCMeta):
"""
This virtual class allows for the creation of technique-specific
Analysis objects.
"""
- def __init__(self, name: str, interface=None, *args, **kwargs):
- super(AnalysisBase, self).__init__(name, *args, **kwargs)
- self.name = name
- self._calculator = None
+ def __init__(
+ self,
+ *,
+ display_name: Optional[str] = None,
+ unique_name: Optional[str] = None,
+ ):
+ super(AnalysisBase, self).__init__(unique_name=unique_name, display_name=display_name)
self._minimizer = None
- self._fitter = None
- self.interface = interface
@abstractmethod
def calculate_theory(self, x: np.ndarray, **kwargs) -> np.ndarray:
@@ -32,17 +35,6 @@ def calculate_theory(self, x: np.ndarray, **kwargs) -> np.ndarray:
def fit(self, x: np.ndarray, y: np.ndarray, e: np.ndarray, **kwargs) -> None:
raise NotImplementedError('fit not implemented')
- @property
- def calculator(self) -> str:
- if self._calculator is None:
- self._calculator = self.interface.current_interface_name
- return self._calculator
-
- @calculator.setter
- def calculator(self, value) -> None:
- # TODO: check if the calculator is available for the given JobType
- self.interface.switch(value, fitter=self._fitter)
-
@property
def minimizer(self) -> MinimizerBase:
return self._minimizer
@@ -53,4 +45,4 @@ def minimizer(self, minimizer: MinimizerBase) -> None:
# required dunder methods
def __str__(self):
- return f'Analysis: {self.name}'
+ return f'Analysis: {self.display_name}'
diff --git a/src/easyscience/job/experiment.py b/src/easyscience/job/experiment.py
index b3e4daa9..7fc85dd6 100644
--- a/src/easyscience/job/experiment.py
+++ b/src/easyscience/job/experiment.py
@@ -1,19 +1,20 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-from ..legacy.obj_base import ObjBase
+from typing import Optional
+from ..base_classes.model_base import ModelBase
-class ExperimentBase(ObjBase):
+
+class ExperimentBase(ModelBase):
"""
This virtual class allows for the creation of technique-specific
Experiment objects.
"""
- def __init__(self, name: str, *args, **kwargs):
- super(ExperimentBase, self).__init__(name, *args, **kwargs)
- self._name = name
+ def __init__(self, display_name: Optional[str] = None, unique_name: Optional[str] = None):
+ super(ExperimentBase, self).__init__(unique_name=unique_name, display_name=display_name)
# required dunder methods
def __str__(self):
- return f'Experiment: {self._name}'
+ return f'Experiment: {self.display_name}'
diff --git a/src/easyscience/job/job.py b/src/easyscience/job/job.py
index bfed8bec..dee825fd 100644
--- a/src/easyscience/job/job.py
+++ b/src/easyscience/job/job.py
@@ -3,22 +3,22 @@
from abc import ABCMeta
from abc import abstractmethod
+from typing import Optional
-from ..legacy.obj_base import ObjBase
+from ..base_classes.model_base import ModelBase
from .analysis import AnalysisBase
from .experiment import ExperimentBase
from .theoreticalmodel import TheoreticalModelBase
-class JobBase(ObjBase, metaclass=ABCMeta):
+class JobBase(ModelBase, metaclass=ABCMeta):
"""
This virtual class allows for the creation of technique-specific Job
objects.
"""
- def __init__(self, name: str, *args, **kwargs):
- super(JobBase, self).__init__(name, *args, **kwargs)
- self.name = name
+ def __init__(self, display_name: Optional[str] = None, unique_name: Optional[str] = None):
+ super(JobBase, self).__init__(unique_name=unique_name, display_name=display_name)
self._theory = None
self._experiment = None
self._analysis = None
diff --git a/src/easyscience/job/theoreticalmodel.py b/src/easyscience/job/theoreticalmodel.py
index 3fa87e50..c255e5cd 100644
--- a/src/easyscience/job/theoreticalmodel.py
+++ b/src/easyscience/job/theoreticalmodel.py
@@ -1,23 +1,20 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-from ..legacy.obj_base import ObjBase
+from typing import Optional
+from ..base_classes.model_base import ModelBase
-class TheoreticalModelBase(ObjBase):
+
+class TheoreticalModelBase(ModelBase):
"""
This virtual class allows for the creation of technique-specific
Theory objects.
"""
- def __init__(self, name: str, *args, **kwargs):
- self._name = name
- super().__init__(name, *args, **kwargs)
+ def __init__(self, display_name: Optional[str] = None, unique_name: Optional[str] = None):
+ super().__init__(unique_name=unique_name, display_name=display_name)
# required dunder methods
def __str__(self):
raise NotImplementedError('Copy not implemented')
-
- def as_dict(self, skip: list = []) -> dict:
- this_dict = super().as_dict(skip=skip)
- return this_dict
diff --git a/src/easyscience/legacy/__init__.py b/src/easyscience/legacy/__init__.py
deleted file mode 100644
index 482abc26..00000000
--- a/src/easyscience/legacy/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from .collection_base import CollectionBase # noqa: F401
-from .obj_base import ObjBase # noqa: F401
-
-__all__ = ['CollectionBase', 'ObjBase']
diff --git a/src/easyscience/legacy/collection_base.py b/src/easyscience/legacy/collection_base.py
deleted file mode 100644
index 5be3386c..00000000
--- a/src/easyscience/legacy/collection_base.py
+++ /dev/null
@@ -1,338 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from collections.abc import MutableSequence
-from numbers import Number
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Callable
-from typing import List
-from typing import Optional
-from typing import Tuple
-from typing import Union
-
-from easyscience import global_object
-from easyscience.base_classes.new_base import NewBase
-from easyscience.global_object.undo_redo import NotarizedDict
-
-from ..base_classes.based_base import BasedBase
-from ..variable.descriptor_base import DescriptorBase
-
-if TYPE_CHECKING:
- from ..fitting.calculators import InterfaceFactoryTemplate
-
-
-class CollectionBase(BasedBase, MutableSequence):
- """
- This is the base class for which all higher level classes are built
- off of.
-
- .. deprecated::
- ``CollectionBase`` is deprecated and will be removed in a future version.
- Please migrate to ``ModelBase`` or ``EasyList`` instead.
-
- NOTE: This object is serializable only if parameters are supplied
- as: ``ObjBase(a=value, b=value)``. For ``Parameter`` or
- ``Descriptor`` objects we can cheat with
- ``ObjBase(*[Descriptor(...), Parameter(...), ...])``.
- """
-
- def __init__(
- self,
- name: str,
- *args: Union[BasedBase, DescriptorBase, NewBase],
- interface: Optional[InterfaceFactoryTemplate] = None,
- unique_name: Optional[str] = None,
- **kwargs: Any,
- ):
- """
- Set up the base collection class.
-
- Parameters
- ----------
- name : str
- Name of this object.
- *args : Union[BasedBase, DescriptorBase, NewBase]
- Initial EasyScience objects to include in the collection.
- interface : Optional[InterfaceFactoryTemplate], default=None
- Interface attached to contained objects. By default, None.
- unique_name : Optional[str], default=None
- Unique identifier for this collection. By default, None.
- **kwargs : Any
- Fields which this class should contain.
-
- Raises
- ------
- AttributeError
- If a provided item is not an EasyScience object or if a
- keyword collides with an internal attribute.
- """
- global_object.log.getLogger('legacy').warning(
- 'CollectionBase is deprecated and will be removed in a future version. '
- 'Please migrate to ModelBase or EasyList.',
- stacklevel=2,
- )
- BasedBase.__init__(self, name, unique_name=unique_name)
- kwargs = {key: kwargs[key] for key in kwargs.keys() if kwargs[key] is not None}
- _args = []
- for item in args:
- if not isinstance(item, list):
- _args.append(item)
- else:
- _args += item
- _kwargs = {}
- for key, item in kwargs.items():
- if isinstance(item, list) and len(item) > 0:
- _args += item
- else:
- _kwargs[key] = item
- kwargs = _kwargs
- for item in list(kwargs.values()) + _args:
- if not issubclass(type(item), (DescriptorBase, BasedBase, NewBase)):
- raise AttributeError('A collection can only be formed from easyscience objects.')
- args = _args
- _kwargs = {}
- for key, item in kwargs.items():
- _kwargs[key] = item
- for arg in args:
- kwargs[arg.unique_name] = arg
- _kwargs[arg.unique_name] = arg
-
- # Set kwargs, also useful for serialization
- self._kwargs = NotarizedDict(**_kwargs)
-
- for key in kwargs.keys():
- if key in self.__dict__.keys() or key in self.__slots__:
- raise AttributeError(
- f'Given kwarg: `{key}`, is an internal attribute. Please rename.'
- )
- if kwargs[key]: # Might be None (empty tuple or list)
- self._global_object.map.add_edge(self, kwargs[key])
- self._global_object.map.reset_type(kwargs[key], 'created_internal')
- if interface is not None:
- kwargs[key].interface = interface
- # TODO wrap getter and setter in Logger
- if interface is not None:
- self.interface = interface
- self._kwargs._stack_enabled = True
-
- def insert(self, index: int, value: Union[DescriptorBase, BasedBase, NewBase]) -> None:
- """
- Insert an object into the collection at an index.
-
- Parameters
- ----------
- index : int
- Index for EasyScience object to be inserted.
- value : Union[DescriptorBase, BasedBase, NewBase]
- Object to be inserted.
-
- Returns
- -------
- None
- None.
-
- Raises
- ------
- AttributeError
- If ``value`` is not an EasyScience object.
- """
- t_ = type(value)
- if issubclass(t_, (BasedBase, DescriptorBase, NewBase)):
- update_key = list(self._kwargs.keys())
- values = list(self._kwargs.values())
- # Update the internal dict
- new_key = value.unique_name
- update_key.insert(index, new_key)
- values.insert(index, value)
- self._kwargs.reorder(**{k: v for k, v in zip(update_key, values)})
- # ADD EDGE
- self._global_object.map.add_edge(self, value)
- self._global_object.map.reset_type(value, 'created_internal')
- value.interface = self.interface
- else:
- raise AttributeError('Only EasyScience objects can be put into an EasyScience group')
-
- def __getitem__(self, idx: Union[int, slice]) -> Union[DescriptorBase, BasedBase, NewBase]:
- """
- Get an item in the collection based on its index.
-
- Parameters
- ----------
- idx : Union[int, slice]
- Index or slice of the collection.
-
- Returns
- -------
- Union[DescriptorBase, BasedBase, NewBase]
- Object at index ``idx``.
-
- Raises
- ------
- IndexError
- If ``idx`` does not resolve to an element.
- TypeError
- If boolean indexing is attempted.
- """
- if isinstance(idx, slice):
- start, stop, step = idx.indices(len(self))
- return self.__class__(
- getattr(self, 'name'), *[self[i] for i in range(start, stop, step)]
- )
- if str(idx) in self._kwargs.keys():
- return self._kwargs[str(idx)]
- if isinstance(idx, str):
- idx = [index for index, item in enumerate(self) if item.name == idx]
- noi = len(idx)
- if noi == 0:
- raise IndexError('Given index does not exist')
- elif noi == 1:
- idx = idx[0]
- else:
- return self.__class__(getattr(self, 'name'), *[self[i] for i in idx])
- elif not isinstance(idx, int) or isinstance(idx, bool):
- if isinstance(idx, bool):
- raise TypeError('Boolean indexing is not supported at the moment')
- try:
- if idx > len(self):
- raise IndexError(f'Given index {idx} is out of bounds')
- except TypeError:
- raise IndexError('Index must be of type `int`/`slice` or an item name (`str`)')
- keys = list(self._kwargs.keys())
- return self._kwargs[keys[idx]]
-
- def __setitem__(self, key: int, value: Union[BasedBase, DescriptorBase, NewBase]) -> None:
- """
- Set an item via it's index.
-
- Parameters
- ----------
- key : int
- Index in self.
- value : Union[BasedBase, DescriptorBase, NewBase]
- Value which index key should be set to.
-
- Raises
- ------
- NotImplementedError
- If ``value`` is neither numeric nor an EasyScience object.
- """
- if isinstance(value, Number): # noqa: S3827
- item = self.__getitem__(key)
- item.value = value
- elif issubclass(type(value), (BasedBase, DescriptorBase, NewBase)):
- update_key = list(self._kwargs.keys())
- values = list(self._kwargs.values())
- old_item = values[key]
- # Update the internal dict
- update_dict = {update_key[key]: value}
- self._kwargs.update(update_dict)
- # ADD EDGE
- self._global_object.map.add_edge(self, value)
- self._global_object.map.reset_type(value, 'created_internal')
- value.interface = self.interface
- # REMOVE EDGE
- self._global_object.map.prune_vertex_from_edge(self, old_item)
- else:
- raise NotImplementedError(
- 'At the moment only numerical values or EasyScience objects can be set.'
- )
-
- def __delitem__(self, key: int) -> None:
- """
- Try to delete an idem by key.
-
- Parameters
- ----------
- key : int
-
- Returns
- -------
- None
- """
- keys = list(self._kwargs.keys())
- item = self._kwargs[keys[key]]
- self._global_object.map.prune_vertex_from_edge(self, item)
- del self._kwargs[keys[key]]
-
- def __len__(self) -> int:
- """
- Get the number of items in this collection.
-
- Returns
- -------
- int
- Number of items in this collection.
- """
- return len(self._kwargs.keys())
-
- def _convert_to_dict(
- self, in_dict: dict, encoder: Any, skip: List[str] = [], **kwargs: Any
- ) -> dict:
- """
- Convert ones self into a serialized form.
-
- Parameters
- ----------
- in_dict : dict
- Dictionary being populated during serialization.
- encoder : Any
- Serializer used to encode child objects.
- skip : List[str], default=[]
- Field names to skip during serialization.
- **kwargs : Any
- Additional keyword arguments passed to the serializer.
-
- Returns
- -------
- dict
- Dictionary of ones self.
- """
- d = {}
- if hasattr(self, '_modify_dict'):
- # any extra keys defined on the inheriting class
- d = self._modify_dict(skip=skip, **kwargs)
- in_dict['data'] = [encoder._convert_to_dict(item, skip=skip, **kwargs) for item in self]
- out_dict = {**in_dict, **d}
- return out_dict
-
- @property
- def data(self) -> Tuple:
- """
- The data function returns a tuple of the keyword arguments
- passed to the constructor. This is useful for when you need to
- pass in a dictionary of data to other functions, such as with
- matplotlib's plot function.
-
- Returns
- -------
- Tuple
- The values of the attributes in a tuple :doc-author:
- Trelent.
- """
- return tuple(self._kwargs.values())
-
- def __repr__(self) -> str:
- return f'{self.__class__.__name__} `{getattr(self, "name")}` of length {len(self)}'
-
- def sort(
- self,
- mapping: Callable[[Union[BasedBase, DescriptorBase, NewBase]], Any],
- reverse: bool = False,
- ) -> None:
- """
- Sort the collection according to the given mapping.
-
- Parameters
- ----------
- mapping : Callable[[Union[BasedBase, DescriptorBase, NewBase]], Any]
- Mapping function to sort the collection. i.e. lambda
- parameter: parameter.value.
- reverse : bool, default=False
- Reverse the sorting. By default, False.
- """
- i = list(self._kwargs.items())
- i.sort(key=lambda x: mapping(x[1]), reverse=reverse)
- self._kwargs.reorder(**{k[0]: k[1] for k in i})
diff --git a/src/easyscience/legacy/dict.py b/src/easyscience/legacy/dict.py
deleted file mode 100644
index 9b36c9f8..00000000
--- a/src/easyscience/legacy/dict.py
+++ /dev/null
@@ -1,179 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-__author__ = 'https://github.com/materialsvirtuallab/monty/blob/master/monty/json.py'
-__version__ = '3.0.0'
-
-
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Dict
-from typing import List
-from typing import Optional
-
-from .template import BaseEncoderDecoder
-
-if TYPE_CHECKING:
- from .component_serializer import ComponentSerializer
-
-_KNOWN_CORE_TYPES = ('Descriptor', 'Parameter')
-
-
-class DictSerializer(BaseEncoderDecoder):
- """
- This is a serializer that can encode and decode EasyScience objects
- to a JSON encoded dictionary.
- """
-
- def encode(
- self,
- obj: ComponentSerializer,
- skip: Optional[List[str]] = None,
- full_encode: bool = False,
- **kwargs: Any,
- ) -> Dict[str, Any]:
- """
- Convert an EasyScience object to a JSON encoded dictionary.
-
- Parameters
- ----------
- obj : ComponentSerializer
- Object to be encoded.
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming the
- encoded object. By default, None.
- full_encode : bool, default=False
- Should the data also be JSON encoded (default False). By
- default, False.
- **kwargs : Any
- Any additional key word arguments to be passed to the
- encoder.
-
- Returns
- -------
- Dict[str, Any]
- Object encoded to dictionary containing all information to
- reform an EasyScience object.
- """
-
- return self._convert_to_dict(obj, skip=skip, full_encode=full_encode, **kwargs)
-
- @classmethod
- def decode(cls, d: Dict[str, Any]) -> ComponentSerializer:
- """
- Decode function.
-
- Parameters
- ----------
- d : Dict[str, Any]
- Dict representation.
-
- Returns
- -------
- ComponentSerializer
- ComponentSerializer class.
- """
-
- return BaseEncoderDecoder._convert_from_dict(d)
-
- @classmethod
- def from_dict(cls, d: Dict[str, Any]) -> ComponentSerializer:
- """
- From dict.
-
- Parameters
- ----------
- d : Dict[str, Any]
- Dict representation.
-
- Returns
- -------
- ComponentSerializer
- ComponentSerializer class.
- """
- return BaseEncoderDecoder._convert_from_dict(d)
-
-
-class DataDictSerializer(DictSerializer):
- """
- This is a serializer that can encode the data in an EasyScience
- object to a JSON encoded dictionary.
- """
-
- def encode(
- self,
- obj: ComponentSerializer,
- skip: Optional[List[str]] = None,
- full_encode: bool = False,
- **kwargs: Any,
- ) -> Dict[str, Any]:
- """
- Convert an EasyScience object to a JSON encoded data dictionary.
-
- Parameters
- ----------
- obj : ComponentSerializer
- Object to be encoded.
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming the
- encoded object. By default, None.
- full_encode : bool, default=False
- Should the data also be JSON encoded (default False). By
- default, False.
- **kwargs : Any
- Any additional key word arguments to be passed to the
- encoder.
-
- Returns
- -------
- Dict[str, Any]
- Object encoded to data dictionary.
-
- Raises
- ------
- ValueError
- If ``skip`` is not a list of strings.
- """
-
- if skip is None:
- skip = []
- elif isinstance(skip, str):
- skip = [skip]
- if not isinstance(skip, list):
- raise ValueError('Skip must be a list of strings.')
- encoded = super().encode(obj, skip=skip, full_encode=full_encode, **kwargs)
- return self._parse_dict(encoded)
-
- @classmethod
- def decode(cls, d: Dict[str, Any]) -> ComponentSerializer:
- """
- This function is not implemented as a data dictionary does not
- contain the necessary information to re-form an EasyScience
- object.
- """
-
- raise NotImplementedError(
- 'It is not possible to reconstitute objects from data only dictionary.'
- )
-
- @staticmethod
- def _parse_dict(in_dict: Dict[str, Any]) -> Dict[str, Any]:
- """Strip out any non-data from a dictionary."""
-
- out_dict = dict()
- for key in in_dict.keys():
- if key[0] == '@':
- if key == '@class' and in_dict[key] not in _KNOWN_CORE_TYPES:
- out_dict['name'] = in_dict[key]
- continue
- out_dict[key] = in_dict[key]
- if isinstance(in_dict[key], dict):
- out_dict[key] = DataDictSerializer._parse_dict(in_dict[key])
- elif isinstance(in_dict[key], list):
- out_dict[key] = [
- DataDictSerializer._parse_dict(x) if isinstance(x, dict) else x
- for x in in_dict[key]
- ]
- return out_dict
diff --git a/src/easyscience/legacy/json.py b/src/easyscience/legacy/json.py
deleted file mode 100644
index 25833fbc..00000000
--- a/src/easyscience/legacy/json.py
+++ /dev/null
@@ -1,139 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-__author__ = 'https://github.com/materialsvirtuallab/monty/blob/master/monty/json.py'
-__version__ = '3.0.0'
-
-
-import json
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Dict
-from typing import List
-
-from .template import BaseEncoderDecoder
-
-if TYPE_CHECKING:
- from .component_serializer import ComponentSerializer
-
-
-class JsonSerializer(BaseEncoderDecoder):
- def encode(self, obj: ComponentSerializer, skip: List[str] = []) -> str:
- """
- Returns a json string representation of the ComponentSerializer
- object.
- """
- ENCODER = type(
- JsonEncoderTemplate.__name__,
- (JsonEncoderTemplate, BaseEncoderDecoder),
- {'skip': skip},
- )
- return json.dumps(obj, cls=ENCODER)
-
- @classmethod
- def decode(cls, data: str) -> ComponentSerializer:
- return json.loads(data, cls=JsonDecoderTemplate)
-
-
-class JsonDataSerializer(BaseEncoderDecoder):
- def encode(self, obj: ComponentSerializer, skip: List[str] = []) -> str:
- """
- Returns a json string representation of the ComponentSerializer
- object.
- """
- from .dict import DataDictSerializer
-
- ENCODER = type(
- JsonEncoderTemplate.__name__,
- (JsonEncoderTemplate, BaseEncoderDecoder),
- {
- 'skip': skip,
- '_converter': lambda *args, **kwargs: DataDictSerializer._parse_dict(
- DataDictSerializer._convert_to_dict(*args, **kwargs)
- ),
- },
- )
-
- return json.dumps(obj, cls=ENCODER)
-
- @classmethod
- def decode(cls, data: str) -> ComponentSerializer:
- raise NotImplementedError(
- 'It is not possible to reconstitute objects from data only objects.'
- )
-
-
-class JsonEncoderTemplate(json.JSONEncoder):
- """
- A Json Encoder which supports the ComponentSerializer API, plus adds
- support for numpy arrays, datetime objects, bson ObjectIds (requires
- bson).
-
- Usage::
-
- # Add it as a *cls* keyword when using json.dump
- json.dumps(object, cls=MontyEncoder)
- """
-
- skip = []
- _converter = BaseEncoderDecoder._convert_to_dict
-
- def default(self, o: Any) -> Dict[str, Any]: # pylint: disable=E0202
- """
- Overriding default method for JSON encoding.
-
- This method does two things: (a) If an object has a to_dict
- property, return the to_dict output. (b) If the @module and
- @class keys are not in the to_dict, add them to the output
- automatically. If the object has no to_dict property, the
- default Python json encoder default method is called.
-
- Parameters
- ----------
- o : Any
- Python object.
-
- Returns
- -------
- Dict[str, Any]
- JSON-serializable dictionary representation of ``o``.
- """
- return self._converter(o, self.skip, full_encode=True)
-
-
-class JsonDecoderTemplate(json.JSONDecoder):
- """
- A Json Decoder which supports the ComponentSerializer API.
-
- By default, the decoder attempts to find a module and name
- associated with a dict. If found, the decoder will generate a
- Pymatgen as a priority. If that fails, the original decoded
- dictionary from the string is returned. Note that nested lists and
- dicts containing pymatgen object will be decoded correctly as well.
-
- Usage:
-
- # Add it as a *cls* keyword when using json.load
- json.loads(json_string, cls=MontyDecoder)
- """
-
- _converter = BaseEncoderDecoder._convert_from_dict
-
- def decode(self, s: str) -> Any:
- """
- Overrides decode from JSONDecoder.
-
- Parameters
- ----------
- s : str
- JSON string representation.
-
- Returns
- -------
- Any
- Decoded object.
- """
- d = json.JSONDecoder.decode(self, s)
- return self.__class__._converter(d)
diff --git a/src/easyscience/legacy/legacy_core.py b/src/easyscience/legacy/legacy_core.py
deleted file mode 100644
index 40a5beac..00000000
--- a/src/easyscience/legacy/legacy_core.py
+++ /dev/null
@@ -1,220 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-import json
-from collections import OrderedDict
-from hashlib import sha1
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Dict
-from typing import List
-from typing import Optional
-
-from ..io.dict import DataDictSerializer
-from ..io.dict import DictSerializer
-from ..io.json import jsanitize
-
-if TYPE_CHECKING:
- from ..io.template import BaseEncoderDecoder
-
-
-class ComponentSerializer:
- """
- This is the base class for all EasyScience objects and deals with
- the data conversion to other formats via the ``encode`` and
- ``decode`` functions.
-
- Shortcuts for dictionary and data dictionary encoding is also
- present.
- """
-
- _CORE = True
-
- def __deepcopy__(self, memo):
- return self.from_dict(self.as_dict())
-
- def encode(
- self,
- skip: Optional[List[str]] = None,
- encoder: Optional[BaseEncoderDecoder] = None,
- **kwargs,
- ) -> Any:
- """
- Use an encoder to covert an EasyScience object into another
- format. Default is to a dictionary using ``DictSerializer``.
-
- Parameters
- ----------
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming the
- encoded object. By default, None.
- encoder : Optional[BaseEncoderDecoder], default=None
- The encoder to be used for encoding the data. By default,
- None.
- **kwargs :
- Any additional key word arguments to be passed to the
- encoder.
-
- Returns
- -------
- Any
- Encoded object containing all information to reform an
- EasyScience object.
- """
- if encoder is None:
- encoder = DictSerializer
- encoder_obj = encoder()
- return encoder_obj.encode(self, skip=skip, **kwargs)
-
- @classmethod
- def decode(cls, obj: Any, decoder: Optional[BaseEncoderDecoder] = None) -> Any:
- """
- Re-create an EasyScience object from the output of an encoder.
- The default decoder is ``DictSerializer``.
-
- Parameters
- ----------
- obj : Any
- Encoded EasyScience object.
- decoder : Optional[BaseEncoderDecoder], default=None
- Decoder to be used to reform the EasyScience object. By
- default, None.
-
- Returns
- -------
- Any
- Reformed EasyScience object.
- """
-
- if decoder is None:
- decoder = DictSerializer
- return decoder.decode(obj)
-
- def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
- """
- Convert an EasyScience object into a full dictionary using
- ``DictSerializer``. This is a shortcut for
- ```obj.encode(encoder=DictSerializer)```
-
- Parameters
- ----------
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming
- the dictionary. By default, None.
-
- Returns
- -------
- Dict[str, Any]
- Encoded object containing all information to reform an
- EasyScience object.
- """
-
- return self.encode(skip=skip, encoder=DictSerializer)
-
- @classmethod
- def from_dict(cls, obj_dict: Dict[str, Any]) -> Any:
- """
- Re-create an EasyScience object from a full encoded dictionary.
-
- Parameters
- ----------
- obj_dict : Dict[str, Any]
- Dictionary containing the serialized contents (from
- ``DictSerializer``) of an EasyScience object.
-
- Returns
- -------
- Any
- Reformed EasyScience object.
- """
-
- return cls.decode(obj_dict, decoder=DictSerializer)
-
- def encode_data(
- self,
- skip: Optional[List[str]] = None,
- encoder: Optional[BaseEncoderDecoder] = None,
- **kwargs,
- ) -> Any:
- """
- Returns just the data in an EasyScience object win the format
- specified by an encoder.
-
- Parameters
- ----------
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming the
- dictionary. By default, None.
- encoder : Optional[BaseEncoderDecoder], default=None
- The encoder to be used for encoding the data. By default,
- None.
- **kwargs :
- Any additional keywords to pass to the encoder when
- encoding.
-
- Returns
- -------
- Any
- Encoded object containing just the data of an EasyScience
- object.
- """
-
- if encoder is None:
- encoder = DataDictSerializer
- return self.encode(skip=skip, encoder=encoder, **kwargs)
-
- def as_data_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
- """
- Returns a dictionary containing just the data of an EasyScience
- object.
-
- Parameters
- ----------
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming the
- dictionary. By default, None.
-
- Returns
- -------
- Dict[str, Any]
- Dictionary containing just the data of an EasyScience
- object.
- """
-
- return self.encode(skip=skip, encoder=DataDictSerializer)
-
- def unsafe_hash(self) -> sha1:
- """
- Returns an hash of the current object.
-
- This uses a generic but low performance method of converting the
- object to a dictionary, flattening any nested keys, and then
- performing a hash on the resulting object
- """
-
- def flatten(obj, seperator='.'):
- # Flattens a dictionary
-
- flat_dict = {}
- for key, value in obj.items():
- if isinstance(value, dict):
- flat_dict.update({
- seperator.join([key, _key]): _value
- for _key, _value in flatten(value).items()
- })
- elif isinstance(value, list):
- list_dict = {
- '{}{}{}'.format(key, seperator, num): item
- for num, item in enumerate(value)
- }
- flat_dict.update(flatten(list_dict))
- else:
- flat_dict[key] = value
-
- return flat_dict
-
- ordered_keys = sorted(flatten(jsanitize(self.as_dict())).items(), key=lambda x: x[0])
- ordered_keys = [item for item in ordered_keys if '@' not in item[0]]
- return sha1(json.dumps(OrderedDict(ordered_keys)).encode('utf-8')) # noqa: S324
diff --git a/src/easyscience/legacy/obj_base.py b/src/easyscience/legacy/obj_base.py
deleted file mode 100644
index 4a70de25..00000000
--- a/src/easyscience/legacy/obj_base.py
+++ /dev/null
@@ -1,171 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from typing import Callable
-from typing import Optional
-
-from easyscience import global_object
-
-from ..base_classes.based_base import BasedBase
-from ..utils.classTools import addLoggedProp
-from ..variable.descriptor_base import DescriptorBase
-
-if TYPE_CHECKING:
- from ..io import SerializerComponent
-
-
-class ObjBase(BasedBase):
- """
- This is the base class for which all higher level classes are built
- off of.
-
- .. deprecated::
- ``ObjBase`` is deprecated and will be removed in a future version.
- Please migrate to ``ModelBase`` instead.
-
- NOTE: This object is serializable only if parameters are supplied
- as: ``ObjBase(a=value, b=value)``. For ``Parameter`` or
- ``Descriptor`` objects we can cheat with
- ``ObjBase(*[Descriptor(...), Parameter(...), ...])``.
- """
-
- def __init__(
- self,
- name: str,
- unique_name: Optional[str] = None,
- *args: Optional[SerializerComponent],
- **kwargs: Optional[SerializerComponent],
- ):
- """
- Set up the base class.
-
- Parameters
- ----------
- name : str
- Name of this object.
- unique_name : Optional[str], default=None
- Unique identifier for this object. By default, None.
- *args : Optional[SerializerComponent]
- Positional serializer components to attach to this object.
- **kwargs : Optional[SerializerComponent]
- Fields which this class should contain.
-
- Raises
- ------
- AttributeError
- If a keyword component name would overwrite an existing
- class attribute.
- """
- global_object.log.getLogger('legacy').warning(
- 'ObjBase is deprecated and will be removed in a future version. '
- 'Please migrate to ModelBase.',
- stacklevel=2,
- )
- super(ObjBase, self).__init__(name=name, unique_name=unique_name)
- # If Parameter or Descriptor is given as arguments...
- for arg in args:
- if issubclass(type(arg), (ObjBase, DescriptorBase)):
- kwargs[getattr(arg, 'name')] = arg
- # Set kwargs, also useful for serialization
- known_keys = self.__dict__.keys()
- self._kwargs = kwargs
- for key in kwargs.keys():
- if key in known_keys:
- raise AttributeError('Kwargs cannot overwrite class attributes in ObjBase.')
- if issubclass(type(kwargs[key]), (BasedBase, DescriptorBase)) or 'CollectionBase' in [
- c.__name__ for c in type(kwargs[key]).__bases__
- ]:
- self._global_object.map.add_edge(self, kwargs[key])
- self._global_object.map.reset_type(kwargs[key], 'created_internal')
- addLoggedProp(
- self,
- key,
- self.__getter(key),
- self.__setter(key),
- get_id=key,
- my_self=self,
- test_class=ObjBase,
- )
-
- def _add_component(self, key: str, component: SerializerComponent) -> None:
- """Dynamically add a component to the class. This is an internal
- method, though can be called remotely. The recommended
- alternative is to use typing, i.e.
-
- .. code-block:: python
-
- class Foo(Bar):
- def __init__(self, foo: Parameter, bar: Parameter):
- '''Init function.'''
- super(Foo, self).__init__(bar=bar)
- self._add_component('foo', foo)
-
- :param key: Name of component to be added
- :param component: Component to be added
- :return: None
- """
- self._kwargs[key] = component
- self._global_object.map.add_edge(self, component)
- self._global_object.map.reset_type(component, 'created_internal')
- addLoggedProp(
- self,
- key,
- self.__getter(key),
- self.__setter(key),
- get_id=key,
- my_self=self,
- test_class=ObjBase,
- )
-
- def __setattr__(self, key: str, value: SerializerComponent) -> None:
- # Assume that the annotation is a ClassVar
- old_obj = None
- if (
- hasattr(self.__class__, '__annotations__')
- and key in self.__class__.__annotations__
- and hasattr(self.__class__.__annotations__[key], '__args__')
- and issubclass(
- getattr(value, '__old_class__', value.__class__),
- self.__class__.__annotations__[key].__args__,
- )
- ):
- if issubclass(type(getattr(self, key, None)), (BasedBase, DescriptorBase)):
- old_obj = self.__getattribute__(key)
- self._global_object.map.prune_vertex_from_edge(self, old_obj)
- self._add_component(key, value)
- else:
- if hasattr(self, key) and issubclass(type(value), (BasedBase, DescriptorBase)):
- old_obj = self.__getattribute__(key)
- self._global_object.map.prune_vertex_from_edge(self, old_obj)
- self._global_object.map.add_edge(self, value)
- super(ObjBase, self).__setattr__(key, value)
- # Update the interface bindings if something changed (BasedBase and Descriptor)
- if old_obj is not None:
- old_interface = getattr(self, 'interface', None)
- if old_interface is not None:
- self.generate_bindings()
-
- def __repr__(self) -> str:
- return f'{self.__class__.__name__} `{getattr(self, "name")}`'
-
- @staticmethod
- def __getter(key: str) -> Callable[[SerializerComponent], SerializerComponent]:
- def getter(obj: SerializerComponent) -> SerializerComponent:
- return obj._kwargs[key]
-
- return getter
-
- @staticmethod
- def __setter(key: str) -> Callable[[SerializerComponent], None]:
- def setter(obj: SerializerComponent, value: float) -> None:
- if issubclass(obj._kwargs[key].__class__, (DescriptorBase)) and not issubclass(
- value.__class__, (DescriptorBase)
- ):
- obj._kwargs[key].value = value
- else:
- obj._kwargs[key] = value
-
- return setter
diff --git a/src/easyscience/legacy/xml.py b/src/easyscience/legacy/xml.py
deleted file mode 100644
index da52ef65..00000000
--- a/src/easyscience/legacy/xml.py
+++ /dev/null
@@ -1,197 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-import sys
-import xml.etree.ElementTree as ET
-from numbers import Number
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import List
-from typing import Optional
-
-import numpy as np
-
-from easyscience import global_object
-
-from ..io.dict import DataDictSerializer
-from ..io.dict import DictSerializer
-from ..io.template import BaseEncoderDecoder
-
-if TYPE_CHECKING:
- from ..io.component_serializer import ComponentSerializer
-
-
-can_intent = (sys.version_info.major > 2) & (sys.version_info.minor > 8)
-
-
-class XMLSerializer(BaseEncoderDecoder):
- """
- This is a serializer that can encode and decode EasyScience objects
- to a basic xml format.
- """
-
- def encode(
- self,
- obj: ComponentSerializer,
- skip: Optional[List[str]] = None,
- data_only: bool = False,
- fast: bool = False,
- use_header: bool = False,
- **kwargs,
- ) -> str:
- """
- Convert an EasyScience object to an XML encoded string.
-
- Note that for speed the ``fast`` setting can be changed to
- ``True``. An XML document with initial block *data* is returned.
-
- Parameters
- ----------
- obj : ComponentSerializer
- Object to be encoded.
- skip : Optional[List[str]], default=None
- List of field names as strings to skip when forming the
- encoded object. By default, None.
- data_only : bool, default=False
- Should only the object's data be encoded. By default, False.
- fast : bool, default=False
- Should the returned string be pretty? This can be turned off
- for speed. By default, False.
- use_header : bool, default=False
- Should a header of ``'?xml version="1.0"
- encoding="UTF-8"?'`` be included? By default, False.
- **kwargs :
- Any additional key-words to pass to the Dictionary
- Serializer.
-
- Returns
- -------
- str
- String containing the XML encoded object.
- """
-
- if skip is None:
- skip = []
- encoder = DictSerializer
- if data_only:
- encoder = DataDictSerializer
- if isinstance(obj, dict):
- obj_dict = obj
- else:
- obj_dict = encoder().encode(obj, skip=skip, full_encode=True, **kwargs)
- block = ET.Element('data')
- self._check_class(block, None, obj_dict, skip=skip)
- header = ''
- if use_header:
- header = '?xml version="1.0" encoding="UTF-8"?'
- if not fast and can_intent:
- ET.indent(block)
- if use_header:
- header += '\n'
- return header + ET.tostring(block, encoding='unicode')
-
- @classmethod
- def decode(cls, data: str) -> ComponentSerializer:
- """
- Decode an EasyScience object which has been encoded in XML
- format.
-
- Parameters
- ----------
- data : str
- String containing XML encoded data.
-
- Returns
- -------
- ComponentSerializer
- Reformed EasyScience object.
- """
-
- data_xml = ET.XML(data)
- out_dict = {}
- for element in data_xml:
- XMLSerializer._element_to_dict(element, out_dict)
- return DictSerializer.decode(out_dict)
-
- @staticmethod
- def _element_to_dict(element, out_dict):
- """Convert an XML element to a dictionary recursively."""
-
- label = element.tag
- if label[0] == '_':
- label = '@' + label[2:]
- if len(element) == 0:
- out_dict[label] = XMLSerializer.string_to_variable(element.text)
- else:
- this_dict = {}
- for el in element:
- XMLSerializer._element_to_dict(el, this_dict)
- if label in out_dict.keys():
- # This object is a list. Create a list.
- old_value = out_dict[label]
- if not isinstance(old_value, list):
- old_value = [old_value]
- old_value.append(this_dict)
- this_dict = old_value
- out_dict[label] = this_dict
-
- @staticmethod
- def string_to_variable(in_string: str):
- """Convert an XML encoded string to JSON form."""
- if in_string is None:
- return in_string
- in_string = in_string.strip()
- if "'" in in_string:
- in_string = in_string.replace("'", '')
- if '"' in in_string:
- in_string = in_string.replace('"', '')
- try:
- value = float(in_string)
- except ValueError:
- if in_string == 'True':
- value = True
- elif in_string == 'False':
- value = False
- elif in_string == 'None':
- value = None
- else:
- value = in_string
- return value
-
- def _check_class(self, element, key: str, value: Any, skip: Optional[List[str]] = None):
- """
- Add a value to an element or create a new element based on input
- type.
- """
- T_ = type(value)
- if isinstance(value, dict):
- for k, v in value.items():
- if k in skip:
- continue
- kk = k
- if k[0] == '@':
- kk = '__' + kk[1:]
- if not isinstance(v, list):
- s = ET.SubElement(element, kk)
- self._check_class(s, kk, v, skip=skip)
- else:
- self._check_class(element, kk, v, skip=skip)
- elif isinstance(value, bool):
- element.text = str(value)
- elif isinstance(value, str):
- element.text = value
- elif isinstance(value, list):
- for i in value:
- s = ET.SubElement(element, key)
- self._check_class(s, None, i, skip=skip)
- elif value is None:
- element.text = 'None'
- elif issubclass(T_, Number):
- element.text = str(value)
- elif issubclass(T_, np.ndarray):
- element.text = str(value.tolist())
- else:
- global_object.log.getLogger('legacy.xml').error('Cannot encode %s to XML', T_)
- raise NotImplementedError
diff --git a/src/easyscience/models/__init__.py b/src/easyscience/models/__init__.py
deleted file mode 100644
index 3fc7f778..00000000
--- a/src/easyscience/models/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# SPDX-FileCopyrightText: 2024 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from .polynomial import Polynomial # noqa: F401
diff --git a/src/easyscience/models/polynomial.py b/src/easyscience/models/polynomial.py
deleted file mode 100644
index 49b4757e..00000000
--- a/src/easyscience/models/polynomial.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# SPDX-FileCopyrightText: 2024 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from typing import ClassVar
-from typing import Iterable
-from typing import Optional
-from typing import Union
-
-import numpy as np
-
-from ..base_classes import CollectionBase
-from ..base_classes import ObjBase
-from ..variable import Parameter
-
-
-class Polynomial(ObjBase):
- """
- A polynomial model.
-
- Attributes
- ----------
- coefficients : ClassVar[CollectionBase]
- Coefficients of the polynomial.
-
- Parameters
- ----------
- name : str, default='polynomial'
- The name of the model.
- coefficients : Optional[Union[Iterable[Union[float, Parameter]], CollectionBase]], default=None
- Coefficients used to populate ``self.coefficients``.
-
- Raises
- ------
- TypeError
- If ``coefficients`` is neither a ``CollectionBase`` nor an
- iterable of floats or ``Parameter`` instances.
- """
-
- coefficients: ClassVar[CollectionBase]
-
- def __init__(
- self,
- name: str = 'polynomial',
- coefficients: Optional[Union[Iterable[Union[float, Parameter]], CollectionBase]] = None,
- ):
- super(Polynomial, self).__init__(name, coefficients=CollectionBase('coefficients'))
- if coefficients is not None:
- if issubclass(type(coefficients), CollectionBase):
- self.coefficients = coefficients
- elif isinstance(coefficients, Iterable):
- for index, item in enumerate(coefficients):
- if issubclass(type(item), Parameter):
- self.coefficients.append(item)
- elif isinstance(item, float):
- self.coefficients.append(Parameter(name='c{}'.format(index), value=item))
- else:
- raise TypeError('Coefficients must be floats or Parameters')
- else:
- raise TypeError('coefficients must be a list or a CollectionBase')
-
- def __call__(self, x: np.ndarray, *args, **kwargs) -> np.ndarray:
- return np.polyval([c.value for c in self.coefficients], x)
-
- def __repr__(self):
- s = []
- if len(self.coefficients) >= 1:
- s += [f'{self.coefficients[0].value}']
- if len(self.coefficients) >= 2:
- s += [f'{self.coefficients[1].value}x']
- if len(self.coefficients) >= 3:
- s += [
- f'{c.value}x^{i + 2}'
- for i, c in enumerate(self.coefficients[2:])
- if c.value != 0
- ]
- s.reverse()
- s = ' + '.join(s)
- return 'Polynomial({}, {})'.format(self.name, s)
diff --git a/src/easyscience/utils/classTools.py b/src/easyscience/utils/classTools.py
deleted file mode 100644
index 151f36c1..00000000
--- a/src/easyscience/utils/classTools.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from typing import List
-from typing import Tuple
-
-from easyscience import global_object
-from easyscience.global_object.hugger.property import LoggedProperty
-
-if TYPE_CHECKING:
- from ..base_classes import BasedBase
- from ..io import SerializerComponent
-
-
-def addLoggedProp(inst: SerializerComponent, name: str, *args, **kwargs) -> None:
- cls = type(inst)
- annotations = getattr(cls, '__annotations__', False)
- if not hasattr(cls, '__perinstance'):
- cls = type(cls.__name__, (cls,), {'__module__': inst.__module__})
- cls.__perinstance = True
- if annotations:
- cls.__annotations__ = annotations
- inst.__old_class__ = inst.__class__
- inst.__class__ = cls
- setattr(cls, name, LoggedProperty(*args, **kwargs))
-
-
-def addProp(inst: SerializerComponent, name: str, *args, **kwargs) -> None:
- cls = type(inst)
- annotations = getattr(cls, '__annotations__', False)
- if not hasattr(cls, '__perinstance'):
- cls = type(cls.__name__, (cls,), {'__module__': __name__})
- cls.__perinstance = True
- if annotations:
- cls.__annotations__ = annotations
- inst.__old_class__ = inst.__class__
- inst.__class__ = cls
-
- setattr(cls, name, property(*args, **kwargs))
-
-
-def removeProp(inst: SerializerComponent, name: str) -> None:
- cls = type(inst)
- if not hasattr(cls, '__perinstance'):
- cls = type(cls.__name__, (cls,), {'__module__': __name__})
- cls.__perinstance = True
- inst.__old_class__ = inst.__class__
- inst.__class__ = cls
- delattr(cls, name)
-
-
-def generatePath(model_obj: BasedBase, skip_first: bool = False) -> Tuple[List[int], List[str]]:
- pars = model_obj.get_parameters()
- start_idx = 0 + int(skip_first)
- unique_names = []
- names = []
- for par in pars:
- route = global_object.map.reverse_route(par.unique_name, model_obj.unique_name)
- objs = [getattr(global_object.map.get_item_by_key(r), 'name') for r in route]
- objs.reverse()
- names.append('.'.join(objs[start_idx:]))
- unique_names.append(par.unique_name)
- return unique_names, names
diff --git a/src/easyscience/variable/__init__.py b/src/easyscience/variable/__init__.py
index 121d5cf9..5b7749eb 100644
--- a/src/easyscience/variable/__init__.py
+++ b/src/easyscience/variable/__init__.py
@@ -1,7 +1,6 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-from .descriptor_any_type import DescriptorAnyType
from .descriptor_array import DescriptorArray
from .descriptor_base import DescriptorBase
from .descriptor_bool import DescriptorBool
@@ -10,11 +9,10 @@
from .parameter import Parameter
__all__ = [
- DescriptorAnyType,
- DescriptorArray,
- DescriptorBase,
- DescriptorBool,
- DescriptorNumber,
- DescriptorStr,
- Parameter,
+ 'DescriptorArray',
+ 'DescriptorBase',
+ 'DescriptorBool',
+ 'DescriptorNumber',
+ 'DescriptorStr',
+ 'Parameter',
]
diff --git a/src/easyscience/variable/descriptor_any_type.py b/src/easyscience/variable/descriptor_any_type.py
deleted file mode 100644
index 157117d8..00000000
--- a/src/easyscience/variable/descriptor_any_type.py
+++ /dev/null
@@ -1,105 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from __future__ import annotations
-
-import numbers
-from typing import Any
-from typing import Dict
-from typing import List
-from typing import Optional
-from typing import Union
-
-import numpy as np
-
-from easyscience.global_object.undo_redo import property_stack
-
-from .descriptor_base import DescriptorBase
-
-
-class DescriptorAnyType(DescriptorBase):
- """
- A ``Descriptor`` for any type that does not fit the other
- Descriptors.
-
- Should be avoided when possible. It was created to hold the symmetry
- operations used in the SpaceGroup class of EasyCrystallography.
- """
-
- def __init__(
- self,
- name: str,
- value: Any,
- unique_name: Optional[str] = None,
- description: Optional[str] = None,
- url: Optional[str] = None,
- display_name: Optional[str] = None,
- parent: Optional[Any] = None,
- ):
- """
- Constructor for the DescriptorAnyType class.
-
- param name: Name of the descriptor param value: Value of the
- descriptor param description: Description of the descriptor
- param url: URL of the descriptor param display_name: Display
- name of the descriptor param parent: Parent of the descriptor ..
- note:: Undo/Redo functionality is implemented for the attributes
- ``variance``, ``error``, ``unit`` and ``value``.
- """
-
- self._value = value
-
- super().__init__(
- name=name,
- unique_name=unique_name,
- description=description,
- url=url,
- display_name=display_name,
- parent=parent,
- )
-
- @property
- def value(self) -> numbers.Number:
- """
- Get the value.
-
- Returns
- -------
- numbers.Number
- Value of self.
- """
- return self._value
-
- @value.setter
- @property_stack
- def value(self, value: Union[list, np.ndarray]) -> None:
- """
- Set the value of self.
-
- Parameters
- ----------
- value : Union[list, np.ndarray]
- New value for the DescriptorAnyType.
- """
- self._value = value
-
- def __copy__(self) -> DescriptorAnyType:
- return super().__copy__()
-
- def __repr__(self) -> str:
- """
- Return a string representation of the DescriptorAnyType, showing
- its name and value.
- """
-
- if hasattr(self._value, '__repr__'):
- value_repr = repr(self._value)
- else:
- value_repr = type(self._value)
-
- return f"<{self.__class__.__name__} '{self._name}': {value_repr}>"
-
- def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
- raw_dict = super().as_dict(skip=skip)
- raw_dict['value'] = self._value
- return raw_dict
diff --git a/src/easyscience/variable/descriptor_array.py b/src/easyscience/variable/descriptor_array.py
index 35f1c022..c8affc75 100644
--- a/src/easyscience/variable/descriptor_array.py
+++ b/src/easyscience/variable/descriptor_array.py
@@ -18,9 +18,6 @@
from scipp import UnitError
from scipp import Variable
-from easyscience.global_object.undo_redo import PropertyStack
-from easyscience.global_object.undo_redo import property_stack
-
from .descriptor_base import DescriptorBase
from .descriptor_number import DescriptorNumber
@@ -34,29 +31,56 @@ class DescriptorArray(DescriptorBase):
def __init__(
self,
- name: str,
value: Union[list, np.ndarray],
+ *,
unit: Optional[Union[str, sc.Unit]] = '',
variance: Optional[Union[list, np.ndarray]] = None,
unique_name: Optional[str] = None,
description: Optional[str] = None,
url: Optional[str] = None,
display_name: Optional[str] = None,
- parent: Optional[Any] = None,
dimensions: Optional[list] = None,
):
"""
Constructor for the DescriptorArray class.
- param name: Name of the descriptor param value: List containing
- the values of the descriptor param unit: Unit of the descriptor
- param variance: Variances of the descriptor param description:
- Description of the descriptor param url: URL of the descriptor
- param display_name: Display name of the descriptor param parent:
- Parent of the descriptor param dimensions: List of dimensions to
- pass to scipp. Will be autogenerated if not supplied. .. note::
- Undo/Redo functionality is implemented for the attributes
- ``variance``, ``error``, ``unit`` and ``value``.
+ All arguments after ``value`` are keyword-only.
+
+ Parameters
+ ----------
+ value : Union[list, np.ndarray]
+ Values of this object.
+ unit : Optional[Union[str, sc.Unit]], default=''
+ This object can have a physical unit associated with it. By
+ default, ''.
+ variance : Optional[Union[list, np.ndarray]], default=None
+ The variances of the values, with the same shape as
+ ``value``. By default, None.
+ unique_name : Optional[str], default=None
+ Unique identifier for this object. By default, None.
+ description : Optional[str], default=None
+ A brief summary of what this object is. By default, None.
+ url : Optional[str], default=None
+ Lookup url for documentation/information. By default, None.
+ display_name : Optional[str], default=None
+ A pretty name for the object. Falls back to ``unique_name``
+ when not given. By default, None.
+ dimensions : Optional[list], default=None
+ List of dimensions to pass to scipp. Autogenerated if not
+ supplied. By default, None.
+
+ Raises
+ ------
+ TypeError
+ If ``value`` or ``variance`` is not a list or NumPy array, or
+ if ``unit`` is not a string or scipp unit.
+ UnitError
+ If ``unit`` is not a valid scipp unit.
+ ValueError
+ If ``variance`` does not match the shape of ``value``, if it
+ contains negative entries, or if ``dimensions`` does not
+ match the shape of ``value``.
+
"""
if not isinstance(value, (list, np.ndarray)):
@@ -99,12 +123,10 @@ def __init__(
# TODO: handle 1xn and nx1 arrays
super().__init__(
- name=name,
unique_name=unique_name,
description=description,
url=url,
display_name=display_name,
- parent=parent,
)
# Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency.
@@ -112,14 +134,12 @@ def __init__(
self.convert_unit(self._base_unit())
@classmethod
- def from_scipp(cls, name: str, full_value: Variable, **kwargs: Any) -> DescriptorArray:
+ def from_scipp(cls, full_value: Variable, **kwargs: Any) -> DescriptorArray:
"""
Create a DescriptorArray from a scipp array.
Parameters
----------
- name : str
- Name of the descriptor.
full_value : Variable
Value of the descriptor as a scipp variable.
**kwargs : Any
@@ -138,7 +158,6 @@ def from_scipp(cls, name: str, full_value: Variable, **kwargs: Any) -> Descripto
if not isinstance(full_value, Variable):
raise TypeError(f'{full_value=} must be a scipp array')
return cls(
- name=name,
value=full_value.values,
unit=full_value.unit,
variance=full_value.variances,
@@ -181,7 +200,6 @@ def value(self) -> numbers.Number:
return self._array.values
@value.setter
- @property_stack
def value(self, value: Union[list, np.ndarray]) -> None:
"""
Set the value of self.
@@ -295,7 +313,6 @@ def variance(self) -> np.ndarray:
return self._array.variances
@variance.setter
- @property_stack
def variance(self, variance: Union[list, np.ndarray]) -> None:
"""
Set the variance of self.
@@ -331,8 +348,9 @@ def variance(self, variance: Union[list, np.ndarray]) -> None:
if not np.all(variance >= 0):
raise ValueError(f'{variance=} must only contain non-negative values.')
- # Values must be floats for optimization
- self._array.variances = variance.astype('float')
+ # Values must be floats for optimization
+ variance = variance.astype('float')
+ self._array.variances = variance
@property
def error(self) -> Optional[np.ndarray]:
@@ -351,7 +369,6 @@ def error(self) -> Optional[np.ndarray]:
return np.sqrt(self._array.variances)
@error.setter
- @property_stack
def error(self, error: Union[list, np.ndarray]) -> None:
"""
Set the standard deviation for the parameter, which updates the
@@ -407,26 +424,12 @@ def convert_unit(self, unit_str: str) -> None:
raise TypeError(f'{unit_str=} must be a string representing a valid scipp unit')
new_unit = sc.Unit(unit_str)
- # Save the current state for undo/redo
- old_array = self._array
-
# Perform the unit conversion
try:
new_array = self._array.to(unit=new_unit)
except Exception as e:
raise UnitError(f'Failed to convert unit: {e}') from e
- # Define the setter function for the undo stack
- def set_array(obj, scalar):
- obj._array = scalar
-
- # Push to undo stack
- self._global_object.stack.push(
- PropertyStack(
- self, set_array, old_array, new_array, text=f'Convert unit to {unit_str}'
- )
- )
-
# Update the array
self._array = new_array
@@ -437,12 +440,12 @@ def __copy__(self) -> DescriptorArray:
def __repr__(self) -> str:
"""
Return a string representation of the DescriptorArray, showing
- its name, value, variance, and unit.
+ its display name, value, variance, and unit.
Large arrays are summarized for brevity.
"""
- # Base string with name
- string = f"<{self.__class__.__name__} '{self._name}': "
+ # Base string with display name
+ string = f"<{self.__class__.__name__} '{self.display_name}': "
# Summarize array values
values_summary = np.array2string(
@@ -472,14 +475,14 @@ def __repr__(self) -> str:
string = string.replace('\n', ',')
return string
- def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
+ def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
"""
Dict representation of the current DescriptorArray.
The dict contains the value, unit and variances, in addition to
the properties of DescriptorBase.
"""
- raw_dict = super().as_dict(skip=skip)
+ raw_dict = super().to_dict(skip=skip)
raw_dict['value'] = self._array.values
raw_dict['unit'] = str(self._array.unit)
raw_dict['variance'] = self._array.variances
@@ -591,9 +594,7 @@ def _apply_operation(
else:
return NotImplemented
- descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_full_value)
- descriptor_array.name = descriptor_array.unique_name
- return descriptor_array
+ return DescriptorArray.from_scipp(full_value=new_full_value)
def _rapply_operation(
self,
@@ -887,9 +888,7 @@ def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorA
raise message from None
if np.any(np.isnan(new_value.values)):
raise ValueError('The result of the exponentiation is not a number')
- descriptor_number = DescriptorArray.from_scipp(name=self.name, full_value=new_value)
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorArray.from_scipp(full_value=new_value)
def __rpow__(self, other: numbers.Number):
"""
@@ -903,9 +902,7 @@ def __rpow__(self, other: numbers.Number):
def __neg__(self) -> DescriptorArray:
"""Negate all values in the DescriptorArray."""
new_value = -self.full_value
- descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_value)
- descriptor_array.name = descriptor_array.unique_name
- return descriptor_array
+ return DescriptorArray.from_scipp(full_value=new_value)
def __abs__(self) -> DescriptorArray:
"""
@@ -916,9 +913,7 @@ def __abs__(self) -> DescriptorArray:
DescriptorArray.
"""
new_value = abs(self.full_value)
- descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_value)
- descriptor_array.name = descriptor_array.unique_name
- return descriptor_array
+ return DescriptorArray.from_scipp(full_value=new_value)
def __getitem__(self, a) -> DescriptorArray:
"""
@@ -926,11 +921,7 @@ def __getitem__(self, a) -> DescriptorArray:
Defer slicing to scipp.
"""
- descriptor = DescriptorArray.from_scipp(
- name=self.name, full_value=self.full_value.__getitem__(a)
- )
- descriptor.name = descriptor.unique_name
- return descriptor
+ return DescriptorArray.from_scipp(full_value=self.full_value.__getitem__(a))
def __delitem__(self, a):
"""
@@ -1027,9 +1018,7 @@ def trace(
)
constructor = DescriptorArray.from_scipp
- descriptor = constructor(name=self.name, full_value=trace)
- descriptor.name = descriptor.unique_name
- return descriptor
+ return constructor(full_value=trace)
def sum(
self, dim: Optional[Union[str, list]] = None
@@ -1057,9 +1046,7 @@ def sum(
else:
constructor = DescriptorArray.from_scipp
- descriptor = constructor(name=self.name, full_value=new_full_value)
- descriptor.name = descriptor.unique_name
- return descriptor
+ return constructor(full_value=new_full_value)
# This is to be implemented at a later time
# def __matmul__(self, other: [DescriptorArray, list]) -> DescriptorArray:
diff --git a/src/easyscience/variable/descriptor_base.py b/src/easyscience/variable/descriptor_base.py
index ba7198bc..ab411763 100644
--- a/src/easyscience/variable/descriptor_base.py
+++ b/src/easyscience/variable/descriptor_base.py
@@ -5,22 +5,21 @@
import abc
from typing import Any
+from typing import Dict
+from typing import List
from typing import Optional
from easyscience import global_object
-from easyscience.global_object.undo_redo import property_stack
-from easyscience.io import SerializerComponent
+from easyscience.base_classes.easy_base import EasyBase
-class DescriptorBase(SerializerComponent, metaclass=abc.ABCMeta):
+class DescriptorBase(EasyBase, metaclass=abc.ABCMeta):
"""
This is the base of all variable descriptions for models.
It contains all information to describe a single unique property of
- an object. This description includes a name and value as well as
- optionally a unit, description and url (for reference material).
- Also implemented is a callback so that the value can be read/set
- from a linked library object.
+ an object. This description includes a value as well as optionally a
+ unit, description and url (for reference material).
A ``Descriptor`` is typically something which describes part of a
model and is non-fittable and generally changes the state of an
@@ -28,24 +27,21 @@ class DescriptorBase(SerializerComponent, metaclass=abc.ABCMeta):
"""
_global_object = global_object
- # Used by serializer
- _REDIRECT = {'parent': None}
def __init__(
self,
- name: str,
+ *,
unique_name: Optional[str] = None,
description: Optional[str] = None,
url: Optional[str] = None,
display_name: Optional[str] = None,
- parent: Optional[Any] = None,
):
"""
This is the base of variables for models.
It contains all information to describe a single unique property
- of an object. This description includes a name, description and
- url (for reference material).
+ of an object. This description includes a description and url
+ (for reference material).
A ``Descriptor`` is typically something which describes part of
a model and is non-fittable and generally changes the state of
@@ -53,8 +49,6 @@ def __init__(
Parameters
----------
- name : str
- Name of this object.
unique_name : Optional[str], default=None
Unique identifier for this object. By default, None.
description : Optional[str], default=None
@@ -62,29 +56,16 @@ def __init__(
url : Optional[str], default=None
Lookup url for documentation/information. By default, None.
display_name : Optional[str], default=None
- A pretty name for the object. By default, None.
- parent : Optional[Any], default=None
- The object which this descriptor is attached to. By default,
- None.
+ A pretty name for the object. Falls back to ``unique_name``
+ when not given. By default, None.
Raises
------
TypeError
- If ``name`` is not a string or if any optional string field
- has an invalid type.
+ If any optional string field has an invalid type.
"""
- if unique_name is None:
- unique_name = global_object.generate_unique_name(self.__class__.__name__)
- self._unique_name = unique_name
-
- if not isinstance(name, str):
- raise TypeError('Name must be a string')
- self._name: str = name
-
- if display_name is not None and not isinstance(display_name, str):
- raise TypeError('Display name must be a string or None')
- self._display_name: str = display_name
+ super().__init__(unique_name=unique_name, display_name=display_name)
if description is not None and not isinstance(description, str):
raise TypeError('Description must be a string or None')
@@ -98,80 +79,6 @@ def __init__(
url = ''
self._url: str = url
- # Let the collective know we've been assimilated
- self._parent = parent
- global_object.map.add_vertex(self, obj_type='created')
- # Make the connection between self and parent
- if parent is not None:
- global_object.map.add_edge(parent, self)
-
- @property
- def name(self) -> str:
- """
- Get the name of the object.
-
- Returns
- -------
- str
- Name of the object.
- """
- return self._name
-
- @name.setter
- @property_stack
- def name(self, new_name: str) -> None:
- """
- Set the name.
-
- Parameters
- ----------
- new_name : str
- Name of the object.
-
- Raises
- ------
- TypeError
- If ``new_name`` is not a string.
- """
- if not isinstance(new_name, str):
- raise TypeError('Name must be a string')
- self._name = new_name
-
- @property
- def display_name(self) -> str:
- """
- Get a pretty display name.
-
- Returns
- -------
- str
- The pretty display name.
- """
- display_name = self._display_name
- if display_name is None:
- display_name = self._name
- return display_name
-
- @display_name.setter
- @property_stack
- def display_name(self, name: Optional[str]) -> None:
- """
- Set the pretty display name.
-
- Parameters
- ----------
- name : Optional[str]
- Pretty display name of the object.
-
- Raises
- ------
- TypeError
- If ``name`` is neither a string nor ``None``.
- """
- if name is not None and not isinstance(name, str):
- raise TypeError('Display name must be a string or None')
- self._display_name = name
-
@property
def description(self) -> str:
"""
@@ -234,40 +141,6 @@ def url(self, url: Optional[str]) -> None:
raise TypeError('url must be a string')
self._url = url
- @property
- def unique_name(self) -> str:
- """
- Get the unique name of this object.
-
- Returns
- -------
- str
- Unique name of this object.
- """
- return self._unique_name
-
- @unique_name.setter
- def unique_name(self, new_unique_name: str):
- """
- Set a new unique name for the object.
-
- The old name is still kept in the map.
-
- Parameters
- ----------
- new_unique_name : str
- New unique name for the object.
-
- Raises
- ------
- TypeError
- If ``new_unique_name`` is not a string.
- """
- if not isinstance(new_unique_name, str):
- raise TypeError('Unique name has to be a string.')
- self._unique_name = new_unique_name
- global_object.map.add_vertex(self)
-
@property
@abc.abstractmethod
def value(self) -> Any:
@@ -281,9 +154,3 @@ def value(self, value: Any) -> None:
@abc.abstractmethod
def __repr__(self) -> str:
"""Return printable representation of the object."""
-
- def __copy__(self) -> DescriptorBase:
- """Return a copy of the object."""
- temp = self.as_dict(skip=['unique_name'])
- new_obj = self.__class__.from_dict(temp)
- return new_obj
diff --git a/src/easyscience/variable/descriptor_bool.py b/src/easyscience/variable/descriptor_bool.py
index 05a09d9d..cad85a50 100644
--- a/src/easyscience/variable/descriptor_bool.py
+++ b/src/easyscience/variable/descriptor_bool.py
@@ -3,11 +3,8 @@
from __future__ import annotations
-from typing import Any
from typing import Optional
-from easyscience.global_object.undo_redo import property_stack
-
from .descriptor_base import DescriptorBase
@@ -16,26 +13,45 @@ class DescriptorBool(DescriptorBase):
def __init__(
self,
- name: str,
value: bool,
+ *,
unique_name: Optional[str] = None,
description: Optional[str] = None,
url: Optional[str] = None,
display_name: Optional[str] = None,
- parent: Optional[Any] = None,
):
+ """
+ Constructor for the DescriptorBool class.
+
+ All arguments after ``value`` are keyword-only.
+
+ Parameters
+ ----------
+ value : bool
+ Value of this object.
+ unique_name : Optional[str], default=None
+ Unique identifier for this object. By default, None.
+ description : Optional[str], default=None
+ A brief summary of what this object is. By default, None.
+ url : Optional[str], default=None
+ Lookup url for documentation/information. By default, None.
+ display_name : Optional[str], default=None
+ A pretty name for the object. Falls back to ``unique_name``
+ when not given. By default, None.
+
+ Raises
+ ------
+ ValueError
+ If ``value`` is not a boolean.
+ """
if not isinstance(value, bool):
raise ValueError(f'{value=} must be type bool')
super().__init__(
- name=name,
unique_name=unique_name,
description=description,
url=url,
display_name=display_name,
- parent=parent,
)
- if not isinstance(value, bool):
- raise TypeError(f'{value=} must be type bool')
self._bool_value = value
@property
@@ -51,7 +67,6 @@ def value(self) -> bool:
return self._bool_value
@value.setter
- @property_stack
def value(self, value: bool) -> None:
"""
Set the value of self.
@@ -78,7 +93,7 @@ def value(self, value: bool) -> None:
def __repr__(self) -> str:
"""Return printable representation."""
class_name = self.__class__.__name__
- obj_name = self._name
+ obj_name = self.display_name
obj_value = self._bool_value
return f"<{class_name} '{obj_name}': {obj_value}>"
diff --git a/src/easyscience/variable/descriptor_number.py b/src/easyscience/variable/descriptor_number.py
index 5bcbcb0d..41fb78d3 100644
--- a/src/easyscience/variable/descriptor_number.py
+++ b/src/easyscience/variable/descriptor_number.py
@@ -17,9 +17,6 @@
from scipp import UnitError
from scipp import Variable
-from easyscience.global_object.undo_redo import PropertyStack
-from easyscience.global_object.undo_redo import property_stack
-
from .descriptor_base import DescriptorBase
@@ -57,33 +54,64 @@ class DescriptorNumber(DescriptorBase):
def __init__(
self,
- name: str,
value: numbers.Number,
+ *,
unit: Optional[Union[str, sc.Unit]] = '',
variance: Optional[numbers.Number] = None,
unique_name: Optional[str] = None,
description: Optional[str] = None,
url: Optional[str] = None,
display_name: Optional[str] = None,
- parent: Optional[Any] = None,
**kwargs: Any, # Additional keyword arguments (used for (de)serialization)
):
"""
Constructor for the DescriptorNumber class.
- param name: Name of the descriptor param value: Value of the
- descriptor param unit: Unit of the descriptor param variance:
- Variance of the descriptor param description: Description of the
- descriptor param url: URL of the descriptor param display_name:
- Display name of the descriptor param parent: Parent of the
- descriptor .. note:: Undo/Redo functionality is implemented for
- the attributes ``variance``, ``error``, ``unit`` and ``value``.
+ All arguments after ``value`` are keyword-only.
+
+ Parameters
+ ----------
+ value : numbers.Number
+ Value of this object.
+ unit : Optional[Union[str, sc.Unit]], default=''
+ This object can have a physical unit associated with it. By
+ default, ''.
+ variance : Optional[numbers.Number], default=None
+ The variance of the value. By default, None.
+ unique_name : Optional[str], default=None
+ Unique identifier for this object. By default, None.
+ description : Optional[str], default=None
+ A brief summary of what this object is. By default, None.
+ url : Optional[str], default=None
+ Lookup url for documentation/information. By default, None.
+ display_name : Optional[str], default=None
+ A pretty name for the object. Falls back to ``unique_name``
+ when not given. By default, None.
+ **kwargs : Any
+ Additional keyword arguments used during (de)serialization.
+
+ Raises
+ ------
+ TypeError
+ If ``value`` or ``variance`` is not a number, if ``unit`` is
+ not a string or scipp unit, or if an unexpected keyword
+ argument is given.
+ UnitError
+ If ``unit`` is not a valid scipp unit.
+ ValueError
+ If ``variance`` is negative.
+
"""
self._observers: List[DescriptorNumber] = []
# Extract serializer_id if provided during deserialization
if '__serializer_id' in kwargs:
self.__serializer_id = kwargs.pop('__serializer_id')
+ if kwargs:
+ raise TypeError(
+ f'{self.__class__.__name__}.__init__() got unexpected keyword '
+ f'argument(s): {", ".join(sorted(kwargs))}.'
+ )
if not isinstance(value, numbers.Number) or isinstance(value, bool):
raise TypeError(f'{value=} must be a number')
@@ -102,12 +130,10 @@ def __init__(
except Exception as message:
raise UnitError(message)
super().__init__(
- name=name,
unique_name=unique_name,
description=description,
url=url,
display_name=display_name,
- parent=parent,
)
# Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency.
@@ -115,14 +141,12 @@ def __init__(
self._convert_unit(self._base_unit())
@classmethod
- def from_scipp(cls, name: str, full_value: Variable, **kwargs: Any) -> DescriptorNumber:
+ def from_scipp(cls, full_value: Variable, **kwargs: Any) -> DescriptorNumber:
"""
Create a DescriptorNumber from a scipp constant.
Parameters
----------
- name : str
- Name of the descriptor.
full_value : Variable
Value of the descriptor as a scipp scalar.
**kwargs : Any
@@ -143,7 +167,6 @@ def from_scipp(cls, name: str, full_value: Variable, **kwargs: Any) -> Descripto
if len(full_value.dims) != 0:
raise TypeError(f'{full_value=} must be a scipp scalar')
return cls(
- name=name,
value=full_value.value,
unit=full_value.unit,
variance=full_value.variance,
@@ -231,7 +254,6 @@ def value(self) -> numbers.Number:
@value.setter
@notify_observers
- @property_stack
def value(self, value: numbers.Number) -> None:
"""
Set the value of self.
@@ -288,7 +310,6 @@ def variance(self) -> float:
@variance.setter
@notify_observers
- @property_stack
def variance(self, variance_float: float) -> None:
"""
Set the variance.
@@ -329,7 +350,6 @@ def error(self) -> float:
@error.setter
@notify_observers
- @property_stack
def error(self, value: float) -> None:
"""
Set the standard deviation for the parameter.
@@ -378,26 +398,12 @@ def _convert_unit(self, unit_str: str) -> None:
raise TypeError(f'{unit_str=} must be a string representing a valid scipp unit')
new_unit = sc.Unit(unit_str)
- # Save the current state for undo/redo
- old_scalar = self._scalar
-
# Perform the unit conversion
try:
new_scalar = self._scalar.to(unit=new_unit)
except Exception as e:
raise UnitError(f'Failed to convert unit: {e}') from e
- # Define the setter function for the undo stack
- def set_scalar(obj, scalar):
- obj._scalar = scalar
-
- # Push to undo stack
- self._global_object.stack.push(
- PropertyStack(
- self, set_scalar, old_scalar, new_scalar, text=f'Convert unit to {unit_str}'
- )
- )
-
# Update the scalar
self._scalar = new_scalar
@@ -422,7 +428,7 @@ def __repr__(self) -> str:
"""Return printable representation."""
string = '<'
string += self.__class__.__name__ + ' '
- string += f"'{self._name}': "
+ string += f"'{self.display_name}': "
if np.abs(self._scalar.value) > 1e4 or (
np.abs(self._scalar.value) < 1e-4 and self._scalar.value != 0
):
@@ -444,8 +450,8 @@ def __repr__(self) -> str:
return string
# return f"<{class_name} '{obj_name}': {obj_value:0.04f}{obj_unit}>"
- def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
- raw_dict = super().as_dict(skip=skip)
+ def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
+ raw_dict = super().to_dict(skip=skip)
raw_dict['value'] = self._scalar.value
raw_dict['unit'] = str(self._scalar.unit)
raw_dict['variance'] = self._scalar.variance
@@ -470,9 +476,7 @@ def __add__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorN
other._convert_unit(original_unit)
else:
return NotImplemented
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_value)
def __radd__(self, other: numbers.Number) -> DescriptorNumber:
if isinstance(other, numbers.Number):
@@ -481,9 +485,7 @@ def __radd__(self, other: numbers.Number) -> DescriptorNumber:
new_value = other + self.full_value
else:
return NotImplemented
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_value)
def __sub__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
if isinstance(other, numbers.Number):
@@ -502,9 +504,7 @@ def __sub__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorN
other._convert_unit(original_unit)
else:
return NotImplemented
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_value)
def __rsub__(self, other: numbers.Number) -> DescriptorNumber:
if isinstance(other, numbers.Number):
@@ -513,9 +513,7 @@ def __rsub__(self, other: numbers.Number) -> DescriptorNumber:
new_value = other - self.full_value
else:
return NotImplemented
- descriptor = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
- descriptor.name = descriptor.unique_name
- return descriptor
+ return DescriptorNumber.from_scipp(full_value=new_value)
def __mul__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
if isinstance(other, numbers.Number):
@@ -524,9 +522,8 @@ def __mul__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorN
new_value = self.full_value * other.full_value
else:
return NotImplemented
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
+ descriptor_number = DescriptorNumber.from_scipp(full_value=new_value)
descriptor_number._convert_unit(descriptor_number._base_unit())
- descriptor_number.name = descriptor_number.unique_name
return descriptor_number
def __rmul__(self, other: numbers.Number) -> DescriptorNumber:
@@ -534,9 +531,7 @@ def __rmul__(self, other: numbers.Number) -> DescriptorNumber:
new_value = other * self.full_value
else:
return NotImplemented
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_value)
def __truediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
if isinstance(other, numbers.Number):
@@ -549,9 +544,8 @@ def __truediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> Descrip
new_value = self.full_value / other.full_value
else:
return NotImplemented
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
+ descriptor_number = DescriptorNumber.from_scipp(full_value=new_value)
descriptor_number._convert_unit(descriptor_number._base_unit())
- descriptor_number.name = descriptor_number.unique_name
return descriptor_number
def __rtruediv__(self, other: numbers.Number) -> DescriptorNumber:
@@ -561,9 +555,7 @@ def __rtruediv__(self, other: numbers.Number) -> DescriptorNumber:
new_value = other / self.full_value
else:
return NotImplemented
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_value)
def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorNumber:
if isinstance(other, numbers.Number):
@@ -582,9 +574,7 @@ def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorN
raise message from None
if np.isnan(new_value.value):
raise ValueError('The result of the exponentiation is not a number')
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_value)
def __rpow__(self, other: numbers.Number) -> numbers.Number:
if isinstance(other, numbers.Number):
@@ -599,15 +589,11 @@ def __rpow__(self, other: numbers.Number) -> numbers.Number:
def __neg__(self) -> DescriptorNumber:
new_value = -self.full_value
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_value)
def __abs__(self) -> DescriptorNumber:
new_value = abs(self.full_value)
- descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=new_value)
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_value)
def _base_unit(self) -> str:
"""
diff --git a/src/easyscience/variable/descriptor_str.py b/src/easyscience/variable/descriptor_str.py
index 5e74e786..13c23258 100644
--- a/src/easyscience/variable/descriptor_str.py
+++ b/src/easyscience/variable/descriptor_str.py
@@ -3,11 +3,8 @@
from __future__ import annotations
-from typing import Any
from typing import Optional
-from easyscience.global_object.undo_redo import property_stack
-
from .descriptor_base import DescriptorBase
@@ -16,21 +13,42 @@ class DescriptorStr(DescriptorBase):
def __init__(
self,
- name: str,
value: str,
+ *,
unique_name: Optional[str] = None,
description: Optional[str] = None,
url: Optional[str] = None,
display_name: Optional[str] = None,
- parent: Optional[Any] = None,
):
+ """
+ Constructor for the DescriptorStr class.
+
+ All arguments after ``value`` are keyword-only.
+
+ Parameters
+ ----------
+ value : str
+ Value of this object.
+ unique_name : Optional[str], default=None
+ Unique identifier for this object. By default, None.
+ description : Optional[str], default=None
+ A brief summary of what this object is. By default, None.
+ url : Optional[str], default=None
+ Lookup url for documentation/information. By default, None.
+ display_name : Optional[str], default=None
+ A pretty name for the object. Falls back to ``unique_name``
+ when not given. By default, None.
+
+ Raises
+ ------
+ ValueError
+ If ``value`` is not a string.
+ """
super().__init__(
- name=name,
unique_name=unique_name,
description=description,
url=url,
display_name=display_name,
- parent=parent,
)
if not isinstance(value, str):
raise ValueError(f'{value=} must be type str')
@@ -49,7 +67,6 @@ def value(self) -> str:
return self._string
@value.setter
- @property_stack
def value(self, value: str) -> None:
"""
Set the value of self.
@@ -76,7 +93,7 @@ def value(self, value: str) -> None:
def __repr__(self) -> str:
"""Return printable representation."""
class_name = self.__class__.__name__
- obj_name = self._name
+ obj_name = self.display_name
obj_value = self._string
return f"<{class_name} '{obj_name}': {obj_value}>"
diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py
index 3ec654f7..81d6c2a7 100644
--- a/src/easyscience/variable/parameter.py
+++ b/src/easyscience/variable/parameter.py
@@ -3,10 +3,8 @@
from __future__ import annotations
-import copy
import numbers
import re
-import weakref
from typing import Any
from typing import Dict
from typing import List
@@ -20,7 +18,6 @@
from scipp import Variable
from easyscience import global_object
-from easyscience.global_object.undo_redo import property_stack
from .descriptor_number import DescriptorNumber
from .descriptor_number import notify_observers
@@ -33,15 +30,10 @@ class Parameter(DescriptorNumber):
It has additional fields to facilitate this.
"""
- # Used by serializer
- # We copy the parent's _REDIRECT and modify it to avoid altering the parent's class dict
- _REDIRECT = DescriptorNumber._REDIRECT.copy()
- _REDIRECT['callback'] = None
-
def __init__(
self,
- name: str,
value: numbers.Number,
+ *,
unit: Optional[Union[str, sc.Unit]] = '',
variance: Optional[numbers.Number] = 0.0,
min: Optional[numbers.Number] = -np.inf,
@@ -51,8 +43,6 @@ def __init__(
description: Optional[str] = None,
url: Optional[str] = None,
display_name: Optional[str] = None,
- callback: property = property(),
- parent: Optional[Any] = None,
**kwargs: Any, # Additional keyword arguments (used for (de)serialization)
):
"""
@@ -62,10 +52,10 @@ def __init__(
for dynamic objects. A parameter has the ability to be used in
fitting and has additional fields to facilitate this.
+ All arguments after ``value`` are keyword-only.
+
Parameters
----------
- name : str
- Name of this object.
value : numbers.Number
Value of this object.
unit : Optional[Union[str, sc.Unit]], default=''
@@ -87,14 +77,8 @@ def __init__(
url : Optional[str], default=None
Lookup url for documentation/information. By default, None.
display_name : Optional[str], default=None
- The name of the object as it should be displayed. By
- default, None.
- callback : property, default=property()
- Callback used to synchronize the parameter with an external
- model.
- parent : Optional[Any], default=None
- The object which is the parent to this one. By default,
- None.
+ The name of the object as it should be displayed. Falls back
+ to ``unique_name`` when not given. By default, None.
**kwargs : Any
Additional keyword arguments used during serialization.
@@ -106,11 +90,6 @@ def __init__(
If ``value`` falls outside the provided bounds or if the
bounds are invalid.
- Notes
- -----
- Undo/Redo functionality is implemented for the attributes
- ``value``, ``variance``, ``error``, ``min``, ``max``,
- ``bounds``, ``fixed``, ``unit``
"""
# Extract and ignore serialization-specific fields from kwargs
kwargs.pop('_dependency_string', None)
@@ -139,7 +118,6 @@ def __init__(
self._max = sc.scalar(float(max), unit=unit)
super().__init__(
- name=name,
value=value,
unit=unit,
variance=variance,
@@ -147,21 +125,12 @@ def __init__(
description=description,
url=url,
display_name=display_name,
- parent=parent,
**kwargs, # Additional keyword arguments (used for (de)serialization)
)
- self._callback = callback # Callback is used by interface to link to model
- if self._callback.fdel is not None:
- weakref.finalize(self, self._callback.fdel)
-
- # Create additional fitting elements
- self._initial_scalar = copy.deepcopy(self._scalar)
-
@classmethod
def from_dependency(
cls,
- name: str,
dependency_expression: str,
dependency_map: Optional[dict] = None,
desired_unit: str | sc.Unit | None = None,
@@ -173,8 +142,6 @@ def from_dependency(
Parameters
----------
- name : str
- The name of the parameter.
dependency_expression : str
The dependency expression to evaluate. This should be a
string which can be evaluated by the ASTEval interpreter.
@@ -198,7 +165,7 @@ def from_dependency(
default_kwargs = {'value': 0.0, 'variance': 0.0, 'min': -np.inf, 'max': np.inf}
# Update with user-provided kwargs, to avoid errors.
default_kwargs.update(kwargs)
- parameter = cls(name=name, **default_kwargs)
+ parameter = cls(**default_kwargs)
parameter.make_dependent_on(
dependency_expression=dependency_expression,
dependency_map=dependency_map,
@@ -235,9 +202,6 @@ def _update(self) -> None:
if self._desired_unit is not None:
self._convert_unit(self._desired_unit)
- if self._callback.fset is not None:
- self._callback.fset(self._scalar.value)
-
self._notify_observers()
else:
global_object.log.getLogger('variable').warning(
@@ -526,18 +490,6 @@ def dependency_map(self, new_map: Dict[str, DescriptorNumber]) -> None:
'Dependency map is read-only. Use `make_dependent_on` to change the dependency map.'
)
- @property
- def value_no_call_back(self) -> numbers.Number:
- """
- Get the currently hold value of self suppressing call back.
-
- Returns
- -------
- numbers.Number
- Value of self without unit.
- """
- return self._scalar.value
-
@property
def full_value(self) -> Variable:
"""
@@ -570,14 +522,9 @@ def value(self) -> numbers.Number:
numbers.Number
Value of self without unit.
"""
- if self._callback.fget is not None:
- existing_value = self._callback.fget()
- if existing_value != self._scalar.value:
- self._scalar.value = existing_value
return self._scalar.value
@value.setter
- @property_stack
def value(self, value: numbers.Number) -> None:
"""
Set the value of self.
@@ -608,9 +555,6 @@ def value(self, value: numbers.Number) -> None:
self._scalar.value = value
- if self._callback.fset is not None:
- self._callback.fset(self._scalar.value)
-
# Notify observers of the change
self._notify_observers()
else:
@@ -745,13 +689,10 @@ def min(self) -> numbers.Number:
return self._min.value
@min.setter
- @property_stack
def min(self, min_value: numbers.Number) -> None:
"""
Set the minimum value for fitting.
- - implements undo/redo functionality.
-
Parameters
----------
min_value : numbers.Number
@@ -804,13 +745,10 @@ def max(self) -> numbers.Number:
return self._max.value
@max.setter
- @property_stack
def max(self, max_value: numbers.Number) -> None:
"""
Get the maximum value for fitting.
- - implements undo/redo functionality.
-
Parameters
----------
max_value : numbers.Number
@@ -863,13 +801,10 @@ def fixed(self) -> bool:
return self._fixed
@fixed.setter
- @property_stack
def fixed(self, fixed: bool) -> None:
"""
Change the parameter vary while fitting state.
- - implements undo/redo functionality.
-
Parameters
----------
fixed : bool
@@ -887,9 +822,6 @@ def fixed(self, fixed: bool) -> None:
if self._independent:
self._fixed = fixed
else:
- if self._global_object.stack.enabled:
- # Remove the recorded change from the stack
- global_object.stack.pop()
raise AttributeError(
'This is a dependent parameter, dependent parameters cannot be fixed.'
)
@@ -903,11 +835,11 @@ def free(self) -> bool:
def free(self, value: bool) -> None:
self.fixed = not value
- def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
+ def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]:
"""
- Overwrite the as_dict method to handle dependency information.
+ Overwrite the to_dict method to handle dependency information.
"""
- raw_dict = super().as_dict(skip=skip)
+ raw_dict = super().to_dict(skip=skip)
# Add dependency information for dependent parameters
if not self._independent:
@@ -970,15 +902,15 @@ def _process_dependency_unique_names(self, dependency_expression: str):
inputted_unique_names += re.findall('(".+?")', dependency_expression)
clean_dependency_string = dependency_expression
- existing_unique_names = self._global_object.map.vertices()
# Add the unique names of the parameters to the ASTEVAL interpreter
for name in inputted_unique_names:
stripped_name = name.strip('\'"')
- if stripped_name not in existing_unique_names:
+ try:
+ dependent_parameter = self._global_object.registry[stripped_name]
+ except KeyError:
raise ValueError(
f'A Parameter with unique_name {stripped_name} does not exist. Please check your dependency expression.'
- ) # noqa: E501
- dependent_parameter = self._global_object.map.get_item_by_key(stripped_name)
+ ) from None # noqa: E501
if isinstance(dependent_parameter, DescriptorNumber):
self._dependency_map['__' + stripped_name + '__'] = dependent_parameter
clean_dependency_string = clean_dependency_string.replace(
@@ -1019,11 +951,6 @@ def from_dict(cls, obj_dict: dict) -> 'Parameter':
return param
- def __copy__(self) -> Parameter:
- new_obj = super().__copy__()
- new_obj._callback = property()
- return new_obj
-
def __repr__(self) -> str:
"""Return printable representation of a Parameter object."""
super_str = super().__repr__()
@@ -1031,6 +958,8 @@ def __repr__(self) -> str:
s = []
if self.fixed:
super_str += ' (fixed)'
+ elif not self.independent:
+ super_str += ' (dependent)'
s.append(super_str)
s.append('bounds=[%s:%s]' % (repr(float(self.min)), repr(float(self.max))))
return '%s>' % ', '.join(s)
@@ -1066,11 +995,7 @@ def __add__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) ->
other._convert_unit(other_unit)
else:
return NotImplemented
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
- parameter.name = parameter.unique_name
- return parameter
+ return Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
def __radd__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
if isinstance(other, numbers.Number):
@@ -1095,11 +1020,7 @@ def __radd__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
self._convert_unit(original_unit)
else:
return NotImplemented
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
- parameter.name = parameter.unique_name
- return parameter
+ return Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
def __sub__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> Parameter:
if isinstance(other, numbers.Number):
@@ -1128,11 +1049,7 @@ def __sub__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) ->
other._convert_unit(other_unit)
else:
return NotImplemented
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
- parameter.name = parameter.unique_name
- return parameter
+ return Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
def __rsub__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
if isinstance(other, numbers.Number):
@@ -1157,21 +1074,13 @@ def __rsub__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
self._convert_unit(original_unit)
else:
return NotImplemented
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
- parameter.name = parameter.unique_name
- return parameter
+ return Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
def __mul__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> Parameter:
if isinstance(other, numbers.Number):
new_full_value = self.full_value * other
if other == 0:
- descriptor_number = DescriptorNumber.from_scipp(
- name=self.name, full_value=new_full_value
- )
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_full_value)
combinations = [self.min * other, self.max * other]
elif isinstance(
other, DescriptorNumber
@@ -1180,11 +1089,7 @@ def __mul__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) ->
if (
other.value == 0 and type(other) is DescriptorNumber
): # Only return DescriptorNumber if other is strictly 0, i.e. not a parameter # noqa: E501
- descriptor_number = DescriptorNumber.from_scipp(
- name=self.name, full_value=new_full_value
- )
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_full_value)
if isinstance(other, Parameter):
combinations = []
for first, second in [
@@ -1205,43 +1110,29 @@ def __mul__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) ->
return NotImplemented
min_value = min(combinations)
max_value = max(combinations)
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
+ parameter = Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
parameter._convert_unit(parameter._base_unit())
- parameter.name = parameter.unique_name
return parameter
def __rmul__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
if isinstance(other, numbers.Number):
new_full_value = other * self.full_value
if other == 0:
- descriptor_number = DescriptorNumber.from_scipp(
- name=self.name, full_value=new_full_value
- )
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_full_value)
combinations = [other * self.min, other * self.max]
elif isinstance(
other, DescriptorNumber
): # Parameter inherits from DescriptorNumber and is also handled here
new_full_value = other.full_value * self.full_value
if other.value == 0:
- descriptor_number = DescriptorNumber.from_scipp(
- name=self.name, full_value=new_full_value
- )
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_full_value)
combinations = [self.min * other.value, self.max * other.value]
else:
return NotImplemented
min_value = min(combinations)
max_value = max(combinations)
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
+ parameter = Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
parameter._convert_unit(parameter._base_unit())
- parameter.name = parameter.unique_name
return parameter
def __truediv__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) -> Parameter:
@@ -1287,11 +1178,8 @@ def __truediv__(self, other: Union[DescriptorNumber, Parameter, numbers.Number])
return NotImplemented
min_value = min(combinations)
max_value = max(combinations)
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
+ parameter = Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
parameter._convert_unit(parameter._base_unit())
- parameter.name = parameter.unique_name
return parameter
def __rtruediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
@@ -1302,22 +1190,14 @@ def __rtruediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parame
new_full_value = other / self.full_value
other_value = other
if other_value == 0:
- descriptor_number = DescriptorNumber.from_scipp(
- name=self.name, full_value=new_full_value
- )
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_full_value)
elif isinstance(
other, DescriptorNumber
): # Parameter inherits from DescriptorNumber and is also handled here
new_full_value = other.full_value / self.full_value
other_value = other.value
if other_value == 0:
- descriptor_number = DescriptorNumber.from_scipp(
- name=self.name, full_value=new_full_value
- )
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_full_value)
else:
return NotImplemented
if self.min < 0 and self.max > 0:
@@ -1336,11 +1216,8 @@ def __rtruediv__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parame
combinations = [other_value / self.min, other_value / self.max]
min_value = min(combinations)
max_value = max(combinations)
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
+ parameter = Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
parameter._convert_unit(parameter._base_unit())
- parameter.name = parameter.unique_name
return parameter
def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
@@ -1365,11 +1242,7 @@ def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
if np.isnan(new_full_value.value):
raise ValueError('The result of the exponentiation is not a number')
if exponent == 0:
- descriptor_number = DescriptorNumber.from_scipp(
- name=self.name, full_value=new_full_value
- )
- descriptor_number.name = descriptor_number.unique_name
- return descriptor_number
+ return DescriptorNumber.from_scipp(full_value=new_full_value)
elif exponent < 0:
if self.min < 0 and self.max > 0:
combinations = [-np.inf, np.inf]
@@ -1391,21 +1264,13 @@ def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> Parameter:
combinations = [combination for combination in combinations if combination >= 0]
min_value = min(combinations)
max_value = max(combinations)
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
- parameter.name = parameter.unique_name
- return parameter
+ return Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
def __neg__(self) -> Parameter:
new_full_value = -self.full_value
min_value = -self.max
max_value = -self.min
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
- parameter.name = parameter.unique_name
- return parameter
+ return Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
def __abs__(self) -> Parameter:
new_full_value = abs(self.full_value)
@@ -1414,11 +1279,7 @@ def __abs__(self) -> Parameter:
combinations.append(0.0)
min_value = min(combinations)
max_value = max(combinations)
- parameter = Parameter.from_scipp(
- name=self.name, full_value=new_full_value, min=min_value, max=max_value
- )
- parameter.name = parameter.unique_name
- return parameter
+ return Parameter.from_scipp(full_value=new_full_value, min=min_value, max=max_value)
def resolve_pending_dependencies(self) -> None:
"""
@@ -1463,9 +1324,9 @@ def resolve_pending_dependencies(self) -> None:
def _find_parameter_by_serializer_id(self, serializer_id: str) -> Optional['DescriptorNumber']:
"""
Find a parameter by its serializer_id from all parameters in the
- global map.
+ object registry.
"""
- for obj in self._global_object.map._store.values():
+ for obj in self._global_object.registry.objects():
if isinstance(obj, DescriptorNumber) and hasattr(
obj, '_DescriptorNumber__serializer_id'
):
diff --git a/src/easyscience/variable/parameter_dependency_resolver.py b/src/easyscience/variable/parameter_dependency_resolver.py
index d231dcd2..fd91411b 100644
--- a/src/easyscience/variable/parameter_dependency_resolver.py
+++ b/src/easyscience/variable/parameter_dependency_resolver.py
@@ -82,8 +82,8 @@ def _collect_parameters(item: Any, parameters: List[Parameter]) -> None:
error_count += 1
serializer_id = getattr(param, '_DescriptorNumber__serializer_id', 'unknown')
errors.append(
- f"Failed to resolve dependencies for parameter '{param.name}'"
- f" (unique_name: '{param.unique_name}', serializer_id: '{serializer_id}'): {e}"
+ f"Failed to resolve dependencies for parameter '{param.unique_name}'"
+ f" (display_name: '{param.display_name}', serializer_id: '{serializer_id}'): {e}"
)
# Report results
diff --git a/tests/conftest.py b/tests/conftest.py
index 2f9a6768..bdad1f01 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,7 +8,10 @@
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_ROOT = PROJECT_ROOT / 'src'
+# Shared test-only models, imported as ``from easy_test_models import ...``
+HELPERS_ROOT = Path(__file__).resolve().parent / 'helpers'
-src_root_str = str(SRC_ROOT)
-if src_root_str not in sys.path:
- sys.path.insert(0, src_root_str)
+for root in (SRC_ROOT, HELPERS_ROOT):
+ root_str = str(root)
+ if root_str not in sys.path:
+ sys.path.insert(0, root_str)
diff --git a/tests/functional/test_smoke.py b/tests/functional/test_smoke.py
deleted file mode 100644
index 23323fc9..00000000
--- a/tests/functional/test_smoke.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import numpy as np
-import pytest
-
-from easyscience import global_object
-from easyscience.models.polynomial import Polynomial
-
-
-@pytest.mark.fast
-def test_polynomial_smoke() -> None:
- """Exercise a minimal user-facing model workflow."""
- global_object.map._clear()
-
- model = Polynomial(name='smoke', coefficients=[1.0, 2.0, 3.0])
- x = np.array([0.0, 1.0, 2.0])
-
- assert [coefficient.name for coefficient in model.coefficients] == ['c0', 'c1', 'c2']
- assert np.allclose(model(x), np.polyval([1.0, 2.0, 3.0], x))
diff --git a/tests/helpers/easy_test_models.py b/tests/helpers/easy_test_models.py
new file mode 100644
index 00000000..8b6a51c8
--- /dev/null
+++ b/tests/helpers/easy_test_models.py
@@ -0,0 +1,111 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""
+Test-only models shared across the test suite.
+
+``tests/conftest.py`` puts this directory on ``sys.path``, so tests
+import from here with ``from easy_test_models import ...``. The module
+name starts with ``easy`` on purpose: the serializer only treats a dict
+as an EasyScience object (and dispatches to ``from_dict``) when its
+``@module`` starts with ``easy``, so this keeps serializer round trips
+working for these models.
+"""
+
+from __future__ import annotations
+
+from typing import Iterable
+
+import numpy as np
+
+from easyscience.base_classes import EasyList
+from easyscience.base_classes import ModelBase
+from easyscience.variable import Parameter
+
+
+class CoefficientModel(ModelBase):
+ """
+ A minimal model holding its parameters in an ``EasyList``.
+
+ It exercises nested containers in serialization, copying, fitting
+ parameter discovery and external calculators. Plain numbers become
+ ``Parameter`` objects named ``c0``, ``c1``, ... The model evaluates
+ as a polynomial with the coefficients in ``numpy.polyval`` order.
+
+ Parameters
+ ----------
+ display_name : str | None, default=None
+ Display name of the model.
+ coefficients : Iterable[float | Parameter] | EasyList | None, default=None
+ Initial coefficients, either numbers/``Parameter`` objects or a
+ ready-made ``EasyList``.
+ unique_name : str | None, default=None
+ Unique name of the model.
+ """
+
+ def __init__(
+ self,
+ display_name: str | None = None,
+ coefficients: Iterable[float | Parameter] | EasyList | None = None,
+ unique_name: str | None = None,
+ ):
+ super().__init__(unique_name=unique_name, display_name=display_name)
+ if isinstance(coefficients, EasyList):
+ self._coefficients = coefficients
+ return
+ self._coefficients = EasyList(protected_types=Parameter)
+ for index, item in enumerate(coefficients or []):
+ if not isinstance(item, Parameter):
+ item = Parameter(value=float(item), display_name=f'c{index}')
+ self._coefficients.append(item)
+
+ @property
+ def coefficients(self) -> EasyList:
+ """The coefficient parameters."""
+ return self._coefficients
+
+ def __call__(self, x: np.ndarray, *args, **kwargs) -> np.ndarray:
+ return np.polyval([c.value for c in self.coefficients], x)
+
+
+class PairModel(ModelBase):
+ """
+ Model holding two parameters as plain attributes.
+
+ Used to check that a nested ``Parameter`` is serialized through its
+ own ``to_dict``, so dependency information survives a round trip.
+
+ Following the ``ModelBase.from_dict`` convention, a plain number is
+ wrapped in a new ``Parameter`` while a ``Parameter`` is used as is.
+
+ Parameters
+ ----------
+ a : float | Parameter, default=0.0
+ First parameter.
+ b : float | Parameter, default=0.0
+ Second parameter, possibly dependent on ``a``.
+ unique_name : str | None, default=None
+ Unique name of the model.
+ display_name : str | None, default=None
+ Display name of the model.
+ """
+
+ def __init__(
+ self,
+ a: float | Parameter = 0.0,
+ b: float | Parameter = 0.0,
+ unique_name: str | None = None,
+ display_name: str | None = None,
+ ):
+ super().__init__(unique_name=unique_name, display_name=display_name)
+ self._a = a if isinstance(a, Parameter) else Parameter(value=float(a), display_name='a')
+ self._b = b if isinstance(b, Parameter) else Parameter(value=float(b), display_name='b')
+
+ @property
+ def a(self) -> Parameter:
+ """First parameter."""
+ return self._a
+
+ @property
+ def b(self) -> Parameter:
+ """Second parameter."""
+ return self._b
diff --git a/tests/integration/fitting/test_fitter.py b/tests/integration/fitting/test_fitter.py
index df17a65c..740c5033 100644
--- a/tests/integration/fitting/test_fitter.py
+++ b/tests/integration/fitting/test_fitter.py
@@ -4,37 +4,43 @@
import numpy as np
import pytest
-from easyscience import AvailableMinimizers
from easyscience import Fitter
-from easyscience import ObjBase
+from easyscience import Minimizers
from easyscience import Parameter
from easyscience.base_classes import ModelBase
from easyscience.fitting.minimizers import FitError
# Model and container of parameters for tests
-class AbsSin(ObjBase):
- phase: Parameter
- offset: Parameter
-
+class _PhaseOffset(ModelBase):
def __init__(self, offset_val: float, phase_val: float):
- offset = Parameter('offset', offset_val)
- phase = Parameter('phase', phase_val)
- super().__init__('sin', offset=offset, phase=phase)
+ super().__init__()
+ self._offset = Parameter(offset_val, display_name='offset')
+ self._phase = Parameter(phase_val, display_name='phase')
- def __call__(self, x):
- return np.abs(np.sin(self.phase.value * x + self.offset.value))
+ @property
+ def offset(self) -> Parameter:
+ return self._offset
+ @offset.setter
+ def offset(self, value: float) -> None:
+ self._offset.value = value
-class AbsSin2D(ObjBase):
- phase: Parameter
- offset: Parameter
+ @property
+ def phase(self) -> Parameter:
+ return self._phase
- def __init__(self, offset_val: float, phase_val: float):
- offset = Parameter('offset', offset_val)
- phase = Parameter('phase', phase_val)
- super().__init__('sin2D', offset=offset, phase=phase)
+ @phase.setter
+ def phase(self, value: float) -> None:
+ self._phase.value = value
+
+
+class AbsSin(_PhaseOffset):
+ def __call__(self, x):
+ return np.abs(np.sin(self.phase.value * x + self.offset.value))
+
+class AbsSin2D(_PhaseOffset):
def __call__(self, x):
X = x[:, :, 0] # x is a 2D array
Y = x[:, :, 1]
@@ -55,8 +61,8 @@ def __call__(self, x):
class StraightLine(ModelBase):
def __init__(self, slope: float, intercept: float):
super().__init__()
- self._slope = Parameter('slope', slope)
- self._intercept = Parameter('intercept', intercept)
+ self._slope = Parameter(slope, display_name='slope')
+ self._intercept = Parameter(intercept, display_name='intercept')
@property
def slope(self) -> Parameter:
@@ -78,8 +84,8 @@ def __call__(self, x: np.ndarray) -> np.ndarray:
return self.slope.value * x + self.intercept.value
-def check_fit_results(result, sp_sin, ref_sin, x, **kwargs):
- assert result.n_pars == len(sp_sin.get_fit_parameters())
+def check_fit_results(result, sp_sin, ref_sin, x, expect_error=True, **kwargs):
+ assert result.n_pars == len(sp_sin.get_free_parameters())
assert result.chi2 == pytest.approx(0, abs=1.5e-3 * (len(result.x) - result.n_pars))
assert result.reduced_chi2 == pytest.approx(0, abs=1.5e-3)
assert result.success
@@ -90,9 +96,14 @@ def check_fit_results(result, sp_sin, ref_sin, x, **kwargs):
assert key in result.p0.keys()
assert result.p0[key] == pytest.approx(value) # Bumps does something strange here
assert np.all(result.x == x)
- for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values()):
- # assert item.error > 0 % This does not work as some methods don't calculate error
- assert item1.error == pytest.approx(0, abs=2.1e-1)
+ for item1, item2 in zip(sp_sin.get_all_parameters(), ref_sin.get_all_parameters()):
+ # Gradient-free methods (e.g. lmfit's powell/cobyla) have no covariance
+ # matrix, so they report error as None rather than a fake 0.0. Every
+ # other method must still produce a real uncertainty.
+ if expect_error:
+ assert item1.error == pytest.approx(0, abs=2.1e-1)
+ else:
+ assert item1.error is None
assert item1.value == pytest.approx(item2.value, abs=5e-3)
y_calc_ref = ref_sin(x)
assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2)
@@ -104,12 +115,12 @@ def check_fit_results(result, sp_sin, ref_sin, x, **kwargs):
'fit_engine',
[
None,
- AvailableMinimizers.LMFit,
- AvailableMinimizers.Bumps,
- AvailableMinimizers.DFO,
+ Minimizers.LMFit,
+ Minimizers.Bumps,
+ Minimizers.DFO,
],
)
-def test_basic_fit(fit_engine: AvailableMinimizers):
+def test_basic_fit(fit_engine: Minimizers):
ref_sin = AbsSin(0.2, np.pi)
sp_sin = AbsSin(0.354, 3.05)
@@ -122,10 +133,7 @@ def test_basic_fit(fit_engine: AvailableMinimizers):
f = Fitter(sp_sin, sp_sin)
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
result = f.fit(x=x, y=y, weights=weights)
@@ -142,9 +150,9 @@ def test_basic_fit(fit_engine: AvailableMinimizers):
'fit_engine',
[
None,
- AvailableMinimizers.LMFit,
- AvailableMinimizers.Bumps,
- AvailableMinimizers.DFO,
+ Minimizers.LMFit,
+ Minimizers.Bumps,
+ Minimizers.DFO,
],
)
def test_fit_result(fit_engine):
@@ -160,20 +168,17 @@ def test_fit_result(fit_engine):
sp_ref1 = {
f'p{item1.unique_name}': item1.value
- for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values())
+ for item1, item2 in zip(sp_sin.get_all_parameters(), ref_sin.get_all_parameters())
}
sp_ref2 = {
f'p{item1.unique_name}': item2.value
- for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values())
+ for item1, item2 in zip(sp_sin.get_all_parameters(), ref_sin.get_all_parameters())
}
f = Fitter(sp_sin, sp_sin)
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
result = f.fit(x, y, weights=weights)
check_fit_results(result, sp_sin, ref_sin, x, sp_ref1=sp_ref1, sp_ref2=sp_ref2)
@@ -184,9 +189,9 @@ def test_fit_result(fit_engine):
'fit_engine',
[
None,
- AvailableMinimizers.LMFit,
- AvailableMinimizers.Bumps,
- AvailableMinimizers.DFO,
+ Minimizers.LMFit,
+ Minimizers.Bumps,
+ Minimizers.DFO,
],
)
def test_basic_max_evaluations(fit_engine):
@@ -202,10 +207,7 @@ def test_basic_max_evaluations(fit_engine):
f = Fitter(sp_sin, sp_sin)
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
f.max_evaluations = 3
result = f.fit(x=x, y=y, weights=weights)
# Result should not be the same as the reference
@@ -218,9 +220,9 @@ def test_basic_max_evaluations(fit_engine):
'fit_engine',
[
None,
- AvailableMinimizers.LMFit,
- AvailableMinimizers.Bumps,
- AvailableMinimizers.DFO,
+ Minimizers.LMFit,
+ Minimizers.Bumps,
+ Minimizers.DFO,
],
)
def test_max_evaluations_populates_fit_result_fields(fit_engine):
@@ -237,10 +239,7 @@ def test_max_evaluations_populates_fit_result_fields(fit_engine):
f = Fitter(sp_sin, sp_sin)
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
f.max_evaluations = 3
result = f.fit(x=x, y=y, weights=weights)
@@ -265,10 +264,7 @@ def test_bumps_max_evaluations_counts_objective_calls() -> None:
sp_sin.phase.fixed = False
f = Fitter(sp_sin, sp_sin)
- try:
- f.switch_minimizer(AvailableMinimizers.Bumps)
- except AttributeError:
- pytest.skip(reason=f'{AvailableMinimizers.Bumps} is not installed')
+ f.switch_minimizer(Minimizers.Bumps)
f.max_evaluations = 3
result = f.fit(x=x, y=y, weights=weights)
@@ -285,9 +281,9 @@ def test_bumps_max_evaluations_counts_objective_calls() -> None:
'fit_engine,tolerance',
[
(None, 10),
- (AvailableMinimizers.LMFit, 10),
- (AvailableMinimizers.Bumps, 10),
- (AvailableMinimizers.DFO, 0.1),
+ (Minimizers.LMFit, 10),
+ (Minimizers.Bumps, 10),
+ (Minimizers.DFO, 0.1),
],
)
def test_basic_tolerance(fit_engine, tolerance):
@@ -303,10 +299,7 @@ def test_basic_tolerance(fit_engine, tolerance):
f = Fitter(sp_sin, sp_sin)
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
f.tolerance = tolerance
result = f.fit(x=x, y=y, weights=weights)
# Result should not be the same as the reference
@@ -330,7 +323,9 @@ def test_lmfit_methods(fit_method):
f = Fitter(sp_sin, sp_sin)
assert fit_method in f._minimizer.supported_methods()
result = f.fit(x, y, weights=weights, method=fit_method)
- check_fit_results(result, sp_sin, ref_sin, x)
+ check_fit_results(
+ result, sp_sin, ref_sin, x, expect_error=fit_method not in ('powell', 'cobyla')
+ )
# @pytest.mark.xfail(reason="known bumps issue")
@@ -348,16 +343,45 @@ def test_bumps_methods(fit_method):
sp_sin.phase.fixed = False
f = Fitter(sp_sin, sp_sin)
- f.switch_minimizer('Bumps')
+ f.switch_minimizer(Minimizers.Bumps)
assert fit_method in f._minimizer.supported_methods()
result = f.fit(x, y, weights=weights, method=fit_method)
check_fit_results(result, sp_sin, ref_sin, x)
+@pytest.mark.fast
+def test_bumps_fit_emits_no_fitness_deprecation_warning():
+ """Regression (CR-1): the classical BUMPS fit path must not read the
+ deprecated ``FitProblem.fitness`` property, which emits a ``UserWarning``
+ on bumps >= 1.0.4 β the ``Curve`` comes back from ``build_curve_problem``
+ directly."""
+ import warnings
+
+ ref_sin = AbsSin(0.2, np.pi)
+ sp_sin = AbsSin(0.354, 3.05)
+
+ x = np.linspace(0, 5, 200)
+ weights = np.ones_like(x)
+ y = ref_sin(x)
+
+ sp_sin.offset.fixed = False
+ sp_sin.phase.fixed = False
+
+ f = Fitter(sp_sin, sp_sin)
+ f.switch_minimizer(Minimizers.Bumps)
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter('always')
+ f.fit(x, y, weights=weights)
+
+ fitness_warnings = [w for w in caught if 'fitness' in str(w.message)]
+ assert fitness_warnings == []
+
+
@pytest.mark.fast
@pytest.mark.parametrize(
'fit_engine',
- [AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO],
+ [Minimizers.LMFit, Minimizers.Bumps, Minimizers.DFO],
)
def test_dependent_parameter(fit_engine):
ref_sin = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5)
@@ -374,13 +398,13 @@ def test_dependent_parameter(fit_engine):
)
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
result = f.fit(x, y, weights=weights)
- check_fit_results(result, sp_sin, ref_sin, x)
+ # With `offset` dependent on `phase` only one parameter varies against
+ # noiseless data, so lmfit's covariance is exactly [[0.]] and it reports
+ # `errorbars=False` -> no uncertainties.
+ check_fit_results(result, sp_sin, ref_sin, x, expect_error=fit_engine is not Minimizers.LMFit)
@pytest.mark.fast
@@ -388,9 +412,9 @@ def test_dependent_parameter(fit_engine):
'fit_engine',
[
None,
- AvailableMinimizers.LMFit,
- AvailableMinimizers.Bumps,
- AvailableMinimizers.DFO,
+ Minimizers.LMFit,
+ Minimizers.Bumps,
+ Minimizers.DFO,
],
)
def test_2D_vectorized(fit_engine):
@@ -402,10 +426,7 @@ def test_2D_vectorized(fit_engine):
weights = np.ones_like(mm(XY))
ff = Fitter(m2, m2)
if fit_engine is not None:
- try:
- ff.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ ff.switch_minimizer(fit_engine)
try:
result = ff.fit(x=XY, y=mm(XY), weights=weights, vectorized=True)
except FitError as e:
@@ -413,7 +434,7 @@ def test_2D_vectorized(fit_engine):
pytest.skip(reason='MemoryError - Matrix too large')
else:
raise e
- assert result.n_pars == len(m2.get_fit_parameters())
+ assert result.n_pars == len(m2.get_free_parameters())
assert result.reduced_chi2 == pytest.approx(0, abs=1.5e-3)
assert result.success
assert np.all(result.x == XY)
@@ -427,9 +448,9 @@ def test_2D_vectorized(fit_engine):
'fit_engine',
[
None,
- AvailableMinimizers.LMFit,
- AvailableMinimizers.Bumps,
- AvailableMinimizers.DFO,
+ Minimizers.LMFit,
+ Minimizers.Bumps,
+ Minimizers.DFO,
],
)
def test_2D_non_vectorized(fit_engine):
@@ -441,10 +462,7 @@ def test_2D_non_vectorized(fit_engine):
weights = np.ones_like(mm(XY.reshape(-1, 2)))
ff = Fitter(m2, m2)
if fit_engine is not None:
- try:
- ff.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ ff.switch_minimizer(fit_engine)
try:
result = ff.fit(x=XY, y=mm(XY.reshape(-1, 2)), weights=weights, vectorized=False)
except FitError as e:
@@ -452,7 +470,7 @@ def test_2D_non_vectorized(fit_engine):
pytest.skip(reason='MemoryError - Matrix too large')
else:
raise e
- assert result.n_pars == len(m2.get_fit_parameters())
+ assert result.n_pars == len(m2.get_free_parameters())
assert result.reduced_chi2 == pytest.approx(0, abs=1.5e-3)
assert result.success
assert np.all(result.x == XY)
@@ -466,9 +484,9 @@ def test_2D_non_vectorized(fit_engine):
'fit_engine',
[
None,
- AvailableMinimizers.LMFit,
- AvailableMinimizers.Bumps,
- AvailableMinimizers.DFO,
+ Minimizers.LMFit,
+ Minimizers.Bumps,
+ Minimizers.DFO,
],
)
def test_fixed_parameter_does_not_change(fit_engine):
@@ -489,10 +507,7 @@ def test_fixed_parameter_does_not_change(fit_engine):
# THEN
f = Fitter(sp_sin, sp_sin)
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
result = f.fit(x=x, y=y, weights=weights)
@@ -529,9 +544,9 @@ def test_fitter_new_model_base_integration():
'fit_engine',
[
None,
- AvailableMinimizers.LMFit,
- AvailableMinimizers.Bumps,
- AvailableMinimizers.DFO,
+ Minimizers.LMFit,
+ Minimizers.Bumps,
+ Minimizers.DFO,
],
)
def test_fitter_variable_weights(fit_engine):
@@ -564,10 +579,7 @@ def run_fit(weights):
f = Fitter(model, model)
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
f.fit(x=x, y=y, weights=weights)
return model.offset.value, model.phase.value
@@ -598,9 +610,9 @@ def _analytic_wls(design: np.ndarray, y: np.ndarray, point_weights: np.ndarray):
'fit_engine',
[
None,
- AvailableMinimizers.LMFit,
- AvailableMinimizers.Bumps,
- AvailableMinimizers.DFO,
+ Minimizers.LMFit,
+ Minimizers.Bumps,
+ Minimizers.DFO,
],
)
def test_weight_convention_matches_analytic_wls(fit_engine):
@@ -638,10 +650,7 @@ def test_weight_convention_matches_analytic_wls(fit_engine):
f = Fitter(model, model)
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(reason=f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
result = f.fit(x=x, y=y, weights=1.0 / sigma)
@@ -649,7 +658,7 @@ def test_weight_convention_matches_analytic_wls(fit_engine):
assert model.slope.value == pytest.approx(beta[0], abs=tolerance)
assert model.intercept.value == pytest.approx(beta[1], abs=tolerance)
- if fit_engine in (None, AvailableMinimizers.LMFit):
+ if fit_engine in (None, Minimizers.LMFit):
# lmfit scales the covariance by reduced chi-square (scale_covar=True)
residual = y - design @ beta
chi2 = float(np.sum((residual / sigma) ** 2))
diff --git a/tests/integration/fitting/test_multi_fitter.py b/tests/integration/fitting/test_multi_fitter.py
index 25de53d5..46807d8e 100644
--- a/tests/integration/fitting/test_multi_fitter.py
+++ b/tests/integration/fitting/test_multi_fitter.py
@@ -4,47 +4,66 @@
import numpy as np
import pytest
-from easyscience import ObjBase
+from easyscience import Minimizers
from easyscience import Parameter
+from easyscience.base_classes import ModelBase
from easyscience.fitting.minimizers import FitError
from easyscience.fitting.multi_fitter import MultiFitter
-class Line(ObjBase):
- m: Parameter
- c: Parameter
-
+class Line(ModelBase):
def __init__(self, m_val: float, c_val: float):
- m = Parameter('m', m_val)
- c = Parameter('c', c_val)
- super(Line, self).__init__('line', m=m, c=c)
+ super().__init__()
+ self._m = Parameter(m_val, display_name='m')
+ self._c = Parameter(c_val, display_name='c')
+
+ @property
+ def m(self) -> Parameter:
+ return self._m
+
+ @m.setter
+ def m(self, value: float) -> None:
+ self._m.value = value
+
+ @property
+ def c(self) -> Parameter:
+ return self._c
+
+ @c.setter
+ def c(self, value: float) -> None:
+ self._c.value = value
def __call__(self, x):
return self.m.value * x + self.c.value
-class AbsSin(ObjBase):
- phase: Parameter
- offset: Parameter
-
+class AbsSin(ModelBase):
def __init__(self, offset_val: float, phase_val: float):
- offset = Parameter('offset', offset_val)
- phase = Parameter('phase', phase_val)
- super().__init__('sin', offset=offset, phase=phase)
+ super().__init__()
+ self._offset = Parameter(offset_val, display_name='offset')
+ self._phase = Parameter(phase_val, display_name='phase')
- def __call__(self, x):
- return np.abs(np.sin(self.phase.value * x + self.offset.value))
+ @property
+ def offset(self) -> Parameter:
+ return self._offset
+ @offset.setter
+ def offset(self, value: float) -> None:
+ self._offset.value = value
-class AbsSin2D(ObjBase):
- phase: Parameter
- offset: Parameter
+ @property
+ def phase(self) -> Parameter:
+ return self._phase
+
+ @phase.setter
+ def phase(self, value: float) -> None:
+ self._phase.value = value
+
+ def __call__(self, x):
+ return np.abs(np.sin(self.phase.value * x + self.offset.value))
- def __init__(self, offset_val: float, phase_val: float):
- offset = Parameter('offset', offset_val)
- phase = Parameter('phase', phase_val)
- super().__init__('sin2D', offset=offset, phase=phase)
+class AbsSin2D(AbsSin):
def __call__(self, x):
X = x[:, :, 0] # x is a 2D array
Y = x[:, :, 1]
@@ -53,7 +72,10 @@ def __call__(self, x):
)
-@pytest.mark.parametrize('fit_engine', [None, 'LMFit', 'Bumps', 'DFO'])
+@pytest.mark.parametrize(
+ 'fit_engine',
+ [None, Minimizers.LMFit, Minimizers.Bumps, Minimizers.DFO],
+)
def test_multi_fit(fit_engine):
ref_sin_1 = AbsSin(0.2, np.pi)
sp_sin_1 = AbsSin(0.354, 3.05)
@@ -80,10 +102,7 @@ def test_multi_fit(fit_engine):
f = MultiFitter([sp_sin_1, sp_sin_2], [sp_sin_1, sp_sin_2])
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
results = f.fit(x=[x1, x2], y=[y1, y2], weights=[weights, weights])
X = [x1, x2]
@@ -91,8 +110,8 @@ def test_multi_fit(fit_engine):
F_ref = [ref_sin_1, ref_sin_2]
F_real = [sp_sin_1, sp_sin_2]
for idx, result in enumerate(results):
- assert result.n_pars == len(sp_sin_1.get_fit_parameters()) + len(
- sp_sin_2.get_fit_parameters()
+ assert result.n_pars == len(sp_sin_1.get_free_parameters()) + len(
+ sp_sin_2.get_free_parameters()
)
assert result.chi2 == pytest.approx(0, abs=1.5e-3 * (len(result.x) - result.n_pars))
assert result.reduced_chi2 == pytest.approx(0, abs=1.5e-3)
@@ -103,7 +122,10 @@ def test_multi_fit(fit_engine):
assert result.residual == pytest.approx(F_real[idx](X[idx]) - F_ref[idx](X[idx]), abs=1e-2)
-@pytest.mark.parametrize('fit_engine', [None, 'LMFit', 'Bumps', 'DFO'])
+@pytest.mark.parametrize(
+ 'fit_engine',
+ [None, Minimizers.LMFit, Minimizers.Bumps, Minimizers.DFO],
+)
def test_multi_fit_propagates_iteration_metadata_and_message(fit_engine):
"""Verify that fit metadata and message are copied into each per-dataset result."""
ref_sin_1 = AbsSin(0.2, np.pi)
@@ -130,10 +152,7 @@ def test_multi_fit_propagates_iteration_metadata_and_message(fit_engine):
f = MultiFitter([sp_sin_1, sp_sin_2], [sp_sin_1, sp_sin_2])
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
results = f.fit(x=[x1, x2], y=[y1, y2], weights=[weights, weights])
for result in results:
@@ -146,7 +165,10 @@ def test_multi_fit_propagates_iteration_metadata_and_message(fit_engine):
assert isinstance(result.message, str)
-@pytest.mark.parametrize('fit_engine', [None, 'LMFit', 'Bumps', 'DFO'])
+@pytest.mark.parametrize(
+ 'fit_engine',
+ [None, Minimizers.LMFit, Minimizers.Bumps, Minimizers.DFO],
+)
def test_multi_fit2(fit_engine):
ref_sin_1 = AbsSin(0.2, np.pi)
sp_sin_1 = AbsSin(0.354, 3.05)
@@ -185,10 +207,7 @@ def test_multi_fit2(fit_engine):
f = MultiFitter([sp_sin_1, sp_line, sp_sin_2], [sp_sin_1, sp_line, sp_sin_2])
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
results = f.fit(x=[x1, x2, x3], y=[y1, y2, y3], weights=[weights, weights, weights])
X = [x1, x2, x3]
@@ -199,9 +218,9 @@ def test_multi_fit2(fit_engine):
assert len(results) == len(X)
for idx, result in enumerate(results):
- assert result.n_pars == len(sp_sin_1.get_fit_parameters()) + len(
- sp_sin_2.get_fit_parameters()
- ) + len(sp_line.get_fit_parameters())
+ assert result.n_pars == len(sp_sin_1.get_free_parameters()) + len(
+ sp_sin_2.get_free_parameters()
+ ) + len(sp_line.get_free_parameters())
assert result.chi2 == pytest.approx(0, abs=1.5e-3 * (len(result.x) - result.n_pars))
assert result.reduced_chi2 == pytest.approx(0, abs=1.5e-3)
assert result.success
@@ -211,7 +230,10 @@ def test_multi_fit2(fit_engine):
assert result.residual == pytest.approx(F_ref[idx](X[idx]) - F_real[idx](X[idx]), abs=1e-2)
-@pytest.mark.parametrize('fit_engine', [None, 'LMFit', 'Bumps', 'DFO'])
+@pytest.mark.parametrize(
+ 'fit_engine',
+ [None, Minimizers.LMFit, Minimizers.Bumps, Minimizers.DFO],
+)
def test_multi_fit_1D_2D(fit_engine):
# Generate fit and reference objects
ref_sin1D = AbsSin(0.2, np.pi)
@@ -241,10 +263,7 @@ def test_multi_fit_1D_2D(fit_engine):
ff = MultiFitter([sp_sin1D, sp_sin2D], [sp_sin1D, sp_sin2D])
if fit_engine is not None:
- try:
- ff.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(f'{fit_engine} is not installed')
+ ff.switch_minimizer(fit_engine)
sp_sin1D.offset.fixed = False
sp_sin1D.phase.fixed = False
@@ -252,10 +271,7 @@ def test_multi_fit_1D_2D(fit_engine):
f = MultiFitter([sp_sin1D, sp_sin2D], [sp_sin1D, sp_sin2D])
if fit_engine is not None:
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(f'{fit_engine} is not installed')
+ f.switch_minimizer(fit_engine)
try:
results = f.fit(
x=[x1D, x2D], y=[y1D, y2D], weights=[weights1D, weights2D], vectorized=True
@@ -271,11 +287,11 @@ def test_multi_fit_1D_2D(fit_engine):
F_ref = [ref_sin1D, ref_sin2D]
F_real = [sp_sin1D, sp_sin2D]
for idx, result in enumerate(results):
- assert result.n_pars == len(sp_sin1D.get_fit_parameters()) + len(
- sp_sin2D.get_fit_parameters()
+ assert result.n_pars == len(sp_sin1D.get_free_parameters()) + len(
+ sp_sin2D.get_free_parameters()
)
if (
- fit_engine != 'DFO'
+ fit_engine != Minimizers.DFO
): # DFO apparently does not fit well with even weights. Can't be bothered to fix
assert result.chi2 == pytest.approx(0, abs=1.5e-3 * (len(result.x) - result.n_pars))
assert result.reduced_chi2 == pytest.approx(0, abs=1.5e-3)
diff --git a/tests/integration/fitting/test_sampler.py b/tests/integration/fitting/test_sampler.py
index 032cc39f..efae0168 100644
--- a/tests/integration/fitting/test_sampler.py
+++ b/tests/integration/fitting/test_sampler.py
@@ -12,48 +12,67 @@
import numpy as np
import pytest
-from easyscience import ObjBase
from easyscience import Parameter
+from easyscience.base_classes import ModelBase
+from easyscience.fitting import Fitter
from easyscience.fitting import Sampler
from easyscience.fitting import SamplingResults
from easyscience.fitting.multi_fitter import MultiFitter
-class Line(ObjBase):
- m: Parameter
- c: Parameter
-
+class Line(ModelBase):
def __init__(self, m_val: float, c_val: float):
- m = Parameter('m', m_val)
- c = Parameter('c', c_val)
- super(Line, self).__init__('line', m=m, c=c)
+ super().__init__()
+ self._m = Parameter(m_val, display_name='m')
+ self._c = Parameter(c_val, display_name='c')
+
+ @property
+ def m(self) -> Parameter:
+ return self._m
+
+ @m.setter
+ def m(self, value: float) -> None:
+ self._m.value = value
+
+ @property
+ def c(self) -> Parameter:
+ return self._c
+
+ @c.setter
+ def c(self, value: float) -> None:
+ self._c.value = value
def __call__(self, x):
return self.m.value * x + self.c.value
-class AbsSin(ObjBase):
- phase: Parameter
- offset: Parameter
-
+class AbsSin(ModelBase):
def __init__(self, offset_val: float, phase_val: float):
- offset = Parameter('offset', offset_val)
- phase = Parameter('phase', phase_val)
- super().__init__('sin', offset=offset, phase=phase)
+ super().__init__()
+ self._offset = Parameter(offset_val, display_name='offset')
+ self._phase = Parameter(phase_val, display_name='phase')
- def __call__(self, x):
- return np.abs(np.sin(self.phase.value * x + self.offset.value))
+ @property
+ def offset(self) -> Parameter:
+ return self._offset
+ @offset.setter
+ def offset(self, value: float) -> None:
+ self._offset.value = value
-class AbsSin2D(ObjBase):
- phase: Parameter
- offset: Parameter
+ @property
+ def phase(self) -> Parameter:
+ return self._phase
- def __init__(self, offset_val: float, phase_val: float):
- offset = Parameter('offset', offset_val)
- phase = Parameter('phase', phase_val)
- super().__init__('sin2D', offset=offset, phase=phase)
+ @phase.setter
+ def phase(self, value: float) -> None:
+ self._phase.value = value
+ def __call__(self, x):
+ return np.abs(np.sin(self.phase.value * x + self.offset.value))
+
+
+class AbsSin2D(AbsSin):
def __call__(self, x):
X = x[:, :, 0] # x is a 2D array
Y = x[:, :, 1]
@@ -62,8 +81,13 @@ def __call__(self, x):
)
-def _bumps_fitter_and_data():
- """Build a 2-parameter BUMPS MultiFitter over a small sine model."""
+def _model_and_data():
+ """Build a 2-parameter sine model and a small dataset to sample.
+
+ No ``Fitter`` is involved: sampling needs only the model, its fit
+ function, the data, and an installed ``bumps`` package.
+ """
+ pytest.importorskip('bumps')
ref_sin = AbsSin(0.2, np.pi)
sp = AbsSin(0.354, 3.05)
sp.offset.fixed = False
@@ -71,22 +95,17 @@ def _bumps_fitter_and_data():
x = np.linspace(0, 5, 50)
y = ref_sin(x)
weights = np.ones_like(x)
- f = MultiFitter([sp], [sp])
- try:
- f.switch_minimizer('Bumps')
- except AttributeError:
- pytest.skip('BUMPS is not installed')
- return f, sp, x, y, weights
+ return sp, x, y, weights
class TestSampler:
- """Integration tests for ``Sampler(f, ...)`` / ``Sampler``."""
+ """Integration tests for ``Sampler``."""
@pytest.mark.filterwarnings('ignore::UserWarning')
def test_sample_returns_results_object(self):
- """sample() returns a populated SamplingResults; to_legacy_dict() has the legacy shape."""
- f, sp, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ """sample() returns a populated SamplingResults, cached on the sampler."""
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
results = sampler.sample(samples=100, burn=20, thin=2)
@@ -97,7 +116,7 @@ def test_sample_returns_results_object(self):
assert results.state is not None
# param_names should match the model's fit parameters
- expected_pars = {p.unique_name for p in sp.get_fit_parameters()}
+ expected_pars = {p.unique_name for p in sp.get_free_parameters()}
assert set(results.param_names) == expected_pars
# results cached on the sampler
@@ -106,14 +125,9 @@ def test_sample_returns_results_object(self):
assert sampler.draws is results.draws
assert sampler.param_names == results.param_names
- # legacy dict shape
- legacy = results.to_legacy_dict()
- assert set(legacy.keys()) == {'draws', 'param_names', 'internal_bumps_object', 'logp'}
- assert legacy['internal_bumps_object'] is results.state
-
@pytest.mark.filterwarnings('ignore::UserWarning')
def test_sample_multi_dataset(self):
- """Multi-dataset sampling via Sampler(f, ...) has correct param_names."""
+ """Multi-dataset sampling has correct param_names."""
ref_sin_1 = AbsSin(0.2, np.pi)
sp_sin_1 = AbsSin(0.354, 3.05)
sp_line = Line(0.43, 6.1)
@@ -133,24 +147,25 @@ def test_sample_multi_dataset(self):
sp_sin_1.phase.fixed = False
sp_line.c.fixed = False
+ pytest.importorskip('bumps')
+ # Direct multi-dataset construction: one container object exposing all
+ # parameters (here the one MultiFitter builds) and one function per
+ # dataset.
f = MultiFitter([sp_sin_1, sp_line], [sp_sin_1, sp_line])
- try:
- f.switch_minimizer('Bumps')
- except AttributeError:
- pytest.skip('BUMPS is not installed')
-
- sampler = Sampler(f, [x1, x2], [y1, y2], [weights, weights])
+ sampler = Sampler(
+ f.fit_object, [sp_sin_1, sp_line], [x1, x2], [y1, y2], [weights, weights]
+ )
results = sampler.sample(samples=100, burn=20, thin=2)
# All parameters across both models should appear
- all_params = {p.unique_name for p in sp_sin_1.get_fit_parameters()}
- all_params |= {p.unique_name for p in sp_line.get_fit_parameters()}
+ all_params = {p.unique_name for p in sp_sin_1.get_free_parameters()}
+ all_params |= {p.unique_name for p in sp_line.get_free_parameters()}
assert set(results.param_names) == all_params
def test_sample_population(self):
"""Passing population should succeed and produce valid draws."""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
results = sampler.sample(samples=100, burn=20, thin=2, population=5)
assert results.draws.shape[0] > 0
@@ -169,13 +184,8 @@ def test_sample_vectorized_2d(self):
sp.offset.fixed = False
sp.phase.fixed = False
- f = MultiFitter([sp], [sp])
- try:
- f.switch_minimizer('Bumps')
- except AttributeError:
- pytest.skip('BUMPS is not installed')
-
- sampler = Sampler(f, [x2D], [y2D], [weights], vectorized=True)
+ pytest.importorskip('bumps')
+ sampler = Sampler(sp, sp, x2D, y2D, weights, vectorized=True)
results = sampler.sample(samples=100, burn=20, thin=2)
assert results.draws.ndim == 2
@@ -183,20 +193,53 @@ def test_sample_vectorized_2d(self):
assert results.draws.shape[1] == len(results.param_names)
@pytest.mark.filterwarnings('ignore::UserWarning')
- def test_fit_function_restored_on_success(self):
- """fit_function must be restored after a successful sample()."""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
- original_func = f.fit_function
+ def test_from_fitter_samples_and_leaves_fitter_untouched(self):
+ """from_fitter() on a plain, unfitted Fitter with its default LMFit
+ minimizer samples fine and never touches the fitter."""
+ sp, x, y, weights = _model_and_data()
+ f = Fitter(sp, sp)
+ assert f.minimizer.package == 'lmfit'
+
+ sampler = Sampler.from_fitter(f, x, y, weights)
+ results = sampler.sample(samples=100, burn=20, thin=2)
- sampler.sample(samples=100, burn=20, thin=2)
- assert f.fit_function is original_func
+ assert results.draws.shape[0] > 0
+ assert f.fit_function is sp
+ assert f.minimizer.package == 'lmfit'
+
+ @pytest.mark.filterwarnings('ignore::UserWarning')
+ def test_from_multi_fitter_untouched_multi_dataset(self):
+ """from_fitter() on a 2-dataset MultiFitter samples each dataset with
+ its own function and leaves the fitter's fit_function alone."""
+ ref_sin = AbsSin(0.2, np.pi)
+ sp_sin = AbsSin(0.354, 3.05)
+ sp_line = Line(0.43, 6.1)
+ sp_sin.offset.fixed = False
+ sp_line.c.fixed = False
+
+ x1 = np.linspace(0, 5, 50)
+ y1 = ref_sin(x1)
+ x2 = np.copy(x1)
+ y2 = Line(1, 4.6)(x2)
+ weights = np.ones_like(x1)
+
+ pytest.importorskip('bumps')
+ f = MultiFitter([sp_sin, sp_line], [sp_sin, sp_line])
+ original = f.fit_function
+ assert original is sp_sin # two distinct per-dataset functions
+
+ sampler = Sampler.from_fitter(f, [x1, x2], [y1, y2], [weights, weights])
+ results = sampler.sample(samples=50, burn=5, thin=1)
+
+ assert f.fit_function is original
+ assert sampler.fit_function == [sp_sin, sp_line]
+ assert results.draws.shape[0] > 0
@pytest.mark.filterwarnings('ignore::UserWarning')
def test_sampler_kwargs_forwarded(self):
"""Per-call sampler_kwargs dict is forwarded to the BUMPS DREAM sampler."""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
results = sampler.sample(samples=100, burn=20, thin=2, sampler_kwargs={'init': 'random'})
@@ -204,34 +247,34 @@ def test_sampler_kwargs_forwarded(self):
assert results.draws.shape[0] > 0
@pytest.mark.filterwarnings('ignore::UserWarning')
- def test_default_sampler_kwargs_merged(self):
+ def test_default_sampler_kwargs_merged(self, monkeypatch):
"""Constructor-level sampler_kwargs defaults are used; per-call kwargs win."""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights], sampler_kwargs={'init': 'random'})
+ from easyscience.fitting.samplers.sampler_bumps import DreamSampler
+
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights, sampler_kwargs={'init': 'random'})
captured = {}
- original_mcmc_sample = type(f.minimizer).mcmc_sample
+ original_run = DreamSampler.run
def spy(self, **kwargs):
captured.update(kwargs.get('sampler_kwargs') or {})
- return original_mcmc_sample(self, **kwargs)
+ return original_run(self, **kwargs)
- try:
- type(f.minimizer).mcmc_sample = spy
- sampler.sample(samples=100, burn=20, thin=2)
- assert captured == {'init': 'random'}
+ monkeypatch.setattr(DreamSampler, 'run', spy)
+
+ sampler.sample(samples=100, burn=20, thin=2)
+ assert captured == {'init': 'random'}
- captured.clear()
- sampler.sample(samples=100, burn=20, thin=2, sampler_kwargs={'init': 'lhs'})
- assert captured == {'init': 'lhs'} # per-call overrides default
- finally:
- type(f.minimizer).mcmc_sample = original_mcmc_sample
+ captured.clear()
+ sampler.sample(samples=100, burn=20, thin=2, sampler_kwargs={'init': 'lhs'})
+ assert captured == {'init': 'lhs'} # per-call overrides default
@pytest.mark.filterwarnings('ignore::UserWarning')
def test_extend_chain(self):
"""extend(additional_samples=) continues the chain; ring-buffer math is done for the user."""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
first = sampler.sample(samples=100, burn=20, thin=1)
n_first = first.draws.shape[0]
@@ -250,8 +293,8 @@ def test_extend_with_thinning_keeps_existing_draws(self):
generations (``Ngen * Npop``), not from the retained-draw count,
which BUMPS divides by the thinning interval.
"""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
first = sampler.sample(samples=1000, burn=20, thin=10)
n_first = first.draws.shape[0]
@@ -264,8 +307,8 @@ def test_extend_with_thinning_keeps_existing_draws(self):
@pytest.mark.filterwarnings('ignore::UserWarning')
def test_extend_total_samples_override(self):
"""extend(total_samples=) bypasses the additional_samples arithmetic."""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
sampler.sample(samples=100, burn=20, thin=1)
extended = sampler.extend(total_samples=150, thin=1)
@@ -282,14 +325,14 @@ def test_extend_after_save_load_roundtrip(self, tmp_path, caplog):
"""
import logging
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
first = sampler.sample(samples=100, burn=20, thin=1)
prefix = str(tmp_path / 'chain')
sampler.save(prefix)
- sampler2 = Sampler(f, [x], [y], [weights])
+ sampler2 = Sampler(sp, sp, x, y, weights)
loaded = sampler2.load_state(prefix)
assert loaded.draws.shape[1] == first.draws.shape[1]
@@ -308,8 +351,8 @@ def test_extend_preserves_nondefault_population(self):
saved state on resume, otherwise BUMPS regenerates the default
population and raises ``Cannot change Nvar, Npop or Ncr on resize``.
"""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
first = sampler.sample(samples=100, burn=20, thin=1, population=5)
first_npop = first.state.Npop
@@ -327,8 +370,8 @@ def test_save_warns_when_fingerprint_unavailable(self, tmp_path, caplog, monkeyp
logs a warning and records ``null`` in the sidecar."""
import logging
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
sampler.sample(samples=100, burn=20, thin=2)
monkeypatch.setattr('easyscience.fitting.sampler._data_fingerprint', lambda *args: None)
@@ -344,14 +387,14 @@ def test_save_warns_when_fingerprint_unavailable(self, tmp_path, caplog, monkeyp
@pytest.mark.filterwarnings('ignore::UserWarning')
def test_load_state_populates_results(self, tmp_path):
"""A freshly loaded sampler reports draws/logp/param_names without resampling."""
- f, sp, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
first = sampler.sample(samples=100, burn=20, thin=2)
prefix = str(tmp_path / 'chain')
sampler.save(prefix)
- sampler2 = Sampler(f, [x], [y], [weights])
+ sampler2 = Sampler(sp, sp, x, y, weights)
assert sampler2.draws is None
loaded = sampler2.load_state(prefix)
@@ -377,22 +420,22 @@ def test_load_short_chain_regression(self, tmp_path):
reader collapses it to a 1-D array and ``load_state`` raises
``IndexError`` without the 2-D coercion workaround.
"""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
sampler.sample(samples=20, burn=5, thin=1)
prefix = str(tmp_path / 'short_chain')
sampler.save(prefix)
- sampler2 = Sampler(f, [x], [y], [weights])
+ sampler2 = Sampler(sp, sp, x, y, weights)
loaded = sampler2.load_state(prefix)
assert loaded.draws.shape[0] > 0
@pytest.mark.filterwarnings('ignore::UserWarning')
def test_load_fingerprint_mismatch_warns(self, tmp_path, caplog):
"""Loading a chain into a sampler bound to different data warns."""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
sampler.sample(samples=100, burn=20, thin=2)
prefix = str(tmp_path / 'chain')
@@ -401,7 +444,7 @@ def test_load_fingerprint_mismatch_warns(self, tmp_path, caplog):
import logging
other_y = y + 0.5
- sampler2 = Sampler(f, [x], [other_y], [weights])
+ sampler2 = Sampler(sp, sp, x, other_y, weights)
with caplog.at_level(logging.WARNING, logger='easyscience.fitting'):
sampler2.load_state(prefix)
assert 'does not match the data fingerprint' in caplog.text
diff --git a/tests/integration/fitting/test_stateless_calculator.py b/tests/integration/fitting/test_stateless_calculator.py
new file mode 100644
index 00000000..69f2c230
--- /dev/null
+++ b/tests/integration/fitting/test_stateless_calculator.py
@@ -0,0 +1,139 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""
+Fitting through an external, stateless calculator.
+
+Core keeps the only copy of parameter state. A calculator reads the
+model when asked to calculate and does not write back. These tests pin
+that contract down with a plain class, without any core calculator
+machinery.
+"""
+
+import numpy as np
+import pytest
+from easy_test_models import CoefficientModel
+
+from easyscience import Minimizers
+from easyscience import Parameter
+from easyscience import global_object
+from easyscience.fitting import Fitter
+from easyscience.fitting.multi_fitter import MultiFitter
+
+
+class PolynomialCalculator:
+ """
+ A stateless calculator owned by a technique library.
+
+ It keeps no copy of any parameter: every evaluation reads the
+ current values from the model it is handed.
+ """
+
+ def __init__(self):
+ self.n_calls = 0
+
+ def calculate(self, model: CoefficientModel, x: np.ndarray) -> np.ndarray:
+ self.n_calls += 1
+ coefficients = [float(c.value) for c in model.coefficients]
+ return np.polyval(coefficients, x)
+
+ def fit_func_for(self, model: CoefficientModel):
+ return lambda x: self.calculate(model, x)
+
+
+@pytest.fixture(autouse=True)
+def clear():
+ global_object.registry.clear()
+ yield
+ global_object.registry.clear()
+
+
+def noise(x: np.ndarray) -> np.ndarray:
+ # Small, reproducible noise so every engine can estimate uncertainties
+ return np.random.default_rng(0).normal(0.0, 0.01, size=x.shape)
+
+
+def snapshot(model: CoefficientModel):
+ return [
+ (p.unique_name, p.value, p.variance, p.min, p.max, p.fixed, p.independent)
+ for p in model.coefficients
+ ]
+
+
+def test_calculation_reads_current_values_and_leaves_model_untouched():
+ # When
+ model = CoefficientModel(coefficients=[2.0, 1.0])
+ calculator = PolynomialCalculator()
+ x = np.linspace(0, 1, 5)
+ before = snapshot(model)
+
+ # Then Expect
+ np.testing.assert_allclose(calculator.calculate(model, x), 2.0 * x + 1.0)
+ assert snapshot(model) == before
+
+ # Then # Edits made through core are seen by the next evaluation
+ model.coefficients[0].value = 3.0
+
+ # Expect
+ np.testing.assert_allclose(calculator.calculate(model, x), 3.0 * x + 1.0)
+
+
+@pytest.mark.parametrize('fit_engine', [Minimizers.LMFit, Minimizers.Bumps, Minimizers.DFO])
+def test_fit_with_dependent_parameter(fit_engine):
+ # When # The intercept is tied to the slope, so only the slope is fitted
+ slope = Parameter(1.0, display_name='slope', min=-10, max=10)
+ intercept = Parameter.from_dependency('0.5 * s', {'s': slope}, display_name='intercept')
+ model = CoefficientModel(coefficients=[slope, intercept])
+ calculator = PolynomialCalculator()
+ x = np.linspace(0, 10, 50)
+ y = 3.0 * x + 1.5 + noise(x)
+ weights = np.ones_like(x)
+
+ fitter = Fitter(model, calculator.fit_func_for(model))
+ fitter.switch_minimizer(fit_engine)
+
+ # Then
+ result = fitter.fit(x=x, y=y, weights=weights)
+
+ # Expect
+ assert result.success
+ assert calculator.n_calls > 0
+ assert [p.unique_name for p in model.get_free_parameters()] == [slope.unique_name]
+ assert slope.value == pytest.approx(3.0, abs=1e-2)
+ assert intercept.value == pytest.approx(0.5 * slope.value)
+ np.testing.assert_allclose(calculator.calculate(model, x), y, atol=0.1)
+
+
+@pytest.mark.parametrize('fit_engine', [Minimizers.LMFit, Minimizers.Bumps, Minimizers.DFO])
+def test_multi_fit_with_shared_parameter(fit_engine):
+ # When # Two models share their intercept
+ shared = Parameter(0.0, display_name='shared', min=-10, max=10)
+ model_1 = CoefficientModel(
+ coefficients=[Parameter(1.0, display_name='slope_1', min=-10, max=10), shared]
+ )
+ model_2 = CoefficientModel(
+ coefficients=[Parameter(1.0, display_name='slope_2', min=-10, max=10), shared]
+ )
+ calculator = PolynomialCalculator()
+ x_1 = np.linspace(0, 5, 30)
+ x_2 = np.linspace(-5, 0, 40)
+ y_1 = 2.0 * x_1 - 1.0 + noise(x_1)
+ y_2 = -3.0 * x_2 - 1.0 + noise(x_2)
+
+ fitter = MultiFitter(
+ [model_1, model_2],
+ [calculator.fit_func_for(model_1), calculator.fit_func_for(model_2)],
+ )
+ fitter.switch_minimizer(fit_engine)
+
+ # Then
+ results = fitter.fit(
+ x=[x_1, x_2], y=[y_1, y_2], weights=[np.ones_like(x_1), np.ones_like(x_2)]
+ )
+
+ # Expect
+ assert all(result.success for result in results)
+ assert len({p.unique_name for p in fitter.fit_object.get_free_parameters()}) == 3
+ assert model_1.coefficients[0].value == pytest.approx(2.0, abs=1e-2)
+ assert model_2.coefficients[0].value == pytest.approx(-3.0, abs=1e-2)
+ assert shared.value == pytest.approx(-1.0, abs=1e-2)
+ assert model_1.coefficients[1] is model_2.coefficients[1]
diff --git a/tests/unit/base_classes/test_collection_base.py b/tests/unit/base_classes/test_collection_base.py
deleted file mode 100644
index 732c3acf..00000000
--- a/tests/unit/base_classes/test_collection_base.py
+++ /dev/null
@@ -1,545 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import pytest
-
-import easyscience
-from easyscience import DescriptorNumber
-from easyscience import ObjBase
-from easyscience import Parameter
-from easyscience import global_object
-from easyscience.base_classes import CollectionBase
-
-test_dict = {
- '@module': 'easyscience.base_classes',
- '@class': 'CollectionBase',
- '@version': easyscience.__version__,
- 'name': 'testing',
- 'data': [
- {
- '@module': DescriptorNumber.__module__,
- '@class': DescriptorNumber.__name__,
- '@version': easyscience.__version__,
- 'name': 'par1',
- 'value': 1.0,
- 'unit': 'dimensionless',
- 'variance': None,
- 'unique_name': 'CollectionBase_0',
- 'description': '',
- 'url': '',
- 'display_name': 'par1',
- }
- ],
-}
-
-
-class Alpha(CollectionBase):
- pass
-
-
-class_constructors = [CollectionBase, Alpha]
-
-
-@pytest.fixture
-def setup_pars():
- d = {
- 'name': 'test',
- 'par1': Parameter('p1', 0.1, fixed=True),
- 'des1': DescriptorNumber('d1', 0.1),
- 'par2': Parameter('p2', 0.1),
- 'des2': DescriptorNumber('d2', 0.1),
- 'par3': Parameter('p3', 0.1),
- }
- return d
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_from_base(cls, setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- coll = cls(name, **setup_pars)
-
- assert coll.name == name
- assert len(coll) == 5
- assert coll.user_data == {}
-
- for item, key in zip(coll, setup_pars.keys()):
- assert item.name == setup_pars[key].name
- assert item.value == setup_pars[key].value
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('value', range(1, 11))
-def test_CollectionBase_from_ObjBase(cls, setup_pars: dict, value: int):
- name = setup_pars['name']
- del setup_pars['name']
- objs = {}
-
- prefix = 'obj'
- for idx in range(value):
- objs[prefix + str(idx)] = ObjBase(prefix + str(idx), **setup_pars)
-
- coll = cls(name, **objs)
-
- assert coll.name == name
- assert len(coll) == value
- assert coll.user_data == {}
-
- idx = 0
- for item, key in zip(coll, objs.keys()):
- assert item.name == prefix + str(idx)
- assert isinstance(item, objs[key].__class__)
- idx += 1
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('value', ('abc', False, (), []))
-def test_CollectionBase_create_fail(cls, setup_pars, value):
- name = setup_pars['name']
- del setup_pars['name']
- setup_pars['to_fail'] = value
-
- with pytest.raises(AttributeError):
- coll = cls(name, **setup_pars)
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('key', ('user_data', '_kwargs', 'interface'))
-def test_CollectionBase_create_fail2(cls, setup_pars, key):
- name = setup_pars['name']
- del setup_pars['name']
- setup_pars[key] = DescriptorNumber('fail_name', 0)
-
- with pytest.raises(AttributeError):
- coll = cls(name, **setup_pars)
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_append_base(cls, setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
-
- new_item_name = 'boo'
- new_item_value = 100
- new_item = Parameter(new_item_name, new_item_value)
-
- coll = cls(name, **setup_pars)
- n_before = len(coll)
-
- coll.append(new_item)
- assert len(coll) == n_before + 1
- assert coll[-1].name == new_item_name
- assert coll[-1].value == new_item_value
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('value', ('abc', False, (), []))
-def test_CollectionBase_append_fail(cls, setup_pars, value):
- name = setup_pars['name']
- del setup_pars['name']
-
- coll = cls(name, **setup_pars)
- with pytest.raises(AttributeError):
- coll.append(value)
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('value', (0, 1, 3, 'par1', 'des1'))
-def test_CollectionBase_getItem(cls, setup_pars, value):
- name = setup_pars['name']
- del setup_pars['name']
-
- coll = cls(name, **setup_pars)
-
- get_item = coll[value]
- if isinstance(value, str):
- key = value
- else:
- key = list(setup_pars.keys())[value]
- assert get_item.name == setup_pars[key].name
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('value', (False, [], (), 100, 100.4))
-def test_CollectionBase_getItem_type_fail(cls, setup_pars, value):
- name = setup_pars['name']
- del setup_pars['name']
-
- coll = cls(name, **setup_pars)
-
- with pytest.raises((IndexError, TypeError)):
- get_item = coll[value]
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_getItem_slice(cls, setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
-
- coll = cls(name, **setup_pars)
-
- get_item = coll[0:2]
- assert len(get_item) == 2
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('value', (0, 1, 3))
-def test_CollectionBase_setItem(cls, setup_pars, value):
- name = setup_pars['name']
- del setup_pars['name']
-
- coll = cls(name, **setup_pars)
- n_coll = len(coll)
- name_coll_idx = coll[value].name
-
- new_item_value = 100
-
- coll[value] = new_item_value
-
- assert len(coll) == n_coll
- assert coll[value].name == name_coll_idx
- assert coll[value].value == new_item_value
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('value', ('abc', (), []))
-def test_CollectionBase_setItem_fail(cls, setup_pars, value):
- name = setup_pars['name']
- del setup_pars['name']
-
- coll = cls(name, **setup_pars)
-
- with pytest.raises(NotImplementedError):
- for idx in range(len(coll)):
- coll[idx] = value
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('value', (0, 1, 3))
-def test_CollectionBase_delItem(cls, setup_pars, value):
- name = setup_pars['name']
- del setup_pars['name']
-
- coll = cls(name, **setup_pars)
- n_coll = len(coll)
- # On del we should shift left
- name_coll_idx = coll[value].name
- name_coll_idxp = coll[value + 1].name
-
- del coll[value]
-
- assert len(coll) == n_coll - 1
- assert coll[value].name == name_coll_idxp
- assert name_coll_idx not in [col.name for col in coll]
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-@pytest.mark.parametrize('value', (0, 1, 3))
-def test_CollectionBase_len(cls, setup_pars, value):
- name = setup_pars['name']
- del setup_pars['name']
-
- keys = list(setup_pars.keys())
- keys = keys[0 : (value + 1)]
-
- coll = cls(name, **{key: setup_pars[key] for key in keys})
- assert len(coll) == (value + 1)
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_get_parameters(cls, setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = cls(name, **setup_pars)
- pars = obj.get_parameters()
- assert len(pars) == 3
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_get_parameters_nested(cls, setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
-
- name2 = name + '_2'
- obj2 = cls(name2, obj=obj, **setup_pars)
-
- pars = obj2.get_parameters()
- assert len(pars) == 6
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_get_fit_parameters(cls, setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = cls(name, **setup_pars)
- pars = obj.get_fit_parameters()
- assert len(pars) == 2
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_get_fit_parameters_nested(cls, setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
-
- name2 = name + '_2'
- obj2 = cls(name2, obj=obj, **setup_pars)
-
- pars = obj2.get_fit_parameters()
- assert len(pars) == 4
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_dir(cls):
- name = 'testing'
- kwargs = {'p1': DescriptorNumber('par1', 1)}
- obj = cls(name, **kwargs)
- d = set(dir(obj))
-
- expected = {
- 'user_data',
- 'reverse',
- 'constraints',
- 'get_fit_parameters',
- 'append',
- 'unique_name',
- 'index',
- 'as_dict',
- 'clear',
- 'extend',
- 'encode',
- 'remove',
- 'interface',
- 'from_dict',
- 'name',
- 'switch_interface',
- 'get_parameters',
- 'insert',
- 'data',
- 'pop',
- 'count',
- 'generate_bindings',
- 'decode',
- 'sort',
- }
- assert not d.difference(expected)
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_as_dict(cls):
- name = 'testing'
- kwargs = {'p1': DescriptorNumber('par1', 1)}
- obj = cls(name, **kwargs)
- d = obj.as_dict()
-
- def check_dict(dict_1: dict, dict_2: dict):
- keys_1 = list(dict_1.keys())
- keys_2 = list(dict_2.keys())
- if 'unique_name' in keys_1:
- del keys_1[keys_1.index('unique_name')]
- if 'unique_name' in keys_2:
- del keys_2[keys_2.index('unique_name')]
-
- assert not set(keys_1).difference(set(keys_2))
-
- def testit(item1, item2):
- if isinstance(item1, dict) and isinstance(item2, dict):
- check_dict(item1, item2)
- elif isinstance(item1, list) and isinstance(item2, list):
- for v1, v2 in zip(item1, item2):
- testit(v1, v2)
- else:
- if isinstance(item1, str) and isinstance(item2, str):
- assert item1 == item2
- elif isinstance(item1, float) and isinstance(item2, float):
- assert item1 == item2
- else:
- assert item1 is item2
-
- keys_1 = list(keys_1)
- keys_1.sort()
- keys_2 = list(keys_2)
- keys_2.sort()
-
- for k1, k2 in zip(keys_1, keys_2):
- if k1[0] == '@':
- continue
- testit(dict_1[k1], dict_2[k2])
-
- check_dict(d, test_dict)
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_from_dict(cls):
- global_object.map._clear() # TODO: figure out why this test fails without this line
- name = 'testing'
- kwargs = {'p1': DescriptorNumber('par1', 1)}
- expected = cls.from_dict(test_dict)
- ref = cls(name, **kwargs)
-
- assert ref.name == expected.name
- assert len(ref) == len(expected)
- for item1, item2 in zip(ref, expected):
- assert item1.name == item2.name
- assert item1.value == item2.value
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_repr(cls):
- name = 'test'
- p1 = Parameter('p1', 1)
- obj = cls(name, p1)
- test_str = str(obj)
- ref_str = f'{cls.__name__} `{name}` of length 1'
- assert test_str == ref_str
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_iterator(cls):
- name = 'test'
- p1 = Parameter('p1', 1)
- p2 = Parameter('p2', 2)
- p3 = Parameter('p3', 3)
- p4 = Parameter('p4', 4)
-
- l_object = [p1, p2, p3, p4]
-
- obj = cls(name, *l_object)
-
- for index, item in enumerate(obj):
- assert item == l_object[index]
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_iterator_dict(cls):
- global_object.map._clear() # TODO: figure out why this test fails without this line
- name = 'test'
- p1 = Parameter('p1', 1)
- p2 = Parameter('p2', 2)
- p3 = Parameter('p3', 3)
- p4 = Parameter('p4', 4)
-
- l_object = [p1, p2, p3, p4]
-
- obj = cls(name, *l_object)
- d = obj.as_dict()
- global_object.map._clear()
- obj2 = cls.from_dict(d)
-
- for index, item in enumerate(obj2):
- assert item.value == l_object[index].value
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_sameName(cls):
- global_object.map._clear() # TODO: figure out why this test fails without this line
- name = 'test'
- p1 = Parameter('p1', 1)
- p2 = Parameter('p1', 2)
- p3 = Parameter('p3', 3)
- p4 = Parameter('p4', 4)
-
- l_object = [p1, p2, p3, p4]
- obj = cls(name, *l_object)
- assert len(l_object) == len(obj)
- for index, item in enumerate(obj):
- assert item == l_object[index]
-
- obj12 = obj['p1']
- assert len(obj12) == 2
- for index, item in enumerate(obj12):
- assert item == l_object[index]
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_set_index(cls):
- name = 'test'
- p1 = Parameter('p1', 1)
- p2 = Parameter('p1', 2)
- p3 = Parameter('p3', 3)
- p4 = Parameter('p4', 4)
-
- l_object = [p1, p2, p3]
- obj = cls(name, *l_object)
-
- idx = 1
- assert obj[idx] == p2
- obj[idx] = p4
- assert obj[idx] == p4
- edges = obj._global_object.map.get_edges(obj)
- assert len(edges) == len(obj)
- for item in obj:
- assert item.unique_name in edges
- assert p2.unique_name not in edges
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_set_index_based(cls):
- name = 'test'
- p1 = Parameter('p1', 1)
- p2 = Parameter('p2', 2)
- p3 = Parameter('p3', 3)
- p4 = Parameter('p4', 4)
- p5 = Parameter('p5', 5)
- d = cls('testing', p1, p2)
-
- l_object = [p3, p4, p5]
- obj = cls(name, *l_object)
-
- idx = 1
- assert obj[idx] == p4
- obj[idx] = d
- assert obj[idx] == d
- edges = obj._global_object.map.get_edges(obj)
- assert len(edges) == len(obj)
- for item in obj:
- assert item.unique_name in edges
- assert p4.unique_name not in edges
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_sort(cls):
- name = 'test'
- v = [1, 4, 3, 2, 5]
- expected = [1, 2, 3, 4, 5]
- d = cls(name, *[Parameter(f'p{i}', v[i]) for i in range(len(v))])
- d.sort(lambda x: x.value)
- for i, item in enumerate(d):
- assert item.value == expected[i]
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBase_sort_reverse(cls):
- name = 'test'
- v = [1, 4, 3, 2, 5]
- expected = [1, 2, 3, 4, 5]
- expected.reverse()
- d = cls(name, *[Parameter(f'p{i}', v[i]) for i in range(len(v))])
- d.sort(lambda x: x.value, reverse=True)
- for i, item in enumerate(d):
- assert item.value == expected[i]
-
-
-class Beta(ObjBase):
- pass
-
-
-@pytest.mark.parametrize('cls', class_constructors)
-def test_CollectionBaseGraph(cls):
- from easyscience import global_object
-
- G = global_object.map
- name = 'test'
- v = [1, 2]
- p = [Parameter(f'p{i}', v[i]) for i in range(len(v))]
- p_id = [_p.unique_name for _p in p]
- bb = cls(name, *p)
- bb_id = bb.unique_name
- b = Beta('b', bb=bb)
- b_id = b.unique_name
- for _id in p_id:
- assert _id in G.get_edges(bb)
- assert len(p) == len(G.get_edges(bb))
- assert bb_id in G.get_edges(b)
- assert 1 == len(G.get_edges(b))
diff --git a/tests/unit/base_classes/test_deprecated_wrappers.py b/tests/unit/base_classes/test_deprecated_wrappers.py
deleted file mode 100644
index 492fc387..00000000
--- a/tests/unit/base_classes/test_deprecated_wrappers.py
+++ /dev/null
@@ -1,87 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-"""Tests for the deprecated wrapper modules in easyscience.base_classes.
-
-These modules now only emit log warnings and re-export from
-easyscience.legacy. We test that the log messages are raised and that
-the classes are still usable.
-"""
-
-import importlib
-import logging
-
-import pytest
-
-from easyscience import global_object
-
-
-@pytest.fixture(autouse=True)
-def _clear_map():
- """Clear the global object map before and after each test."""
- global_object.map._clear()
- yield
- global_object.map._clear()
-
-
-# ---------------------------------------------------------------------------
-# Deprecated wrapper: easyscience.base_classes.collection_base
-# ---------------------------------------------------------------------------
-
-
-def test_import_collection_base_warns(caplog: 'pytest.LogCaptureFixture'):
- """Importing easyscience.base_classes.collection_base logs a WARNING."""
- import easyscience.base_classes.collection_base
-
- with caplog.at_level(logging.WARNING, logger='easyscience'):
- importlib.reload(easyscience.base_classes.collection_base)
-
- assert len(caplog.records) >= 1, 'Expected at least one log record'
- messages = [r.message for r in caplog.records]
- combined = ' '.join(messages)
- assert 'deprecated' in combined.lower()
- assert 'legacy.collection_base' in combined
-
-
-def test_collection_base_still_works_from_deprecated(caplog: 'pytest.LogCaptureFixture'):
- """The class imported from the deprecated wrapper still works."""
- with caplog.at_level(logging.ERROR, logger='easyscience'):
- from easyscience.base_classes.collection_base import CollectionBase # noqa: F811
-
- from easyscience import Parameter
-
- p = Parameter('p1', 1.0)
- coll = CollectionBase('test', p)
- assert len(coll) == 1
- assert coll[0].name == 'p1'
-
-
-# ---------------------------------------------------------------------------
-# Deprecated wrapper: easyscience.base_classes.obj_base
-# ---------------------------------------------------------------------------
-
-
-def test_import_obj_base_warns(caplog: 'pytest.LogCaptureFixture'):
- """Importing easyscience.base_classes.obj_base logs a WARNING."""
- import easyscience.base_classes.obj_base
-
- with caplog.at_level(logging.WARNING, logger='easyscience'):
- importlib.reload(easyscience.base_classes.obj_base)
-
- assert len(caplog.records) >= 1, 'Expected at least one log record'
- messages = [r.message for r in caplog.records]
- combined = ' '.join(messages)
- assert 'deprecated' in combined.lower()
- assert 'legacy.obj_base' in combined
-
-
-def test_obj_base_still_works_from_deprecated(caplog: 'pytest.LogCaptureFixture'):
- """The class imported from the deprecated wrapper still works."""
- with caplog.at_level(logging.ERROR, logger='easyscience'):
- from easyscience.base_classes.obj_base import ObjBase # noqa: F811
-
- from easyscience import Parameter
-
- p = Parameter('p1', 1.0)
- obj = ObjBase('test', p1=p)
- assert obj.p1.value == 1.0
diff --git a/tests/unit/base_classes/test_new_base.py b/tests/unit/base_classes/test_easy_base.py
similarity index 55%
rename from tests/unit/base_classes/test_new_base.py
rename to tests/unit/base_classes/test_easy_base.py
index 7d74cac2..338d3be5 100644
--- a/tests/unit/base_classes/test_new_base.py
+++ b/tests/unit/base_classes/test_easy_base.py
@@ -4,79 +4,92 @@
import pytest
from easyscience import global_object
-from easyscience.base_classes import NewBase
+from easyscience.base_classes import EasyBase
-class NestedNewBase(NewBase):
- def __init__(self, unique_name=None, display_name=None, nested_new_base=None):
+class NestedEasyBase(EasyBase):
+ def __init__(self, unique_name=None, display_name=None, nested_easy_base=None):
super().__init__(unique_name=unique_name, display_name=display_name)
- self.nested_new_base = nested_new_base or NewBase()
+ self.nested_easy_base = nested_easy_base or EasyBase()
-class TestNewBase:
+class TestEasyBase:
@pytest.fixture
def clear(self):
- # Clear the global object map before each test
- global_object.map._clear()
+ # Clear the global object registry before each test
+ global_object.registry.clear()
def test_constructor_defaults(self, clear):
# When Then
- obj = NewBase()
+ obj = EasyBase()
# Expect
- assert obj._unique_name == 'NewBase_0'
+ assert obj._unique_name == 'EasyBase_0'
assert obj._default_unique_name is True
assert obj._display_name is None
- assert global_object.map.get_item_by_key('NewBase_0') is obj
+ assert global_object.registry['EasyBase_0'] is obj
def test_constructor_with_arguments(self):
# When Then
- obj = NewBase(unique_name='custom_name', display_name='My Object')
+ obj = EasyBase(unique_name='custom_name', display_name='My Object')
# Expect
assert obj._unique_name == 'custom_name'
assert obj._default_unique_name is False
assert obj._display_name == 'My Object'
- assert global_object.map.get_item_by_key('custom_name') is obj
+ assert global_object.registry['custom_name'] is obj
def test_constructor_invalid_unique_name(self):
# When Then Expect
with pytest.raises(TypeError, match='Unique name has to be a string.'):
- NewBase(unique_name=123)
+ EasyBase(unique_name=123)
def test_constructor_invalid_display_name(self):
# When Then Expect
with pytest.raises(TypeError, match='Display name must be a string or None'):
- NewBase(display_name=456)
+ EasyBase(display_name=456)
- def test_arg_spec(self):
+ def test_no_arg_spec_override(self):
# When
- obj = NewBase()
+ obj = EasyBase()
+ # Expect
+ assert not hasattr(obj, '_arg_spec')
+
+ def test_to_dict_includes_keyword_only_args(self, clear):
+ # When
+ class KeywordOnly(EasyBase):
+ def __init__(self, value, *args, unit='', unique_name=None, **kwargs):
+ super().__init__(unique_name=unique_name)
+ self.value = value
+ self.unit = unit
+
+ obj = KeywordOnly(1.0, unit='m', unique_name='kw')
# Then
- arg_spec = obj._arg_spec
+ d = obj.to_dict()
# Expect
- assert 'unique_name' in arg_spec
- assert 'display_name' in arg_spec
+ assert d['value'] == 1.0
+ assert d['unit'] == 'm'
+ assert d['unique_name'] == 'kw'
def test_unique_name_setter(self, clear):
# When
- obj = NewBase()
+ obj = EasyBase()
# Then
obj.unique_name = 'new_unique_name'
# Expect
assert obj.unique_name == 'new_unique_name'
assert obj._default_unique_name is False
- assert global_object.map.get_item_by_key('new_unique_name') is obj
- assert global_object.map.get_item_by_key('NewBase_0') is obj
+ assert global_object.registry['new_unique_name'] is obj
+ assert 'EasyBase_0' not in global_object.registry
def test_unique_name_setter_invalid(self):
# When
- obj = NewBase()
+ obj = EasyBase()
# Then Expect
with pytest.raises(TypeError, match='Unique name has to be a string.'):
obj.unique_name = 789
def test_default_display_name(self):
# When
- obj = NewBase()
+ obj = EasyBase()
# Then
display_name = obj.display_name
# Expect
@@ -85,7 +98,7 @@ def test_default_display_name(self):
def test_display_name_setter(self):
# When
- obj = NewBase()
+ obj = EasyBase()
# Then
obj.display_name = 'Pretty Name'
# Expect
@@ -94,102 +107,129 @@ def test_display_name_setter(self):
def test_display_name_setter_invalid(self):
# When
- obj = NewBase()
+ obj = EasyBase()
# Then Expect
with pytest.raises(TypeError, match='Display name must be a string or None'):
obj.display_name = 101112
def test_to_dict_full_params(self):
# When
- obj = NewBase(unique_name='test_name', display_name='Test Object')
+ obj = EasyBase(unique_name='test_name', display_name='Test Object')
# Then
obj_dict = obj.to_dict()
# Expect
assert isinstance(obj_dict, dict)
assert obj_dict['unique_name'] == 'test_name'
assert obj_dict['display_name'] == 'Test Object'
- assert obj_dict['@module'] == 'easyscience.base_classes.new_base'
- assert obj_dict['@class'] == 'NewBase'
+ assert obj_dict['@module'] == 'easyscience.base_classes.easy_base'
+ assert obj_dict['@class'] == 'EasyBase'
assert '@version' in obj_dict
def test_to_dict_default_params(self):
# When
- obj = NewBase()
+ obj = EasyBase()
# Then
obj_dict = obj.to_dict()
# Expect
assert isinstance(obj_dict, dict)
- assert obj_dict['@module'] == 'easyscience.base_classes.new_base'
- assert obj_dict['@class'] == 'NewBase'
+ assert obj_dict['@module'] == 'easyscience.base_classes.easy_base'
+ assert obj_dict['@class'] == 'EasyBase'
assert '@version' in obj_dict
assert 'unique_name' not in obj_dict
assert 'display_name' not in obj_dict
+ def test_to_dict_does_not_mutate_skip(self):
+ obj = EasyBase()
+ skip = ['foo']
+ obj.to_dict(skip=skip)
+ assert skip == ['foo']
+
+ def test_to_dict_nested_object_keeps_own_names(self):
+ """A parent without names must not strip names from a nested object."""
+
+ class Holder(EasyBase):
+ def __init__(self, item=None, unique_name=None, display_name=None):
+ super().__init__(unique_name=unique_name, display_name=display_name)
+ self._item = item
+
+ @property
+ def item(self):
+ return self._item
+
+ inner = EasyBase(unique_name='inner', display_name='Inner')
+ outer = Holder(item=inner) # auto unique_name, no display_name
+
+ d = outer.to_dict()
+
+ assert 'unique_name' not in d
+ assert 'display_name' not in d
+ assert d['item']['unique_name'] == 'inner'
+ assert d['item']['display_name'] == 'Inner'
+
def test_to_dict_with_skip(self):
# When
- obj = NewBase(unique_name='skip_test', display_name='Skip Test Object')
+ obj = EasyBase(unique_name='skip_test', display_name='Skip Test Object')
# Then
obj_dict = obj.to_dict(skip=['display_name'])
# Expect
assert isinstance(obj_dict, dict)
assert obj_dict['unique_name'] == 'skip_test'
assert 'display_name' not in obj_dict
- assert obj_dict['@module'] == 'easyscience.base_classes.new_base'
- assert obj_dict['@class'] == 'NewBase'
+ assert obj_dict['@module'] == 'easyscience.base_classes.easy_base'
+ assert obj_dict['@class'] == 'EasyBase'
assert '@version' in obj_dict
- def test_to_dict_nested_new_base(self):
+ def test_to_dict_nested_easy_base(self):
# When
- obj = NewBase()
- parent_obj = NestedNewBase(unique_name='parent', display_name='Parent Object')
- parent_obj.nested_new_base = obj
+ obj = EasyBase()
+ parent_obj = NestedEasyBase(unique_name='parent', display_name='Parent Object')
+ parent_obj.nested_easy_base = obj
# Then
obj_dict = parent_obj.to_dict()
# Expect
assert isinstance(obj_dict, dict)
- assert 'nested_new_base' in obj_dict
+ assert 'nested_easy_base' in obj_dict
assert 'unique_name' in obj_dict
assert 'display_name' in obj_dict
- assert 'unique_name' not in obj_dict['nested_new_base']
- assert 'display_name' not in obj_dict['nested_new_base']
+ assert 'unique_name' not in obj_dict['nested_easy_base']
+ assert 'display_name' not in obj_dict['nested_easy_base']
- def test_to_dict_nested_new_base_reverse(self):
+ def test_to_dict_nested_easy_base_reverse(self):
# When
- obj = NewBase(unique_name='child', display_name='Child Object')
- parent_obj = NestedNewBase()
- parent_obj.nested_new_base = obj
+ obj = EasyBase(unique_name='child', display_name='Child Object')
+ parent_obj = NestedEasyBase()
+ parent_obj.nested_easy_base = obj
# Then
obj_dict = parent_obj.to_dict()
# Expect
assert isinstance(obj_dict, dict)
- assert 'nested_new_base' in obj_dict
+ assert 'nested_easy_base' in obj_dict
assert 'unique_name' not in obj_dict
assert 'display_name' not in obj_dict
- assert 'unique_name' in obj_dict['nested_new_base']
- assert 'display_name' in obj_dict['nested_new_base']
-
+ assert 'unique_name' in obj_dict['nested_easy_base']
+ assert 'display_name' in obj_dict['nested_easy_base']
def test_from_dict(self):
# When
obj_dict = {
- '@module': 'easyscience.base_classes.new_base',
- '@class': 'NewBase',
+ '@module': 'easyscience.base_classes.easy_base',
+ '@class': 'EasyBase',
'unique_name': 'from_dict_name',
'display_name': 'From Dict Object',
}
# Then
- obj = NewBase.from_dict(obj_dict)
+ obj = EasyBase.from_dict(obj_dict)
# Expect
- assert isinstance(obj, NewBase)
+ assert isinstance(obj, EasyBase)
assert obj.unique_name == 'from_dict_name'
assert obj.display_name == 'From Dict Object'
- assert global_object.map.get_item_by_key('from_dict_name') is obj
+ assert global_object.registry['from_dict_name'] is obj
def test_from_dict_not_easyscience(self):
# When
obj_dict = {
'@module': 'some.other.module',
- '@class': 'NewBase',
+ '@class': 'EasyBase',
'unique_name': 'invalid_from_dict',
'display_name': 'Invalid From Dict Object',
}
@@ -197,12 +237,12 @@ def test_from_dict_not_easyscience(self):
with pytest.raises(
ValueError, match='Input must be a dictionary representing an EasyScience object.'
):
- NewBase.from_dict(obj_dict)
+ EasyBase.from_dict(obj_dict)
def test_from_dict_wrong_class(self):
# When
obj_dict = {
- '@module': 'easyscience.base_classes.new_base',
+ '@module': 'easyscience.base_classes.easy_base',
'@class': 'SomeOtherClass',
'unique_name': 'wrong_class_name',
'display_name': 'Wrong Class Object',
@@ -210,13 +250,13 @@ def test_from_dict_wrong_class(self):
# Then Expect
with pytest.raises(
ValueError,
- match='Class name in dictionary does not match the expected class: NewBase.',
+ match='Class name in dictionary does not match the expected class: EasyBase.',
):
- NewBase.from_dict(obj_dict)
+ EasyBase.from_dict(obj_dict)
def test__dir__(self):
# When
- obj = NewBase()
+ obj = EasyBase()
dir_list = dir(obj)
# Then
expected_attributes = [
@@ -231,24 +271,24 @@ def test__dir__(self):
def test_copy(self):
# When
- obj = NewBase(unique_name='original_name', display_name='Original Object')
+ obj = EasyBase(unique_name='original_name', display_name='Original Object')
# Then
obj_copy = obj.__copy__()
# Expect
- assert isinstance(obj_copy, NewBase)
+ assert isinstance(obj_copy, EasyBase)
assert obj_copy.unique_name != obj.unique_name
assert obj_copy.display_name == obj.display_name
- assert global_object.map.get_item_by_key(obj_copy.unique_name) is obj_copy
+ assert global_object.registry[obj_copy.unique_name] is obj_copy
def test_deepcopy(self):
# When
- obj = NewBase(unique_name='deepcopy_name', display_name='Deep Copy Object')
+ obj = EasyBase(unique_name='deepcopy_name', display_name='Deep Copy Object')
# Then
import copy
obj_deepcopy = copy.deepcopy(obj)
# Expect
- assert isinstance(obj_deepcopy, NewBase)
+ assert isinstance(obj_deepcopy, EasyBase)
assert obj_deepcopy.unique_name != obj.unique_name
assert obj_deepcopy.display_name == obj.display_name
- assert global_object.map.get_item_by_key(obj_deepcopy.unique_name) is obj_deepcopy
+ assert global_object.registry[obj_deepcopy.unique_name] is obj_deepcopy
diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py
index 978d5571..9c776501 100644
--- a/tests/unit/base_classes/test_easy_list.py
+++ b/tests/unit/base_classes/test_easy_list.py
@@ -7,23 +7,23 @@
import pytest
from easyscience import global_object
+from easyscience.base_classes.easy_base import EasyBase
from easyscience.base_classes.easy_list import EasyList
from easyscience.base_classes.model_base import ModelBase
-from easyscience.base_classes.new_base import NewBase
-from easyscience.io.serializer_base import SerializerBase
+from easyscience.base_classes.serializer_base import SerializerBase
from easyscience.variable import DescriptorNumber
from easyscience.variable import Parameter
-class Alpha(NewBase):
- """Concrete subclass of NewBase for testing."""
+class Alpha(EasyBase):
+ """Concrete subclass of EasyBase for testing."""
def __init__(self, unique_name=None, display_name=None):
super().__init__(unique_name=unique_name, display_name=display_name)
-class Beta(NewBase):
- """Another concrete subclass of NewBase for testing."""
+class Beta(EasyBase):
+ """Another concrete subclass of EasyBase for testing."""
def __init__(self, unique_name=None, display_name=None):
super().__init__(unique_name=unique_name, display_name=display_name)
@@ -32,10 +32,12 @@ def __init__(self, unique_name=None, display_name=None):
class MockModel(ModelBase):
"""A ModelBase subclass with a Parameter and a DescriptorNumber for testing get_all_variables."""
- def __init__(self, unique_name=None, display_name=None, temperature=25, volume=1.0, items=None):
+ def __init__(
+ self, unique_name=None, display_name=None, temperature=25, volume=1.0, items=None
+ ):
super().__init__(unique_name=unique_name, display_name=display_name)
- self._temperature = Parameter(name='temperature', value=temperature)
- self._volume = DescriptorNumber(name='volume', value=volume)
+ self._temperature = Parameter(display_name='temperature', value=temperature)
+ self._volume = DescriptorNumber(display_name='volume', value=volume)
if items is None:
items = EasyList()
self._items = items
@@ -62,7 +64,7 @@ class MockModelNested(ModelBase):
def __init__(self, unique_name=None, display_name=None, component=None, pressure=0):
super().__init__(unique_name=unique_name, display_name=display_name)
- self._pressure = Parameter(name='pressure', value=pressure)
+ self._pressure = Parameter(display_name='pressure', value=pressure)
self._component = component or MockModel(unique_name='inner', temperature=30, volume=2.0)
@property
@@ -81,7 +83,7 @@ def component(self):
class TestEasyList:
@pytest.fixture(autouse=True)
def clear(self):
- global_object.map._clear()
+ global_object.registry.clear()
@pytest.fixture
def populated_list(self):
@@ -96,7 +98,7 @@ def test_empty_init(self):
el = EasyList()
assert len(el) == 0
assert len(el._protected_types) == 1
- assert el._protected_types[0] == NewBase
+ assert el._protected_types[0] == EasyBase
def test_init_with_items(self):
a = Alpha(unique_name='a1')
@@ -120,11 +122,11 @@ def test_init_list_of_protected_types(self):
assert el._protected_types == [Alpha, Beta]
def test_init_invalid_protected_types_raises(self):
- with pytest.raises(TypeError, match='protected_types must be a NewBase subclass'):
+ with pytest.raises(TypeError, match='protected_types must be an EasyBase subclass'):
EasyList(protected_types=int)
def test_init_invalid_protected_types_list_raises(self):
- with pytest.raises(TypeError, match='protected_types must be a NewBase subclass'):
+ with pytest.raises(TypeError, match='protected_types must be an EasyBase subclass'):
EasyList(protected_types=[int, str])
def test_init_with_data_kwarg(self):
@@ -146,10 +148,10 @@ def test_append_correct_type_succeeds(self):
el.append(a)
assert len(el) == 1
- def test_append_non_newbase_raises(self):
+ def test_append_non_easybase_raises(self):
el = EasyList()
with pytest.raises(TypeError, match='Items must be one of'):
- el.append('not a NewBase')
+ el.append('not an EasyBase')
def test_insert_wrong_type_raises(self):
el = EasyList(protected_types=Alpha)
@@ -555,7 +557,7 @@ def test_to_dict(self):
assert d['protected_types'][0]['@class'] == 'Alpha'
def test_to_dict_default_protected_type_not_serialized(self):
- a1 = NewBase(unique_name='nb1')
+ a1 = EasyBase(unique_name='nb1')
el = EasyList(a1, unique_name='my_list')
d = el.to_dict()
assert 'protected_types' not in d
@@ -588,19 +590,96 @@ def test_from_dict_round_trip(self):
a2 = Alpha(unique_name='a2')
el = EasyList(a1, a2, unique_name='my_list', protected_types=Alpha)
d = el.to_dict()
- # Clear the global map so deserialized objects can reuse the same unique names
- global_object.map._clear()
+ # Clear the global registry so deserialized objects can reuse the same unique names
+ global_object.registry.clear()
el2 = EasyList.from_dict(d)
assert len(el2) == 2
assert el2[0].unique_name == 'a1'
assert el2[1].unique_name == 'a2'
assert d == el2.to_dict() # The dicts should be the same after round trip
+ def test_to_dict_forwards_skip_to_items(self):
+ a1 = Alpha(unique_name='a1', display_name='first')
+ el = EasyList(a1, unique_name='my_list', protected_types=Alpha)
+
+ d = el.to_dict(skip=['display_name'])
+
+ assert 'display_name' not in d['data'][0]
+ assert d['data'][0]['unique_name'] == 'a1'
+
+ def test_to_dict_unnamed_list_keeps_item_names(self):
+ """A list without its own names must not strip names from its items."""
+ a1 = Alpha(unique_name='a1', display_name='first')
+ el = EasyList(a1, protected_types=Alpha) # auto unique_name, no display_name
+
+ d = el.to_dict()
+
+ assert 'unique_name' not in d
+ assert 'display_name' not in d
+ assert d['data'][0]['unique_name'] == 'a1'
+ assert d['data'][0]['display_name'] == 'first'
+
+ def test_to_dict_does_not_mutate_skip(self):
+ el = EasyList(Alpha(unique_name='a1'), protected_types=Alpha)
+ skip = ['display_name']
+
+ el.to_dict(skip=skip)
+
+ assert skip == ['display_name']
+
+ def test_to_dict_without_skip(self):
+ """``to_dict`` works when called without ``skip``."""
+ a1 = Alpha(unique_name='a1')
+ el = EasyList(a1, protected_types=Alpha)
+
+ d = el.to_dict()
+
+ assert d['data'][0]['unique_name'] == 'a1'
+ assert d['protected_types'][0]['@class'] == 'Alpha'
+ assert not hasattr(el, '_convert_to_dict')
+
+ def test_to_dict_nested_list_uses_override(self):
+ """A list held by another object is serialized by its own ``to_dict``."""
+ inner = EasyList(Alpha(unique_name='a1'), protected_types=Alpha)
+ outer = EasyList(inner, unique_name='outer')
+
+ d = outer.to_dict()
+
+ nested = d['data'][0]
+ assert nested['@class'] == 'EasyList'
+ assert nested['protected_types'][0]['@class'] == 'Alpha'
+ assert nested['data'][0]['unique_name'] == 'a1'
+
+ global_object.registry.clear()
+ outer2 = EasyList.from_dict(d)
+ assert isinstance(outer2[0], EasyList)
+ assert outer2[0]._protected_types == [Alpha]
+ assert outer2[0][0].unique_name == 'a1'
+
+ def test_from_dict_round_trip_keyword_only_arguments(self):
+ a1 = Alpha(unique_name='a1')
+ el = EasyList(a1, unique_name='my_list', display_name='My list', protected_types=Alpha)
+ d = el.to_dict()
+ assert d['unique_name'] == 'my_list'
+ assert d['display_name'] == 'My list'
+ global_object.registry.clear()
+ el2 = EasyList.from_dict(d)
+ assert el2.unique_name == 'my_list'
+ assert el2.display_name == 'My list'
+ assert el2._protected_types == [Alpha]
+
+ def test_to_dict_skip(self):
+ a1 = Alpha(unique_name='a1')
+ el = EasyList(a1, unique_name='my_list', display_name='My list', protected_types=Alpha)
+ d = el.to_dict(skip=['display_name'])
+ assert 'display_name' not in d
+ assert 'protected_types' in d
+
def test_from_dict_round_trip_empty(self):
el = EasyList(unique_name='my_list', protected_types=Alpha)
d = el.to_dict()
- # Clear the global map so deserialized objects can reuse the same unique names
- global_object.map._clear()
+ # Clear the global registry so deserialized objects can reuse the same unique names
+ global_object.registry.clear()
el2 = EasyList.from_dict(d)
assert len(el2) == 0
assert d == el2.to_dict() # The dicts should be the same after round trip
@@ -630,8 +709,8 @@ def test_from_dict_modelbase_round_trip(self, monkeypatch):
)
# Then
list_dict = el.to_dict()
- # Clear the global map so deserialized objects can reuse the same unique names
- global_object.map._clear()
+ # Clear the global registry so deserialized objects can reuse the same unique names
+ global_object.registry.clear()
el2 = EasyList.from_dict(list_dict)
# Expect
assert len(el2) == 2
@@ -643,9 +722,12 @@ def test_from_dict_missing_data_key(self):
el_dict = {
'@module': 'easyscience',
'@class': 'EasyList',
- 'protected_types': [{'@module': 'easyscience.base_classes', '@class': 'ModelBase'}]
+ 'protected_types': [{'@module': 'easyscience.base_classes', '@class': 'ModelBase'}],
}
- with pytest.raises(ValueError, match='The provided dictionary does not represent an EasyList. Missing the "data" key.'):
+ with pytest.raises(
+ ValueError,
+ match='The provided dictionary does not represent an EasyList. Missing the "data" key.',
+ ):
EasyList.from_dict(el_dict)
# --- get_all_variables ---
@@ -656,9 +738,9 @@ def test_get_all_variables_empty_list(self):
assert el.get_all_variables() == []
def test_get_all_variables_no_modelbase_elements(self):
- """An EasyList with only plain NewBase elements (not ModelBase) should return an empty list."""
+ """An EasyList with only plain EasyBase elements (not ModelBase) should return an empty list."""
a1 = Alpha(unique_name='a1')
- a2 = NewBase(unique_name='nb1')
+ a2 = EasyBase(unique_name='nb1')
el = EasyList(a1, a2)
assert el.get_all_variables() == []
@@ -668,13 +750,13 @@ def test_get_all_variables_single_modelbase(self):
el = EasyList(m1, protected_types=ModelBase)
vars = el.get_all_variables()
assert len(vars) == 2
- names = {v.name for v in vars}
+ names = {v.display_name for v in vars}
assert 'temperature' in names
assert 'volume' in names
# Verify specific values
- temp_var = next(v for v in vars if v.name == 'temperature')
+ temp_var = next(v for v in vars if v.display_name == 'temperature')
assert temp_var.value == 10
- vol_var = next(v for v in vars if v.name == 'volume')
+ vol_var = next(v for v in vars if v.display_name == 'volume')
assert vol_var.value == 5.0
def test_get_all_variables_multiple_modelbase(self):
@@ -684,17 +766,17 @@ def test_get_all_variables_multiple_modelbase(self):
el = EasyList(m1, m2, protected_types=ModelBase)
vars = el.get_all_variables()
assert len(vars) == 4
- names = {v.name for v in vars}
+ names = {v.display_name for v in vars}
assert names == {'temperature', 'volume'}
def test_get_all_variables_mixed_elements(self):
- """Only ModelBase-derived elements contribute variables; plain NewBase elements are skipped."""
+ """Only ModelBase-derived elements contribute variables; plain EasyBase elements are skipped."""
m1 = MockModel(unique_name='m1', temperature=10, volume=5.0)
a1 = Alpha(unique_name='a1')
el = EasyList(m1, a1)
vars = el.get_all_variables()
assert len(vars) == 2
- names = {v.name for v in vars}
+ names = {v.display_name for v in vars}
assert names == {'temperature', 'volume'}
def test_get_all_variables_nested_model(self):
@@ -705,7 +787,7 @@ def test_get_all_variables_nested_model(self):
vars = el.get_all_variables()
# parent: pressure (Parameter), inner: temperature (Parameter), volume (DescriptorNumber)
assert len(vars) == 3
- names = {v.name for v in vars}
+ names = {v.display_name for v in vars}
assert names == {'pressure', 'temperature', 'volume'}
def test_get_all_variables_returns_descriptorbase_instances(self):
@@ -718,10 +800,70 @@ def test_get_all_variables_returns_descriptorbase_instances(self):
assert isinstance(v, DescriptorBase)
+ def test_get_all_variables_bare_parameters(self):
+ """Bare Parameters passed to the constructor should be collected directly."""
+ p1 = Parameter(value=1.0, display_name='a')
+ p2 = Parameter(value=2.0, display_name='b')
+ el = EasyList(p1, p2)
+ vars = el.get_all_variables()
+ assert vars == [p1, p2]
+ assert el.get_free_parameters() == [p1, p2]
+
+ def test_get_all_variables_bare_descriptors(self):
+ """Bare DescriptorNumbers are collected as variables but are not fit parameters."""
+ p1 = Parameter(value=1.0, display_name='a')
+ d1 = DescriptorNumber(value=2.0, display_name='b')
+ el = EasyList(p1, d1)
+ assert el.get_all_variables() == [p1, d1]
+ assert el.get_all_parameters() == [p1]
+ assert el.get_free_parameters() == [p1]
+
+ def test_get_all_variables_fixed_parameter_not_fitted(self):
+ """A fixed bare Parameter is returned as a variable but not as a fit parameter."""
+ free = Parameter(value=1.0, display_name='a')
+ fixed = Parameter(value=2.0, fixed=True, display_name='b')
+ el = EasyList(free, fixed)
+ assert el.get_all_variables() == [free, fixed]
+ assert el.get_free_parameters() == [free]
+
+ def test_get_all_variables_parameters_from_list(self):
+ """A plain list of Parameters should be flattened and collected."""
+ p1 = Parameter(value=1.0, display_name='a')
+ p2 = Parameter(value=2.0, display_name='b')
+ el = EasyList([p1, p2])
+ assert len(el) == 2
+ assert el.get_all_variables() == [p1, p2]
+
+ def test_get_all_variables_parameters_from_tuple(self):
+ """A plain tuple of Parameters should be flattened and collected."""
+ p1 = Parameter(value=1.0, display_name='a')
+ p2 = Parameter(value=2.0, display_name='b')
+ el = EasyList((p1, p2))
+ assert len(el) == 2
+ assert el.get_all_variables() == [p1, p2]
+
+ def test_get_all_variables_bare_parameters_and_model(self):
+ """Bare Parameters and ModelBase items should both contribute variables."""
+ p1 = Parameter(value=1.0, display_name='a')
+ m1 = MockModel(unique_name='m1', temperature=10, volume=5.0)
+ el = EasyList(p1, m1)
+ vars = el.get_all_variables()
+ assert len(vars) == 3
+ assert vars[0] is p1
+ assert {v.display_name for v in vars[1:]} == {'temperature', 'volume'}
+
+ def test_get_all_variables_bare_parameters_in_nested_easylist(self):
+ """Bare Parameters inside a nested EasyList should be collected by the outer list."""
+ p1 = Parameter(value=1.0, display_name='a')
+ p2 = Parameter(value=2.0, display_name='b')
+ inner_list = EasyList(p1)
+ outer_list = EasyList(inner_list, p2)
+ assert outer_list.get_all_variables() == [p1, p2]
+
def test_get_all_variables_nested_easylist(self):
- """An EasyList containing another EasyList with mixed NewBase/ModelBase elements
+ """An EasyList containing another EasyList with mixed EasyBase/ModelBase elements
should collect variables from the inner EasyList's ModelBase items,
- skipping plain NewBase items."""
+ skipping plain EasyBase items."""
inner_model = MockModel(unique_name='inner_m', temperature=50, volume=3.0)
inner_plain = Alpha(unique_name='inner_a')
inner_list = EasyList(inner_model, inner_plain)
@@ -751,7 +893,7 @@ def test_get_all_variables_nested_easylist(self):
assert isinstance(v, DescriptorNumber)
# Collect temperatures and volumes from both models
- temps = {v.value for v in vars if v.name == 'temperature'}
- vols = {v.value for v in vars if v.name == 'volume'}
+ temps = {v.value for v in vars if v.display_name == 'temperature'}
+ vols = {v.value for v in vars if v.display_name == 'volume'}
assert temps == {50, 70}
assert vols == {3.0, 4.0}
diff --git a/tests/unit/base_classes/test_model_base.py b/tests/unit/base_classes/test_model_base.py
index e91c20a9..47f9337a 100644
--- a/tests/unit/base_classes/test_model_base.py
+++ b/tests/unit/base_classes/test_model_base.py
@@ -9,7 +9,7 @@
from easyscience import Parameter
from easyscience import global_object
from easyscience.base_classes import ModelBase
-from easyscience.io import SerializerBase
+from easyscience.base_classes import SerializerBase
from easyscience.variable import DescriptorStr
@@ -20,9 +20,11 @@ class MockModelComponent(ModelBase):
def __init__(self, display_name=None, unique_name=None, temperature=0, room_temperature=22):
super().__init__(display_name=display_name, unique_name=unique_name)
- self._temperature = Parameter(name='temperature', value=temperature)
- self._room_temperature = DescriptorNumber(name='room_temperature', value=room_temperature)
- self._status = DescriptorStr(name='status', value='OK')
+ self._temperature = Parameter(display_name='temperature', value=temperature)
+ self._room_temperature = DescriptorNumber(
+ display_name='room_temperature', value=room_temperature
+ )
+ self._status = DescriptorStr(display_name='status', value='OK')
@property
def temperature(self):
@@ -56,8 +58,8 @@ class MockModelFull(ModelBase):
def __init__(self, component=None, display_name=None, unique_name=None, pressure=0, area=1):
super().__init__(display_name=display_name, unique_name=unique_name)
- self._pressure = Parameter(name='pressure', value=pressure)
- self._area = Parameter(name='area', value=area)
+ self._pressure = Parameter(display_name='pressure', value=pressure)
+ self._area = Parameter(display_name='area', value=area)
if component is not None:
self._component = component
else:
@@ -102,8 +104,8 @@ def nested_model(self, monkeypatch):
@pytest.fixture
def clear(self):
- # Clear the global object map before each test
- global_object.map._clear()
+ # Clear the global object registry before each test
+ global_object.registry.clear()
def test_init(self):
# When Then
@@ -156,15 +158,6 @@ def test_get_fittable_parameters_nested(self, nested_model):
)
assert nested_model.component.get_all_variables.call_count == 1
- def test_get_fit_parameters_alias(self, monkeypatch):
- # When
- model = MockModelFull(pressure=1)
- monkeypatch.setattr(model, 'get_free_parameters', MagicMock())
- # Then
- fit_params = model.get_fit_parameters()
- # Expect
- assert model.get_free_parameters.call_count == 1
-
def test_get_free_parameters_nested(self, nested_model):
# When Then
free_params = nested_model.get_free_parameters()
@@ -193,7 +186,7 @@ def test_from_dict(self, monkeypatch, clear):
)
),
)
- global_object.map._clear()
+ global_object.registry.clear()
# Then
new_model = MockModelComponent.from_dict(obj_dict)
# Expect
@@ -202,11 +195,10 @@ def test_from_dict(self, monkeypatch, clear):
assert new_model._room_temperature.value == 22.0
assert isinstance(new_model._temperature, Parameter)
assert isinstance(new_model._room_temperature, DescriptorNumber)
- assert len(global_object.map.vertices()) == 4
- assert global_object.map.get_item_by_key('Parameter_0') is new_model._temperature
+ assert len(global_object.registry) == 4
+ assert global_object.registry['Parameter_0'] is new_model._temperature
assert (
- len([param for param in global_object.map.vertices() if param.startswith('Parameter')])
- == 1
+ len([param for param in global_object.registry if param.startswith('Parameter')]) == 1
)
def test_from_dict_nested(self, monkeypatch, clear):
@@ -234,7 +226,7 @@ def test_from_dict_nested(self, monkeypatch, clear):
)
),
)
- global_object.map._clear()
+ global_object.registry.clear()
# Then
new_model = MockModelFull.from_dict(obj_dict)
# Expect
@@ -248,15 +240,14 @@ def test_from_dict_nested(self, monkeypatch, clear):
assert isinstance(new_model._area, Parameter)
assert isinstance(new_model.component._temperature, Parameter)
assert isinstance(new_model.component._room_temperature, DescriptorNumber)
- assert len(global_object.map.vertices()) == 7
- assert global_object.map.get_item_by_key('Parameter_0') in [
+ assert len(global_object.registry) == 7
+ assert global_object.registry['Parameter_0'] in [
new_model._pressure,
new_model._area,
new_model.component._temperature,
]
assert (
- len([param for param in global_object.map.vertices() if param.startswith('Parameter')])
- == 3
+ len([param for param in global_object.registry if param.startswith('Parameter')]) == 3
)
def test_from_dict_not_easyscience(self):
@@ -312,7 +303,7 @@ def test_from_dict_parameter_setting_failure(self, monkeypatch, clear):
'temperature',
property(lambda self: (_ for _ in ()).throw(Exception('Simulated failure'))),
)
- global_object.map._clear()
+ global_object.registry.clear()
# Then Expect
with pytest.raises(
SyntaxError,
diff --git a/tests/unit/base_classes/test_model_with_easy_list.py b/tests/unit/base_classes/test_model_with_easy_list.py
new file mode 100644
index 00000000..187b2637
--- /dev/null
+++ b/tests/unit/base_classes/test_model_with_easy_list.py
@@ -0,0 +1,112 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Behaviour of a ModelBase that holds its parameters in an EasyList."""
+
+import copy
+
+import numpy as np
+import pytest
+from easy_test_models import CoefficientModel
+
+from easyscience import global_object
+from easyscience.base_classes import EasyList
+from easyscience.variable import Parameter
+
+
+@pytest.fixture(autouse=True)
+def clear():
+ """Clear global object registry before each test."""
+ global_object.registry.clear()
+
+
+@pytest.mark.parametrize(
+ 'coefficients',
+ [(1.0,), (1.0, 2.0), (1.0, 2.0, 3.0), (-1.0, -2.0, -3.0), (0.72, 6.48, -0.48)],
+)
+def test_numbers_become_parameters(coefficients):
+ model = CoefficientModel(coefficients=coefficients)
+
+ assert all(isinstance(c, Parameter) for c in model.coefficients)
+ assert [c.value for c in model.coefficients] == list(coefficients)
+ assert [c.display_name for c in model.coefficients] == [
+ f'c{i}' for i in range(len(coefficients))
+ ]
+ x = np.linspace(0, 10, 100)
+ assert np.allclose(model(x), np.polyval(coefficients, x))
+
+
+def test_default_initialization():
+ model = CoefficientModel(display_name='empty')
+
+ assert model.display_name == 'empty'
+ assert len(model.coefficients) == 0
+ assert model.coefficients._protected_types == [Parameter]
+
+
+def test_parameters_are_kept():
+ p0 = Parameter(value=5.0, display_name='a')
+ p1 = Parameter(value=2.0, display_name='b')
+
+ model = CoefficientModel(coefficients=[p0, p1, 1.0])
+
+ assert model.coefficients[0] is p0
+ assert model.coefficients[1] is p1
+ assert model.coefficients[2].display_name == 'c2'
+
+
+def test_easy_list_is_used_directly():
+ collection = EasyList(Parameter(value=1.0), Parameter(value=2.0), protected_types=Parameter)
+
+ model = CoefficientModel(coefficients=collection)
+
+ assert model.coefficients is collection
+
+
+def test_get_free_parameters():
+ """Parameters inside the EasyList are exposed for fitting."""
+ model = CoefficientModel(coefficients=[1.0, 2.0])
+
+ assert model.get_free_parameters() == list(model.coefficients)
+
+
+def test_dict_round_trip():
+ model = CoefficientModel(display_name='round_trip', coefficients=[1.0, 2.0, 3.0])
+
+ new_model = CoefficientModel.from_dict(model.to_dict())
+
+ assert new_model.display_name == 'round_trip'
+ assert [c.value for c in new_model.coefficients] == [1.0, 2.0, 3.0]
+ assert [c.display_name for c in new_model.coefficients] == ['c0', 'c1', 'c2']
+
+
+def test_dict_round_trip_unnamed_model_keeps_coefficient_names():
+ """The model's own missing names must not be skipped on nested items."""
+ model = CoefficientModel(
+ coefficients=[Parameter(1.0, display_name='c0', unique_name='my_c0'), 2.0]
+ )
+
+ d = model.to_dict()
+ global_object.registry.clear() # the explicit name is kept, so free it first
+ new_model = CoefficientModel.from_dict(d)
+
+ assert 'unique_name' not in d
+ assert 'display_name' not in d
+ assert [c.display_name for c in new_model.coefficients] == ['c0', 'c1']
+ assert new_model.coefficients[0].unique_name == 'my_c0'
+
+
+@pytest.mark.parametrize('copier', [copy.copy, copy.deepcopy], ids=['copy', 'deepcopy'])
+def test_copy_with_named_coefficients(copier):
+ """Copies get fresh coefficient identities, even for explicit names."""
+ model = CoefficientModel(
+ coefficients=[Parameter(1.0, unique_name='coefficient'), Parameter(2.0)]
+ )
+
+ data = model.to_dict(skip=['unique_name'])
+ assert all('unique_name' not in c for c in data['coefficients']['data'])
+
+ new_model = copier(model)
+
+ assert [c.value for c in new_model.coefficients] == [1.0, 2.0]
+ assert new_model.coefficients[0].unique_name != 'coefficient'
+ assert all(new is not old for new, old in zip(new_model.coefficients, model.coefficients))
diff --git a/tests/unit/base_classes/test_obj_base.py b/tests/unit/base_classes/test_obj_base.py
deleted file mode 100644
index 5eb1b2ea..00000000
--- a/tests/unit/base_classes/test_obj_base.py
+++ /dev/null
@@ -1,498 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from contextlib import contextmanager
-from copy import copy
-from typing import ClassVar
-from typing import List
-from typing import Optional
-from typing import Type
-from typing import Union
-
-import numpy as np
-import pytest
-
-import easyscience
-from easyscience import DescriptorNumber
-from easyscience import ObjBase
-from easyscience import Parameter
-from easyscience import global_object
-from easyscience.io import SerializerDict
-
-
-@pytest.fixture
-def clear():
- global_object.map._clear()
-
-
-@pytest.fixture
-def setup_pars():
- d = {
- 'name': 'test',
- 'par1': Parameter('p1', 0.1, fixed=True),
- 'des1': DescriptorNumber('d1', 0.1),
- 'par2': Parameter('p2', 0.1),
- 'des2': DescriptorNumber('d2', 0.1),
- 'par3': Parameter('p3', 0.1),
- }
- return d
-
-
-@contextmanager
-def not_raises(expected_exception: Union[Type[BaseException], List[Type[BaseException]]]):
- try:
- yield
- except expected_exception:
- raise pytest.fail(
- 'Did raise exception {0} when it should not.'.format(repr(expected_exception))
- )
- except Exception as err:
- raise pytest.fail('An unexpected exception {0} raised.'.format(repr(err)))
-
-
-@pytest.mark.parametrize(
- 'a, kw',
- [
- ([], ['par1']),
- (['par1'], []),
- (['par1'], ['par2']),
- (['par1', 'des1'], ['par2', 'des2']),
- ],
-)
-def test_ObjBase_create(setup_pars: dict, a: List[str], kw: List[str]):
- name = setup_pars['name']
- args = []
- for key in a:
- args.append(setup_pars[key])
- kwargs = {}
- for key in kw:
- kwargs[key] = setup_pars[key]
- base = ObjBase(name, None, *args, **kwargs)
- assert base.name == name
- for key in a:
- item = getattr(base, setup_pars[key].name)
- assert isinstance(item, setup_pars[key].__class__)
-
-
-def test_ObjBase_copy(setup_pars: dict):
- # When
- name = setup_pars['name']
- args = []
- for key in ['par1', 'des1']:
- args.append(setup_pars[key])
- kwargs = {}
- for key in ['par2', 'des2']:
- kwargs[key] = setup_pars[key]
- base = ObjBase(name, None, *args, **kwargs)
-
- # Then
- base_copy = copy(base)
-
- # Expect
- assert base_copy.name == name
- assert base_copy.unique_name != base.unique_name
-
- for key in ['par1', 'des1']:
- item = getattr(base, setup_pars[key].name)
- assert isinstance(item, setup_pars[key].__class__)
-
-
-def test_ObjBase_get(setup_pars: dict):
- name = setup_pars['name']
- explicit_name1 = 'par1'
- explicit_name2 = 'par2'
- kwargs = {
- setup_pars[explicit_name1].name: setup_pars[explicit_name1],
- setup_pars[explicit_name2].name: setup_pars[explicit_name2],
- }
- obj = ObjBase(name, **kwargs)
- with not_raises(AttributeError):
- p1: Parameter = obj.p1
- with not_raises(AttributeError):
- p2: Parameter = obj.p2
-
-
-def test_ObjBase_set(setup_pars: dict):
- name = setup_pars['name']
- explicit_name1 = 'par1'
- kwargs = {
- setup_pars[explicit_name1].name: setup_pars[explicit_name1],
- }
- obj = ObjBase(name, **kwargs)
- new_value = 5.0
- with not_raises([AttributeError, ValueError]):
- obj.p1 = new_value
- assert obj.p1.value == new_value
-
-
-def test_ObjBase_get_parameters(setup_pars: dict):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
- pars = obj.get_fit_parameters()
- assert isinstance(pars, list)
- assert len(pars) == 2
- par_names = [par.name for par in pars]
- assert 'p2' in par_names
- assert 'p3' in par_names
-
-
-def test_ObjBase_fit_objects(setup_pars: dict):
- pass
-
-
-def test_ObjBase_as_dict(clear, setup_pars: dict):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
- obtained = obj.as_dict()
- assert isinstance(obtained, dict)
- expected = {
- '@module': 'easyscience.legacy.obj_base',
- '@class': 'ObjBase',
- '@version': easyscience.__version__,
- 'name': 'test',
- 'par1': {
- '@module': Parameter.__module__,
- '@class': Parameter.__name__,
- '@version': easyscience.__version__,
- 'name': 'p1',
- 'value': 0.1,
- 'variance': 0.0,
- 'min': -np.inf,
- 'max': np.inf,
- 'fixed': True,
- 'unit': 'dimensionless',
- },
- 'des1': {
- '@module': DescriptorNumber.__module__,
- '@class': DescriptorNumber.__name__,
- '@module': DescriptorNumber.__module__,
- '@class': DescriptorNumber.__name__,
- '@version': easyscience.__version__,
- 'name': 'd1',
- 'value': 0.1,
- 'unit': 'dimensionless',
- 'description': '',
- 'url': '',
- 'display_name': 'd1',
- },
- 'par2': {
- '@module': Parameter.__module__,
- '@class': Parameter.__name__,
- '@version': easyscience.__version__,
- 'name': 'p2',
- 'value': 0.1,
- 'variance': 0.0,
- 'min': -np.inf,
- 'max': np.inf,
- 'fixed': False,
- 'unit': 'dimensionless',
- },
- 'des2': {
- '@module': DescriptorNumber.__module__,
- '@class': DescriptorNumber.__name__,
- '@module': DescriptorNumber.__module__,
- '@class': DescriptorNumber.__name__,
- '@version': easyscience.__version__,
- 'name': 'd2',
- 'value': 0.1,
- 'unit': 'dimensionless',
- 'description': '',
- 'url': '',
- 'display_name': 'd2',
- },
- 'par3': {
- '@module': Parameter.__module__,
- '@class': Parameter.__name__,
- '@version': easyscience.__version__,
- 'name': 'p3',
- 'value': 0.1,
- 'variance': 0.0,
- 'min': -np.inf,
- 'max': np.inf,
- 'fixed': False,
- 'unit': 'dimensionless',
- },
- }
-
- def check_dict(check, item):
- if isinstance(check, dict) and isinstance(item, dict):
- if '@module' in item.keys():
- with not_raises([ValueError, AttributeError]):
- global_object.map._clear()
- this_obj = SerializerDict().decode(item)
-
- for key in check.keys():
- assert key in item.keys()
- check_dict(check[key], item[key])
- else:
- assert isinstance(item, type(check))
- assert item == check
-
- check_dict(expected, obtained)
-
-
-def test_ObjBase_dict_roundtrip(clear, setup_pars: dict):
- # When
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars, unique_name='special_name')
- obj_dict = obj.as_dict()
-
- global_object.map._clear()
-
- # Then
- new_obj = ObjBase.from_dict(obj_dict)
-
- # Expect
- new_obj_dict = new_obj.as_dict()
- assert obj_dict == new_obj_dict
-
-
-def test_ObjBase_dir(setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
- expected = [
- 'encode',
- 'decode',
- 'as_dict',
- 'des1',
- 'des2',
- 'from_dict',
- 'generate_bindings',
- 'get_fit_parameters',
- 'get_parameters',
- 'interface',
- 'unique_name',
- 'name',
- 'par1',
- 'par2',
- 'par3',
- 'switch_interface',
- 'user_data',
- ]
- obtained = dir(obj)
- print(obtained)
- print(expected)
- assert len(obtained) == len(expected)
- assert obtained == sorted(obtained)
- assert len(set(expected).difference(set(obtained))) == 0
-
-
-def test_ObjBase_get_parameters(setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
- pars = obj.get_parameters()
- assert len(pars) == 3
-
-
-def test_ObjBase_get_parameters_nested(setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
-
- name2 = name + '_2'
- obj2 = ObjBase(name2, obj=obj, **setup_pars)
-
- pars = obj2.get_parameters()
- assert len(pars) == 6
-
- pars = obj.get_parameters()
- assert len(pars) == 3
-
-
-def test_ObjBase_get_fit_parameters(setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
- pars = obj.get_fit_parameters()
- assert len(pars) == 2
-
-
-def test_ObjBase_get_fit_parameters_nested(setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
-
- name2 = name + '_2'
- obj2 = ObjBase(name2, obj=obj, **setup_pars)
-
- pars = obj2.get_fit_parameters()
- assert len(pars) == 4
-
- pars = obj.get_fit_parameters()
- assert len(pars) == 2
-
-
-def test_ObjBase__add_component(setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
-
- p = Parameter('added_par', 1)
- new_item_name = 'Added'
- obj._add_component(new_item_name, p)
-
- assert hasattr(obj, new_item_name)
- a = getattr(obj, new_item_name)
- assert isinstance(a, Parameter)
-
-
-def test_ObjBase_name(setup_pars):
- name = setup_pars['name']
- del setup_pars['name']
- obj = ObjBase(name, **setup_pars)
- assert obj.name == name
-
-
-def test_Base_GETSET():
- class A(ObjBase):
- def __init__(self, a: Parameter):
- super(A, self).__init__('a', a=a)
-
- @classmethod
- def from_pars(cls, a: float):
- return cls(a=Parameter('a', a))
-
- a_start = 5
- a_end = 10
- a = A.from_pars(a_start)
- graph = a._global_object.map
-
- assert a.a.value == a_start
- assert len(graph.get_edges(a)) == 1
-
- setattr(a, 'a', a_end)
- assert a.a.value == a_end
- assert len(graph.get_edges(a)) == 1
-
-
-def test_Base_GETSET():
- class A(ObjBase):
- def __init__(self, a: Parameter):
- super(A, self).__init__('a', a=a)
- b = 0
-
- @classmethod
- def from_pars(cls, a: float):
- return cls(a=Parameter('a', a))
-
- a = A.from_pars(5)
- b_new = 10
- a.b = b_new
- assert a.b == b_new
-
-
-def test_Base_GETSET_v2():
- class A(ObjBase):
- a: ClassVar[Parameter]
-
- def __init__(self, a: Parameter):
- super(A, self).__init__('a', a=a)
-
- @classmethod
- def from_pars(cls, a: float):
- return cls(a=Parameter('a', a))
-
- a_start = 5
- a_end = 10
- a = A.from_pars(a_start)
- graph = a._global_object.map
-
- assert a.a.value == a_start
- assert len(graph.get_edges(a)) == 1
-
- setattr(a, 'a', a_end)
- assert a.a.value == a_end
- assert len(graph.get_edges(a)) == 1
-
-
-def test_Base_GETSET_v3():
- class A(ObjBase):
- a: ClassVar[Parameter]
-
- def __init__(self, a: Parameter):
- super(A, self).__init__('a', a=a)
-
- @classmethod
- def from_pars(cls, a: float):
- return cls(a=Parameter('a', a))
-
- a_start = 5
- a_end = 10
- a = A.from_pars(a_start)
- graph = a._global_object.map
-
- assert a.a.value == a_start
- assert len(graph.get_edges(a)) == 1
- a_ = Parameter('a', a_end)
- assert a.a.unique_name in graph.get_edges(a)
- a__ = a.a
-
- setattr(a, 'a', a_)
- assert a.a.value == a_end
- assert len(graph.get_edges(a)) == 1
- assert a_.unique_name in graph.get_edges(a)
- assert a__.unique_name not in graph.get_edges(a)
-
-
-def test_BaseCreation():
- class A(ObjBase):
- def __init__(self, a: Optional[Union[Parameter, float]] = None):
- super(A, self).__init__('A', a=Parameter('a', 1.0))
- if a is not None:
- self.a = a
-
- a = A()
- assert a.a.value == 1.0
- a = A(2.0)
- assert a.a.value == 2.0
- a = A(Parameter('a', 3.0))
- assert a.a.value == 3.0
- a.a = 4.0
- assert a.a.value == 4.0
-
- class B(ObjBase):
- def __init__(self, b: Optional[Union[A, Parameter, float]] = None):
- super(B, self).__init__('B', b=A())
- if b is not None:
- if isinstance(b, (float, Parameter)):
- b = A(b)
- self.b = b
-
- b = B()
- assert b.b.a.value == 1.0
- b = B(2.0)
- assert b.b.a.value == 2.0
- b = B(A(3.0))
- assert b.b.a.value == 3.0
- b.b.a = 4.0
- assert b.b.a.value == 4.0
-
-
-def test_unique_name_generator(clear):
- # When Then
- test_obj = ObjBase(name='test')
- # Expect
- assert test_obj.unique_name == 'ObjBase_0'
-
-
-def test_unique_name_change(clear):
- # When
- test_obj = ObjBase(name='test')
- # Then
- test_obj.unique_name = 'test'
- # Expect
- assert test_obj.unique_name == 'test'
-
-
-@pytest.mark.parametrize('input', [2, 2.0, [2], {2}, None])
-def test_unique_name_change_exception(input):
- # When
- test_obj = ObjBase(name='test')
- # Then Expect
- with pytest.raises(TypeError):
- test_obj.unique_name = input
diff --git a/tests/unit/io/test_serializer_base.py b/tests/unit/base_classes/test_serializer_base.py
similarity index 83%
rename from tests/unit/io/test_serializer_base.py
rename to tests/unit/base_classes/test_serializer_base.py
index c3c8dd21..5af6c63b 100644
--- a/tests/unit/io/test_serializer_base.py
+++ b/tests/unit/base_classes/test_serializer_base.py
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: BSD-3-Clause
import datetime
+import json
from enum import Enum
from typing import Any
from typing import List
@@ -16,9 +17,16 @@
from easyscience import DescriptorNumber
from easyscience import Parameter
from easyscience import global_object
-from easyscience.base_classes import NewBase
-from easyscience.io import SerializerBase
-from easyscience.io import SerializerComponent
+from easyscience.base_classes import EasyBase
+from easyscience.base_classes import ModelBase
+from easyscience.base_classes import SerializerBase
+
+
+class MockSerializable:
+ """Minimal stand-in for an object that knows how to encode itself."""
+
+ def encode(self, skip: Optional[List[str]] = None, encoder=None, **kwargs) -> Any:
+ return (encoder or ConcreteSerializer)().encode(self, skip=skip, **kwargs)
class TestEnum(Enum):
@@ -26,7 +34,7 @@ class TestEnum(Enum):
ANOTHER_VALUE = 42
-class MockSerializerComponent(SerializerComponent):
+class MockSerializableObj(MockSerializable):
"""Mock serializer component for testing"""
def __init__(
@@ -35,25 +43,34 @@ def __init__(
self.name = name
self.value = value
self.optional_param = optional_param
- self._kwargs = kwargs
self.unique_name = f'mock_{name}'
self._global_object = True
-class MockSerializerWithRedirect(SerializerComponent):
- """Mock with _REDIRECT for testing redirect functionality"""
-
- _REDIRECT = {'special_attr': lambda obj: obj.value * 2, 'none_attr': None}
+class MockSerializerWithArgSpec(MockSerializable):
+ """Mock with an _arg_spec that leaves out one constructor argument"""
def __init__(self, name: str = 'test', value: int = 1, special_attr: int = 5):
self.name = name
self.value = value
self.special_attr = special_attr
- self.unique_name = f'redirect_{name}'
+ self.unique_name = f'arg_spec_{name}'
self._global_object = True
+ @property
+ def _arg_spec(self):
+ return {'name', 'value'}
+
+
+class MockModelWithParameter(ModelBase):
+ """Model holding a Parameter as a constructor argument"""
+
+ def __init__(self, p: Optional[Parameter] = None):
+ super().__init__()
+ self._p = p
-class MockSerializerWithConvertToDict(SerializerComponent):
+
+class MockSerializerWithConvertToDict(MockSerializable):
"""Mock with custom _convert_to_dict method"""
def __init__(self, name: str = 'test', value: int = 1):
@@ -70,7 +87,7 @@ def _convert_to_dict(self, d, encoder, skip=None, **kwargs):
class ConcreteSerializer(SerializerBase):
"""Concrete implementation for testing abstract methods"""
- def encode(self, obj: SerializerComponent, skip: Optional[List[str]] = None, **kwargs) -> Any:
+ def encode(self, obj: Any, skip: Optional[List[str]] = None, **kwargs) -> Any:
return self._convert_to_dict(obj, skip=skip, **kwargs)
@classmethod
@@ -82,13 +99,9 @@ class TestSerializerBase:
@pytest.fixture
def clear(self):
"""Clear everything before and after each test"""
- global_object.map._clear()
- if global_object.stack:
- global_object.stack.clear()
+ global_object.registry.clear()
yield
- global_object.map._clear()
- if global_object.stack:
- global_object.stack.clear()
+ global_object.registry.clear()
@pytest.fixture
def serializer(self):
@@ -96,7 +109,7 @@ def serializer(self):
@pytest.fixture
def mock_obj(self):
- return MockSerializerComponent('test_obj', 42, 'optional_value')
+ return MockSerializableObj('test_obj', 42, 'optional_value')
def test_abstract_methods_are_placeholders(self):
"""Test that SerializerBase can be instantiated but abstract methods just pass"""
@@ -231,7 +244,6 @@ def test_convert_from_dict_easyscience_object(self, clear):
'@module': 'easyscience.variable.parameter',
'@class': 'Parameter',
'@version': '0.6.0',
- 'name': 'test_param',
'value': 5.0,
'unit': 'm',
'variance': 0.1,
@@ -245,7 +257,7 @@ def test_convert_from_dict_easyscience_object(self, clear):
result = SerializerBase._convert_from_dict(param_dict)
assert isinstance(result, Parameter)
- assert result.name == 'test_param'
+ assert result.display_name == 'test_param'
assert result.value == 5.0
assert str(result.unit) == 'm'
@@ -290,13 +302,37 @@ def test_convert_to_dict_with_skip(self, serializer, mock_obj, clear):
assert 'value' not in result
assert 'optional_param' not in result
- def test_convert_to_dict_with_redirect(self, serializer, clear):
- """Test _convert_to_dict with _REDIRECT"""
- obj = MockSerializerWithRedirect('redirect_test', 10)
+ def test_convert_to_dict_with_arg_spec(self, serializer, clear):
+ """Test _convert_to_dict only collects the names in _arg_spec"""
+ obj = MockSerializerWithArgSpec('arg_spec_test', 10)
result = serializer._convert_to_dict(obj)
- assert result['special_attr'] == 20 # 10 * 2 from redirect
- assert 'none_attr' not in result # Should be skipped due to None redirect
+ assert result['name'] == 'arg_spec_test'
+ assert result['value'] == 10
+ assert 'special_attr' not in result
+
+ def test_convert_to_dict_nested_parameter_round_trip(self, serializer, clear):
+ """A Parameter nested in a model survives a dict round trip"""
+ model = MockModelWithParameter(
+ p=Parameter(2.0, unit='m', variance=0.1, min=0, max=5, fixed=True, display_name='p')
+ )
+ result = serializer._convert_to_dict(model)
+
+ nested = result['p']
+ assert nested['@class'] == 'Parameter'
+ json.dumps(nested)
+
+ global_object.registry.clear()
+ restored = Parameter.from_dict(nested)
+
+ assert restored is not model._p
+ assert restored.value == 2.0
+ assert restored.unit == 'm'
+ assert restored.variance == 0.1
+ assert restored.min == 0
+ assert restored.max == 5
+ assert restored.fixed is True
+ assert restored.display_name == 'p'
def test_convert_to_dict_with_custom_convert_to_dict(self, serializer, clear):
"""Test _convert_to_dict with custom _convert_to_dict method"""
@@ -311,7 +347,7 @@ def test_convert_to_dict_with_enum_object(self, serializer, clear):
"""Test _convert_to_dict when the object itself is an enum"""
# Test that enum values in objects remain as enums without full_encode
- class MockObjWithEnum(SerializerComponent):
+ class MockObjWithEnum(MockSerializable):
def __init__(self, name: str, enum_val: TestEnum):
self.name = name
self.enum_val = enum_val
@@ -329,7 +365,7 @@ def test_convert_to_dict_full_encode(self, serializer, clear):
"""Test _convert_to_dict with full_encode=True"""
dt = datetime.datetime(2023, 10, 17, 14, 30, 45)
- class MockObjWithDateTime(SerializerComponent):
+ class MockObjWithDateTime(MockSerializable):
def __init__(self, name: str, dt: datetime.datetime):
self.name = name
self.dt = dt
@@ -346,7 +382,7 @@ def __init__(self, name: str, dt: datetime.datetime):
def test_convert_to_dict_without_global_object(self, serializer):
"""Test _convert_to_dict with object without _global_object"""
- class MockObjNoGlobal(SerializerComponent):
+ class MockObjNoGlobal(MockSerializable):
def __init__(self, name: str):
self.name = name
@@ -356,10 +392,10 @@ def __init__(self, name: str):
assert result['name'] == 'test'
assert 'unique_name' not in result
- def test_convert_to_dict_with_arg_spec(self, serializer, clear):
+ def test_convert_to_dict_with_instance_arg_spec(self, serializer, clear):
"""Test _convert_to_dict with custom _arg_spec"""
- class MockObjCustomArgSpec(SerializerComponent):
+ class MockObjCustomArgSpec(MockSerializable):
def __init__(self, name: str, value: int, extra: str = 'default'):
self.name = name
self.value = value
@@ -377,7 +413,7 @@ def __init__(self, name: str, value: int, extra: str = 'default'):
def test_recursive_encoder_with_lists(self, serializer, clear):
"""Test _recursive_encoder with lists"""
- mock_obj = MockSerializerComponent('list_test', 1)
+ mock_obj = MockSerializableObj('list_test', 1)
data = [mock_obj, 'string', 42, {'key': 'value'}]
result = serializer._recursive_encoder(data)
@@ -392,7 +428,7 @@ def test_recursive_encoder_with_lists(self, serializer, clear):
def test_recursive_encoder_with_dicts(self, serializer, clear):
"""Test _recursive_encoder with dictionaries"""
- mock_obj = MockSerializerComponent('dict_test', 2)
+ mock_obj = MockSerializableObj('dict_test', 2)
data = {'obj': mock_obj, 'simple': 'value'}
result = serializer._recursive_encoder(data)
@@ -404,7 +440,7 @@ def test_recursive_encoder_with_dicts(self, serializer, clear):
def test_recursive_encoder_with_tuples(self, serializer, clear):
"""Test _recursive_encoder with tuples"""
- mock_obj = MockSerializerComponent('tuple_test', 3)
+ mock_obj = MockSerializableObj('tuple_test', 3)
data = (mock_obj, 'string', 42)
result = serializer._recursive_encoder(data)
@@ -425,36 +461,36 @@ def test_recursive_encoder_builtin_encode_method(self, serializer):
def test_recursive_encoder_with_mutable_sequence(self, serializer, clear):
"""Test _recursive_encoder with MutableSequence objects"""
- from easyscience.base_classes import CollectionBase
+ from easyscience.base_classes import EasyList
- d0 = DescriptorNumber('a', 0) # type: ignore
- d1 = DescriptorNumber('b', 1) # type: ignore
- collection = CollectionBase('test_collection', d0, d1)
+ d0 = DescriptorNumber(0, display_name='a') # type: ignore
+ d1 = DescriptorNumber(1, display_name='b') # type: ignore
+ collection = EasyList(d0, d1)
result = serializer._recursive_encoder(collection)
assert isinstance(result, dict)
- assert result['@class'] == 'CollectionBase'
- assert 'data' in result
+ assert result['@class'] == 'EasyList'
+ assert len(result['data']) == 2
- @patch('easyscience.io.serializer_base.import_module')
+ @patch('easyscience.base_classes.serializer_base.import_module')
def test_convert_to_dict_no_version(self, mock_import, serializer, clear):
"""Test _convert_to_dict when module has no __version__"""
mock_module = Mock()
del mock_module.__version__ # Remove version attribute
mock_import.return_value = mock_module
- mock_obj = MockSerializerComponent('no_version', 1)
+ mock_obj = MockSerializableObj('no_version', 1)
result = serializer._convert_to_dict(mock_obj)
assert result['@version'] is None
- @patch('easyscience.io.serializer_base.import_module')
+ @patch('easyscience.base_classes.serializer_base.import_module')
def test_convert_to_dict_import_error(self, mock_import, serializer, clear):
"""Test _convert_to_dict when import_module raises ImportError"""
mock_import.side_effect = ImportError('Module not found')
- mock_obj = MockSerializerComponent('import_error', 1)
+ mock_obj = MockSerializableObj('import_error', 1)
result = serializer._convert_to_dict(mock_obj)
assert result['@version'] is None
@@ -462,7 +498,7 @@ def test_convert_to_dict_import_error(self, mock_import, serializer, clear):
def test_convert_to_dict_attribute_error_handling(self, serializer, clear):
"""Test _convert_to_dict handles AttributeError for missing attributes"""
- class MockObjMissingAttrs(SerializerComponent):
+ class MockObjMissingAttrs(MockSerializable):
def __init__(self, name: str, missing_param: str = 'default'):
self.name = name
self.unique_name = f'missing_{name}'
@@ -471,31 +507,13 @@ def __init__(self, name: str, missing_param: str = 'default'):
obj = MockObjMissingAttrs('test')
- with pytest.raises(NotImplementedError, match='Unable to automatically determine as_dict'):
+ with pytest.raises(NotImplementedError, match='Unable to automatically determine to_dict'):
serializer._convert_to_dict(obj)
- def test_convert_to_dict_with_kwargs_attribute(self, serializer, clear):
- """Test _convert_to_dict with _kwargs attribute handling"""
-
- class MockObjWithKwargs(SerializerComponent):
- def __init__(self, name: str, value: int):
- self.name = name
- self.value = value
- self.unique_name = f'kwargs_{name}'
- self._global_object = True
- # Set up _kwargs to test the kwargs handling path
- self._kwargs = {'extra_param': 'extra_value'}
-
- obj = MockObjWithKwargs('test', 42)
- result = serializer._convert_to_dict(obj)
-
- # The extra_param from _kwargs should be included
- assert result['extra_param'] == 'extra_value'
-
def test_convert_to_dict_varargs_handling(self, serializer, clear):
"""Test _convert_to_dict with varargs (*args) handling"""
- class MockObjWithVarargs(SerializerComponent):
+ class MockObjWithVarargs(MockSerializable):
def __init__(self, name: str, *args):
self.name = name
self.args = args
@@ -539,7 +557,7 @@ def test_convert_from_dict_edge_cases(self):
def test_concrete_serializer_implementation(self, clear):
"""Test that ConcreteSerializer works correctly"""
serializer = ConcreteSerializer()
- mock_obj = MockSerializerComponent('concrete_test', 100)
+ mock_obj = MockSerializableObj('concrete_test', 100)
# Test encode
encoded = serializer.encode(mock_obj)
@@ -548,9 +566,9 @@ def test_concrete_serializer_implementation(self, clear):
assert encoded['value'] == 100
# Test decode
- global_object.map._clear() # Clear before decode
+ global_object.registry.clear() # Clear before decode
decoded = ConcreteSerializer.decode(encoded)
- assert isinstance(decoded, MockSerializerComponent)
+ assert isinstance(decoded, MockSerializableObj)
assert decoded.name == 'concrete_test'
assert decoded.value == 100
@@ -567,7 +585,18 @@ def complex_func(self, required_arg, optional_arg='default', *args, **kwargs):
assert spec.varkw == 'kwargs'
assert spec.defaults == ('default',)
- @patch('easyscience.io.serializer_base.np', None)
+ def test_get_arg_spec_keyword_only(self):
+ """Keyword-only arguments are included after positional ones"""
+
+ def kw_func(self, value, *, unit='', unique_name=None, **kwargs):
+ pass
+
+ spec, args = SerializerBase.get_arg_spec(kw_func)
+
+ assert args == ['value', 'unit', 'unique_name']
+ assert spec.kwonlyargs == ['unit', 'unique_name']
+
+ @patch('easyscience.base_classes.serializer_base.np', None)
def test_encode_objs_without_numpy(self):
"""Test _encode_objs when numpy is not available"""
# This test patches np to None to simulate numpy not being installed
@@ -579,7 +608,7 @@ def test_encode_objs_without_numpy(self):
def test_recursive_encoder_with_nested_structures(self, serializer, clear):
"""Test _recursive_encoder with deeply nested structures"""
- mock_obj = MockSerializerComponent('nested', 1)
+ mock_obj = MockSerializableObj('nested', 1)
data = {'level1': {'level2': [mock_obj, {'level3': mock_obj}]}}
@@ -646,23 +675,23 @@ def test_deserialize_value_easyscience_uses_from_dict(self, monkeypatch):
# When
serialized_dict = {
'@module': 'easyscience.base_classes',
- '@class': 'NewBase',
+ '@class': 'EasyBase',
'display_name': 'test',
}
- monkeypatch.setattr(NewBase, 'from_dict', MagicMock())
+ monkeypatch.setattr(EasyBase, 'from_dict', MagicMock())
# Then
obj = SerializerBase._deserialize_value(serialized_dict)
# Expect
- NewBase.from_dict.assert_called_once_with(serialized_dict)
+ EasyBase.from_dict.assert_called_once_with(serialized_dict)
def test_deserialize_value_easyscience_no_from_dict(self, monkeypatch):
# When
serialized_dict = {
'@module': 'easyscience.base_classes',
- '@class': 'NewBase',
+ '@class': 'EasyBase',
'display_name': 'test',
}
- monkeypatch.delattr(NewBase, 'from_dict')
+ monkeypatch.delattr(EasyBase, 'from_dict')
monkeypatch.setattr(SerializerBase, '_convert_from_dict', MagicMock())
# Then
obj = SerializerBase._deserialize_value(serialized_dict)
@@ -673,7 +702,7 @@ def test_deserialize_value_easyscience_import_error(self, monkeypatch):
# When
serialized_dict = {
'@module': 'easyscience.base_classes',
- '@class': 'NewBase',
+ '@class': 'EasyBase',
'display_name': 'test',
}
monkeypatch.setattr(SerializerBase, '_import_class', MagicMock(side_effect=ImportError()))
@@ -701,7 +730,7 @@ def test_deserialize_dict(self, monkeypatch):
'_deserialize_value',
MagicMock(
side_effect=[
- Parameter(name='param1', value=10.0),
+ Parameter(display_name='param1', value=10.0),
np.array([0, 1], dtype=np.int64),
]
),
diff --git a/tests/unit/fitting/calculators/test_interface_factory.py b/tests/unit/fitting/calculators/test_interface_factory.py
deleted file mode 100644
index ce5bb17a..00000000
--- a/tests/unit/fitting/calculators/test_interface_factory.py
+++ /dev/null
@@ -1,563 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import logging
-from unittest.mock import MagicMock
-
-import pytest
-
-from easyscience import global_object
-from easyscience.fitting.calculators.interface_factory import InterfaceFactoryTemplate
-from easyscience.fitting.calculators.interface_factory import ItemContainer
-
-
-class TestInterfaceFactoryTemplate:
- @pytest.fixture
- def clear(self):
- """Clear global map to avoid test contamination"""
- global_object.map._clear()
- yield
- global_object.map._clear()
-
- @pytest.fixture
- def mock_interface1(self):
- """Create a mock interface class"""
-
- class MockInterface1:
- name = 'MockInterface1'
-
- def __init__(self, *args, **kwargs):
- self.args = args
- self.kwargs = kwargs
-
- def fit_func(self, *args, **kwargs):
- return 'result1'
-
- def create(self, model):
- return []
-
- return MockInterface1
-
- @pytest.fixture
- def mock_interface2(self):
- """Create a second mock interface class"""
-
- class MockInterface2:
- def __init__(self, *args, **kwargs):
- self.args = args
- self.kwargs = kwargs
-
- def fit_func(self, *args, **kwargs):
- return 'result2'
-
- def create(self, model):
- return []
-
- return MockInterface2
-
- @pytest.fixture
- def mock_interface_with_custom_name(self):
- """Create a mock interface with custom name attribute"""
-
- class MockInterfaceCustom:
- name = 'CustomName'
-
- def __init__(self, *args, **kwargs):
- self.args = args
- self.kwargs = kwargs
-
- def fit_func(self, *args, **kwargs):
- return 'custom_result'
-
- def create(self, model):
- return []
-
- return MockInterfaceCustom
-
- @pytest.fixture
- def factory_single_interface(self, clear, mock_interface1):
- """Create factory with single interface"""
- return InterfaceFactoryTemplate([mock_interface1])
-
- @pytest.fixture
- def factory_multiple_interfaces(self, clear, mock_interface1, mock_interface2):
- """Create factory with multiple interfaces"""
- return InterfaceFactoryTemplate([mock_interface1, mock_interface2])
-
- def test_init_with_single_interface_default_selection(self, clear, mock_interface1):
- """Test initialization with single interface uses first as default"""
- # When
- factory = InterfaceFactoryTemplate([mock_interface1])
-
- # Then
- assert factory._interfaces == [mock_interface1]
- assert factory._current_interface == mock_interface1
- assert factory() is not None
- assert isinstance(factory(), mock_interface1)
-
- def test_init_with_empty_interface_list_raises_error(self, clear):
- """Test initialization with empty interface list raises NotImplementedError"""
- # When/Then
- with pytest.raises(NotImplementedError):
- InterfaceFactoryTemplate([])
-
- def test_init_with_specific_interface_name(self, clear, mock_interface1, mock_interface2):
- """Test initialization with specific interface name"""
- # When
- factory = InterfaceFactoryTemplate(
- [mock_interface1, mock_interface2], interface_name='MockInterface2'
- )
-
- # Then
- assert factory._current_interface == mock_interface2
- assert isinstance(factory(), mock_interface2)
-
- def test_init_passes_args_and_kwargs_to_interface(self, clear, mock_interface1):
- """Test that args and kwargs are passed to interface constructor"""
- # When
- factory = InterfaceFactoryTemplate([mock_interface1], 'arg1', 'arg2', kwarg1='value1')
-
- # Then
- interface_obj = factory()
- assert interface_obj.args == ('arg1', 'arg2')
- assert interface_obj.kwargs == {'kwarg1': 'value1'}
-
- def test_create_with_interface_name(self, factory_multiple_interfaces, mock_interface2):
- """Test create method with specific interface name"""
- # When
- factory_multiple_interfaces.create(interface_name='MockInterface2')
-
- # Then
- assert factory_multiple_interfaces._current_interface == mock_interface2
- assert isinstance(factory_multiple_interfaces(), mock_interface2)
-
- def test_create_without_interface_name_uses_first(
- self, factory_multiple_interfaces, mock_interface1
- ):
- """Test create method without interface name uses first interface"""
- # When
- factory_multiple_interfaces.create()
-
- # Then
- assert factory_multiple_interfaces._current_interface == mock_interface1
-
- def test_available_interfaces_property(self, factory_multiple_interfaces):
- """Test available_interfaces property returns correct names"""
- # When
- interfaces = factory_multiple_interfaces.available_interfaces
-
- # Then
- assert 'MockInterface1' in interfaces
- assert 'MockInterface2' in interfaces
- assert len(interfaces) == 2
-
- def test_current_interface_property(self, factory_single_interface, mock_interface1):
- """Test current_interface property returns correct interface class"""
- # When/Then
- assert factory_single_interface.current_interface == mock_interface1
-
- def test_current_interface_name_property(self, factory_single_interface):
- """Test current_interface_name property returns correct name"""
- # When/Then
- assert factory_single_interface.current_interface_name == 'MockInterface1'
-
- def test_switch_to_valid_interface(self, factory_multiple_interfaces, mock_interface2):
- """Test switching to a valid interface"""
- # When
- factory_multiple_interfaces.switch('MockInterface2')
-
- # Then
- assert factory_multiple_interfaces._current_interface == mock_interface2
- assert isinstance(factory_multiple_interfaces(), mock_interface2)
-
- def test_switch_to_invalid_interface_raises_error(self, factory_single_interface):
- """Test switching to invalid interface raises AttributeError"""
- # When/Then
- with pytest.raises(AttributeError, match='The user supplied interface is not valid'):
- factory_single_interface.switch('NonExistentInterface')
-
- def test_switch_with_fitter_having_fit_object_with_update_bindings(
- self, factory_single_interface
- ):
- """Test switch with fitter that has _fit_object with update_bindings method"""
- # Given
- mock_fit_object = MagicMock()
- mock_fit_object.update_bindings = MagicMock()
- mock_fitter = MagicMock()
- mock_fitter._fit_object = mock_fit_object
-
- # When
- factory_single_interface.switch('MockInterface1', fitter=mock_fitter)
-
- # Then
- mock_fit_object.update_bindings.assert_called_once()
-
- def test_switch_with_fitter_having_generate_bindings(self, factory_single_interface):
- """Test switch with fitter that has generate_bindings method"""
- # Given
- mock_fitter = MagicMock()
- mock_fitter.generate_bindings = MagicMock()
- del mock_fitter._fit_object # Ensure _fit_object doesn't exist
-
- # When
- factory_single_interface.switch('MockInterface1', fitter=mock_fitter)
-
- # Then
- mock_fitter.generate_bindings.assert_called_once()
-
- def test_switch_with_fitter_exception_handling(
- self, factory_single_interface, caplog: 'pytest.LogCaptureFixture'
- ):
- """Test switch handles exceptions in fitter binding updates gracefully"""
- # Given
- mock_fit_object = MagicMock()
- mock_fit_object.update_bindings.side_effect = Exception('Test exception')
- mock_fitter = MagicMock()
- mock_fitter._fit_object = mock_fit_object
-
- # When
- with caplog.at_level(logging.WARNING, logger='easyscience.fitting.calculators'):
- factory_single_interface.switch('MockInterface1', fitter=mock_fitter)
-
- # Then
- assert 'Unable to auto generate bindings' in caplog.text
- assert 'Test exception' in caplog.text
-
- def test_fit_func_property_returns_callable(self, factory_single_interface):
- """Test fit_func property returns a callable"""
- # When
- fit_func = factory_single_interface.fit_func
-
- # Then
- assert callable(fit_func)
-
- def test_fit_func_calls_interface_fit_func(self, factory_single_interface):
- """Test fit_func calls the underlying interface's fit_func"""
- # When
- result = factory_single_interface.fit_func('arg1', kwarg1='value1')
-
- # Then
- assert result == 'result1'
-
- def test_call_method(self, factory_single_interface):
- """Test call method delegates to fit_func"""
- # When
- result = factory_single_interface.call('arg1', kwarg1='value1')
-
- # Then
- assert result == 'result1'
-
- def test_generate_bindings_with_matching_properties(self, factory_single_interface):
- """Test generate_bindings method with matching properties"""
- # Given
- mock_model = MagicMock()
- mock_prop = MagicMock()
- mock_prop.name = 'test_param'
- mock_prop.value = 42
- mock_model._get_linkable_attributes.return_value = [mock_prop]
-
- mock_item = MagicMock()
- mock_item.name_conversion = {'test_param': 'internal_param'}
- mock_item.make_prop.return_value = MagicMock()
-
- # Mock the interface object's create method
- interface_obj = factory_single_interface()
- interface_obj.create = MagicMock(return_value=[mock_item])
-
- # When
- factory_single_interface.generate_bindings(mock_model)
-
- # Then
- mock_item.make_prop.assert_called_once_with('test_param')
- assert mock_prop._callback is not None
-
- def test_generate_bindings_with_value_no_call_back_property(self, factory_single_interface):
- """Test generate_bindings handles properties with value_no_call_back"""
- # Given
- mock_model = MagicMock()
- mock_prop = MagicMock()
- mock_prop.name = 'test_param'
- mock_prop.value_no_call_back = 24
- mock_model._get_linkable_attributes.return_value = [mock_prop]
-
- mock_item = MagicMock()
- mock_item.name_conversion = {'test_param': 'internal_param'}
- mock_callback = MagicMock()
- mock_item.make_prop.return_value = mock_callback
-
- # Mock the interface object's create method
- interface_obj = factory_single_interface()
- interface_obj.create = MagicMock(return_value=[mock_item])
-
- # When
- factory_single_interface.generate_bindings(mock_model)
-
- # Then
- mock_callback.fset.assert_called_once_with(24)
-
- def test_generate_bindings_skips_non_matching_properties(self, factory_single_interface):
- """Test generate_bindings skips properties not in name_conversion"""
- # Given
- mock_model = MagicMock()
- mock_prop = MagicMock()
- mock_prop.name = 'non_matching_param'
- mock_model._get_linkable_attributes.return_value = [mock_prop]
-
- mock_item = MagicMock()
- mock_item.name_conversion = {'different_param': 'internal_param'}
-
- # Mock the interface object's create method
- interface_obj = factory_single_interface()
- interface_obj.create = MagicMock(return_value=[mock_item])
-
- # When
- factory_single_interface.generate_bindings(mock_model)
-
- # Then
- mock_item.make_prop.assert_not_called()
-
- def test_call_dunder_method_returns_interface_obj(self, factory_single_interface):
- """Test __call__ method returns the interface object"""
- # When
- result1 = factory_single_interface()
- result2 = factory_single_interface()
-
- # Then
- assert result1 is not None
- assert result1 is result2 # Should return the same object
-
- def test_reduce_method_for_pickle_support(self, factory_single_interface):
- """Test __reduce__ method for pickle serialization support"""
- # When
- result = factory_single_interface.__reduce__()
-
- # Then
- assert len(result) == 2
- assert result[0] == InterfaceFactoryTemplate.__state_restore__
- assert result[1][0] == InterfaceFactoryTemplate
- assert result[1][1] == 'MockInterface1'
-
- def test_state_restore_static_method_implementation_issue(
- self, clear, mock_interface1, mock_interface2
- ):
- """Test __state_restore__ static method - demonstrates implementation issue"""
- # Given
- original_factory = InterfaceFactoryTemplate([mock_interface1, mock_interface2])
-
- # When/Then - this implementation has a bug where it tries to call cls() without arguments
- # which would fail since the constructor requires interface_list parameter
- with pytest.raises(TypeError):
- InterfaceFactoryTemplate.__state_restore__(InterfaceFactoryTemplate, 'MockInterface2')
-
- def test_return_name_static_method_with_name_attribute(self, mock_interface_with_custom_name):
- """Test return_name static method with interface having name attribute"""
- # When
- name = InterfaceFactoryTemplate.return_name(mock_interface_with_custom_name)
-
- # Then
- assert name == 'CustomName'
-
- def test_return_name_static_method_without_name_attribute(self, mock_interface1):
- """Test return_name static method without name attribute uses __name__"""
- # When
- name = InterfaceFactoryTemplate.return_name(mock_interface1)
-
- # Then
- assert name == 'MockInterface1'
-
- def test_switch_with_fitter_generate_bindings_exception(
- self, factory_single_interface, caplog: 'pytest.LogCaptureFixture'
- ):
- """Test switch handles exceptions in fitter.generate_bindings gracefully"""
- # Given
- mock_fitter = MagicMock()
- mock_fitter.generate_bindings.side_effect = Exception('Generate bindings test exception')
- # Ensure _fit_object doesn't exist so it goes to the elif branch
- if hasattr(mock_fitter, '_fit_object'):
- del mock_fitter._fit_object
-
- # When
- with caplog.at_level(logging.WARNING, logger='easyscience.fitting.calculators'):
- factory_single_interface.switch('MockInterface1', fitter=mock_fitter)
-
- # Then
- assert 'Unable to auto generate bindings' in caplog.text
- assert 'Generate bindings test exception' in caplog.text
-
- def test_generate_bindings_with_property_without_value_no_call_back(
- self, factory_single_interface
- ):
- """Test generate_bindings handles properties without value_no_call_back (descriptor objects)"""
- # Given
- mock_model = MagicMock()
- mock_prop = MagicMock()
- mock_prop.name = 'test_param'
- mock_prop.value = 99 # This will be used since no value_no_call_back attribute
- # Explicitly remove value_no_call_back to force the else branch
- del mock_prop.value_no_call_back
- mock_model._get_linkable_attributes.return_value = [mock_prop]
-
- mock_item = MagicMock()
- mock_item.name_conversion = {'test_param': 'internal_param'}
- mock_callback = MagicMock()
- mock_item.make_prop.return_value = mock_callback
-
- interface_obj = factory_single_interface()
- interface_obj.create = MagicMock(return_value=[mock_item])
-
- # When
- factory_single_interface.generate_bindings(mock_model)
-
- # Then
- mock_callback.fset.assert_called_once_with(99)
-
-
-class TestItemContainer:
- @pytest.fixture
- def mock_getter(self):
- """Mock getter function"""
-
- def getter(link_name, key):
- return f'got_{link_name}_{key}'
-
- return getter
-
- @pytest.fixture
- def mock_setter(self):
- """Mock setter function"""
-
- def setter(link_name, **kwargs):
- pass
-
- return setter
-
- @pytest.fixture
- def item_container(self, mock_getter, mock_setter):
- """Create ItemContainer instance"""
- return ItemContainer(
- link_name='test_link',
- name_conversion={'param1': 'internal_param1', 'param2': 'internal_param2'},
- getter_fn=mock_getter,
- setter_fn=mock_setter,
- )
-
- def test_item_container_creation(self, item_container):
- """Test ItemContainer can be created with required fields"""
- # Then
- assert item_container.link_name == 'test_link'
- assert item_container.name_conversion == {
- 'param1': 'internal_param1',
- 'param2': 'internal_param2',
- }
- assert callable(item_container.getter_fn)
- assert callable(item_container.setter_fn)
-
- def test_convert_key_with_existing_key(self, item_container):
- """Test convert_key method with existing key"""
- # When
- result = item_container.convert_key('param1')
-
- # Then
- assert result == 'internal_param1'
-
- def test_convert_key_with_non_existing_key(self, item_container):
- """Test convert_key method with non-existing key returns None"""
- # When
- result = item_container.convert_key('non_existing')
-
- # Then
- assert result is None
-
- def test_make_prop_returns_property(self, item_container):
- """Test make_prop method returns a property object"""
- # When
- prop = item_container.make_prop('param1')
-
- # Then
- assert isinstance(prop, property)
- assert prop.fget is not None
- assert prop.fset is not None
-
- def test_make_getter_function_behavior(self, item_container):
- """Test the behavior of the __make_getter method indirectly"""
- # When
- prop = item_container.make_prop('param1')
-
- # Then
- assert isinstance(prop, property)
- assert prop.fget is not None
- assert callable(prop.fget)
-
- def test_make_setter_function_behavior(self, item_container):
- """Test the behavior of the __make_setter method indirectly"""
- # Given
- setter_calls = []
-
- def mock_setter(link_name, **kwargs):
- setter_calls.append((link_name, kwargs))
-
- container = ItemContainer(
- link_name='test_link',
- name_conversion={'param1': 'internal_param1'},
- getter_fn=lambda x, y: None,
- setter_fn=mock_setter,
- )
-
- # When
- prop = container.make_prop('param1')
-
- # Then
- assert isinstance(prop, property)
- assert prop.fset is not None
- assert callable(prop.fset)
-
- def test_convert_key_behavior_in_property_creation(self, item_container):
- """Test that convert_key behavior is used correctly in property creation"""
- # When
- prop1 = item_container.make_prop('param1') # exists in name_conversion
- prop2 = item_container.make_prop('non_existing_param') # doesn't exist
-
- # Then
- # Both should create valid property objects
- assert isinstance(prop1, property)
- assert isinstance(prop2, property)
- # The difference is in the internal key mapping which affects runtime behavior
-
- def test_actual_getter_setter_execution(self):
- """Test that getter and setter functions are actually executed"""
- # Given
- getter_calls = []
- setter_calls = []
-
- def mock_getter(link_name, key):
- getter_calls.append((link_name, key))
- return f'value_for_{key}'
-
- def mock_setter(link_name, **kwargs):
- setter_calls.append((link_name, kwargs))
-
- container = ItemContainer(
- link_name='test_link',
- name_conversion={'param1': 'internal_param1'},
- getter_fn=mock_getter,
- setter_fn=mock_setter,
- )
-
- # When
- prop = container.make_prop('param1')
-
- # Execute the getter function directly (this covers the actual getter execution)
- getter_func = prop.fget
- getter_result = getter_func() # type: ignore
-
- # Execute the setter function directly (this covers the actual setter execution)
- setter_func = prop.fset
- setter_func('test_value') # type: ignore
-
- # Then
- assert getter_result == 'value_for_internal_param1'
- assert len(getter_calls) == 1
- assert getter_calls[0] == ('test_link', 'internal_param1')
-
- assert len(setter_calls) == 1
- assert setter_calls[0] == ('test_link', {'internal_param1': 'test_value'})
diff --git a/tests/unit/legacy/__init__.py b/tests/unit/fitting/minimizers/bumps_utils/__init__.py
similarity index 99%
rename from tests/unit/legacy/__init__.py
rename to tests/unit/fitting/minimizers/bumps_utils/__init__.py
index b1990ec8..4e798e20 100644
--- a/tests/unit/legacy/__init__.py
+++ b/tests/unit/fitting/minimizers/bumps_utils/__init__.py
@@ -1,3 +1,2 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-
diff --git a/tests/unit/fitting/minimizers/bumps_utils/test_problem.py b/tests/unit/fitting/minimizers/bumps_utils/test_problem.py
new file mode 100644
index 00000000..23912f55
--- /dev/null
+++ b/tests/unit/fitting/minimizers/bumps_utils/test_problem.py
@@ -0,0 +1,166 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Unit tests for the shared BUMPS problem-construction helpers."""
+
+from unittest.mock import MagicMock
+
+import numpy as np
+import pytest
+
+import easyscience.fitting.minimizers.bumps_utils.problem
+from easyscience.fitting.minimizers.bumps_utils import build_curve_problem
+from easyscience.fitting.minimizers.bumps_utils import parameter_names
+from easyscience.fitting.minimizers.bumps_utils import parameter_snapshot
+from easyscience.fitting.minimizers.bumps_utils import to_bumps_parameter
+
+
+class TestToBumpsParameter:
+ def test_convert_parameter_object(self) -> None:
+ from easyscience.variable import Parameter
+
+ param = Parameter(42.0, min=0.0, max=100.0, display_name='thickness')
+ param.fixed = False
+
+ result = to_bumps_parameter(param)
+
+ # to_bumps_parameter uses obj.unique_name which is auto-assigned
+ assert result.name.startswith('p')
+ assert result.value == 42.0
+ assert result.bounds == (0.0, 100.0)
+ assert result.fixed is False
+
+ def test_convert_fixed_parameter(self) -> None:
+ from easyscience.variable import Parameter
+
+ param = Parameter(5.0, min=0.0, max=20.0, display_name='roughness')
+ param.fixed = True
+
+ result = to_bumps_parameter(param)
+
+ assert result.name.startswith('p')
+ assert result.fixed is True
+
+
+class TestBuildCurveProblem:
+ """Curve/FitProblem assembly, with the BUMPS classes mocked out."""
+
+ @pytest.fixture(autouse=True)
+ def _mock_bumps_classes(self, monkeypatch):
+ self.mock_curve_cls = MagicMock(return_value='curve')
+ self.mock_problem_cls = MagicMock(return_value='problem')
+ monkeypatch.setattr(
+ easyscience.fitting.minimizers.bumps_utils.problem, 'Curve', self.mock_curve_cls
+ )
+ monkeypatch.setattr(
+ easyscience.fitting.minimizers.bumps_utils.problem,
+ 'FitProblem',
+ self.mock_problem_cls,
+ )
+ self.mock_convert = MagicMock(side_effect=lambda par: f'converted-{par.unique_name}')
+ monkeypatch.setattr(
+ easyscience.fitting.minimizers.bumps_utils.problem,
+ 'to_bumps_parameter',
+ self.mock_convert,
+ )
+
+ @staticmethod
+ def _engine_with_cached_pars(cached_pars):
+ engine = MagicMock()
+ engine._generate_fit_function = MagicMock(
+ return_value=MagicMock(return_value=np.array([2.0]))
+ )
+ engine._cached_pars = cached_pars
+ return engine
+
+ def test_uses_cached_parameters_by_default(self):
+ cached_par = MagicMock()
+ cached_par.unique_name = 'alpha'
+ engine = self._engine_with_cached_pars({'alpha': cached_par})
+
+ problem, counter, curve = build_curve_problem(
+ engine, np.array([1.0]), np.array([2.0]), np.array([4.0])
+ )
+
+ assert problem == 'problem'
+ # The Curve is surfaced directly so callers never have to read the
+ # deprecated ``FitProblem.fitness`` property (CR-1).
+ assert curve == 'curve'
+ engine._generate_fit_function.assert_called_once_with()
+ self.mock_convert.assert_called_once_with(cached_par)
+ assert self.mock_curve_cls.call_args.kwargs['palpha'] == 'converted-alpha'
+ self.mock_problem_cls.assert_called_once_with('curve')
+
+ def test_curve_receives_data_and_dy(self):
+ """weights are converted to dy = 1 / weights."""
+ engine = self._engine_with_cached_pars({})
+ x = np.array([1.0, 2.0])
+ y = np.array([10.0, 20.0])
+ weights = np.array([2.0, 4.0])
+
+ build_curve_problem(engine, x, y, weights)
+
+ call = self.mock_curve_cls.call_args
+ np.testing.assert_array_equal(call.args[1], x)
+ np.testing.assert_array_equal(call.args[2], y)
+ np.testing.assert_array_equal(call.kwargs['dy'], 1 / weights)
+
+ def test_counter_wraps_fit_function(self):
+ """The returned EvalCounter wraps the wrapped fit function and counts calls."""
+ inner = MagicMock(return_value=np.array([11.0, 22.0]))
+ engine = self._engine_with_cached_pars({})
+ engine._generate_fit_function = MagicMock(return_value=inner)
+
+ _, counter, _ = build_curve_problem(
+ engine, np.array([1.0]), np.array([2.0]), np.array([4.0])
+ )
+
+ # The counter itself is what Curve receives as the fit function.
+ assert self.mock_curve_cls.call_args.args[0] is counter
+ assert counter.count == 0
+ counter(np.array([1.0]))
+ assert counter.count == 1
+ inner.assert_called_once()
+
+
+class TestParameterNames:
+ def test_strips_prefix(self):
+ params = []
+ for name in ('palpha', 'pbeta'):
+ p = MagicMock()
+ p.name = name
+ params.append(p)
+ problem = MagicMock()
+ problem._parameters = params
+
+ assert parameter_names(problem) == ['alpha', 'beta']
+
+ def test_tolerates_none_name(self):
+ p = MagicMock()
+ p.name = None
+ problem = MagicMock()
+ problem._parameters = [p]
+
+ assert parameter_names(problem) == ['']
+
+
+class TestParameterSnapshot:
+ def test_snapshot_from_point(self) -> None:
+ mock_problem = MagicMock()
+ mock_problem.labels.return_value = ['palpha', 'pbeta']
+
+ point = np.array([1.5, 2.5])
+
+ snapshot = parameter_snapshot(mock_problem, point)
+
+ assert snapshot == {'alpha': 1.5, 'beta': 2.5}
+ mock_problem.getp.assert_not_called()
+
+ def test_snapshot_falls_back_to_getp(self) -> None:
+ mock_problem = MagicMock()
+ mock_problem.labels.return_value = ['palpha']
+ mock_problem.getp.return_value = np.array([3.5])
+
+ snapshot = parameter_snapshot(mock_problem, None)
+
+ assert snapshot == {'alpha': 3.5}
+ mock_problem.getp.assert_called_once()
diff --git a/tests/unit/fitting/minimizers/test_factory.py b/tests/unit/fitting/minimizers/test_factory.py
index 8ee4df6c..4be4dd4c 100644
--- a/tests/unit/fitting/minimizers/test_factory.py
+++ b/tests/unit/fitting/minimizers/test_factory.py
@@ -5,14 +5,13 @@
import pytest
-from easyscience import AvailableMinimizers
-from easyscience.fitting.available_minimizers import from_string_to_enum
+from easyscience import Minimizers
from easyscience.fitting.minimizers import MinimizerBase
from easyscience.fitting.minimizers.factory import factory
class TestFactory:
- def pull_minminizer(self, minimizer: AvailableMinimizers) -> MinimizerBase:
+ def pull_minminizer(self, minimizer: Minimizers) -> MinimizerBase:
mock_fit_object = MagicMock()
mock_fit_function = MagicMock()
minimizer = factory(minimizer, mock_fit_object, mock_fit_function)
@@ -21,12 +20,12 @@ def pull_minminizer(self, minimizer: AvailableMinimizers) -> MinimizerBase:
@pytest.mark.parametrize(
'minimizer_method,minimizer_enum',
[
- ('leastsq', AvailableMinimizers.LMFit),
- ('leastsq', AvailableMinimizers.LMFit_leastsq),
- ('powell', AvailableMinimizers.LMFit_powell),
- ('cobyla', AvailableMinimizers.LMFit_cobyla),
- ('differential_evolution', AvailableMinimizers.LMFit_differential_evolution),
- ('least_squares', AvailableMinimizers.LMFit_scipy_least_squares),
+ ('leastsq', Minimizers.LMFit),
+ ('leastsq', Minimizers.LMFit_leastsq),
+ ('powell', Minimizers.LMFit_powell),
+ ('cobyla', Minimizers.LMFit_cobyla),
+ ('differential_evolution', Minimizers.LMFit_differential_evolution),
+ ('least_squares', Minimizers.LMFit_scipy_least_squares),
],
)
def test_factory_lm_fit(self, minimizer_method, minimizer_enum):
@@ -37,10 +36,10 @@ def test_factory_lm_fit(self, minimizer_method, minimizer_enum):
@pytest.mark.parametrize(
'minimizer_method,minimizer_enum',
[
- ('amoeba', AvailableMinimizers.Bumps),
- ('amoeba', AvailableMinimizers.Bumps_simplex),
- ('newton', AvailableMinimizers.Bumps_newton),
- ('lm', AvailableMinimizers.Bumps_lm),
+ ('amoeba', Minimizers.Bumps),
+ ('amoeba', Minimizers.Bumps_simplex),
+ ('newton', Minimizers.Bumps_newton),
+ ('lm', Minimizers.Bumps_lm),
],
)
def test_factory_bumps_fit(self, minimizer_method, minimizer_enum):
@@ -50,61 +49,9 @@ def test_factory_bumps_fit(self, minimizer_method, minimizer_enum):
@pytest.mark.parametrize(
'minimizer_method,minimizer_enum',
- [('leastsq', AvailableMinimizers.DFO), ('leastsq', AvailableMinimizers.DFO_leastsq)],
+ [('leastsq', Minimizers.DFO), ('leastsq', Minimizers.DFO_leastsq)],
)
def test_factory_dfo_fit(self, minimizer_method, minimizer_enum):
minimizer = self.pull_minminizer(minimizer_enum)
assert minimizer._method == minimizer_method
assert minimizer.package == 'dfo'
-
-
-@pytest.mark.parametrize(
- 'minimizer_name,expected',
- [
- ('LMFit', AvailableMinimizers.LMFit),
- ('LMFit_leastsq', AvailableMinimizers.LMFit_leastsq),
- ('LMFit_powell', AvailableMinimizers.LMFit_powell),
- ('LMFit_cobyla', AvailableMinimizers.LMFit_cobyla),
- ('LMFit_differential_evolution', AvailableMinimizers.LMFit_differential_evolution),
- ('LMFit_scipy_least_squares', AvailableMinimizers.LMFit_scipy_least_squares),
- ],
-)
-def test_from_string_to_enum_lmfit(minimizer_name, expected):
- assert from_string_to_enum(minimizer_name) == expected
-
-
-@pytest.mark.parametrize(
- 'minimizer_name,expected',
- [
- ('Bumps', AvailableMinimizers.Bumps),
- ('Bumps_simplex', AvailableMinimizers.Bumps_simplex),
- ('Bumps_newton', AvailableMinimizers.Bumps_newton),
- ('Bumps_lm', AvailableMinimizers.Bumps_lm),
- ],
-)
-def test_from_string_to_enum_bumps(minimizer_name, expected):
- assert from_string_to_enum(minimizer_name) == expected
-
-
-@pytest.mark.parametrize(
- 'minimizer_name,expected',
- [('DFO', AvailableMinimizers.DFO), ('DFO_leastsq', AvailableMinimizers.DFO_leastsq)],
-)
-def test_from_string_to_enum_dfo(minimizer_name, expected):
- assert from_string_to_enum(minimizer_name) == expected
-
-
-def test_available_minimizers():
- assert AvailableMinimizers.LMFit
- assert AvailableMinimizers.LMFit_leastsq
- assert AvailableMinimizers.LMFit_powell
- assert AvailableMinimizers.LMFit_cobyla
- assert AvailableMinimizers.LMFit_differential_evolution
- assert AvailableMinimizers.LMFit_scipy_least_squares
- assert AvailableMinimizers.Bumps
- assert AvailableMinimizers.Bumps_simplex
- assert AvailableMinimizers.Bumps_newton
- assert AvailableMinimizers.Bumps_lm
- assert AvailableMinimizers.DFO
- assert AvailableMinimizers.DFO_leastsq
- assert len(AvailableMinimizers) == 12
diff --git a/tests/unit/fitting/minimizers/test_minimizer_base.py b/tests/unit/fitting/minimizers/test_minimizer_base.py
index 86ec4292..467264f4 100644
--- a/tests/unit/fitting/minimizers/test_minimizer_base.py
+++ b/tests/unit/fitting/minimizers/test_minimizer_base.py
@@ -61,7 +61,7 @@ def test_evaluate(self, minimizer: MinimizerBase):
# Then
result = minimizer.evaluate(
'x',
- minimizer_parameters={'parms_key': 'parms_val'},
+ parameters={'parms_key': 'parms_val'},
kwargs={'kwargs_key': 'kwargs_val'},
)
@@ -84,7 +84,7 @@ def test_evaluate_no_fit_function(self, minimizer: MinimizerBase):
# Then
minimizer.evaluate(
'x',
- minimizer_parameters={'parms_key': 'parms_val'},
+ parameters={'parms_key': 'parms_val'},
kwargs={'kwargs_key': 'kwargs_val'},
)
@@ -108,11 +108,11 @@ def test_evaluate_no_parameters(self, minimizer: MinimizerBase):
def test_evaluate_exception(self, minimizer: MinimizerBase):
# When
- minimizer_parameters = 'not dict type'
+ parameters = 'not dict type'
# Then Expect
with pytest.raises(TypeError):
- minimizer.evaluate('x', minimizer_parameters=minimizer_parameters)
+ minimizer.evaluate('x', parameters=parameters)
def test_prepare_parameters(self, minimizer: MinimizerBase):
# When
@@ -142,7 +142,7 @@ def test_generate_fit_function(self, minimizer: MinimizerBase) -> None:
mock_parm_2.unique_name = 'mock_parm_2'
mock_parm_2.value = 2.0
mock_parm_2.error = 0.2
- minimizer._object.get_fit_parameters = MagicMock(return_value=[mock_parm_1, mock_parm_2])
+ minimizer._object.get_free_parameters = MagicMock(return_value=[mock_parm_1, mock_parm_2])
# Then
fit_function = minimizer._generate_fit_function()
diff --git a/tests/unit/fitting/minimizers/test_minimizer_bumps.py b/tests/unit/fitting/minimizers/test_minimizer_bumps.py
index 28d4a049..93e42aac 100644
--- a/tests/unit/fitting/minimizers/test_minimizer_bumps.py
+++ b/tests/unit/fitting/minimizers/test_minimizer_bumps.py
@@ -41,16 +41,19 @@ def test_all_methods(self, minimizer: Bumps) -> None:
# When Then Expect
assert minimizer.all_methods() == ['amoeba', 'de', 'dream', 'newton', 'lm']
+ def test_all_methods_returns_a_copy(self, minimizer: Bumps) -> None:
+ """Callers must not be able to mutate the module-level list in place."""
+ methods = minimizer.all_methods()
+ methods.append('tampered')
+
+ assert 'tampered' not in minimizer.all_methods()
+
def test_supported_methods(self, minimizer: Bumps) -> None:
# When Then Expect
assert set(minimizer.supported_methods()) == set(['newton', 'lm', 'amoeba'])
def test_fit(self, minimizer: Bumps, monkeypatch) -> None:
# When
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
# Mock FitDriver. driver.fit() returns (x, fx); driver.stderr() returns dx.
mock_driver_instance = MagicMock()
mock_driver_instance.clip = MagicMock()
@@ -65,17 +68,17 @@ def test_fit(self, minimizer: Bumps, monkeypatch) -> None:
# Prepare a mock parameter with .name = 'pmock_parm_1'
mock_bumps_param = MagicMock()
mock_bumps_param.name = 'pmock_parm_1'
- # Patch FitProblem to have _parameters attribute as expected
- mock_FitProblem_instance = MagicMock()
- mock_FitProblem_instance._parameters = [mock_bumps_param]
- mock_FitProblem = MagicMock(return_value=mock_FitProblem_instance)
+ # A mock problem with _parameters, plus the Curve model returned
+ # directly by the helper (never via the deprecated problem.fitness)
+ mock_model = MagicMock()
+ mock_problem = MagicMock()
+ mock_problem._parameters = [mock_bumps_param]
+ mock_counter = MagicMock()
+ mock_build = MagicMock(return_value=(mock_problem, mock_counter, mock_model))
monkeypatch.setattr(
- easyscience.fitting.minimizers.minimizer_bumps, 'FitProblem', mock_FitProblem
+ easyscience.fitting.minimizers.minimizer_bumps, 'build_curve_problem', mock_build
)
- mock_model = MagicMock()
- mock_model_function = MagicMock(return_value=mock_model)
- minimizer._make_model = MagicMock(return_value=mock_model_function)
minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results')
cached_par = MagicMock()
@@ -84,11 +87,10 @@ def test_fit(self, minimizer: Bumps, monkeypatch) -> None:
minimizer._cached_pars = cached_pars
minimizer._cached_pars_vals = {'mock_parm_1': (1, 0.0)}
- # Patch _set_parameter_fit_result
- def fake_set_parameter_fit_result(fit_result, stack_status, par_list):
- for index, name in enumerate([par.name for par in par_list]):
- dict_name = name[len('p') :]
- minimizer._cached_pars[dict_name].value = fit_result.x[index]
+ # Patch _set_parameter_fit_result. It now receives prefix-stripped names.
+ def fake_set_parameter_fit_result(fit_result, par_names):
+ for index, name in enumerate(par_names):
+ minimizer._cached_pars[name].value = fit_result.x[index]
minimizer._set_parameter_fit_result = fake_set_parameter_fit_result
@@ -104,7 +106,17 @@ def fake_set_parameter_fit_result(fit_result, stack_status, par_list):
mock_FitDriver.assert_called_once()
mock_driver_instance.clip.assert_called_once()
mock_driver_instance.fit.assert_called_once()
- minimizer._make_model.assert_called_once_with(parameters=None)
+ # The problem is built via the shared helper and its Curve is cached
+ mock_build.assert_called_once()
+ build_args = mock_build.call_args
+ assert build_args.args[0] is minimizer
+ assert np.array_equal(build_args.args[1], np.asarray(1.0))
+ assert np.array_equal(build_args.args[2], np.asarray(2.0))
+ assert np.array_equal(build_args.args[3], np.asarray(1))
+ assert build_args.kwargs == {}
+ assert minimizer._eval_counter is mock_counter
+ assert minimizer._cached_model is mock_model
+ assert mock_FitDriver.call_args.kwargs['problem'] is mock_problem
# _gen_fit_results is called with the OptimizeResult built from driver.fit()
minimizer._gen_fit_results.assert_called_once()
passed_result = minimizer._gen_fit_results.call_args.args[0]
@@ -114,8 +126,6 @@ def fake_set_parameter_fit_result(fit_result, stack_status, par_list):
'max_evaluations': None,
'tolerance': None,
}
- mock_model_function.assert_called_once_with(1.0, 2.0, 1)
- mock_FitProblem.assert_called_once_with(mock_model)
@pytest.mark.parametrize(
'weights',
@@ -134,35 +144,7 @@ def test_fit_weight_exceptions(self, minimizer: Bumps, weights) -> None:
with pytest.raises(ValueError):
minimizer.fit(x=np.array([1, 2, 3]), y=np.array([1, 2, 3]), weights=weights)
- def test_make_model(self, minimizer: Bumps, monkeypatch) -> None:
- # When
- mock_fit_function = MagicMock(return_value=np.array([11, 22]))
- minimizer._generate_fit_function = MagicMock(return_value=mock_fit_function)
-
- mock_parm_1 = MagicMock()
- mock_parm_1.unique_name = 'mock_parm_1'
- minimizer.convert_to_par_object = MagicMock(return_value='converted_parm_1')
-
- mock_Curve = MagicMock(return_value='curve')
- monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_bumps, 'Curve', mock_Curve)
-
- # Then
- model = minimizer._make_model(parameters=[mock_parm_1])
- curve_for_model = model(
- x=np.array([1, 2]), y=np.array([10, 20]), weights=np.array([100, 200])
- )
- wrapped_fit_function = mock_Curve.call_args[0][0]
- wrapped_fit_function(np.array([1, 2]), pmock_parm_1=3)
-
- # Expect
- minimizer._generate_fit_function.assert_called_once_with()
- assert minimizer._eval_counter is wrapped_fit_function
- assert minimizer._eval_counter.count == 1
- assert all(mock_Curve.call_args[0][1] == np.array([1, 2]))
- assert all(mock_Curve.call_args[0][2] == np.array([10, 20]))
- assert curve_for_model == 'curve'
-
- def test_set_parameter_fit_result_no_stack_status(self, minimizer: Bumps):
+ def test_set_parameter_fit_result(self, minimizer: Bumps):
# When
minimizer._cached_pars = {
'a': MagicMock(),
@@ -179,15 +161,8 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: Bumps):
mock_fit_result.x = np.array([1.0, 2.0])
mock_fit_result.dx = np.array([0.1, 0.2])
- # The new argument: par_list (list of mock parameters)
- mock_par_a = MagicMock()
- mock_par_a.name = 'pa'
- mock_par_b = MagicMock()
- mock_par_b.name = 'pb'
- par_list = [mock_par_a, mock_par_b]
-
- # Then
- minimizer._set_parameter_fit_result(mock_fit_result, False, par_list)
+ # Then - names arrive already stripped of the minimizer prefix
+ minimizer._set_parameter_fit_result(mock_fit_result, ['a', 'b'])
# Expect
assert minimizer._cached_pars['a'].value == 1.0
@@ -195,6 +170,21 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: Bumps):
assert minimizer._cached_pars['b'].value == 2.0
assert minimizer._cached_pars['b'].error == 0.2
+ def test_set_parameter_fit_result_without_stderr(self, minimizer: Bumps):
+ """Fitters that cannot produce a covariance hand back ``dx=None``;
+ those parameters get ``error=None`` (no uncertainty information)
+ rather than a misleading ``0.0``."""
+ minimizer._cached_pars = {'a': MagicMock()}
+
+ mock_fit_result = MagicMock()
+ mock_fit_result.x = np.array([1.0])
+ mock_fit_result.dx = None
+
+ minimizer._set_parameter_fit_result(mock_fit_result, ['a'])
+
+ assert minimizer._cached_pars['a'].value == 1.0
+ assert minimizer._cached_pars['a'].error is None
+
def test_gen_fit_results(
self, minimizer: Bumps, monkeypatch, caplog: 'pytest.LogCaptureFixture'
):
@@ -258,7 +248,7 @@ def test_gen_fit_results(
assert domain_fit_results.fit_args is None
assert domain_fit_results.engine_result == mock_fit_result
minimizer.evaluate.assert_called_once_with(
- 'x', minimizer_parameters={'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'}
+ 'x', parameters={'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'}
)
@pytest.mark.parametrize(
@@ -302,6 +292,51 @@ def test_gen_fit_results_max_evaluations_boundary(
assert mock_domain_fit_results.success is expected_success
+ def test_gen_fit_results_applies_extra_kwargs(self, minimizer: Bumps) -> None:
+ """Extra kwargs land on a real FitResults. Guarding the copy on the
+ current value instead of `hasattr` would drop every one of them, since
+ all FitResults fields start out falsy."""
+ mock_cached_model = MagicMock()
+ mock_cached_model.x = np.array([1.0])
+ mock_cached_model.y = np.array([2.0])
+ mock_cached_model.dy = np.array([1.0])
+ mock_cached_model.pars = {'ppar_1': 0}
+ minimizer._cached_model = mock_cached_model
+ minimizer._cached_pars = {'par_1': MagicMock(value=1.0)}
+ minimizer._p_0 = {}
+ minimizer._eval_counter = None
+ minimizer.evaluate = MagicMock(return_value=np.array([2.0]))
+
+ mock_fit_result = MagicMock()
+ mock_fit_result.success = True
+ mock_fit_result.nit = 1
+
+ results = minimizer._gen_fit_results(mock_fit_result, x_matrices='copied')
+
+ assert results.x_matrices == 'copied'
+
+ def test_gen_fit_results_propagates_failure_message(self, minimizer: Bumps) -> None:
+ mock_cached_model = MagicMock()
+ mock_cached_model.x = np.array([1.0])
+ mock_cached_model.y = np.array([2.0])
+ mock_cached_model.dy = np.array([1.0])
+ mock_cached_model.pars = {'ppar_1': 0}
+ minimizer._cached_model = mock_cached_model
+ minimizer._cached_pars = {'par_1': MagicMock(value=1.0)}
+ minimizer._p_0 = {}
+ minimizer._eval_counter = None
+ minimizer.evaluate = MagicMock(return_value=np.array([2.0]))
+
+ mock_fit_result = MagicMock()
+ mock_fit_result.success = False
+ mock_fit_result.nit = 1
+ mock_fit_result.message = 'Fit aborted before convergence'
+
+ results = minimizer._gen_fit_results(mock_fit_result)
+
+ assert results.success is False
+ assert results.message == 'Fit aborted before convergence'
+
def test_resolve_fitclass_valid(self, minimizer: Bumps) -> None:
# When Then
fitclass = Bumps._resolve_fitclass('lm')
@@ -316,10 +351,6 @@ def test_resolve_fitclass_invalid(self, minimizer: Bumps) -> None:
def test_fit_progress_callback(self, minimizer: Bumps, monkeypatch) -> None:
# When
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
progress_callback = MagicMock(return_value=True)
mock_driver_instance = MagicMock()
@@ -334,16 +365,14 @@ def test_fit_progress_callback(self, minimizer: Bumps, monkeypatch) -> None:
mock_bumps_param = MagicMock()
mock_bumps_param.name = 'pmock_parm_1'
- mock_FitProblem_instance = MagicMock()
- mock_FitProblem_instance._parameters = [mock_bumps_param]
- mock_FitProblem = MagicMock(return_value=mock_FitProblem_instance)
+ mock_problem = MagicMock()
+ mock_problem._parameters = [mock_bumps_param]
monkeypatch.setattr(
- easyscience.fitting.minimizers.minimizer_bumps, 'FitProblem', mock_FitProblem
+ easyscience.fitting.minimizers.minimizer_bumps,
+ 'build_curve_problem',
+ MagicMock(return_value=(mock_problem, MagicMock(), MagicMock())),
)
- mock_model = MagicMock()
- mock_model_function = MagicMock(return_value=mock_model)
- minimizer._make_model = MagicMock(return_value=mock_model_function)
minimizer._set_parameter_fit_result = MagicMock()
minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results')
@@ -363,70 +392,10 @@ def test_fit_progress_callback(self, minimizer: Bumps, monkeypatch) -> None:
monitors = driver_call_kwargs.kwargs.get('monitors', driver_call_kwargs[1].get('monitors'))
assert len(monitors) == 1
assert isinstance(monitors[0], BumpsProgressMonitor)
- assert monitors[0]._problem is mock_FitProblem_instance
+ assert monitors[0]._problem is mock_problem
assert monitors[0]._callback is progress_callback
assert monitors[0]._payload_builder == minimizer._build_progress_payload
- def test_fit_uses_supplied_model_and_optional_kwargs(
- self, minimizer: Bumps, monkeypatch
- ) -> None:
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
- mock_driver_instance = MagicMock()
- mock_driver_instance.clip = MagicMock()
- mock_driver_instance.fit = MagicMock(return_value=(np.array([3.0]), 0.0))
- mock_driver_instance.stderr = MagicMock(return_value=np.array([0.1]))
- mock_driver_instance.monitor_runner.history.step = [0]
- mock_FitDriver = MagicMock(return_value=mock_driver_instance)
- monkeypatch.setattr(
- easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver
- )
-
- mock_bumps_param = MagicMock()
- mock_bumps_param.name = 'pmock_parm_1'
- mock_problem = MagicMock()
- mock_problem._parameters = [mock_bumps_param]
- monkeypatch.setattr(
- easyscience.fitting.minimizers.minimizer_bumps,
- 'FitProblem',
- MagicMock(return_value=mock_problem),
- )
-
- minimizer._make_model = MagicMock()
- minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results')
- minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba'))
- minimizer._set_parameter_fit_result = MagicMock()
- minimizer._cached_pars = {'mock_parm_1': MagicMock(value=1.0)}
- minimizer._cached_pars_vals = {'mock_parm_1': (1.0, 0.0)}
-
- supplied_model = MagicMock()
- minimizer_kwargs = {'existing_option': 'minimizer'}
- engine_kwargs = {'engine_option': 'engine'}
-
- result = minimizer.fit(
- x=np.array([1.0]),
- y=np.array([2.0]),
- weights=np.array([1.0]),
- model=supplied_model,
- tolerance=0.25,
- max_evaluations=7,
- minimizer_kwargs=minimizer_kwargs,
- engine_kwargs=engine_kwargs,
- )
-
- assert result == 'gen_fit_results'
- minimizer._make_model.assert_not_called()
- fit_driver_kwargs = mock_FitDriver.call_args.kwargs
- assert fit_driver_kwargs['problem'] is mock_problem
- assert fit_driver_kwargs['existing_option'] == 'minimizer'
- assert fit_driver_kwargs['engine_option'] == 'engine'
- assert fit_driver_kwargs['ftol'] == 0.25
- assert fit_driver_kwargs['xtol'] == 0.25
- assert fit_driver_kwargs['steps'] == 7
- mock_driver_instance.fit.assert_called_once()
-
def test_fit_rejects_non_callable_progress_callback(
self, minimizer: Bumps, monkeypatch
) -> None:
@@ -442,7 +411,6 @@ def test_fit_rejects_non_callable_progress_callback(
x=np.array([1.0]),
y=np.array([2.0]),
weights=np.array([1.0]),
- model=MagicMock(),
progress_callback='not-callable',
)
@@ -522,60 +490,6 @@ def test_build_progress_payload_reduced_chi2_positive_dof(self, minimizer: Bumps
((), {'nllf': 5.0, 'norm': True}),
]
- def test_current_parameter_snapshot(self, minimizer: Bumps) -> None:
- # When
- mock_problem = MagicMock()
- mock_problem.labels.return_value = ['palpha', 'pbeta']
-
- point = np.array([1.5, 2.5])
-
- # Then
- snapshot = minimizer._current_parameter_snapshot(mock_problem, point)
-
- # Expect
- assert snapshot == {'alpha': 1.5, 'beta': 2.5}
-
- @pytest.mark.parametrize('par_list', [None, [MagicMock(unique_name='alpha')]])
- def test_convert_to_pars_obj_optional_parameter_list(
- self, minimizer: Bumps, par_list, monkeypatch
- ) -> None:
- object_parameters = [MagicMock(unique_name='beta')]
- minimizer._object = MagicMock()
- minimizer._object.get_fit_parameters = MagicMock(return_value=object_parameters)
- monkeypatch.setattr(
- Bumps,
- 'convert_to_par_object',
- staticmethod(lambda parameter: parameter.unique_name),
- )
-
- converted = minimizer.convert_to_pars_obj(par_list)
-
- expected_parameters = object_parameters if par_list is None else par_list
- assert converted == [parameter.unique_name for parameter in expected_parameters]
- if par_list is None:
- minimizer._object.get_fit_parameters.assert_called_once_with()
- else:
- minimizer._object.get_fit_parameters.assert_not_called()
-
- def test_make_model_without_parameters_uses_cached_parameters(
- self, minimizer: Bumps, monkeypatch
- ) -> None:
- minimizer._generate_fit_function = MagicMock(
- return_value=MagicMock(return_value=np.array([2.0]))
- )
- minimizer._cached_pars = {'alpha': MagicMock(value=1.0)}
- minimizer.convert_to_par_object = MagicMock(return_value='converted-alpha')
-
- mock_curve = MagicMock(return_value='curve')
- monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_bumps, 'Curve', mock_curve)
-
- model = minimizer._make_model()
- curve = model(np.array([1.0]), np.array([2.0]), np.array([3.0]))
-
- assert curve == 'curve'
- minimizer.convert_to_par_object.assert_called_once_with(minimizer._cached_pars['alpha'])
- assert mock_curve.call_args.kwargs['palpha'] == 'converted-alpha'
-
def test_bumps_progress_monitor_calls_callback(self, minimizer: Bumps) -> None:
# When
callback = MagicMock(return_value=True)
@@ -603,10 +517,6 @@ def test_bumps_progress_monitor_calls_callback(self, minimizer: Bumps) -> None:
def test_fit_exception_restores_values(self, minimizer: Bumps, monkeypatch) -> None:
# When
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
from easyscience.variable import Parameter
parameter = MagicMock(Parameter)
@@ -622,16 +532,13 @@ def test_fit_exception_restores_values(self, minimizer: Bumps, monkeypatch) -> N
easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver
)
- mock_FitProblem_instance = MagicMock()
- mock_FitProblem_instance._parameters = []
- mock_FitProblem = MagicMock(return_value=mock_FitProblem_instance)
+ mock_problem = MagicMock()
+ mock_problem._parameters = []
monkeypatch.setattr(
- easyscience.fitting.minimizers.minimizer_bumps, 'FitProblem', mock_FitProblem
+ easyscience.fitting.minimizers.minimizer_bumps,
+ 'build_curve_problem',
+ MagicMock(return_value=(mock_problem, MagicMock(), MagicMock())),
)
-
- mock_model = MagicMock()
- mock_model_function = MagicMock(return_value=mock_model)
- minimizer._make_model = MagicMock(return_value=mock_model_function)
minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba'))
# Then Expect
@@ -683,462 +590,113 @@ def test_gen_fit_results_uses_nit_for_budget_check(
# ===================================================================
-# Bumps.mcmc_sample() β Bayesian DREAM sampling
+# fit() β tolerance / budget defaults are reported, never forced
# ===================================================================
-class TestBumpsSample:
- """Tests for the ``Bumps.mcmc_sample()`` method and its helpers."""
-
- # Sentinel value to signal "set fitter.state = None" in _setup_driver_mock
- ABORT = object()
+class TestFitToleranceAndBudgetDefaults:
+ """BUMPS pairs an independent ftol/xtol default per fitter. Resolving
+ them for reporting must not push a single collapsed value back into the
+ fitter, which would silently tighten its convergence criteria."""
@pytest.fixture
def minimizer(self) -> Bumps:
return Bumps(
obj='obj',
fit_function='fit_function',
- minimizer_enum=MagicMock(package='bumps', method='amoeba'),
- )
-
- @pytest.fixture(autouse=True)
- def _mock_bumps_internals(self, monkeypatch):
- """Prevent sample() from constructing real BUMPS objects.
-
- ``sample()`` imports ``DreamFit`` and ``FitProblem`` from the real
- ``bumps`` package internally, which would try to build real model
- objects. We redirect those to mocks and also mock ``FitDriver``
- (which *is* a module-level import) so the whole flow stays under
- test control.
-
- Also mock ``_make_model`` on the class so that the ``minimizer``
- fixture (which uses ``obj='obj'``) doesn't fail inside ``sample()``.
- """
- import bumps.fitters
- import bumps.names
-
- monkeypatch.setattr(bumps.fitters, 'DreamFit', MagicMock())
- monkeypatch.setattr(bumps.names, 'FitProblem', MagicMock(return_value=MagicMock()))
- monkeypatch.setattr(
- Bumps, '_make_model', MagicMock(return_value=MagicMock(return_value=MagicMock()))
+ minimizer_enum=MagicMock(package='bumps', method='newton'),
)
- def _setup_driver_mock(
- self, monkeypatch, fitter_state_value=None, fit_result=None, fit_side_effect=None
- ):
- """Helper to create a mocked FitDriver with configurable behavior.
-
- :param fitter_state_value: If ``None``, ``driver.fitter.state`` will be
- a regular MagicMock (non-None). Pass ``ABORT`` to set it to ``None``
- and simulate user abort.
- """
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
+ @staticmethod
+ def _patch_driver_and_problem(minimizer: Bumps, monkeypatch) -> MagicMock:
mock_driver = MagicMock()
- mock_driver.clip = MagicMock()
-
- if fit_side_effect is not None:
- mock_driver.fit.side_effect = fit_side_effect
- else:
- mock_driver.fit.return_value = fit_result or (np.array([1.0]), 0.0)
-
+ mock_driver.fit = MagicMock(return_value=(np.array([42.0]), 0.0))
mock_driver.stderr = MagicMock(return_value=np.array([0.1]))
-
- if fitter_state_value is TestBumpsSample.ABORT:
- mock_driver.fitter.state = None
- else:
- mock_state = MagicMock()
- mock_state.Nvar = 1
- mock_state.Npop = 5
- mock_state.labels = ['p_param_0']
- mock_draw = MagicMock()
- mock_draw.points = np.array([[1.0]])
- mock_draw.logp = np.array([0.5])
- mock_state.draw.return_value = mock_draw
- mock_driver.fitter.state = mock_state
-
+ mock_driver.monitor_runner.history.step = [0]
mock_FitDriver = MagicMock(return_value=mock_driver)
monkeypatch.setattr(
easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver
)
- return mock_FitDriver, mock_driver
-
- @pytest.mark.parametrize(
- 'kwargs, match',
- [
- ({'samples': 0}, 'samples must be a positive integer'),
- ({'samples': -1}, 'samples must be a positive integer'),
- ({'burn': -1}, 'burn must be a non-negative integer'),
- ({'thin': 0}, 'thin must be a positive integer'),
- ],
- )
- def test_sample_invalid_args(self, minimizer: Bumps, kwargs, match) -> None:
- """Invalid samples/burn/thin values raise ValueError before any sampling.
-
- This is the single source of truth for these checks β the higher-level
- ``Sampler`` relies on it.
- """
- with pytest.raises(ValueError, match=match):
- minimizer.mcmc_sample(
- x=np.array([1.0]),
- y=np.array([0.1]),
- weights=np.array([1.0]),
- samples=kwargs.get('samples', 10),
- burn=kwargs.get('burn', 0),
- thin=kwargs.get('thin', 1),
- )
-
- @pytest.mark.parametrize(
- 'overrides, match',
- [
- ({'y': np.array([0.1])}, 'x and y must have the same shape'),
- ({'x': np.array([1.0, np.nan])}, 'x cannot contain NaN'),
- ({'y': np.array([0.1, np.inf])}, 'y cannot contain NaN'),
- ({'weights': np.array([1.0])}, 'Weights must have the same shape'),
- ({'weights': np.array([1.0, np.nan])}, 'Weights cannot be NaN'),
- ({'weights': np.array([1.0, 0.0])}, 'Weights must be strictly positive'),
- ],
- )
- def test_sample_invalid_data(self, minimizer: Bumps, overrides, match) -> None:
- """Shape mismatches and non-finite/non-positive data raise ValueError
- before any sampling."""
- data = {
- 'x': np.array([1.0, 2.0]),
- 'y': np.array([0.1, 0.2]),
- 'weights': np.array([1.0, 1.0]),
- }
- data.update(overrides)
- with pytest.raises(ValueError, match=match):
- minimizer.mcmc_sample(**data, samples=10, burn=0, thin=1)
-
- def test_sample_basic(self, minimizer: Bumps, monkeypatch) -> None:
- """Verify that mcmc_sample() returns a dict with expected keys."""
- mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
-
- result = minimizer.mcmc_sample(
- x=np.array([1.0, 2.0]),
- y=np.array([0.1, 0.2]),
- weights=np.array([1.0, 1.0]),
- samples=100,
- burn=20,
- thin=2,
- population=5,
- )
- assert isinstance(result, dict)
- assert 'draws' in result
- assert 'param_names' in result
- assert 'internal_bumps_object' in result
- assert 'logp' in result
- mock_FitDriver.assert_called_once()
-
- def test_sample_with_progress_callback(self, minimizer: Bumps, monkeypatch) -> None:
- """Verify progress callback is wired up as a monitor."""
- mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
- progress_callback = MagicMock()
-
- result = minimizer.mcmc_sample(
- x=np.array([1.0]),
- y=np.array([0.1]),
- weights=np.array([1.0]),
- samples=10,
- burn=5,
- thin=1,
- progress_callback=progress_callback,
+ mock_problem = MagicMock()
+ mock_problem._parameters = []
+ monkeypatch.setattr(
+ easyscience.fitting.minimizers.minimizer_bumps,
+ 'build_curve_problem',
+ MagicMock(return_value=(mock_problem, MagicMock(count=3), MagicMock())),
)
- assert result is not None
- call_kwargs = mock_FitDriver.call_args.kwargs
- assert 'monitors' in call_kwargs
- assert len(call_kwargs['monitors']) == 1
- assert isinstance(call_kwargs['monitors'][0], BumpsProgressMonitor)
-
- def test_sample_aborted_by_user_raises_fit_error(self, minimizer: Bumps, monkeypatch) -> None:
- """Verify that sampling abortion raises FitError."""
- self._setup_driver_mock(monkeypatch, fitter_state_value=TestBumpsSample.ABORT)
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
-
- with pytest.raises(FitError, match='Sampling aborted by user'):
- minimizer.mcmc_sample(x=np.array([1.0]), y=np.array([0.1]), weights=np.array([1.0]))
+ minimizer._gen_fit_results = MagicMock(return_value='result')
+ minimizer._set_parameter_fit_result = MagicMock()
+ minimizer._cached_pars = {}
+ minimizer._cached_pars_vals = {}
+ return mock_FitDriver
- def test_sample_driver_exception_restores_parameters(
+ def test_tolerance_none_does_not_override_fitter_defaults(
self, minimizer: Bumps, monkeypatch
) -> None:
- """Verify that a driver exception during sampling restores parameter values."""
- self._setup_driver_mock(monkeypatch, fit_side_effect=RuntimeError('driver failed'))
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
- minimizer._restore_parameter_values = MagicMock()
+ mock_FitDriver = self._patch_driver_and_problem(minimizer, monkeypatch)
- with pytest.raises(RuntimeError, match='driver failed'):
- minimizer.mcmc_sample(x=np.array([1.0]), y=np.array([0.1]), weights=np.array([1.0]))
+ minimizer.fit(x=np.array([1.0]), y=np.array([2.0]), weights=np.array([1.0]))
- minimizer._restore_parameter_values.assert_called_once()
+ # The real 'newton' settings are ftol=1e-6 / xtol=1e-12. Neither may be
+ # forwarded, or BUMPS would run against a tolerance the caller never asked for.
+ driver_kwargs = mock_FitDriver.call_args.kwargs
+ assert 'ftol' not in driver_kwargs
+ assert 'xtol' not in driver_kwargs
+ assert 'steps' not in driver_kwargs
+
+ # ...but the resolved defaults are still reported for the budget check.
+ gen_kwargs = minimizer._gen_fit_results.call_args.kwargs
+ assert gen_kwargs['tolerance'] == 1e-12 # min(ftol, xtol)
+ assert gen_kwargs['max_evaluations'] == 3000 # 'newton' default steps
- def test_sample_population_param(self, minimizer: Bumps, monkeypatch) -> None:
- """population kwarg is forwarded to DREAM as pop."""
- mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
+ def test_explicit_tolerance_is_forwarded(self, minimizer: Bumps, monkeypatch) -> None:
+ mock_FitDriver = self._patch_driver_and_problem(minimizer, monkeypatch)
- minimizer.mcmc_sample(
+ minimizer.fit(
x=np.array([1.0]),
- y=np.array([0.1]),
+ y=np.array([2.0]),
weights=np.array([1.0]),
- samples=10,
- burn=0,
- thin=1,
- population=7,
+ tolerance=1e-3,
+ max_evaluations=11,
)
- call_kwargs = mock_FitDriver.call_args.kwargs
- assert call_kwargs['pop'] == 7
+ driver_kwargs = mock_FitDriver.call_args.kwargs
+ assert driver_kwargs['ftol'] == 1e-3
+ assert driver_kwargs['xtol'] == 1e-3
+ assert driver_kwargs['steps'] == 11
- def test_sample_sampler_kwargs_forwarded(self, minimizer: Bumps, monkeypatch) -> None:
- """sampler_kwargs entries are merged into the DREAM kwargs."""
- mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
+ def test_minimizer_kwargs_is_not_mutated(self, minimizer: Bumps, monkeypatch) -> None:
+ mock_FitDriver = self._patch_driver_and_problem(minimizer, monkeypatch)
- minimizer.mcmc_sample(
+ minimizer_kwargs = {'existing': 'value'}
+ minimizer.fit(
x=np.array([1.0]),
- y=np.array([0.1]),
+ y=np.array([2.0]),
weights=np.array([1.0]),
- samples=10,
- burn=0,
- thin=1,
- sampler_kwargs={'trim': False},
- )
-
- assert mock_FitDriver.call_args.kwargs['trim'] is False
-
- def test_sample_rejects_non_callable_callback(self, minimizer: Bumps, monkeypatch) -> None:
- import bumps.names
-
- monkeypatch.setattr(bumps.names, 'FitProblem', MagicMock(return_value=MagicMock()))
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
-
- with pytest.raises(ValueError, match='progress_callback must be callable'):
- minimizer.mcmc_sample(
- x=np.array([1.0]),
- y=np.array([0.1]),
- weights=np.array([1.0]),
- samples=10,
- burn=5,
- thin=1,
- progress_callback='not-callable',
- )
-
- # --- Resume-state tests -------------------------------------------------
-
- def _make_resume_state_mock(self, *, nvar=2, npop=10, labels=None):
- """Build a mock MCMCDraw for resume tests.
-
- BUMPS labels follow the pattern ``'p'`` (the
- ``MINIMIZER_PARAMETER_PREFIX`` concatenated with the unique name),
- e.g. ``'pFilm_thickness'``.
-
- :param nvar: Number of parameters.
- :param npop: Population size.
- :param labels: Parameter labels (defaults to ``['pa', 'pb']``
- which strip to ``['a', 'b']``).
- """
- if labels is None:
- labels = ['pa', 'pb']
- mock_state = MagicMock()
- mock_state.Nvar = nvar
- mock_state.Npop = npop
- mock_state.labels = labels
- mock_draw = MagicMock()
- mock_draw.points = np.ones((20, nvar))
- mock_draw.logp = np.ones(20)
- mock_state.draw.return_value = mock_draw
- return mock_state
-
- def _make_problem_with_parameters(self, param_names):
- """Build a mock FitProblem whose ``_parameters`` yields the given names."""
- params = []
- for name in param_names:
- p = MagicMock()
- p.name = 'p' + name
- params.append(p)
- mock_problem = MagicMock()
- mock_problem._parameters = params
- return mock_problem
-
- def test_sample_resume_state(self, minimizer: Bumps, monkeypatch) -> None:
- """Verify resume_state is forwarded to driver.fit()."""
- mock_FitDriver, mock_driver = self._setup_driver_mock(monkeypatch)
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
- resume_state = self._make_resume_state_mock()
-
- import bumps.names
-
- monkeypatch.setattr(
- bumps.names,
- 'FitProblem',
- MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])),
- )
-
- result = minimizer.mcmc_sample(
- x=np.array([1.0, 2.0]),
- y=np.array([0.1, 0.2]),
- weights=np.array([1.0, 1.0]),
- samples=10,
- burn=0,
- thin=1,
- resume_state=resume_state,
- )
-
- assert result is not None
- # Verify a fit_state (defensive copy of resume_state) was passed to driver.fit()
- call_kwargs = mock_driver.fit.call_args.kwargs
- assert call_kwargs.get('fit_state') is not None
- assert call_kwargs['fit_state'] is not resume_state
-
- def test_sample_resume_param_mismatch_raises(self, minimizer: Bumps, monkeypatch) -> None:
- """Parameter count mismatch raises ValueError before driver.fit()."""
- import bumps.names
-
- monkeypatch.setattr(
- bumps.names,
- 'FitProblem',
- MagicMock(return_value=self._make_problem_with_parameters(['a'])),
- )
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
- # resume_state has 2 params, model has 1
- resume_state = self._make_resume_state_mock(nvar=2)
-
- with pytest.raises(ValueError, match='resume_state has 2 parameters'):
- minimizer.mcmc_sample(
- x=np.array([1.0]),
- y=np.array([0.1]),
- weights=np.array([1.0]),
- samples=10,
- burn=0,
- thin=1,
- resume_state=resume_state,
- )
-
- def test_sample_resume_param_name_mismatch_raises(self, minimizer: Bumps, monkeypatch) -> None:
- """Parameter name/order mismatch raises ValueError before driver.fit()."""
- import bumps.names
-
- monkeypatch.setattr(
- bumps.names,
- 'FitProblem',
- MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])),
- )
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
- # resume_state has labels ['px', 'py'] β stripped to ['x', 'y']
- # Current model has params ['pa', 'pb'] β stripped to ['a', 'b']
- # β mismatch
- resume_state = self._make_resume_state_mock(nvar=2, labels=['px', 'py'])
-
- with pytest.raises(ValueError, match='Parameter names/order mismatch'):
- minimizer.mcmc_sample(
- x=np.array([1.0]),
- y=np.array([0.1]),
- weights=np.array([1.0]),
- samples=10,
- burn=0,
- thin=1,
- resume_state=resume_state,
- )
-
- def test_sample_resume_population_mismatch_raises(self, minimizer: Bumps, monkeypatch) -> None:
- """Explicit population differing from state.Npop raises ValueError."""
- import bumps.names
-
- monkeypatch.setattr(
- bumps.names,
- 'FitProblem',
- MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])),
- )
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
- resume_state = self._make_resume_state_mock(nvar=2, npop=10)
-
- with pytest.raises(ValueError, match='would produce'):
- minimizer.mcmc_sample(
- x=np.array([1.0]),
- y=np.array([0.1]),
- weights=np.array([1.0]),
- samples=10,
- burn=0,
- thin=1,
- population=3, # ceil(3*2)=6 β 10
- resume_state=resume_state,
- )
-
- def test_sample_resume_forces_burn_to_zero(
- self, minimizer: Bumps, monkeypatch, caplog: 'pytest.LogCaptureFixture'
- ) -> None:
- """burn>0 with resume_state warns and is forced to 0."""
- mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
- import bumps.names
-
- monkeypatch.setattr(
- bumps.names,
- 'FitProblem',
- MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])),
- )
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
- resume_state = self._make_resume_state_mock()
-
- with caplog.at_level(logging.WARNING, logger='easyscience.fitting.bumps'):
- minimizer.mcmc_sample(
- x=np.array([1.0]),
- y=np.array([0.1]),
- weights=np.array([1.0]),
- samples=10,
- burn=5,
- thin=1,
- resume_state=resume_state,
- )
-
- assert 'ignored on resume' in caplog.text
- # burn must be forced to 0 in the kwargs passed to BUMPS
- assert mock_FitDriver.call_args.kwargs['burn'] == 0
-
- def test_sample_resume_unlabeled_state_warns_and_uses_absolute_pop(
- self, minimizer: Bumps, monkeypatch, caplog: 'pytest.LogCaptureFixture'
- ) -> None:
- """A state reloaded from disk carries default labels ('P0', ...), so
- name validation is skipped with a warning, and the saved population is
- reproduced as a negative pop (BUMPS' absolute-chain-count convention)."""
- mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
- import bumps.names
-
- monkeypatch.setattr(
- bumps.names,
- 'FitProblem',
- MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])),
+ tolerance=1e-3,
+ max_evaluations=11,
+ minimizer_kwargs=minimizer_kwargs,
+ engine_kwargs={'engine': 'option'},
)
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
- resume_state = self._make_resume_state_mock(nvar=2, npop=10, labels=['P0', 'P1'])
- with caplog.at_level(logging.WARNING, logger='easyscience.fitting.bumps'):
- minimizer.mcmc_sample(
- x=np.array([1.0]),
- y=np.array([0.1]),
- weights=np.array([1.0]),
- samples=10,
- burn=0,
- thin=1,
- resume_state=resume_state,
- )
+ # Both mappings reach the driver...
+ driver_kwargs = mock_FitDriver.call_args.kwargs
+ assert driver_kwargs['existing'] == 'value'
+ assert driver_kwargs['engine'] == 'option'
- assert 'does not carry parameter names' in caplog.text
- assert mock_FitDriver.call_args.kwargs['pop'] == -10
+ # ...and the caller's mapping is untouched, so reusing it cannot leak
+ # settings from one fit into the next.
+ assert minimizer_kwargs == {'existing': 'value'}
# ===================================================================
-# _build_sample_progress_payload
+# fit() β unsuccessful and aborted outcomes
# ===================================================================
-class TestBuildSampleProgressPayload:
+class TestFitUnsuccessfulOutcomes:
@pytest.fixture
def minimizer(self) -> Bumps:
return Bumps(
@@ -1147,143 +705,96 @@ def minimizer(self) -> Bumps:
minimizer_enum=MagicMock(package='bumps', method='amoeba'),
)
- def test_payload_structure_and_sampling_flag(self, minimizer: Bumps) -> None:
- b = minimizer
-
- mock_problem = MagicMock()
- mock_problem.chisq.side_effect = [25.0, 12.5]
- mock_problem.labels.return_value = ['palpha']
- mock_problem.getp.return_value = np.array([1.0])
- b._cached_pars = {'alpha': MagicMock(value=1.0)}
-
- payload = b._build_sample_progress_payload(mock_problem, 7, np.array([1.0]), 12.5)
-
- assert payload['iteration'] == 7
- assert payload['chi2'] == 25.0
- assert payload['reduced_chi2'] == 12.5
- assert payload['parameter_values'] == {'alpha': 1.0}
- assert payload['sampling'] is True
- assert payload['finished'] is False
- assert payload['refresh_plots'] is False
-
- def test_payload_keys(self, minimizer: Bumps) -> None:
- b = minimizer
- mock_problem = MagicMock()
- mock_problem.chisq.side_effect = [10.0, 5.0]
- mock_problem.labels.return_value = ['pa']
- mock_problem.getp.return_value = np.array([5.0])
- b._cached_pars = {'a': MagicMock(value=5.0)}
-
- payload = b._build_sample_progress_payload(mock_problem, 1, np.array([5.0]), nllf=5.0)
-
- expected_keys = {
- 'iteration',
- 'chi2',
- 'reduced_chi2',
- 'parameter_values',
- 'refresh_plots',
- 'finished',
- 'sampling',
- }
- assert set(payload.keys()) == expected_keys
+ @staticmethod
+ def _patch(minimizer: Bumps, monkeypatch, driver_result, history_step=None) -> MagicMock:
+ mock_driver = MagicMock()
+ mock_driver.fit = MagicMock(return_value=driver_result)
+ mock_driver.stderr = MagicMock(return_value=np.array([0.1]))
+ mock_driver.monitor_runner.history.step = [] if history_step is None else history_step
+ monkeypatch.setattr(
+ easyscience.fitting.minimizers.minimizer_bumps,
+ 'FitDriver',
+ MagicMock(return_value=mock_driver),
+ )
- def test_delegates_to_build_progress_payload(self, minimizer: Bumps) -> None:
- """_build_sample_progress_payload calls _build_progress_payload and adds sampling."""
mock_problem = MagicMock()
-
- # Patch _build_progress_payload to track calls
- base_payload = {
- 'iteration': 3,
- 'chi2': 42.0,
- 'reduced_chi2': 21.0,
- 'parameter_values': {'x': 7.0},
- 'refresh_plots': False,
- 'finished': False,
- }
- with patch.object(
- minimizer, '_build_progress_payload', return_value=base_payload
- ) as mock_bpp:
- result = minimizer._build_sample_progress_payload(
- mock_problem, 3, np.array([7.0]), 21.0
- )
-
- mock_bpp.assert_called_once_with(mock_problem, 3, np.array([7.0]), 21.0)
- assert result == {**base_payload, 'sampling': True}
-
-
-# ===================================================================
-# _set_parameter_fit_result with stack_status=True
-# ===================================================================
-
-
-class TestSetParameterFitResultWithStack:
- @pytest.fixture
- def minimizer(self) -> Bumps:
- return Bumps(
- obj='obj',
- fit_function='fit_function',
- minimizer_enum=MagicMock(package='bumps', method='amoeba'),
+ mock_problem._parameters = []
+ monkeypatch.setattr(
+ easyscience.fitting.minimizers.minimizer_bumps,
+ 'build_curve_problem',
+ MagicMock(return_value=(mock_problem, MagicMock(count=3), MagicMock())),
)
- def test_stack_status_true_calls_begin_end_macro(self, minimizer: Bumps) -> None:
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
- minimizer._cached_pars = {'a': MagicMock(), 'b': MagicMock()}
- minimizer._cached_pars['a'].value = 'old_a'
- minimizer._cached_pars['b'].value = 'old_b'
+ minimizer._gen_fit_results = MagicMock(return_value='result')
+ minimizer._set_parameter_fit_result = MagicMock()
+ minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba'))
+ minimizer._cached_pars = {}
+ minimizer._cached_pars_vals = {}
+ return mock_driver
+
+ def test_no_solution_is_reported_not_raised(self, minimizer: Bumps, monkeypatch) -> None:
+ """BUMPS returns x=None for a failed optimization (e.g. LM landing on
+ non-finite values). That is a non-converged fit, not an exception."""
+ self._patch(minimizer, monkeypatch, driver_result=(None, None), history_step=[4])
minimizer._restore_parameter_values = MagicMock()
- mock_fit_result = MagicMock()
- mock_fit_result.x = np.array([1.0, 2.0])
- mock_fit_result.dx = np.array([0.1, 0.2])
-
- mock_par_a = MagicMock()
- mock_par_a.name = 'pa'
- mock_par_b = MagicMock()
- mock_par_b.name = 'pb'
- par_list = [mock_par_a, mock_par_b]
-
- minimizer._set_parameter_fit_result(mock_fit_result, True, par_list)
+ result = minimizer.fit(x=np.array([1.0]), y=np.array([2.0]), weights=np.array([1.0]))
- assert minimizer._cached_pars['a'].value == 1.0
- assert minimizer._cached_pars['a'].error == 0.1
- assert minimizer._cached_pars['b'].value == 2.0
- assert minimizer._cached_pars['b'].error == 0.2
+ assert result == 'result'
+ passed = minimizer._gen_fit_results.call_args.args[0]
+ assert passed.success is False
+ assert passed.x is None
+ assert passed.dx is None # stderr() needs a solution to expand around
+ assert 'did not converge' in passed.message
+ # Parameters are rolled back and never written from a missing solution
minimizer._restore_parameter_values.assert_called_once()
+ minimizer._set_parameter_fit_result.assert_not_called()
+ def test_abort_is_reported_as_unsuccessful(self, minimizer: Bumps, monkeypatch) -> None:
+ self._patch(
+ minimizer, monkeypatch, driver_result=(np.array([42.0]), 0.0), history_step=[2]
+ )
-# ===================================================================
-# convert_to_par_object
-# ===================================================================
-
-
-class TestConvertToParObject:
- def test_convert_parameter_object(self) -> None:
- from easyscience.variable import Parameter
+ result = minimizer.fit(
+ x=np.array([1.0]),
+ y=np.array([2.0]),
+ weights=np.array([1.0]),
+ abort_test=lambda: True,
+ )
- param = Parameter('thickness', 42.0, min=0.0, max=100.0)
- param.fixed = False
+ assert result == 'result'
+ passed = minimizer._gen_fit_results.call_args.args[0]
+ assert passed.success is False
+ assert passed.message == 'Fit aborted before convergence'
+ # The best point reached before the abort is still applied
+ minimizer._set_parameter_fit_result.assert_called_once()
- result = Bumps.convert_to_par_object(param)
+ def test_empty_step_history_does_not_raise(self, minimizer: Bumps, monkeypatch) -> None:
+ """An abort before the fitter reports its first step leaves the BUMPS
+ history trace empty; indexing it would raise IndexError."""
+ self._patch(minimizer, monkeypatch, driver_result=(np.array([42.0]), 0.0), history_step=[])
- # convert_to_par_object uses obj.unique_name which is auto-assigned
- assert result.name.startswith('p')
- assert result.value == 42.0
- assert result.bounds == (0.0, 100.0)
- assert result.fixed is False
+ result = minimizer.fit(x=np.array([1.0]), y=np.array([2.0]), weights=np.array([1.0]))
- def test_convert_fixed_parameter(self) -> None:
- from easyscience.variable import Parameter
+ assert result == 'result'
+ assert minimizer._gen_fit_results.call_args.args[0].nit is None
- param = Parameter('roughness', 5.0, min=0.0, max=20.0)
- param.fixed = True
+ def test_successful_fit_reports_success(self, minimizer: Bumps, monkeypatch) -> None:
+ self._patch(
+ minimizer, monkeypatch, driver_result=(np.array([42.0]), 0.0), history_step=[7]
+ )
- result = Bumps.convert_to_par_object(param)
+ minimizer.fit(
+ x=np.array([1.0]),
+ y=np.array([2.0]),
+ weights=np.array([1.0]),
+ abort_test=lambda: False,
+ )
- assert result.name.startswith('p')
- assert result.fixed is True
+ passed = minimizer._gen_fit_results.call_args.args[0]
+ assert passed.success is True
+ assert passed.message == 'Fit converged'
+ assert passed.nit == 7
# ===================================================================
@@ -1301,10 +812,6 @@ def minimizer(self) -> Bumps:
)
def test_abort_test_passed_to_fit_driver(self, minimizer: Bumps, monkeypatch) -> None:
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
mock_driver = MagicMock()
mock_driver.clip = MagicMock()
mock_driver.fit = MagicMock(return_value=(np.array([42.0]), 0.0))
@@ -1319,11 +826,10 @@ def test_abort_test_passed_to_fit_driver(self, minimizer: Bumps, monkeypatch) ->
mock_problem._parameters = []
monkeypatch.setattr(
easyscience.fitting.minimizers.minimizer_bumps,
- 'FitProblem',
- MagicMock(return_value=mock_problem),
+ 'build_curve_problem',
+ MagicMock(return_value=(mock_problem, MagicMock(), MagicMock())),
)
- minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock()))
minimizer._gen_fit_results = MagicMock(return_value='result')
minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba'))
minimizer._set_parameter_fit_result = MagicMock()
diff --git a/tests/unit/fitting/minimizers/test_minimizer_dfo.py b/tests/unit/fitting/minimizers/test_minimizer_dfo.py
index 823dcce5..56bf3a49 100644
--- a/tests/unit/fitting/minimizers/test_minimizer_dfo.py
+++ b/tests/unit/fitting/minimizers/test_minimizer_dfo.py
@@ -46,10 +46,6 @@ def test_supported_methods(self, minimizer: DFO) -> None:
def test_fit(self, minimizer: DFO) -> None:
# When
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
mock_model = MagicMock()
mock_model_function = MagicMock(return_value=mock_model)
minimizer._make_model = MagicMock(return_value=mock_model_function)
@@ -70,19 +66,12 @@ def test_fit(self, minimizer: DFO) -> None:
minimizer._dfo_fit.assert_called_once_with(
cached_pars, mock_model, user_params={'logging.save_diagnostic_info': True}
)
- minimizer._make_model.assert_called_once_with(
- parameters=None,
- callback=None,
- )
- minimizer._set_parameter_fit_result.assert_called_once_with('fit', False)
+ minimizer._make_model.assert_called_once_with(callback=None)
+ minimizer._set_parameter_fit_result.assert_called_once_with('fit')
minimizer._gen_fit_results.assert_called_once_with('fit', 1)
mock_model_function.assert_called_once_with(1.0, 2.0, 1)
def test_fit_passes_callback_to_model_builder(self, minimizer: DFO) -> None:
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
mock_model = MagicMock()
mock_model_function = MagicMock(return_value=mock_model)
minimizer._make_model = MagicMock(return_value=mock_model_function)
@@ -98,78 +87,7 @@ def test_fit_passes_callback_to_model_builder(self, minimizer: DFO) -> None:
minimizer.fit(x=1.0, y=2.0, weights=1, callback=callback)
- minimizer._make_model.assert_called_once_with(
- parameters=None,
- callback=callback,
- )
-
- def test_fit_wraps_supplied_model_with_explicit_callback(self, minimizer: DFO) -> None:
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
- supplied_model = MagicMock()
- wrapped_model = MagicMock()
- explicit_callback = MagicMock()
-
- minimizer._make_model = MagicMock()
- minimizer._wrap_model_with_callback = MagicMock(return_value=wrapped_model)
- minimizer._get_callback_parameter_names = MagicMock(return_value=['palpha'])
- minimizer._dfo_fit = MagicMock(return_value='fit')
- minimizer._set_parameter_fit_result = MagicMock()
- minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results')
- minimizer._cached_pars = {'alpha': MagicMock(value=1.0)}
-
- result = minimizer.fit(
- x=np.array([1.0]),
- y=np.array([2.0]),
- weights=np.array([1.0]),
- model=supplied_model,
- callback=explicit_callback,
- )
-
- assert result == 'gen_fit_results'
- minimizer._make_model.assert_not_called()
- minimizer._wrap_model_with_callback.assert_called_once_with(
- supplied_model,
- ['palpha'],
- explicit_callback,
- )
- minimizer._dfo_fit.assert_called_once_with(
- minimizer._cached_pars,
- wrapped_model,
- user_params={'logging.save_diagnostic_info': True},
- )
-
- def test_fit_uses_supplied_model_without_callback(self, minimizer: DFO) -> None:
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
- supplied_model = MagicMock()
-
- minimizer._make_model = MagicMock()
- minimizer._wrap_model_with_callback = MagicMock()
- minimizer._dfo_fit = MagicMock(return_value='fit')
- minimizer._set_parameter_fit_result = MagicMock()
- minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results')
- minimizer._cached_pars = {'alpha': MagicMock(value=1.0)}
-
- result = minimizer.fit(
- x=np.array([1.0]),
- y=np.array([2.0]),
- weights=np.array([1.0]),
- model=supplied_model,
- )
-
- assert result == 'gen_fit_results'
- minimizer._make_model.assert_not_called()
- minimizer._wrap_model_with_callback.assert_not_called()
- minimizer._dfo_fit.assert_called_once_with(
- minimizer._cached_pars,
- supplied_model,
- user_params={'logging.save_diagnostic_info': True},
- )
+ minimizer._make_model.assert_called_once_with(callback=callback)
def test_generate_fit_function(self, minimizer: DFO) -> None:
# When
@@ -184,7 +102,7 @@ def test_generate_fit_function(self, minimizer: DFO) -> None:
mock_parm_2.unique_name = 'mock_parm_2'
mock_parm_2.value = 2.0
mock_parm_2.error = 0.2
- minimizer._object.get_fit_parameters = MagicMock(return_value=[mock_parm_1, mock_parm_2])
+ minimizer._object.get_free_parameters = MagicMock(return_value=[mock_parm_1, mock_parm_2])
# Then
fit_function = minimizer._generate_fit_function()
@@ -219,14 +137,13 @@ def test_make_model(self, minimizer: DFO) -> None:
minimizer._generate_fit_function = MagicMock(return_value=mock_fit_function)
mock_parm_1 = MagicMock()
- mock_parm_1.unique_name = 'mock_parm_1'
mock_parm_1.value = 1000.0
mock_parm_2 = MagicMock()
- mock_parm_2.unique_name = 'mock_parm_2'
mock_parm_2.value = 2000.0
+ minimizer._cached_pars = {'mock_parm_1': mock_parm_1, 'mock_parm_2': mock_parm_2}
# Then
- model = minimizer._make_model(parameters=[mock_parm_1, mock_parm_2])
+ model = minimizer._make_model()
residuals_for_model = model(
x=np.array([1, 2]),
y=np.array([10, 20]),
@@ -247,15 +164,14 @@ def test_make_model_callback(self, minimizer: DFO) -> None:
minimizer._generate_fit_function = MagicMock(return_value=mock_fit_function)
mock_parm_1 = MagicMock()
- mock_parm_1.unique_name = 'mock_parm_1'
mock_parm_1.value = 1000.0
mock_parm_2 = MagicMock()
- mock_parm_2.unique_name = 'mock_parm_2'
mock_parm_2.value = 2000.0
+ minimizer._cached_pars = {'mock_parm_1': mock_parm_1, 'mock_parm_2': mock_parm_2}
callback = MagicMock()
- model = minimizer._make_model(parameters=[mock_parm_1, mock_parm_2], callback=callback)
+ model = minimizer._make_model(callback=callback)
residuals_for_model = model(
x=np.array([1, 2]),
y=np.array([10, 20]),
@@ -299,7 +215,7 @@ def test_make_model_without_parameters_uses_cached_parameters(self, minimizer: D
assert mock_fit_function.call_args.kwargs == {'palpha': 1111.0}
- def test_set_parameter_fit_result_no_stack_status(self, minimizer: DFO):
+ def test_set_parameter_fit_result(self, minimizer: DFO):
# When
minimizer._cached_pars = {
'a': MagicMock(),
@@ -316,7 +232,7 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: DFO):
minimizer._error_from_jacobian = MagicMock(return_value=np.array([[0.1, 0.0], [0.0, 0.2]]))
# Then
- minimizer._set_parameter_fit_result(mock_fit_result, False)
+ minimizer._set_parameter_fit_result(mock_fit_result)
# Expect
assert minimizer._cached_pars['a'].value == 1.0
@@ -613,10 +529,6 @@ def test_fit_generic_exception_resets_parameters_and_raises_fit_error(
) -> None:
"""When _dfo_fit raises a non-FitError exception, fit() must reset
parameter values to cached originals and re-raise as FitError."""
- from easyscience import global_object
-
- global_object.stack.enabled = True
-
mock_model = MagicMock()
mock_model_function = MagicMock(return_value=mock_model)
minimizer._make_model = MagicMock(return_value=mock_model_function)
@@ -634,14 +546,9 @@ def test_fit_generic_exception_resets_parameters_and_raises_fit_error(
assert cached_par_1.value == 1.0
assert cached_par_2.value == 2.0
- assert global_object.stack.enabled is True
def test_fit_fit_error_resets_parameters_and_reraises(self, minimizer: DFO) -> None:
"""When _dfo_fit raises FitError, fit() must reset parameter values and re-raise it."""
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
mock_model = MagicMock()
mock_model_function = MagicMock(return_value=mock_model)
minimizer._make_model = MagicMock(return_value=mock_model_function)
@@ -684,10 +591,6 @@ def test_progress_callback_creates_adapter_when_no_explicit_callback(
) -> None:
"""When progress_callback is provided without an explicit callback,
fit() should auto-create a DFO callback adapter."""
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
mock_model = MagicMock()
mock_model_function = MagicMock(return_value=mock_model)
minimizer._make_model = MagicMock(return_value=mock_model_function)
@@ -730,10 +633,6 @@ def test_progress_callback_creates_adapter_when_no_explicit_callback(
def test_progress_callback_not_used_when_explicit_callback_given(self, minimizer: DFO) -> None:
"""When both progress_callback and callback are given, the explicit
callback takes precedence and progress_callback is ignored."""
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
mock_model = MagicMock()
mock_model_function = MagicMock(return_value=mock_model)
minimizer._make_model = MagicMock(return_value=mock_model_function)
@@ -759,22 +658,6 @@ def test_progress_callback_not_used_when_explicit_callback_given(self, minimizer
call_kwargs = minimizer._make_model.call_args[1]
assert call_kwargs['callback'] is explicit_cb
- @pytest.mark.parametrize(
- ('parameters', 'expected_names'),
- [
- ([MagicMock(unique_name='alpha')], ['palpha']),
- (None, ['pbeta']),
- ],
- )
- def test_get_callback_parameter_names_optional_parameters(
- self, minimizer: DFO, parameters, expected_names
- ) -> None:
- minimizer._cached_pars = {'beta': MagicMock(value=1.0)}
-
- parameter_names = minimizer._get_callback_parameter_names(parameters)
-
- assert parameter_names == expected_names
-
def test_wrap_model_with_callback_invokes_on_each_evaluation(self, minimizer: DFO) -> None:
callback = MagicMock()
wrapped_model = minimizer._wrap_model_with_callback(
diff --git a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py
index 781893d9..1c4dc228 100644
--- a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py
+++ b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py
@@ -115,10 +115,6 @@ def test_make_model_no_pars(self, minimizer: LMFit, monkeypatch) -> None:
def test_fit(self, minimizer: LMFit) -> None:
# When
- from easyscience import global_object
-
- global_object.stack.enabled = False
-
mock_model = MagicMock()
mock_model.fit = MagicMock(return_value='fit')
minimizer._make_model = MagicMock(return_value=mock_model)
@@ -135,27 +131,9 @@ def test_fit(self, minimizer: LMFit) -> None:
)
assert callable(mock_model.fit.call_args.kwargs['iter_cb'])
minimizer._make_model.assert_called_once_with()
- minimizer._set_parameter_fit_result.assert_called_once_with('fit', False)
+ minimizer._set_parameter_fit_result.assert_called_once_with('fit')
minimizer._gen_fit_results.assert_called_once_with('fit', iterations=None)
- def test_fit_model(self, minimizer: LMFit) -> None:
- # When
- mock_model = MagicMock()
- mock_model.fit = MagicMock(return_value='fit')
- minimizer._make_model = MagicMock(return_value=mock_model)
- minimizer._set_parameter_fit_result = MagicMock()
- minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results')
-
- # Then
- minimizer.fit(x=1.0, y=2.0, weights=1, model=mock_model)
-
- # Expect
- mock_model.fit.assert_called_once_with(
- 2.0, x=1.0, weights=1, max_nfev=None, iter_cb=ANY, fit_kws={}, method='leastsq'
- )
- assert callable(mock_model.fit.call_args.kwargs['iter_cb'])
- minimizer._make_model.assert_not_called()
-
def test_fit_method(self, minimizer: LMFit) -> None:
# When
mock_model = MagicMock()
@@ -212,6 +190,48 @@ def test_fit_kwargs(self, minimizer: LMFit) -> None:
)
assert callable(mock_model.fit.call_args.kwargs['iter_cb'])
+ @pytest.mark.parametrize(
+ 'minimizer_method, passed_method, expected',
+ [
+ ('leastsq', None, {'ftol': 0.1}),
+ ('least_squares', None, {'ftol': 0.1}),
+ ('powell', None, {'tol': 0.1}),
+ ('differential_evolution', None, {'tol': 0.1}),
+ ('cobyla', None, {'tol': 0.1}),
+ ('leastsq', 'powell', {'tol': 0.1}),
+ ('powell', 'leastsq', {'ftol': 0.1}),
+ ('nelder', None, {}),
+ ],
+ ids=[
+ 'leastsq',
+ 'least_squares',
+ 'powell',
+ 'differential_evolution',
+ 'cobyla',
+ 'explicit_method_overrides',
+ 'explicit_leastsq_overrides',
+ 'unmapped_method',
+ ],
+ )
+ def test_get_fit_kws_tolerance(
+ self, minimizer: LMFit, minimizer_method, passed_method, expected
+ ) -> None:
+ # When
+ minimizer._method = minimizer_method
+
+ # Then
+ fit_kws = minimizer._get_fit_kws(passed_method, 0.1, None)
+
+ # Expect
+ assert fit_kws == expected
+
+ def test_get_fit_kws_no_tolerance(self, minimizer: LMFit) -> None:
+ # When Then
+ fit_kws = minimizer._get_fit_kws(None, None, {'existing': 'kwarg'})
+
+ # Expect
+ assert fit_kws == {'existing': 'kwarg'}
+
def test_fit_progress_callback(self, minimizer: LMFit) -> None:
# When
progress_callback = MagicMock(return_value=True)
@@ -229,9 +249,7 @@ def test_fit_progress_callback(self, minimizer: LMFit) -> None:
iter_cb = mock_model.fit.call_args.kwargs['iter_cb']
assert callable(iter_cb)
- def test_fit_progress_callback_with_supplied_model_uses_iter_params(
- self, minimizer: LMFit
- ) -> None:
+ def test_fit_progress_callback_uses_iter_params(self, minimizer: LMFit) -> None:
progress_callback = MagicMock(return_value=True)
mock_model = MagicMock()
mock_param_alpha = MagicMock()
@@ -252,12 +270,10 @@ def test_fit_progress_callback_with_supplied_model_uses_iter_params(
x=np.array([1.0, 2.0]),
y=np.array([1.0, 2.0]),
weights=np.array([1.0, 1.0]),
- model=mock_model,
progress_callback=progress_callback,
)
assert result == 'gen_fit_results'
- minimizer._make_model.assert_not_called()
payload = progress_callback.call_args[0][0]
assert payload['parameter_values'] == {'alpha': 1.0}
assert payload['chi2'] == 25.0
@@ -385,87 +401,7 @@ def test_gen_fit_results_success_does_not_warn(
assert len(caplog.records) == 0
assert result.success is True
- def test_convert_to_pars_obj(self, minimizer: LMFit, monkeypatch) -> None:
- # When
- minimizer._object = MagicMock()
- minimizer._object.get_fit_parameters = MagicMock(return_value=['parm_1', 'parm_2'])
-
- minimizer.convert_to_par_object = MagicMock(return_value='convert_to_par_object')
-
- mock_lm_parameter = MagicMock()
- mock_lm_parameter.add_many = MagicMock(return_value='add_many')
- mock_LMParameters = MagicMock(return_value=mock_lm_parameter)
- monkeypatch.setattr(
- easyscience.fitting.minimizers.minimizer_lmfit, 'LMParameters', mock_LMParameters
- )
-
- # Then
- pars = minimizer.convert_to_pars_obj()
-
- # Expect
- assert pars == 'add_many'
- assert minimizer.convert_to_par_object.call_count == 2
- minimizer._object.get_fit_parameters.assert_called_once_with()
- minimizer.convert_to_par_object.assert_called_with('parm_2')
- mock_lm_parameter.add_many.assert_called_once_with([
- 'convert_to_par_object',
- 'convert_to_par_object',
- ])
-
- def test_convert_to_pars_obj_with_parameters(self, minimizer: LMFit, monkeypatch) -> None:
- # When
- minimizer.convert_to_par_object = MagicMock(return_value='convert_to_par_object')
-
- mock_lm_parameter = MagicMock()
- mock_lm_parameter.add_many = MagicMock(return_value='add_many')
- mock_LMParameters = MagicMock(return_value=mock_lm_parameter)
- monkeypatch.setattr(
- easyscience.fitting.minimizers.minimizer_lmfit, 'LMParameters', mock_LMParameters
- )
-
- # Then
- pars = minimizer.convert_to_pars_obj(['parm_1', 'parm_2'])
-
- # Expect
- assert pars == 'add_many'
- assert minimizer.convert_to_par_object.call_count == 2
- minimizer.convert_to_par_object.assert_called_with('parm_2')
- mock_lm_parameter.add_many.assert_called_once_with([
- 'convert_to_par_object',
- 'convert_to_par_object',
- ])
-
- def test_convert_to_par_object(self, minimizer: LMFit, monkeypatch) -> None:
- # When
- mock_lm_parameter = MagicMock()
- mock_LMParameter = MagicMock(return_value=mock_lm_parameter)
- monkeypatch.setattr(
- easyscience.fitting.minimizers.minimizer_lmfit, 'LMParameter', mock_LMParameter
- )
-
- mock_parm = MagicMock(Parameter)
- mock_parm.value = 1.0
- mock_parm.fixed = True
- mock_parm.min = -10.0
- mock_parm.max = 10.0
- mock_parm.unique_name = 'key_converted'
-
- # Then
- par = minimizer.convert_to_par_object(mock_parm)
-
- # Expect
- assert par == mock_lm_parameter
- mock_LMParameter.assert_called_once_with(
- 'pkey_converted',
- value=1.0,
- vary=False,
- min=-10.0,
- max=10.0,
- expr=None,
- brute_step=None,
- )
-
- def test_set_parameter_fit_result_no_stack_status(self, minimizer: LMFit) -> None:
+ def test_set_parameter_fit_result(self, minimizer: LMFit) -> None:
# When
minimizer._cached_pars = {
'a': MagicMock(),
@@ -485,7 +421,7 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: LMFit) -> Non
mock_fit_result.errorbars = True
# Then
- minimizer._set_parameter_fit_result(mock_fit_result, False)
+ minimizer._set_parameter_fit_result(mock_fit_result)
# Expect
assert minimizer._cached_pars['a'].value == 1.0
@@ -493,7 +429,7 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: LMFit) -> Non
assert minimizer._cached_pars['b'].value == 2.0
assert minimizer._cached_pars['b'].error == 0.2
- def test_set_parameter_fit_result_no_stack_status_no_error(self, minimizer: LMFit) -> None:
+ def test_set_parameter_fit_result_no_error(self, minimizer: LMFit) -> None:
# When
minimizer._cached_pars = {
'a': MagicMock(),
@@ -513,13 +449,13 @@ def test_set_parameter_fit_result_no_stack_status_no_error(self, minimizer: LMFi
mock_fit_result.errorbars = False
# Then
- minimizer._set_parameter_fit_result(mock_fit_result, False)
+ minimizer._set_parameter_fit_result(mock_fit_result)
# Expect
assert minimizer._cached_pars['a'].value == 1.0
- assert minimizer._cached_pars['a'].error == 0.0
+ assert minimizer._cached_pars['a'].error is None
assert minimizer._cached_pars['b'].value == 2.0
- assert minimizer._cached_pars['b'].error == 0.0
+ assert minimizer._cached_pars['b'].error is None
def test_gen_fit_results(self, minimizer: LMFit, monkeypatch) -> None:
# When
diff --git a/src/easyscience/global_object/hugger/__init__.py b/tests/unit/fitting/samplers/__init__.py
similarity index 99%
rename from src/easyscience/global_object/hugger/__init__.py
rename to tests/unit/fitting/samplers/__init__.py
index b1990ec8..4e798e20 100644
--- a/src/easyscience/global_object/hugger/__init__.py
+++ b/tests/unit/fitting/samplers/__init__.py
@@ -1,3 +1,2 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-
diff --git a/tests/unit/fitting/samplers/test_sampler_bumps.py b/tests/unit/fitting/samplers/test_sampler_bumps.py
new file mode 100644
index 00000000..c755094c
--- /dev/null
+++ b/tests/unit/fitting/samplers/test_sampler_bumps.py
@@ -0,0 +1,486 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Unit tests for ``DreamSampler`` β mirrors
+``src/easyscience/fitting/samplers/sampler_bumps.py``.
+
+Ported from the former ``TestBumpsSample`` suite in
+``tests/unit/fitting/minimizers/test_minimizer_bumps.py`` when the former
+``Bumps.mcmc_sample`` moved here as ``DreamSampler.run`` (easyscience/core#280).
+That entry point, and ``Fitter.mcmc_sample``, have since been removed β
+``Sampler`` and ``DreamSampler.run`` are the supported APIs.
+"""
+
+import logging
+from unittest.mock import MagicMock
+
+import numpy as np
+import pytest
+
+import easyscience.fitting.samplers.sampler_bumps
+from easyscience.fitting.engine_base import EngineBase
+from easyscience.fitting.minimizers.bumps_utils import BumpsProgressMonitor
+from easyscience.fitting.minimizers.utils import FitError
+from easyscience.fitting.samplers import DreamSampler
+
+
+class TestDreamSamplerRun:
+ """Tests for ``DreamSampler.run()`` and its helpers."""
+
+ # Sentinel value to signal "set fitter.state = None" in _setup_driver_mock
+ ABORT = object()
+
+ @pytest.fixture
+ def engine(self) -> DreamSampler:
+ return DreamSampler(obj='obj', fit_function='fit_function')
+
+ @pytest.fixture(autouse=True)
+ def _mock_bumps_internals(self, monkeypatch):
+ """Prevent run() from constructing real BUMPS objects.
+
+ ``run()`` imports ``DreamFit`` from the real ``bumps`` package
+ internally and builds its problem via ``build_curve_problem``,
+ which would try to build real model objects. We redirect those
+ to mocks and also mock ``FitDriver`` (a module-level import) so
+ the whole flow stays under test control.
+ """
+ import bumps.fitters
+
+ monkeypatch.setattr(bumps.fitters, 'DreamFit', MagicMock())
+ self._set_problem(monkeypatch, MagicMock())
+
+ @staticmethod
+ def _set_problem(monkeypatch, problem):
+ """Point ``build_curve_problem`` at a canned (problem, counter, curve) triple."""
+ monkeypatch.setattr(
+ easyscience.fitting.samplers.sampler_bumps,
+ 'build_curve_problem',
+ MagicMock(return_value=(problem, MagicMock(), MagicMock())),
+ )
+
+ def _setup_driver_mock(
+ self, monkeypatch, fitter_state_value=None, fit_result=None, fit_side_effect=None
+ ):
+ """Helper to create a mocked FitDriver with configurable behavior.
+
+ :param fitter_state_value: If ``None``, ``driver.fitter.state`` will be
+ a regular MagicMock (non-None). Pass ``ABORT`` to set it to ``None``
+ and simulate user abort.
+ """
+ mock_driver = MagicMock()
+ mock_driver.clip = MagicMock()
+
+ if fit_side_effect is not None:
+ mock_driver.fit.side_effect = fit_side_effect
+ else:
+ mock_driver.fit.return_value = fit_result or (np.array([1.0]), 0.0)
+
+ mock_driver.stderr = MagicMock(return_value=np.array([0.1]))
+
+ if fitter_state_value is TestDreamSamplerRun.ABORT:
+ mock_driver.fitter.state = None
+ else:
+ mock_state = MagicMock()
+ mock_state.Nvar = 1
+ mock_state.Npop = 5
+ mock_state.labels = ['p_param_0']
+ mock_draw = MagicMock()
+ mock_draw.points = np.array([[1.0]])
+ mock_draw.logp = np.array([0.5])
+ mock_state.draw.return_value = mock_draw
+ mock_driver.fitter.state = mock_state
+
+ mock_FitDriver = MagicMock(return_value=mock_driver)
+ monkeypatch.setattr(
+ easyscience.fitting.samplers.sampler_bumps, 'FitDriver', mock_FitDriver
+ )
+ return mock_FitDriver, mock_driver
+
+ def test_is_an_engine(self, engine: DreamSampler) -> None:
+ assert isinstance(engine, EngineBase)
+ assert engine.package == 'bumps'
+
+ @pytest.mark.parametrize(
+ 'kwargs, match',
+ [
+ ({'samples': 0}, 'samples must be a positive integer'),
+ ({'samples': -1}, 'samples must be a positive integer'),
+ ({'burn': -1}, 'burn must be a non-negative integer'),
+ ({'thin': 0}, 'thin must be a positive integer'),
+ ],
+ )
+ def test_run_invalid_args(self, engine: DreamSampler, kwargs, match) -> None:
+ """Invalid samples/burn/thin values raise ValueError before any sampling.
+
+ This is the single source of truth for these checks β the higher-level
+ ``Sampler`` relies on it.
+ """
+ with pytest.raises(ValueError, match=match):
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=kwargs.get('samples', 10),
+ burn=kwargs.get('burn', 0),
+ thin=kwargs.get('thin', 1),
+ )
+
+ @pytest.mark.parametrize(
+ 'overrides, match',
+ [
+ ({'y': np.array([0.1])}, 'x and y must have the same shape'),
+ ({'x': np.array([1.0, np.nan])}, 'x cannot contain NaN'),
+ ({'y': np.array([0.1, np.inf])}, 'y cannot contain NaN'),
+ ({'weights': np.array([1.0])}, 'Weights must have the same shape'),
+ ({'weights': np.array([1.0, np.nan])}, 'Weights cannot be NaN'),
+ ({'weights': np.array([1.0, 0.0])}, 'Weights must be strictly positive'),
+ ],
+ )
+ def test_run_invalid_data(self, engine: DreamSampler, overrides, match) -> None:
+ """Shape mismatches and non-finite/non-positive data raise ValueError
+ before any sampling."""
+ data = {
+ 'x': np.array([1.0, 2.0]),
+ 'y': np.array([0.1, 0.2]),
+ 'weights': np.array([1.0, 1.0]),
+ }
+ data.update(overrides)
+ with pytest.raises(ValueError, match=match):
+ engine.run(**data, samples=10, burn=0, thin=1)
+
+ def test_run_rejects_none_weights(self, engine: DreamSampler) -> None:
+ """weights are required; a None slipping past ``Sampler`` still gets a
+ clear ValueError from the dtype guard rather than an opaque numpy
+ TypeError out of ``np.isfinite`` (CR-5)."""
+ with pytest.raises(ValueError, match='weights must hold numeric values'):
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=None,
+ samples=10,
+ burn=0,
+ thin=1,
+ )
+
+ def test_run_basic(self, engine: DreamSampler, monkeypatch) -> None:
+ """Verify that run() returns a dict with expected keys."""
+ mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
+
+ result = engine.run(
+ x=np.array([1.0, 2.0]),
+ y=np.array([0.1, 0.2]),
+ weights=np.array([1.0, 1.0]),
+ samples=100,
+ burn=20,
+ thin=2,
+ population=5,
+ )
+
+ assert isinstance(result, dict)
+ assert 'draws' in result
+ assert 'param_names' in result
+ assert 'internal_bumps_object' in result
+ assert 'logp' in result
+ mock_FitDriver.assert_called_once()
+
+ def test_run_with_progress_callback(self, engine: DreamSampler, monkeypatch) -> None:
+ """Verify progress callback is wired up as a monitor."""
+ mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
+ progress_callback = MagicMock()
+
+ result = engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=10,
+ burn=5,
+ thin=1,
+ progress_callback=progress_callback,
+ )
+
+ assert result is not None
+ call_kwargs = mock_FitDriver.call_args.kwargs
+ assert 'monitors' in call_kwargs
+ assert len(call_kwargs['monitors']) == 1
+ assert isinstance(call_kwargs['monitors'][0], BumpsProgressMonitor)
+
+ def test_run_aborted_by_user_raises_fit_error(self, engine: DreamSampler, monkeypatch) -> None:
+ """Verify that sampling abortion raises FitError."""
+ self._setup_driver_mock(monkeypatch, fitter_state_value=TestDreamSamplerRun.ABORT)
+
+ with pytest.raises(FitError, match='Sampling aborted by user'):
+ engine.run(x=np.array([1.0]), y=np.array([0.1]), weights=np.array([1.0]))
+
+ def test_run_driver_exception_restores_parameters(
+ self, engine: DreamSampler, monkeypatch
+ ) -> None:
+ """Verify that a driver exception during sampling restores parameter values."""
+ self._setup_driver_mock(monkeypatch, fit_side_effect=RuntimeError('driver failed'))
+ engine._restore_parameter_values = MagicMock()
+
+ with pytest.raises(RuntimeError, match='driver failed'):
+ engine.run(x=np.array([1.0]), y=np.array([0.1]), weights=np.array([1.0]))
+
+ engine._restore_parameter_values.assert_called_once()
+
+ def test_run_population_param(self, engine: DreamSampler, monkeypatch) -> None:
+ """population kwarg is forwarded to DREAM as pop."""
+ mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
+
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=10,
+ burn=0,
+ thin=1,
+ population=7,
+ )
+
+ call_kwargs = mock_FitDriver.call_args.kwargs
+ assert call_kwargs['pop'] == 7
+
+ def test_run_sampler_kwargs_forwarded(self, engine: DreamSampler, monkeypatch) -> None:
+ """sampler_kwargs entries are merged into the DREAM kwargs."""
+ mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
+
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=10,
+ burn=0,
+ thin=1,
+ sampler_kwargs={'trim': False},
+ )
+
+ assert mock_FitDriver.call_args.kwargs['trim'] is False
+
+ def test_run_rejects_non_callable_callback(self, engine: DreamSampler) -> None:
+ with pytest.raises(ValueError, match='progress_callback must be callable'):
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=10,
+ burn=5,
+ thin=1,
+ progress_callback='not-callable',
+ )
+
+ # --- Resume-state tests -------------------------------------------------
+
+ def _make_resume_state_mock(self, *, nvar=2, npop=10, labels=None):
+ """Build a mock MCMCDraw for resume tests.
+
+ BUMPS labels follow the pattern ``'p'`` (the
+ ``PARAMETER_PREFIX`` concatenated with the unique name),
+ e.g. ``'pFilm_thickness'``.
+
+ :param nvar: Number of parameters.
+ :param npop: Population size.
+ :param labels: Parameter labels (defaults to ``['pa', 'pb']``
+ which strip to ``['a', 'b']``).
+ """
+ if labels is None:
+ labels = ['pa', 'pb']
+ mock_state = MagicMock()
+ mock_state.Nvar = nvar
+ mock_state.Npop = npop
+ mock_state.labels = labels
+ mock_draw = MagicMock()
+ mock_draw.points = np.ones((20, nvar))
+ mock_draw.logp = np.ones(20)
+ mock_state.draw.return_value = mock_draw
+ return mock_state
+
+ def _make_problem_with_parameters(self, param_names):
+ """Build a mock FitProblem whose ``_parameters`` yields the given names."""
+ params = []
+ for name in param_names:
+ p = MagicMock()
+ p.name = 'p' + name
+ params.append(p)
+ mock_problem = MagicMock()
+ mock_problem._parameters = params
+ return mock_problem
+
+ def test_run_resume_state(self, engine: DreamSampler, monkeypatch) -> None:
+ """Verify resume_state is forwarded to driver.fit()."""
+ mock_FitDriver, mock_driver = self._setup_driver_mock(monkeypatch)
+ resume_state = self._make_resume_state_mock()
+ self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b']))
+
+ result = engine.run(
+ x=np.array([1.0, 2.0]),
+ y=np.array([0.1, 0.2]),
+ weights=np.array([1.0, 1.0]),
+ samples=10,
+ burn=0,
+ thin=1,
+ resume_state=resume_state,
+ )
+
+ assert result is not None
+ # Verify a fit_state (defensive copy of resume_state) was passed to driver.fit()
+ call_kwargs = mock_driver.fit.call_args.kwargs
+ assert call_kwargs.get('fit_state') is not None
+ assert call_kwargs['fit_state'] is not resume_state
+
+ def test_run_resume_param_mismatch_raises(self, engine: DreamSampler, monkeypatch) -> None:
+ """Parameter count mismatch raises ValueError before driver.fit()."""
+ self._set_problem(monkeypatch, self._make_problem_with_parameters(['a']))
+ # resume_state has 2 params, model has 1
+ resume_state = self._make_resume_state_mock(nvar=2)
+
+ with pytest.raises(ValueError, match='resume_state has 2 parameters'):
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=10,
+ burn=0,
+ thin=1,
+ resume_state=resume_state,
+ )
+
+ def test_run_resume_param_name_mismatch_raises(
+ self, engine: DreamSampler, monkeypatch
+ ) -> None:
+ """Parameter name/order mismatch raises ValueError before driver.fit()."""
+ self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b']))
+ # resume_state has labels ['px', 'py'] β stripped to ['x', 'y']
+ # Current model has params ['pa', 'pb'] β stripped to ['a', 'b']
+ # β mismatch
+ resume_state = self._make_resume_state_mock(nvar=2, labels=['px', 'py'])
+
+ with pytest.raises(ValueError, match='Parameter names/order mismatch'):
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=10,
+ burn=0,
+ thin=1,
+ resume_state=resume_state,
+ )
+
+ def test_run_resume_population_mismatch_raises(
+ self, engine: DreamSampler, monkeypatch
+ ) -> None:
+ """Explicit population differing from state.Npop raises ValueError."""
+ self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b']))
+ resume_state = self._make_resume_state_mock(nvar=2, npop=10)
+
+ with pytest.raises(ValueError, match='would produce'):
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=10,
+ burn=0,
+ thin=1,
+ population=3, # ceil(3*2)=6 β 10
+ resume_state=resume_state,
+ )
+
+ def test_run_resume_forces_burn_to_zero(
+ self, engine: DreamSampler, monkeypatch, caplog: 'pytest.LogCaptureFixture'
+ ) -> None:
+ """burn>0 with resume_state warns and is forced to 0."""
+ mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
+ self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b']))
+ resume_state = self._make_resume_state_mock()
+
+ with caplog.at_level(logging.WARNING, logger='easyscience.fitting.bumps'):
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=10,
+ burn=5,
+ thin=1,
+ resume_state=resume_state,
+ )
+
+ assert 'ignored on resume' in caplog.text
+ # burn must be forced to 0 in the kwargs passed to BUMPS
+ assert mock_FitDriver.call_args.kwargs['burn'] == 0
+
+ def test_run_resume_unlabeled_state_warns_and_uses_absolute_pop(
+ self, engine: DreamSampler, monkeypatch, caplog: 'pytest.LogCaptureFixture'
+ ) -> None:
+ """A state reloaded from disk carries default labels ('P0', ...), so
+ name validation is skipped with a warning, and the saved population is
+ reproduced as a negative pop (BUMPS' absolute-chain-count convention)."""
+ mock_FitDriver, _ = self._setup_driver_mock(monkeypatch)
+ self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b']))
+ resume_state = self._make_resume_state_mock(nvar=2, npop=10, labels=['P0', 'P1'])
+
+ with caplog.at_level(logging.WARNING, logger='easyscience.fitting.bumps'):
+ engine.run(
+ x=np.array([1.0]),
+ y=np.array([0.1]),
+ weights=np.array([1.0]),
+ samples=10,
+ burn=0,
+ thin=1,
+ resume_state=resume_state,
+ )
+
+ assert 'does not carry parameter names' in caplog.text
+ assert mock_FitDriver.call_args.kwargs['pop'] == -10
+
+
+class TestDreamSamplerProgressPayload:
+ """``DreamSampler._build_sample_progress_payload``."""
+
+ @pytest.fixture
+ def engine(self) -> DreamSampler:
+ return DreamSampler(obj='obj', fit_function='fit_function')
+
+ def test_payload_structure_and_sampling_flag(self, engine: DreamSampler) -> None:
+ mock_problem = MagicMock()
+ mock_problem.chisq.side_effect = [25.0, 12.5]
+ mock_problem.labels.return_value = ['palpha']
+ mock_problem.getp.return_value = np.array([1.0])
+
+ payload = engine._build_sample_progress_payload(
+ mock_problem, 7, np.array([1.0]), 12.5, 100
+ )
+
+ assert payload['iteration'] == 7
+ assert payload['total_steps'] == 100
+ assert payload['chi2'] == 25.0
+ assert payload['reduced_chi2'] == 12.5
+ assert payload['parameter_values'] == {'alpha': 1.0}
+ assert payload['sampling'] is True
+ assert payload['finished'] is False
+ assert payload['refresh_plots'] is False
+ # The nllf already computed by the sampler is reused β no model
+ # re-evaluation via setp.
+ mock_problem.chisq.assert_any_call(nllf=12.5, norm=False)
+ mock_problem.chisq.assert_any_call(nllf=12.5, norm=True)
+ mock_problem.setp.assert_not_called()
+
+ def test_payload_keys(self, engine: DreamSampler) -> None:
+ """Same keys as the classical-fit payload, plus ``sampling``."""
+ mock_problem = MagicMock()
+ mock_problem.chisq.side_effect = [10.0, 5.0]
+ mock_problem.labels.return_value = ['pa']
+ mock_problem.getp.return_value = np.array([5.0])
+
+ payload = engine._build_sample_progress_payload(
+ mock_problem, 1, np.array([5.0]), nllf=5.0, total_steps=50
+ )
+
+ expected_keys = {
+ 'iteration',
+ 'chi2',
+ 'reduced_chi2',
+ 'parameter_values',
+ 'refresh_plots',
+ 'finished',
+ 'sampling',
+ 'total_steps',
+ }
+ assert set(payload.keys()) == expected_keys
diff --git a/tests/unit/fitting/samplers/test_validation.py b/tests/unit/fitting/samplers/test_validation.py
new file mode 100644
index 00000000..6a4b5dc0
--- /dev/null
+++ b/tests/unit/fitting/samplers/test_validation.py
@@ -0,0 +1,36 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Unit tests for the DREAM run-settings validation helper."""
+
+import pytest
+
+from easyscience.fitting.samplers.validation import validate_run_settings
+
+
+class TestValidateRunSettings:
+ @pytest.mark.parametrize(
+ 'kwargs, match',
+ [
+ ({'samples': 0}, 'samples must be a positive integer'),
+ ({'samples': -1}, 'samples must be a positive integer'),
+ ({'samples': 10.0}, 'samples must be a positive integer'),
+ # bool is an int subclass; True must not sneak through as 1.
+ ({'samples': True}, 'samples must be a positive integer'),
+ ({'burn': -1}, 'burn must be a non-negative integer'),
+ ({'burn': 1.5}, 'burn must be a non-negative integer'),
+ ({'burn': True}, 'burn must be a non-negative integer'),
+ ({'burn': False}, 'burn must be a non-negative integer'),
+ ({'thin': 0}, 'thin must be a positive integer'),
+ ({'thin': 2.0}, 'thin must be a positive integer'),
+ ({'thin': True}, 'thin must be a positive integer'),
+ ],
+ )
+ def test_invalid_settings_raise(self, kwargs, match):
+ settings = {'samples': 10, 'burn': 0, 'thin': 1}
+ settings.update(kwargs)
+ with pytest.raises(ValueError, match=match):
+ validate_run_settings(**settings)
+
+ def test_valid_settings_pass(self):
+ validate_run_settings(samples=1, burn=0, thin=1)
+ validate_run_settings(samples=10000, burn=2000, thin=10)
diff --git a/tests/unit/fitting/test_engine_base.py b/tests/unit/fitting/test_engine_base.py
new file mode 100644
index 00000000..92886ccf
--- /dev/null
+++ b/tests/unit/fitting/test_engine_base.py
@@ -0,0 +1,47 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Unit tests for the engine-agnostic helpers in ``engine_base.py``."""
+
+import numpy as np
+import pytest
+
+from easyscience.fitting.engine_base import EngineBase
+
+validate_arrays = EngineBase.validate_arrays
+
+
+class TestValidateArrays:
+ @staticmethod
+ def _data():
+ return {
+ 'x': np.array([1.0, 2.0]),
+ 'y': np.array([0.1, 0.2]),
+ 'weights': np.array([1.0, 1.0]),
+ }
+
+ @pytest.mark.parametrize(
+ 'overrides, match',
+ [
+ ({'y': np.array([0.1])}, 'x and y must have the same shape'),
+ ({'x': np.array([1.0, np.nan])}, 'x cannot contain NaN'),
+ ({'x': np.array([1.0, np.inf])}, 'x cannot contain NaN'),
+ ({'y': np.array([0.1, np.nan])}, 'y cannot contain NaN'),
+ ({'y': np.array([0.1, np.inf])}, 'y cannot contain NaN'),
+ ({'weights': np.array([1.0])}, 'Weights must have the same shape'),
+ ({'weights': np.array([1.0, np.nan])}, 'Weights cannot be NaN'),
+ ({'weights': np.array([1.0, np.inf])}, 'Weights cannot be NaN'),
+ ({'weights': np.array([1.0, 0.0])}, 'Weights must be strictly positive'),
+ ({'weights': np.array([1.0, -1.0])}, 'Weights must be strictly positive'),
+ ({'x': np.array([1.0, None], dtype=object)}, 'x must hold numeric values'),
+ ({'y': np.array(['a', 'b'])}, 'y must hold numeric values'),
+ ({'weights': np.asarray(None)}, 'weights must hold numeric values'),
+ ],
+ )
+ def test_invalid_arrays_raise(self, overrides, match):
+ data = self._data()
+ data.update(overrides)
+ with pytest.raises(ValueError, match=match):
+ validate_arrays(**data)
+
+ def test_valid_arrays_pass(self):
+ validate_arrays(**self._data())
diff --git a/tests/unit/fitting/test_fitter.py b/tests/unit/fitting/test_fitter.py
index 702f5e59..400052b0 100644
--- a/tests/unit/fitting/test_fitter.py
+++ b/tests/unit/fitting/test_fitter.py
@@ -7,8 +7,8 @@
import pytest
import easyscience.fitting.fitter
-from easyscience import AvailableMinimizers
from easyscience import Fitter
+from easyscience import Minimizers
class TestFitter:
@@ -24,9 +24,9 @@ def test_constructor(self, fitter: Fitter):
assert fitter._fit_object == self.mock_fit_object
assert fitter._fit_function == self.mock_fit_function
assert fitter._dependent_dims is None
- assert fitter._enum_current_minimizer is None # == AvailableMinimizers.LMFit_leastsq
+ assert fitter._enum_current_minimizer is None # == Minimizers.LMFit_leastsq
assert fitter._minimizer is None
- fitter._update_minimizer.assert_called_once_with(AvailableMinimizers.LMFit_leastsq)
+ fitter._update_minimizer.assert_called_once_with(Minimizers.LMFit_leastsq)
def test_make_model(self, fitter: Fitter):
# When
@@ -54,19 +54,6 @@ def test_evaluate(self, fitter: Fitter):
assert result == 'result'
mock_minimizer.evaluate.assert_called_once_with('pars')
- def test_convert_to_pars_obj(self, fitter: Fitter):
- # When
- mock_minimizer = MagicMock()
- mock_minimizer.convert_to_pars_obj = MagicMock(return_value='obj')
- fitter._minimizer = mock_minimizer
-
- # Then
- obj = fitter.convert_to_pars_obj('pars')
-
- # Expect
- assert obj == 'obj'
- mock_minimizer.convert_to_pars_obj.assert_called_once_with('pars')
-
def test_initialize(self, fitter: Fitter):
# When
mock_fit_object = MagicMock()
@@ -80,41 +67,31 @@ def test_initialize(self, fitter: Fitter):
assert fitter._fit_function == mock_fit_function
fitter._update_minimizer.count(2)
- def test_create(self, fitter: Fitter, monkeypatch):
+ def test_switch_minimizer(self, fitter: Fitter):
# When
fitter._update_minimizer = MagicMock()
- mock_string_to_enum = MagicMock(return_value=10)
- monkeypatch.setattr(easyscience.fitting.fitter, 'from_string_to_enum', mock_string_to_enum)
# Then
- fitter.create('great-minimizer')
+ fitter.switch_minimizer(Minimizers.Bumps)
# Expect
- mock_string_to_enum.assert_called_once_with('great-minimizer')
- fitter._update_minimizer.assert_called_once_with(10)
+ fitter._update_minimizer.assert_called_once_with(Minimizers.Bumps)
- def test_switch_minimizer(self, fitter: Fitter, monkeypatch):
+ def test_switch_minimizer_rejects_string(self, fitter: Fitter):
# When
- mock_minimizer = MagicMock()
- fitter._minimizer = mock_minimizer
- mock_string_to_enum = MagicMock(return_value=10)
- monkeypatch.setattr(easyscience.fitting.fitter, 'from_string_to_enum', mock_string_to_enum)
-
- # Then
- fitter.switch_minimizer('great-minimizer')
+ fitter._update_minimizer = MagicMock()
- # Expect
- fitter._update_minimizer.count(2)
- mock_string_to_enum.assert_called_once_with('great-minimizer')
+ # Then Expect
+ with pytest.raises(TypeError, match='Minimizers'):
+ fitter.switch_minimizer('Bumps')
+ fitter._update_minimizer.assert_not_called()
def test_update_minimizer(self, monkeypatch):
# When
mock_fit_object = MagicMock()
mock_fit_function = MagicMock()
- mock_string_to_enum = MagicMock(return_value=10)
mock_factory = MagicMock(return_value='minimizer')
- monkeypatch.setattr(easyscience.fitting.fitter, 'from_string_to_enum', mock_string_to_enum)
monkeypatch.setattr(easyscience.fitting.fitter, 'factory', mock_factory)
fitter = Fitter(mock_fit_object, mock_fit_function)
@@ -130,20 +107,7 @@ def test_available_minimizers(self, fitter: Fitter):
minimizers = fitter.available_minimizers
# Then Expect
- assert minimizers == [
- 'LMFit',
- 'LMFit_leastsq',
- 'LMFit_powell',
- 'LMFit_cobyla',
- 'LMFit_differential_evolution',
- 'LMFit_scipy_least_squares',
- 'Bumps',
- 'Bumps_simplex',
- 'Bumps_newton',
- 'Bumps_lm',
- 'DFO',
- 'DFO_leastsq',
- ]
+ assert minimizers == list(Minimizers)
def test_minimizer(self, fitter: Fitter):
# When
diff --git a/tests/unit/fitting/test_multi_fitter.py b/tests/unit/fitting/test_multi_fitter.py
index ac897653..885755da 100644
--- a/tests/unit/fitting/test_multi_fitter.py
+++ b/tests/unit/fitting/test_multi_fitter.py
@@ -1,25 +1,41 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
+import logging
from unittest.mock import MagicMock
import numpy as np
import pytest
-from easyscience import ObjBase
from easyscience import Parameter
+from easyscience import global_object
+from easyscience.base_classes import EasyList
+from easyscience.base_classes import ModelBase
from easyscience.fitting.fitter import Fitter
from easyscience.fitting.multi_fitter import MultiFitter
-class Line(ObjBase):
- m: Parameter
- c: Parameter
-
+class Line(ModelBase):
def __init__(self, m_val: float, c_val: float):
- m = Parameter('m', m_val)
- c = Parameter('c', c_val)
- super().__init__('line', m=m, c=c)
+ super().__init__()
+ self._m = Parameter(m_val, display_name='m')
+ self._c = Parameter(c_val, display_name='c')
+
+ @property
+ def m(self) -> Parameter:
+ return self._m
+
+ @m.setter
+ def m(self, value: float) -> None:
+ self._m.value = value
+
+ @property
+ def c(self) -> Parameter:
+ return self._c
+
+ @c.setter
+ def c(self, value: float) -> None:
+ self._c.value = value
def __call__(self, x):
return self.m.value * x + self.c.value
@@ -142,6 +158,33 @@ def test_handles_single_dataset(self):
assert np.allclose(results[0].y_calc, [1.1, 2.1, 3.1])
+# ===================================================================
+# MultiFitter._fit_function_wrapper
+# ===================================================================
+
+
+class TestFitFunctionWrapper:
+ def test_fit_function_restored_with_multiple_datasets(self):
+ """Wrapping must not leave ``fit_function`` pointing at the last
+ dataset's function (regression: sampling on a 2+ dataset MultiFitter
+ silently swapped the user-visible fit function)."""
+ fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)]
+ mf = MultiFitter(fit_objects, fit_objects)
+ original = mf.fit_function
+ assert original is fit_objects[0]
+
+ x = [np.array([0.0, 1.0, 2.0]), np.array([0.0, 1.0])]
+ mf._dependent_dims = [(3,), (2,)]
+ wrapped = mf._fit_function_wrapper(x, flatten=True)
+
+ assert mf.fit_function is original
+
+ # Each wrapped section still evaluates its own dataset's function.
+ y = wrapped(np.zeros(5))
+ expected = np.hstack([fit_objects[0](x[0]), fit_objects[1](x[1])])
+ assert np.allclose(y, expected)
+
+
# ===================================================================
# MultiFitter._precompute_reshaping with weights=None
# ===================================================================
@@ -165,3 +208,95 @@ def test_weights_all_none_returns_none(self):
assert w_new is None
assert len(dims) == 2
+
+ # ===================================================================
+ # The EasyList container
+ # ===================================================================
+
+ def test_container_is_an_easy_list(self):
+ fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)]
+
+ assert isinstance(MultiFitter(fit_objects, fit_objects).fit_object, EasyList)
+
+ def test_sequence_contract(self):
+ fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)]
+ container = MultiFitter(fit_objects, fit_objects).fit_object
+
+ assert container[0] is fit_objects[0]
+ assert container[1] is fit_objects[1]
+ assert container[-1] is fit_objects[1]
+ assert list(container[0:1]) == [fit_objects[0]]
+ assert len(container) == 2
+ assert list(container) == fit_objects
+ assert fit_objects[0] in container
+ assert container[fit_objects[0].unique_name] is fit_objects[0]
+
+ def test_get_free_parameters_concatenates_in_order(self):
+ fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)]
+ container = MultiFitter(fit_objects, fit_objects).fit_object
+
+ assert container.get_free_parameters() == [
+ *fit_objects[0].get_free_parameters(),
+ *fit_objects[1].get_free_parameters(),
+ ]
+
+ def test_rejects_foreign_object(self):
+ with pytest.raises(TypeError, match='Items must be one of'):
+ MultiFitter([Line(1.0, 0.5), 'not a model'], [None, None])
+
+ def test_rejects_bare_parameter(self):
+ """The ModelBase-gated EasyList rejects bare parameters (a
+ Parameter is an EasyBase, but EasyList only harvests parameters
+ from ModelBase members)."""
+ model = Line(1.0, 0.5)
+ with pytest.raises(TypeError, match='Items must be one of'):
+ MultiFitter([model, Parameter(1.0, display_name='p')], [model, None])
+
+ def test_flattens_nested_list(self):
+ """Nested lists are flattened."""
+ models = [Line(1.0, 0.5), Line(2.0, 1.5)]
+
+ container = MultiFitter([models[0], [models[1]]], models).fit_object
+
+ assert list(container) == models
+
+ def test_accepts_fit_objects_as_a_tuple(self):
+ """Any sequence works, not just a list.
+
+ easyreflectometry passes its models as a tuple; a container that
+ only flattened lists wrapped the whole tuple as a single item.
+ """
+ models = (Line(1.0, 0.5), Line(2.0, 1.5))
+
+ container = MultiFitter(models, list(models)).fit_object
+
+ assert list(container) == list(models)
+
+ # ===================================================================
+ # Constructor defaults
+ # ===================================================================
+
+ def test_default_arguments_do_not_crash(self):
+ """Both arguments default to None; construction must not index into them."""
+ mf = MultiFitter()
+
+ assert isinstance(mf.fit_object, EasyList)
+ assert len(mf.fit_object) == 0
+ assert mf._fit_functions == []
+ assert mf.fit_function is None
+
+ def test_none_arguments_are_treated_as_empty(self):
+ mf = MultiFitter(None, None)
+
+ assert len(mf.fit_object) == 0
+ assert mf._fit_functions == []
+ assert mf.fit_function is None
+
+ def test_accepts_tuple_arguments(self):
+ """Any sequence works thanks to *-unpacking."""
+ models = (Line(1.0, 0.5), Line(2.0, 1.5))
+ mf = MultiFitter(models, models)
+
+ assert list(mf.fit_object) == list(models)
+ assert mf._fit_functions == list(models)
+ assert mf.fit_function is models[0]
diff --git a/tests/unit/fitting/test_reshaping.py b/tests/unit/fitting/test_reshaping.py
new file mode 100644
index 00000000..aada7c91
--- /dev/null
+++ b/tests/unit/fitting/test_reshaping.py
@@ -0,0 +1,100 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Unit tests for ``reshaping.py`` β the data reshaping and fit-function
+wrapping shared by ``Fitter``, ``MultiFitter`` and ``Sampler``."""
+
+import numpy as np
+import pytest
+
+from easyscience.fitting.reshaping import inject_x
+from easyscience.fitting.reshaping import inject_x_multi
+from easyscience.fitting.reshaping import reshape_dataset
+from easyscience.fitting.reshaping import reshape_datasets
+
+
+def _grid(n: int, m: int):
+ """A vectorized ``(n, m, 2)`` coordinate grid and its ``(n, m)`` dependent values."""
+ X, Y = np.meshgrid(np.linspace(0, 1, m), np.linspace(0, 1, n))
+ x = np.stack((X, Y), axis=2)
+ return x, X + 2 * Y
+
+
+def _sum_xy(x):
+ return x[..., 0] + 2 * x[..., 1]
+
+
+class TestReshapeDataset:
+ def test_1d_dims_are_y_shape(self):
+ x = np.linspace(0, 1, 5)
+ x_fit, x_new, y_new, w_new, dims = reshape_dataset(x, 2 * x, np.ones(5), vectorized=False)
+ assert dims == (5,)
+ assert x_fit.shape == y_new.shape == w_new.shape == (5,)
+
+ def test_vectorized_dims_are_y_shape_not_x_shape(self):
+ """For multi-dimensional coordinates the dependent dims must exclude
+ the coordinate-component axis: ``(2, 3, 2)`` x holds 6 observations."""
+ x, y = _grid(2, 3)
+ _, x_new, y_new, _, dims = reshape_dataset(x, y, None, vectorized=True)
+ assert dims == (2, 3)
+ assert y_new.shape == (6,)
+ assert x_new.shape == (2, 3, 2)
+
+ def test_non_vectorized_nd_dims_are_y_shape(self):
+ x = np.random.default_rng(0).random((6, 2))
+ y = np.arange(6.0)
+ _, x_new, y_new, _, dims = reshape_dataset(x, y, None, vectorized=False)
+ assert dims == (6,)
+ assert x_new.shape == (6, 2)
+
+
+class TestInjectX:
+ def test_injects_real_x_and_flattens(self):
+ x, y = _grid(2, 3)
+ wrapped = inject_x(_sum_xy, x, flatten=True)
+ np.testing.assert_allclose(wrapped(np.zeros(6)), y.flatten())
+
+
+class TestInjectXMulti:
+ def test_two_multidimensional_datasets_are_sliced_by_observation_count(self):
+ """Regression: slicing by the product of the x shape allocated twice as
+ many output positions as observations for vectorized grids, which
+ raised on the first dataset and silently clipped on the last."""
+ x1, y1 = _grid(2, 3)
+ x2, y2 = _grid(3, 4)
+ x_fit, x_new, y_new, _, dims = reshape_datasets([x1, x2], [y1, y2], None, vectorized=True)
+ assert dims == [(2, 3), (3, 4)]
+ assert y_new.shape == (18,)
+
+ wrapped = inject_x_multi([_sum_xy, _sum_xy], x_new, dims)
+ np.testing.assert_allclose(wrapped(x_fit), np.hstack([y1.flatten(), y2.flatten()]))
+
+ def test_multidimensional_dataset_first_then_1d(self):
+ """Dataset boundaries hold regardless of order; a 2D dataset that is
+ not last used to raise a broadcast error."""
+ x1, y1 = _grid(2, 3)
+ x2 = np.linspace(0, 1, 4)
+ y2 = 3 * x2
+ x_fit, x_new, y_new, _, dims = reshape_datasets([x1, x2], [y1, y2], None, vectorized=True)
+ assert dims == [(2, 3), (4,)]
+
+ wrapped = inject_x_multi([_sum_xy, lambda x: 3 * x], x_new, dims)
+ np.testing.assert_allclose(wrapped(x_fit), np.hstack([y1.flatten(), y2]))
+
+ def test_weights_none_for_all_datasets(self):
+ x = np.linspace(0, 1, 3)
+ _, _, _, w_new, _ = reshape_datasets([x, x], [x, x], [None, None], vectorized=False)
+ assert w_new is None
+
+ def test_weights_are_concatenated(self):
+ x = np.linspace(0, 1, 3)
+ _, _, _, w_new, _ = reshape_datasets(
+ [x, x], [x, x], [np.ones(3), 2 * np.ones(3)], vectorized=False
+ )
+ np.testing.assert_array_equal(w_new, [1, 1, 1, 2, 2, 2])
+
+
+@pytest.mark.parametrize('vectorized', [False, True])
+def test_shape_mismatch_raises(vectorized):
+ x = np.linspace(0, 1, 4)
+ with pytest.raises(ValueError, match='shape of the x and y data must be the same'):
+ reshape_dataset(x, np.zeros(3), None, vectorized=vectorized)
diff --git a/tests/unit/fitting/test_sampler.py b/tests/unit/fitting/test_sampler.py
index 665c1d66..9671865f 100644
--- a/tests/unit/fitting/test_sampler.py
+++ b/tests/unit/fitting/test_sampler.py
@@ -13,34 +13,52 @@
import numpy as np
import pytest
-from easyscience import ObjBase
from easyscience import Parameter
+from easyscience.base_classes import ModelBase
+from easyscience.fitting import Fitter
from easyscience.fitting import Sampler
from easyscience.fitting import SamplingResults
-from easyscience.fitting.minimizers.minimizer_base import MINIMIZER_PARAMETER_PREFIX
+from easyscience.fitting.engine_base import PARAMETER_PREFIX
from easyscience.fitting.multi_fitter import MultiFitter
from easyscience.fitting.sampler import _data_fingerprint
from easyscience.fitting.sampler import load_chain
-class AbsSin(ObjBase):
- phase: Parameter
- offset: Parameter
-
+class AbsSin(ModelBase):
def __init__(self, offset_val: float, phase_val: float):
- offset = Parameter('offset', offset_val)
- phase = Parameter('phase', phase_val)
- super().__init__('sin', offset=offset, phase=phase)
+ super().__init__()
+ self._offset = Parameter(offset_val, display_name='offset')
+ self._phase = Parameter(phase_val, display_name='phase')
+
+ @property
+ def offset(self) -> Parameter:
+ return self._offset
+
+ @offset.setter
+ def offset(self, value: float) -> None:
+ self._offset.value = value
+
+ @property
+ def phase(self) -> Parameter:
+ return self._phase
+
+ @phase.setter
+ def phase(self, value: float) -> None:
+ self._phase.value = value
def __call__(self, x):
return np.abs(np.sin(self.phase.value * x + self.offset.value))
-class _StubFitter:
- """Duck-types the Fitter attributes checked by the Sampler constructor."""
+class _StubModel:
+ """Duck-types the model attribute checked by the Sampler constructor."""
+
+ def get_free_parameters(self):
+ return []
+
- minimizer = None
- fit_function = None
+def _identity(x):
+ return x
class _StubState:
@@ -50,8 +68,9 @@ def __init__(self, labels):
self.labels = list(labels)
-def _bumps_fitter_and_data():
- """Build a 2-parameter BUMPS MultiFitter over a small sine model."""
+def _model_and_data():
+ """Build a 2-parameter sine model and a small dataset to sample."""
+ pytest.importorskip('bumps')
ref_sin = AbsSin(0.2, np.pi)
sp = AbsSin(0.354, 3.05)
sp.offset.fixed = False
@@ -59,12 +78,7 @@ def _bumps_fitter_and_data():
x = np.linspace(0, 5, 50)
y = ref_sin(x)
weights = np.ones_like(x)
- f = MultiFitter([sp], [sp])
- try:
- f.switch_minimizer('Bumps')
- except AttributeError:
- pytest.skip('BUMPS is not installed')
- return f, sp, x, y, weights
+ return sp, x, y, weights
def _xyw():
@@ -93,68 +107,109 @@ def _make_state(ngen=6, npop=5, nvar=2, seed=7):
class TestSamplerConstructorValidation:
- def test_rejects_fitter_without_minimizer(self):
+ def test_rejects_fit_object_without_fit_parameters(self):
x, y, w = _xyw()
- with pytest.raises(TypeError, match='fitter must be a configured Fitter'):
- Sampler(object(), [x], [y], [w])
+ with pytest.raises(TypeError, match='fit_object must be an EasyScience model'):
+ Sampler(object(), [_identity], [x], [y], [w])
+
+ def test_rejects_non_callable_fit_function(self):
+ x, y, w = _xyw()
+ with pytest.raises(TypeError, match='fit_function must be callable'):
+ Sampler(_StubModel(), 'not-callable', x, y, w)
+ with pytest.raises(TypeError, match='fit_function must be callable'):
+ Sampler(_StubModel(), [_identity, None], [x, x], [y, y], [w, w])
+
+ def test_rejects_fit_function_structure_mismatch(self):
+ """One callable for a list of datasets (or vice versa) is an error:
+ multi-dataset sampling takes one fit function per dataset."""
+ x, y, w = _xyw()
+ with pytest.raises(ValueError, match='fit_function must be a list of callables'):
+ Sampler(_StubModel(), _identity, [x], [y], [w])
+ with pytest.raises(ValueError, match='fit_function must be a list of callables'):
+ Sampler(_StubModel(), [_identity], x, y, w)
+
+ def test_rejects_fit_function_count_mismatch(self):
+ x, y, w = _xyw()
+ with pytest.raises(ValueError, match='one callable per dataset'):
+ Sampler(_StubModel(), [_identity], [x, x], [y, y], [w, w])
+
+ def test_rejects_list_of_fit_objects(self):
+ """Multiple datasets take one container object exposing all the
+ parameters, not a bare list of models."""
+ sp_1 = AbsSin(0.1, 1.0)
+ sp_2 = AbsSin(0.2, 2.0)
+ x, y, w = _xyw()
+ with pytest.raises(TypeError, match='fit_object must be an EasyScience model'):
+ Sampler([sp_1, sp_2], [sp_1, sp_2], [x, x], [y, y], [w, w])
+
+ def test_requires_weights(self):
+ """Sampling has no default weighting, so weights are a required
+ argument rather than a None that only blows up at sample()."""
+ x, y, _ = _xyw()
+ with pytest.raises(TypeError, match='weights'):
+ Sampler(_StubModel(), [_identity], [x], [y])
def test_rejects_mixed_array_and_list(self):
x, y, w = _xyw()
with pytest.raises(ValueError, match='both be arrays or both be lists'):
- Sampler(_StubFitter(), [x], y)
+ Sampler(_StubModel(), [_identity], [x], y, [w])
def test_rejects_dataset_count_mismatch(self):
x, y, w = _xyw()
with pytest.raises(ValueError, match='same number of datasets'):
- Sampler(_StubFitter(), [x, x], [y])
+ Sampler(_StubModel(), [_identity, _identity], [x, x], [y], [w, w])
def test_rejects_weights_structure_mismatch(self):
x, y, w = _xyw()
with pytest.raises(ValueError, match='weights must match the structure'):
- Sampler(_StubFitter(), [x], [y], w)
+ Sampler(_StubModel(), [_identity], [x], [y], w)
def test_rejects_weights_count_mismatch(self):
x, y, w = _xyw()
with pytest.raises(ValueError, match='weights must hold the same number'):
- Sampler(_StubFitter(), [x], [y], [w, w])
+ Sampler(_StubModel(), [_identity], [x], [y], [w, w])
def test_rejects_non_bool_vectorized(self):
x, y, w = _xyw()
with pytest.raises(TypeError, match='vectorized must be a bool'):
- Sampler(_StubFitter(), [x], [y], [w], vectorized=1)
+ Sampler(_StubModel(), [_identity], [x], [y], [w], vectorized=1)
def test_rejects_non_dict_sampler_kwargs(self):
x, y, w = _xyw()
with pytest.raises(TypeError, match='sampler_kwargs must be a dict'):
- Sampler(_StubFitter(), [x], [y], [w], sampler_kwargs=[('init', 'random')])
+ Sampler(_StubModel(), [_identity], [x], [y], [w], sampler_kwargs=[('init', 'random')])
def test_accepts_single_arrays(self):
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), x, y, w)
+ sampler = Sampler(_StubModel(), _identity, x, y, w)
assert sampler.results is None
class TestSamplerDataBinding:
def test_properties_expose_bound_data(self):
x, y, w = _xyw()
- f = _StubFitter()
- sampler = Sampler(f, [x], [y], [w])
- assert sampler.fitter is f
- np.testing.assert_array_equal(sampler.x[0], x)
- np.testing.assert_array_equal(sampler.y[0], y)
- np.testing.assert_array_equal(sampler.weights[0], w)
-
- def test_weights_property_none_when_unset(self):
- x, y, _ = _xyw()
- sampler = Sampler(_StubFitter(), [x], [y])
- assert sampler.weights is None
+ model = _StubModel()
+ sampler = Sampler(model, _identity, x, y, w)
+ assert sampler.fit_object is model
+ assert sampler.fit_function is _identity
+ np.testing.assert_array_equal(sampler.x, x)
+ np.testing.assert_array_equal(sampler.y, y)
+ np.testing.assert_array_equal(sampler.weights, w)
+
+ def test_properties_keep_list_structure(self):
+ """Multi-dataset inputs come back as lists, in the order given."""
+ x, y, w = _xyw()
+ sampler = Sampler(_StubModel(), [_identity, _identity], [x, 2 * x], [y, y], [w, w])
+ assert sampler.fit_function == [_identity, _identity]
+ assert len(sampler.x) == 2
+ np.testing.assert_array_equal(sampler.x[1], 2 * x)
def test_inputs_are_copied(self):
"""Mutating the caller's arrays after construction must not change the
bound data (nor the save() fingerprint derived from it)."""
x, y, w = _xyw()
y_original = y.copy()
- sampler = Sampler(_StubFitter(), [x], [y], [w])
+ sampler = Sampler(_StubModel(), [_identity], [x], [y], [w])
fingerprint_before = sampler._fingerprint()
y[:] = 0.0
@@ -164,7 +219,7 @@ def test_inputs_are_copied(self):
def test_bound_arrays_are_read_only(self):
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), x, y, w)
+ sampler = Sampler(_StubModel(), _identity, x, y, w)
with pytest.raises(ValueError, match='read-only'):
sampler.x[0] = 99.0
@@ -172,8 +227,8 @@ def test_data_properties_have_no_setters(self):
"""Bound data is deliberately immutable β sample new data with a new
Sampler, so a chain can never be extended against different data."""
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), [x], [y], [w])
- for name in ('fitter', 'x', 'y', 'weights'):
+ sampler = Sampler(_StubModel(), [_identity], [x], [y], [w])
+ for name in ('fit_object', 'fit_function', 'x', 'y', 'weights'):
with pytest.raises(AttributeError):
setattr(sampler, name, None)
@@ -181,20 +236,20 @@ def test_data_properties_have_no_setters(self):
class TestSamplerPathValidation:
def test_save_rejects_non_pathlike(self):
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), [x], [y], [w])
+ sampler = Sampler(_StubModel(), [_identity], [x], [y], [w])
with pytest.raises(TypeError, match='path must be a str or os.PathLike'):
sampler.save(123)
def test_save_accepts_pathlike(self, tmp_path):
"""A Path object passes validation; the empty sampler then raises RuntimeError."""
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), [x], [y], [w])
+ sampler = Sampler(_StubModel(), [_identity], [x], [y], [w])
with pytest.raises(RuntimeError, match='No chain state to save'):
sampler.save(tmp_path / 'chain')
def test_load_state_rejects_non_pathlike(self):
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), [x], [y], [w])
+ sampler = Sampler(_StubModel(), [_identity], [x], [y], [w])
with pytest.raises(TypeError, match='path must be a str or os.PathLike'):
sampler.load_state(123)
@@ -209,36 +264,30 @@ def test_load_chain_rejects_bad_skip(self, tmp_path, skip):
class TestSamplerErrorPaths:
- def test_sample_requires_bumps(self):
- """sample() must raise RuntimeError if the minimizer is not BUMPS β
- and must not mutate the fitter (no needless minimizer rebuild)."""
- sp = AbsSin(0.354, 3.05)
- f = MultiFitter([sp], [sp])
-
+ def test_sample_requires_bumps_package(self, monkeypatch):
+ """sample() must raise RuntimeError when the bumps package is not
+ installed."""
x, y, w = _xyw()
- sampler = Sampler(f, [x], [y], [w])
- minimizer_before = f.minimizer
- with pytest.raises(RuntimeError, match='Bayesian sampling requires a BUMPS minimizer'):
+ sampler = Sampler(_StubModel(), _identity, x, y, w)
+ monkeypatch.setattr(
+ 'easyscience.fitting.minimizers.minimizers.bumps_engine_available', False
+ )
+ with pytest.raises(RuntimeError, match='requires the bumps package'):
sampler.sample(samples=10, burn=5, thin=1)
- assert f.minimizer is minimizer_before
- def test_fit_function_restored_on_error(self):
- """fit_function must be restored even when the minimizer raises."""
- f, _, x, y, weights = _bumps_fitter_and_data()
- sampler = Sampler(f, [x], [y], [weights])
- original_func = f.fit_function
+ def test_engine_argument_errors_propagate(self):
+ """Invalid ``samples`` is rejected by the engine (single source of
+ validation) and surfaces unchanged."""
+ sp, x, y, weights = _model_and_data()
+ sampler = Sampler(sp, sp, x, y, weights)
- # Invalid `samples` is rejected by the minimizer (single source of
- # validation) *after* the fitter has been mutated for sampling.
with pytest.raises(ValueError, match='samples must be a positive integer'):
sampler.sample(samples=-1, burn=5, thin=1)
- assert f.fit_function is original_func
-
def test_extend_requires_existing_state(self):
"""extend() before sample()/load_state() raises RuntimeError."""
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), [x], [y], [w])
+ sampler = Sampler(_StubModel(), [_identity], [x], [y], [w])
with pytest.raises(RuntimeError, match='No chain to extend'):
sampler.extend(additional_samples=10)
@@ -246,7 +295,7 @@ def test_extend_requires_existing_state(self):
def test_save_raises_without_state(self, tmp_path):
"""save() before sample() raises RuntimeError."""
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), [x], [y], [w])
+ sampler = Sampler(_StubModel(), [_identity], [x], [y], [w])
with pytest.raises(RuntimeError, match='No chain state to save'):
sampler.save(str(tmp_path / 'chain'))
@@ -255,7 +304,7 @@ def test_sample_warns_when_replacing_existing_chain(self, monkeypatch, caplog):
"""sample() over an existing chain logs a replace warning; a fresh
sampler does not."""
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), [x], [y], [w])
+ sampler = Sampler(_StubModel(), [_identity], [x], [y], [w])
dummy = SamplingResults(
draws=np.zeros((1, 1)), param_names=['p'], logp=np.zeros(1), state=object()
@@ -320,7 +369,7 @@ def test_missing_sidecar_falls_back_to_state_labels(self, tmp_path):
assert names == ['P0', 'P1']
def test_fallback_strips_minimizer_prefix_from_labels(self, tmp_path):
- self.state.labels = [f'{MINIMIZER_PARAMETER_PREFIX}a', f'{MINIMIZER_PARAMETER_PREFIX}b']
+ self.state.labels = [f'{PARAMETER_PREFIX}a', f'{PARAMETER_PREFIX}b']
_, names, _ = load_chain(str(tmp_path / 'chain'))
assert names == ['a', 'b']
@@ -345,74 +394,61 @@ class TestSamplerConstructorDataValidation:
"""Scalars, strings and empty/ragged data must be rejected at construction."""
def test_rejects_scalar_x(self):
- _, y, _ = _xyw()
+ _, y, w = _xyw()
with pytest.raises(ValueError, match='x must be an array of values, got a scalar'):
- Sampler(_StubFitter(), 5.0, y)
+ Sampler(_StubModel(), _identity, 5.0, y, w)
def test_rejects_scalar_dataset_in_list(self):
- x, y, _ = _xyw()
+ x, y, w = _xyw()
with pytest.raises(ValueError, match=r'y\[1\] must be an array of values'):
- Sampler(_StubFitter(), [x, x], [y, 3.0])
+ Sampler(_StubModel(), [_identity, _identity], [x, x], [y, 3.0], [w, w])
def test_rejects_string_data(self):
- x, _, _ = _xyw()
+ x, _, w = _xyw()
with pytest.raises(TypeError, match='y must hold numeric values'):
- Sampler(_StubFitter(), x, 'abc')
+ Sampler(_StubModel(), _identity, x, 'abc', w)
def test_rejects_non_numeric_object_array(self):
_, y, _ = _xyw()
with pytest.raises(TypeError, match='x must hold numeric values'):
- Sampler(_StubFitter(), np.array([{}, {}], dtype=object), y)
+ Sampler(_StubModel(), _identity, np.array([{}, {}], dtype=object), y, np.ones(2))
def test_rejects_empty_array(self):
with pytest.raises(ValueError, match='x must not be empty'):
- Sampler(_StubFitter(), np.array([]), np.array([]))
+ Sampler(_StubModel(), _identity, np.array([]), np.array([]), np.array([]))
def test_rejects_ragged_dataset(self):
with pytest.raises(TypeError, match=r'x\[0\] could not be converted'):
- Sampler(_StubFitter(), [[1.0, [2.0, 3.0]]], [np.zeros(3)])
+ Sampler(_StubModel(), [_identity], [[1.0, [2.0, 3.0]]], [np.zeros(3)], [np.ones(3)])
def test_rejects_scalar_weights(self):
x, y, _ = _xyw()
with pytest.raises(ValueError, match='weights must be an array of values'):
- Sampler(_StubFitter(), x, y, 2.0)
+ Sampler(_StubModel(), _identity, x, y, 2.0)
- def test_weights_list_may_hold_none_entries(self):
+ def test_rejects_none_weight_entry(self):
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), [x, x], [y, y], [w, None])
- assert sampler.weights[1] is None
+ with pytest.raises(TypeError, match=r'weights\[1\] must hold numeric values'):
+ Sampler(_StubModel(), [_identity, _identity], [x, x], [y, y], [w, None])
class TestDataFingerprint:
def test_returns_none_on_unhashable_data(self):
assert _data_fingerprint([object()], [], []) is None
- def test_fingerprint_without_weights(self):
- x, y, _ = _xyw()
- sampler = Sampler(_StubFitter(), x, y)
+ def test_fingerprint_of_single_arrays(self):
+ x, y, w = _xyw()
+ sampler = Sampler(_StubModel(), _identity, x, y, w)
assert isinstance(sampler._fingerprint(), str)
-class TestSamplingResultsLegacyDict:
- def test_to_legacy_dict_maps_fields(self):
- state = object()
- results = SamplingResults(
- draws=np.ones((2, 1)), param_names=['p'], logp=np.zeros(2), state=state
- )
- legacy = results.to_legacy_dict()
- assert legacy['internal_bumps_object'] is state
- assert legacy['param_names'] == ['p']
- np.testing.assert_array_equal(legacy['draws'], results.draws)
- np.testing.assert_array_equal(legacy['logp'], results.logp)
-
-
class TestSamplerRunEngine:
"""The ``_run`` tail: results construction, storage, and kwarg merging,
- with the minimizer's sampling entry point stubbed out."""
+ with the ``DreamSampler`` engine stubbed out."""
def test_run_stores_results_and_exposes_properties(self, monkeypatch):
- f, _, x, y, weights = _bumps_fitter_and_data()
- from easyscience.fitting.minimizers.minimizer_bumps import Bumps
+ sp, x, y, weights = _model_and_data()
+ from easyscience.fitting.samplers.sampler_bumps import DreamSampler
canned = {
'draws': np.arange(8.0).reshape(4, 2),
@@ -422,14 +458,13 @@ def test_run_stores_results_and_exposes_properties(self, monkeypatch):
}
captured = {}
- def fake_mcmc_sample(self, **kwargs):
+ def fake_run(self, **kwargs):
captured.update(kwargs)
return dict(canned)
- monkeypatch.setattr(Bumps, 'mcmc_sample', fake_mcmc_sample)
+ monkeypatch.setattr(DreamSampler, 'run', fake_run)
- sampler = Sampler(f, [x], [y], [weights], sampler_kwargs={'trim': False})
- original_func = f.fit_function
+ sampler = Sampler(sp, sp, x, y, weights, sampler_kwargs={'trim': False})
results = sampler.sample(samples=100, burn=10, thin=2, sampler_kwargs={'init': 'lhs'})
assert isinstance(results, SamplingResults)
@@ -443,8 +478,72 @@ def fake_mcmc_sample(self, **kwargs):
assert captured['samples'] == 100
assert captured['burn'] == 10
assert captured['resume_state'] is None
- # The fitter's fit function is restored after the run.
- assert f.fit_function is original_func
+
+ def test_run_binds_engine_to_model_and_wrapped_function(self, monkeypatch):
+ """The engine is built from the sampler's own model and a wrapped
+ fit function; no Fitter or minimizer is involved."""
+ sp, x, y, weights = _model_and_data()
+ from easyscience.fitting.samplers.sampler_bumps import DreamSampler
+
+ constructed = {}
+ original_init = DreamSampler.__init__
+
+ def spy_init(self, obj, fit_function):
+ constructed['obj'] = obj
+ constructed['fit_function'] = fit_function
+ original_init(self, obj, fit_function)
+
+ canned = {
+ 'draws': np.zeros((2, 2)),
+ 'param_names': ['offset', 'phase'],
+ 'logp': np.zeros(2),
+ 'internal_bumps_object': object(),
+ }
+ monkeypatch.setattr(DreamSampler, '__init__', spy_init)
+ monkeypatch.setattr(DreamSampler, 'run', lambda self, **kwargs: dict(canned))
+
+ sampler = Sampler(sp, sp, x, y, weights)
+ results = sampler.sample(samples=10, burn=0, thin=1)
+
+ assert results.param_names == ['offset', 'phase']
+ assert constructed['obj'] is sp
+ assert callable(constructed['fit_function'])
+ assert constructed['fit_function'] is not sp
+
+
+class TestSamplerFromFitter:
+ """``from_fitter`` mirrors direct construction and leaves the fitter alone."""
+
+ def test_from_plain_fitter(self):
+ sp, x, y, weights = _model_and_data()
+ f = Fitter(sp, sp)
+
+ sampler = Sampler.from_fitter(f, x, y, weights, sampler_kwargs={'init': 'lhs'})
+
+ assert sampler.fit_object is sp
+ assert sampler.fit_function is sp
+ np.testing.assert_array_equal(sampler.x, x)
+ assert sampler._default_sampler_kwargs == {'init': 'lhs'}
+
+ def test_from_multi_fitter(self):
+ sp_1 = AbsSin(0.1, 1.0)
+ sp_2 = AbsSin(0.2, 2.0)
+ x, y, w = _xyw()
+ f = MultiFitter([sp_1, sp_2], [sp_1, sp_2])
+
+ sampler = Sampler.from_fitter(f, [x, x], [y, y], [w, w])
+
+ # The fitter's container object exposes every model's parameters.
+ assert sampler.fit_object is f.fit_object
+ assert sampler.fit_function == [sp_1, sp_2]
+ assert f.fit_function is sp_1 # the fitter is untouched
+
+ def test_multi_fitter_requires_list_data(self):
+ sp_1 = AbsSin(0.1, 1.0)
+ x, y, w = _xyw()
+ f = MultiFitter([sp_1], [sp_1])
+ with pytest.raises(ValueError, match='fit_function must be a list of callables'):
+ Sampler.from_fitter(f, x, y, w)
class TestSamplerExtendArithmetic:
@@ -453,7 +552,7 @@ class TestSamplerExtendArithmetic:
@staticmethod
def _sampler_with_stub_state(monkeypatch, ngen=7, npop=5):
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), x, y, w)
+ sampler = Sampler(_StubModel(), _identity, x, y, w)
sampler._state = SimpleNamespace(Ngen=ngen, Npop=npop)
captured = {}
dummy = SamplingResults(
@@ -493,7 +592,7 @@ class TestSamplerPersistenceRoundTrip:
@staticmethod
def _sampler_with_state():
x, y, w = _xyw()
- sampler = Sampler(_StubFitter(), x, y, w)
+ sampler = Sampler(_StubModel(), _identity, x, y, w)
state = _make_state()
_draw = state.draw()
sampler._state = state
@@ -558,7 +657,7 @@ def test_load_state_populates_results(self, tmp_path, caplog):
sampler.save(prefix)
x, y, w = _xyw()
- fresh = Sampler(_StubFitter(), x, y, w)
+ fresh = Sampler(_StubModel(), _identity, x, y, w)
with caplog.at_level(logging.WARNING, logger='easyscience.fitting'):
results = fresh.load_state(prefix)
@@ -576,7 +675,7 @@ def test_load_state_warns_on_different_data(self, tmp_path, caplog):
sampler.save(prefix)
x, y, w = _xyw()
- other = Sampler(_StubFitter(), x, 2.0 * y, w)
+ other = Sampler(_StubModel(), _identity, x, 2.0 * y, w)
with caplog.at_level(logging.WARNING, logger='easyscience.fitting'):
other.load_state(prefix)
diff --git a/tests/unit/global_object/test_entry_list_comprehensive.py b/tests/unit/global_object/test_entry_list_comprehensive.py
deleted file mode 100644
index 7a15ba2f..00000000
--- a/tests/unit/global_object/test_entry_list_comprehensive.py
+++ /dev/null
@@ -1,320 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from unittest.mock import MagicMock
-
-from easyscience.global_object.map import _EntryList
-
-
-class TestEntryListComprehensive:
- """Comprehensive tests for _EntryList class"""
-
- def test_inheritance(self):
- """Test that _EntryList properly inherits from list"""
- entry = _EntryList([1, 2, 3])
-
- # Should have list functionality
- assert len(entry) == 3
- assert entry[0] == 1
- assert 2 in entry
-
- # Can append like a normal list
- entry.append(4)
- assert len(entry) == 4
-
- def test_init_with_list_data(self):
- """Test initialization with existing list data"""
- data = ['a', 'b', 'c']
- entry = _EntryList(data, my_type='created')
-
- assert len(entry) == 3
- assert entry[0] == 'a'
- assert 'created' in entry.type
-
- def test_known_types_validation(self):
- """Test validation of known types"""
- entry = _EntryList()
- known_types = {'argument', 'created', 'created_internal', 'returned'}
-
- # Test all known types
- for known_type in known_types:
- entry.type = known_type
- assert known_type in entry.type
-
- # Test unknown type
- entry.type = 'unknown_type'
- assert 'unknown_type' not in entry.type
-
- def test_finalizer_attribute(self):
- """Test finalizer attribute handling"""
- entry = _EntryList()
-
- # Initially None
- assert entry.finalizer is None
-
- # Can set to any value
- mock_finalizer = MagicMock()
- entry.finalizer = mock_finalizer
- assert entry.finalizer is mock_finalizer
-
- def test_delitem_override(self):
- """Test __delitem__ override"""
- entry = _EntryList(['a', 'b', 'c'])
-
- # Should work like normal list deletion
- del entry[1]
- assert len(entry) == 2
- assert entry == ['a', 'c']
-
- def test_repr_variations(self):
- """Test various __repr__ scenarios"""
- # Empty type, no finalizer
- entry = _EntryList()
- repr_str = str(entry)
- assert 'Undefined' in repr_str
- assert 'Without' in repr_str
-
- # Single type, no finalizer
- entry.type = 'created'
- repr_str = str(entry)
- assert 'created' in repr_str
- assert 'Without' in repr_str
-
- # Multiple types, no finalizer
- entry.type = 'argument'
- repr_str = str(entry)
- assert 'created' in repr_str
- assert 'argument' in repr_str
- assert 'Without' in repr_str
-
- # With finalizer
- entry.finalizer = MagicMock()
- repr_str = str(entry)
- assert 'Without' not in repr_str
-
- def test_type_setter_edge_cases(self):
- """Test edge cases for type setter"""
- entry = _EntryList()
-
- # Setting None should not crash
- entry.type = None
- assert None not in entry.type
-
- # Setting empty string
- entry.type = ''
- assert '' not in entry.type
-
- # Setting numeric value
- entry.type = 123
- assert 123 not in entry.type
-
- def test_remove_type_edge_cases(self):
- """Test edge cases for remove_type"""
- entry = _EntryList()
- entry.type = 'created'
- entry.type = 'argument'
-
- # Remove type that exists
- entry.remove_type('created')
- assert 'created' not in entry.type
- assert 'argument' in entry.type
-
- # Remove type that doesn't exist
- entry.remove_type('returned') # Not in list
- assert 'argument' in entry.type # Should be unchanged
-
- # Remove unknown type
- entry.remove_type('unknown')
- assert 'argument' in entry.type # Should be unchanged
-
- # Remove None
- entry.remove_type(None)
- assert 'argument' in entry.type # Should be unchanged
-
- def test_reset_type_variations(self):
- """Test reset_type with various inputs"""
- entry = _EntryList()
- entry.type = 'created'
- entry.type = 'argument'
-
- # Reset with valid type
- entry.reset_type('returned')
- assert entry.type == ['returned']
-
- # Reset with invalid type
- entry.type = 'created' # Add something first
- entry.reset_type('invalid')
- assert entry.type == [] # Should be empty since invalid
-
- # Reset with None
- entry.type = 'created'
- entry.reset_type(None)
- assert entry.type == []
-
- def test_boolean_properties_combinations(self):
- """Test boolean properties with multiple types"""
- entry = _EntryList()
-
- # Add multiple types
- entry.type = 'created'
- entry.type = 'argument'
- entry.type = 'returned'
-
- # All should be true
- assert entry.is_created
- assert entry.is_argument
- assert entry.is_returned
- assert not entry.is_created_internal # Not added
-
- # Remove one
- entry.remove_type('argument')
- assert entry.is_created
- assert not entry.is_argument
- assert entry.is_returned
-
- def test_list_functionality_preserved(self):
- """Test that list functionality is preserved"""
- entry = _EntryList()
-
- # Basic list operations
- entry.append('a')
- entry.append('b')
- entry.insert(1, 'x')
- assert entry == ['a', 'x', 'b']
-
- # List methods
- assert entry.index('x') == 1
- assert entry.count('a') == 1
-
- # Slicing
- subset = entry[1:]
- assert subset == ['x', 'b']
-
- # Iteration
- items = [item for item in entry]
- assert items == ['a', 'x', 'b']
-
- def test_type_persistence_during_list_operations(self):
- """Test that type information persists during list operations"""
- entry = _EntryList(my_type='created')
- entry.type = 'argument'
-
- # Perform list operations
- entry.append('item1')
- entry.extend(['item2', 'item3'])
- entry.sort()
-
- # Type information should be preserved
- assert entry.is_created
- assert entry.is_argument
-
- def test_equality_with_regular_list(self):
- """Test equality comparison with regular list"""
- entry = _EntryList(['a', 'b', 'c'])
- regular_list = ['a', 'b', 'c']
-
- # Should be equal in content
- assert entry == regular_list
- assert regular_list == entry
-
- # But not the same type
- assert type(entry) != type(regular_list)
- assert isinstance(entry, _EntryList)
- assert isinstance(entry, list)
-
- def test_copy_behavior(self):
- """Test copying behavior"""
- import copy
-
- entry = _EntryList(['a', 'b'], my_type='created')
- entry.finalizer = MagicMock()
-
- # Shallow copy
- shallow_copy = copy.copy(entry)
- assert shallow_copy == entry
- assert shallow_copy.type == entry.type
- assert shallow_copy.finalizer is entry.finalizer
-
- # Deep copy
- deep_copy = copy.deepcopy(entry)
- assert deep_copy == entry
- assert deep_copy.type == entry.type
- # Finalizer might be different in deep copy
-
- def test_memory_efficiency(self):
- """Test that _EntryList doesn't consume excessive memory"""
- # Create many entries
- entries = []
- for i in range(1000):
- entry = _EntryList([f'item_{i}'], my_type='created')
- entries.append(entry)
-
- # Should not raise memory errors
- assert len(entries) == 1000
- assert all(entry.is_created for entry in entries)
-
- def test_thread_safety_simulation(self):
- """Test basic thread safety aspects (simulation)"""
- import threading
- import time
-
- entry = _EntryList()
- results = []
-
- def add_type(type_name, delay=0.001):
- time.sleep(delay)
- entry.type = type_name
- results.append(type_name)
-
- # Simulate concurrent access
- threads = []
- for i, type_name in enumerate(['created', 'argument', 'returned']):
- thread = threading.Thread(target=add_type, args=(type_name,))
- threads.append(thread)
-
- for thread in threads:
- thread.start()
-
- for thread in threads:
- thread.join()
-
- # All types should be added (order might vary)
- assert len(set(entry.type)) == 3
- assert all(t in entry.type for t in ['created', 'argument', 'returned'])
-
- def test_pickle_serialization(self):
- """Test pickle serialization support"""
- import pickle
-
- entry = _EntryList(['a', 'b', 'c'], my_type='created')
- entry.type = 'argument'
-
- # Serialize
- pickled = pickle.dumps(entry)
-
- # Deserialize
- unpickled = pickle.loads(pickled)
-
- # Should preserve data and types
- assert unpickled == entry
- assert unpickled.type == entry.type
- assert unpickled.is_created
- assert unpickled.is_argument
-
- def test_subclassing(self):
- """Test that _EntryList can be subclassed"""
-
- class CustomEntryList(_EntryList):
- def __init__(self, *args, custom_attr=None, **kwargs):
- super().__init__(*args, **kwargs)
- self.custom_attr = custom_attr
-
- def custom_method(self):
- return f'Custom: {len(self)} items'
-
- custom = CustomEntryList(['x', 'y'], my_type='created', custom_attr='test')
-
- assert custom.custom_attr == 'test'
- assert custom.custom_method() == 'Custom: 2 items'
- assert custom.is_created
- assert len(custom) == 2
diff --git a/tests/unit/global_object/test_global_object.py b/tests/unit/global_object/test_global_object.py
index 719fee5a..e2cd5e66 100644
--- a/tests/unit/global_object/test_global_object.py
+++ b/tests/unit/global_object/test_global_object.py
@@ -16,8 +16,7 @@ def test_init(self):
# Expect
assert global_object.log == GlobalObject().log
- assert global_object.map == GlobalObject().map
- assert global_object.stack == GlobalObject().stack
+ assert global_object.registry == GlobalObject().registry
assert global_object.debug == GlobalObject().debug
def test_generate_unique_name(self):
@@ -33,13 +32,13 @@ def test_generate_unique_name_already_taken(self):
global_object = GlobalObject()
# Block the other_name_prefix_2 name
keep_due_toweakref_1 = DescriptorBool(
- name='test', value=True, unique_name='other_name_prefix_2'
+ display_name='test', value=True, unique_name='other_name_prefix_2'
)
keep_due_toweakref_2 = DescriptorBool(
- name='test', value=True, unique_name='other_name_prefix_a_3'
+ display_name='test', value=True, unique_name='other_name_prefix_a_3'
)
keep_due_toweakref_3 = DescriptorBool(
- name='test', value=True, unique_name='almost_other_name_prefix_3'
+ display_name='test', value=True, unique_name='almost_other_name_prefix_3'
)
# Then
@@ -50,10 +49,10 @@ def test_generate_unique_name_already_taken(self):
@pytest.fixture
def clear_global_map(self):
- """Clear global map before and after each test"""
- global_object.map._clear()
+ """Clear global registry before and after each test"""
+ global_object.registry.clear()
yield
- global_object.map._clear()
+ global_object.registry.clear()
def test_singleton_behavior(self):
"""Test that GlobalObject is truly a singleton"""
@@ -64,23 +63,9 @@ def test_singleton_behavior(self):
# Then
assert obj1 is obj2
assert id(obj1) == id(obj2)
- assert obj1.map is obj2.map
+ assert obj1.registry is obj2.registry
assert obj1.log is obj2.log
- def test_instantiate_stack(self, clear_global_map):
- """Test stack instantiation"""
- # Given
- global_obj = GlobalObject()
-
- # When
- global_obj.instantiate_stack()
-
- # Then
- assert global_obj.stack is not None
- from easyscience.global_object.undo_redo import UndoStack
-
- assert isinstance(global_obj.stack, UndoStack)
-
def test_debug_property(self):
"""Test debug property access"""
# Given
@@ -94,7 +79,7 @@ def test_debug_property(self):
assert global_obj.debug is global_obj2.debug
def test_generate_unique_name_empty_map(self, clear_global_map):
- """Test unique name generation with empty map"""
+ """Test unique name generation with empty registry"""
# Given
global_obj = GlobalObject()
@@ -109,9 +94,9 @@ def test_generate_unique_name_with_gaps(self, clear_global_map):
# Given
global_obj = GlobalObject()
# Create objects with non-sequential names
- keep1 = DescriptorBool(name='test', value=True, unique_name='prefix_0')
- keep2 = DescriptorBool(name='test', value=True, unique_name='prefix_2')
- keep3 = DescriptorBool(name='test', value=True, unique_name='prefix_5')
+ keep1 = DescriptorBool(display_name='test', value=True, unique_name='prefix_0')
+ keep2 = DescriptorBool(display_name='test', value=True, unique_name='prefix_2')
+ keep3 = DescriptorBool(display_name='test', value=True, unique_name='prefix_5')
# When
name = global_obj.generate_unique_name('prefix')
@@ -123,8 +108,8 @@ def test_generate_unique_name_non_numeric_suffix(self, clear_global_map):
"""Test that non-numeric suffixes are ignored"""
# Given
global_obj = GlobalObject()
- keep1 = DescriptorBool(name='test', value=True, unique_name='prefix_abc')
- keep2 = DescriptorBool(name='test', value=True, unique_name='prefix_1')
+ keep1 = DescriptorBool(display_name='test', value=True, unique_name='prefix_abc')
+ keep2 = DescriptorBool(display_name='test', value=True, unique_name='prefix_1')
# When
name = global_obj.generate_unique_name('prefix')
@@ -136,8 +121,8 @@ def test_generate_unique_name_similar_prefixes(self, clear_global_map):
"""Test that similar but different prefixes don't interfere"""
# Given
global_obj = GlobalObject()
- keep1 = DescriptorBool(name='test', value=True, unique_name='test_param_5')
- keep2 = DescriptorBool(name='test', value=True, unique_name='test_parameter_10')
+ keep1 = DescriptorBool(display_name='test', value=True, unique_name='test_param_5')
+ keep2 = DescriptorBool(display_name='test', value=True, unique_name='test_parameter_10')
# When
name1 = global_obj.generate_unique_name('test_param')
@@ -153,25 +138,14 @@ def test_integration_with_parameter_creation(self, clear_global_map):
global_obj = GlobalObject()
# When
- param1 = Parameter(name='test1', value=1.0)
- param2 = Parameter(name='test2', value=2.0)
+ param1 = Parameter(display_name='test1', value=1.0)
+ param2 = Parameter(display_name='test2', value=2.0)
# Then
- assert len(global_obj.map.vertices()) == 2
- assert param1.unique_name in global_obj.map.vertices()
- assert param2.unique_name in global_obj.map.vertices()
+ assert len(global_obj.registry) == 2
+ assert param1.unique_name in global_obj.registry
+ assert param2.unique_name in global_obj.registry
# Test retrieval
- retrieved1 = global_obj.map.get_item_by_key(param1.unique_name)
+ retrieved1 = global_obj.registry[param1.unique_name]
assert retrieved1 is param1
-
- def test_script_manager_access(self):
- """Test that script manager is accessible"""
- # Given
- global_obj = GlobalObject()
-
- # Then
- assert hasattr(global_obj, 'script')
- from easyscience.global_object.hugger.hugger import ScriptManager
-
- assert isinstance(global_obj.script, ScriptManager)
diff --git a/tests/unit/global_object/test_integration_comprehensive.py b/tests/unit/global_object/test_integration_comprehensive.py
index 61268954..f5a83801 100644
--- a/tests/unit/global_object/test_integration_comprehensive.py
+++ b/tests/unit/global_object/test_integration_comprehensive.py
@@ -2,30 +2,40 @@
# SPDX-License-Identifier: BSD-3-Clause
import gc
-from unittest.mock import patch
import pytest
+from easy_test_models import CoefficientModel
-from easyscience import ObjBase
+from easyscience import ModelBase
from easyscience import Parameter
from easyscience import global_object
from easyscience.global_object.global_object import GlobalObject
from easyscience.variable import DescriptorBool
+class Container(ModelBase):
+ """ModelBase holding arbitrary named children."""
+
+ def __init__(self, display_name=None, **children):
+ super().__init__(display_name=display_name)
+ self._children = children
+
+ def __getattr__(self, key):
+ children = self.__dict__.get('_children', {})
+ if key in children:
+ return children[key]
+ raise AttributeError(key)
+
+
class TestGlobalObjectIntegration:
"""Integration tests for GlobalObject ecosystem"""
@pytest.fixture
def clear_all(self):
"""Clear everything before and after each test"""
- global_object.map._clear()
- if global_object.stack:
- global_object.stack.clear()
+ global_object.registry.clear()
yield
- global_object.map._clear()
- if global_object.stack:
- global_object.stack.clear()
+ global_object.registry.clear()
def test_parameter_lifecycle_integration(self, clear_all):
"""Test complete parameter lifecycle with global object integration"""
@@ -33,78 +43,58 @@ def test_parameter_lifecycle_integration(self, clear_all):
global_obj = GlobalObject()
# When - Create parameter
- param = Parameter(name='test_param', value=10.0, unit='m')
+ param = Parameter(display_name='test_param', value=10.0, unit='m')
- # Then - Should be registered in global map
- assert global_obj.map.is_known(param)
- assert param.unique_name in global_obj.map.vertices()
- assert 'created' in global_obj.map.find_type(param)
+ # Then - Should be registered in global registry
+ assert param.unique_name in global_obj.registry
- # When - Modify parameter with undo/redo enabled
- if not global_obj.stack:
- global_obj.instantiate_stack()
- global_obj.stack.enabled = True
-
- original_value = param.value
+ # When - Modify parameter
param.value = 20.0
- # Then - Should be able to undo/redo
- assert param.value == 20.0
- assert global_obj.stack.canUndo()
-
- global_obj.stack.undo()
- assert param.value == original_value
-
- global_obj.stack.redo()
+ # Then
assert param.value == 20.0
+ assert global_obj.registry[param.unique_name] is param
# When - Delete parameter
param_name = param.unique_name
- original_count = len(global_obj.map.vertices())
+ original_count = len(global_obj.registry)
del param
gc.collect()
- # Then - Should be removed from global map (via weak references)
+ # Then - Should be removed from global registry (via weak references)
# Note: garbage collection timing can vary, so we check count decrease
# or explicit cleanup
- final_count = len(global_obj.map.vertices())
- if param_name in global_obj.map.vertices():
+ final_count = len(global_obj.registry)
+ if param_name in global_obj.registry:
# If still present, manually clean up for test consistency
- global_obj.map.prune(param_name)
- assert param_name not in global_obj.map.vertices()
+ global_obj.registry.discard(param_name)
+ assert param_name not in global_obj.registry
- def test_objbase_parameter_relationship(self, clear_all):
- """Test ObjBase containing parameters integration"""
+ def test_model_parameter_relationship(self, clear_all):
+ """Test a model containing parameters integration"""
# Given
global_obj = GlobalObject()
- param1 = Parameter(name='length', value=10.0, unit='m')
- param2 = Parameter(name='width', value=5.0, unit='m')
+ param1 = Parameter(display_name='length', value=10.0, unit='m')
+ param2 = Parameter(display_name='width', value=5.0, unit='m')
- # When - Create ObjBase with parameters
- obj = ObjBase(name='rectangle', length=param1, width=param2)
+ # When - Create a container with parameters
+ obj = Container(display_name='rectangle', length=param1, width=param2)
- # Then - All should be in global map
- assert global_obj.map.is_known(obj)
- assert global_obj.map.is_known(param1)
- assert global_obj.map.is_known(param2)
+ # Then - All should be in global registry
+ assert obj.unique_name in global_obj.registry
+ assert param1.unique_name in global_obj.registry
+ assert param2.unique_name in global_obj.registry
- # Check relationships in map
- obj_edges = global_obj.map.get_edges(obj)
- assert param1.unique_name in obj_edges
- assert param2.unique_name in obj_edges
-
- # When - Modify through ObjBase
- if not global_obj.stack:
- global_obj.instantiate_stack()
- global_obj.stack.enabled = True
+ # Check that the container holds the parameters
+ assert obj.length is param1
+ assert obj.width is param2
+ # When - Modify through the container
obj.length.value = 15.0
- # Then - Should be able to undo
- assert global_obj.stack.canUndo()
- global_obj.stack.undo()
- assert obj.length.value == 10.0
+ # Then - The change is seen through the shared parameter
+ assert param1.value == 15.0
def test_unique_name_generation_integration(self, clear_all):
"""Test unique name generation across different object types"""
@@ -114,7 +104,7 @@ def test_unique_name_generation_integration(self, clear_all):
# When - Create multiple objects of same type
params = []
for i in range(5):
- param = Parameter(name=f'param_{i}', value=float(i))
+ param = Parameter(display_name=f'param_{i}', value=float(i))
params.append(param)
# Then - Should have unique names
@@ -126,174 +116,48 @@ def test_unique_name_generation_integration(self, clear_all):
assert name == f'Parameter_{i}'
# When - Create mixed object types
- obj = ObjBase(name='test_obj')
- desc = DescriptorBool(name='test_desc', value=True)
+ obj = Container(display_name='test_obj')
+ desc = DescriptorBool(display_name='test_desc', value=True)
# Then - Should not interfere with each other's naming
- assert obj.unique_name == 'ObjBase_0'
+ assert obj.unique_name == 'Container_0'
assert desc.unique_name == 'DescriptorBool_0'
- def test_map_vertex_type_management(self, clear_all):
- """Test comprehensive vertex type management"""
- # Given
- global_obj = GlobalObject()
- param = Parameter(name='test', value=1.0)
-
- # When - Check initial type
- initial_types = global_obj.map.find_type(param)
- assert 'created' in initial_types
-
- # When - Change type
- global_obj.map.change_type(param, 'argument')
- updated_types = global_obj.map.find_type(param)
- assert 'created' in updated_types
- assert 'argument' in updated_types
-
- # When - Reset type
- global_obj.map.reset_type(param, 'returned')
- final_types = global_obj.map.find_type(param)
- assert final_types == ['returned']
-
- # When - Filter by type
- returned_objs = global_obj.map.returned_objs
- assert param.unique_name in returned_objs
-
- created_objs = global_obj.map.created_objs
- assert param.unique_name not in created_objs
-
@pytest.mark.skip(reason='Weak reference timing can be inconsistent in tests')
def test_weak_reference_cleanup_integration(self, clear_all):
- """Test that weak references properly clean up the map"""
+ """Test that weak references properly clean up the registry"""
# Given
global_obj = GlobalObject()
# When - Create objects
- param1 = Parameter(name='temp1', value=1.0)
- param2 = Parameter(name='temp2', value=2.0)
- obj = ObjBase(name='temp_obj', param1=param1, param2=param2)
+ param1 = Parameter(display_name='temp1', value=1.0)
+ param2 = Parameter(display_name='temp2', value=2.0)
+ obj = Container(display_name='temp_obj', param1=param1, param2=param2)
param1_name = param1.unique_name
param2_name = param2.unique_name
obj_name = obj.unique_name
# Then - All should be present
- assert global_obj.map.is_known(param1)
- assert global_obj.map.is_known(param2)
- assert global_obj.map.is_known(obj)
+ assert param1_name in global_obj.registry
+ assert param2_name in global_obj.registry
+ assert obj_name in global_obj.registry
# When - Delete objects
del param1
gc.collect()
# Then - param1 should be removed
- assert param1_name not in global_obj.map.vertices()
- assert param2_name in global_obj.map.vertices()
- assert obj_name in global_obj.map.vertices()
+ assert param1_name not in global_obj.registry
+ assert param2_name in global_obj.registry
+ assert obj_name in global_obj.registry
# When - Delete remaining objects
del param2, obj
gc.collect()
- # Then - Map should be empty
- assert len(global_obj.map.vertices()) == 0
-
- def test_complex_undo_redo_scenario(self, clear_all):
- """Test complex undo/redo scenario with multiple objects"""
- # Given
- global_obj = GlobalObject()
- if not global_obj.stack:
- global_obj.instantiate_stack()
- global_obj.stack.enabled = True
-
- # Create a complex object structure
- length = Parameter(name='length', value=10.0, unit='m')
- width = Parameter(name='width', value=5.0, unit='m')
- height = Parameter(name='height', value=3.0, unit='m')
-
- box = ObjBase(name='box', length=length, width=width, height=height)
-
- # When - Perform multiple operations in a macro
- global_obj.stack.beginMacro('Resize box')
-
- length.value = 15.0
- width.value = 7.0
- height.value = 4.0
-
- global_obj.stack.endMacro()
-
- # Then - All changes should be applied
- assert box.length.value == 15.0
- assert box.width.value == 7.0
- assert box.height.value == 4.0
-
- # When - Undo macro
- assert global_obj.stack.canUndo()
- assert global_obj.stack.undoText() == 'Resize box'
-
- global_obj.stack.undo()
-
- # Then - All changes should be reverted
- assert box.length.value == 10.0
- assert box.width.value == 5.0
- assert box.height.value == 3.0
-
- # When - Redo macro
- assert global_obj.stack.canRedo()
- global_obj.stack.redo()
-
- # Then - All changes should be reapplied
- assert box.length.value == 15.0
- assert box.width.value == 7.0
- assert box.height.value == 4.0
-
- def test_map_path_finding_integration(self, clear_all):
- """Test map path finding with real object relationships"""
- # Given
- global_obj = GlobalObject()
-
- # Create a hierarchy: container -> sub_container -> parameter
- param = Parameter(name='value', value=42.0)
- sub_container = ObjBase(name='sub', value=param)
- main_container = ObjBase(name='main', sub=sub_container)
-
- # When - Find path from main to parameter
- path = global_obj.map.find_path(main_container.unique_name, param.unique_name)
-
- # Then - Should find the path through the hierarchy
- assert len(path) >= 2
- assert path[0] == main_container.unique_name
- assert path[-1] == param.unique_name
-
- # When - Find reverse path
- reverse_path = global_obj.map.reverse_route(param.unique_name, main_container.unique_name)
-
- # Then - Should be the reverse
- assert reverse_path[0] == param.unique_name
- assert reverse_path[-1] == main_container.unique_name
-
- def test_map_connectivity_with_isolated_objects(self, clear_all):
- """Test map connectivity detection"""
- # Given
- global_obj = GlobalObject()
-
- # When - Create connected objects
- param1 = Parameter(name='connected1', value=1.0)
- param2 = Parameter(name='connected2', value=2.0)
- container = ObjBase(name='container', p1=param1, p2=param2)
-
- # Then - Map should be connected
- # TODO: Depending on implementation, connectivity might vary
- # assert global_obj.map.is_connected()
-
- # When - Create isolated object
- isolated = Parameter(name='isolated', value=99.0)
- # Remove its automatic connection by clearing edges
- # (In real usage, isolated objects would be rare)
-
- # Then - Map might still be connected depending on implementation
- # This tests the connectivity algorithm
- connectivity = global_obj.map.is_connected()
- assert isinstance(connectivity, bool)
+ # Then - registry should be empty
+ assert len(global_obj.registry) == 0
def test_error_handling_integration(self, clear_all):
"""Test error handling across the global object system"""
@@ -301,17 +165,17 @@ def test_error_handling_integration(self, clear_all):
global_obj = GlobalObject()
# When - Try to get non-existent object
- with pytest.raises(ValueError, match='Item not in map'):
- global_obj.map.get_item_by_key('non_existent')
+ with pytest.raises(KeyError):
+ global_obj.registry['non_existent']
# When - Try to add object with duplicate name
- param1 = Parameter(name='test', value=1.0)
+ param1 = Parameter(display_name='test', value=1.0)
param1_name = param1.unique_name
- # Create another with same unique name (should fail in add_vertex)
+ # Create another with same unique name (should fail in add)
with pytest.raises(ValueError, match='already exists'):
# This simulates what would happen if we tried to add duplicate
- global_obj.map.add_vertex(param1) # param1 already added during creation
+ global_obj.registry.add(param1) # param1 already added during creation
def test_memory_pressure_simulation(self, clear_all):
"""Test system behavior under memory pressure"""
@@ -321,11 +185,11 @@ def test_memory_pressure_simulation(self, clear_all):
# When - Create many objects
objects = []
for i in range(100):
- param = Parameter(name=f'param_{i}', value=float(i))
- obj = ObjBase(name=f'obj_{i}', param=param)
+ param = Parameter(display_name=f'param_{i}', value=float(i))
+ obj = Container(display_name=f'obj_{i}', param=param)
objects.append((param, obj))
- initial_vertex_count = len(global_obj.map.vertices())
+ initial_vertex_count = len(global_obj.registry)
assert initial_vertex_count == 200 # 100 params + 100 objs
# When - Delete half the objects
@@ -337,16 +201,16 @@ def test_memory_pressure_simulation(self, clear_all):
# Force garbage collection
gc.collect()
- # Then - Map should have fewer vertices
- current_vertex_count = len(global_obj.map.vertices())
+ # Then - registry should have fewer vertices
+ current_vertex_count = len(global_obj.registry)
assert current_vertex_count <= initial_vertex_count
# When - Delete all remaining objects
del objects
gc.collect()
- # Then - Map should be mostly empty (might have some remaining references)
- final_vertex_count = len(global_obj.map.vertices())
+ # Then - registry should be mostly empty (might have some remaining references)
+ final_vertex_count = len(global_obj.registry)
assert final_vertex_count < initial_vertex_count
def test_serialization_integration_with_global_state(self, clear_all):
@@ -355,64 +219,34 @@ def test_serialization_integration_with_global_state(self, clear_all):
global_obj = GlobalObject()
# Create objects
- param = Parameter(name='test_param', value=123.45, unit='kg')
- obj = ObjBase(name='test_obj', param=param)
+ param = Parameter(display_name='test_param', value=123.45, unit='kg')
+ obj = CoefficientModel(display_name='test_obj', coefficients=[param])
- original_vertex_count = len(global_obj.map.vertices())
+ original_vertex_count = len(global_obj.registry)
# When - Serialize objects
- param_dict = param.as_dict()
- obj_dict = obj.as_dict()
+ param_dict = param.to_dict()
+ obj_dict = obj.to_dict()
# Clear global state
- global_obj.map._clear()
- assert len(global_obj.map.vertices()) == 0
+ global_obj.registry.clear()
+ assert len(global_obj.registry) == 0
# When - Deserialize objects
new_param = Parameter.from_dict(param_dict)
- new_obj = ObjBase.from_dict(obj_dict)
+ new_obj = CoefficientModel.from_dict(obj_dict)
- # Then - Should be registered in global map again
- assert len(global_obj.map.vertices()) >= 2
- assert global_obj.map.is_known(new_param)
- assert global_obj.map.is_known(new_obj)
+ # Then - Should be registered in global registry again
+ assert len(global_obj.registry) >= 2
+ assert new_param.unique_name in global_obj.registry
+ assert new_obj.unique_name in global_obj.registry
# Objects should be functionally equivalent
- assert new_param.name == param.name
+ assert new_param.display_name == param.display_name
assert new_param.value == param.value
assert new_param.unit == param.unit
-
- def test_debug_mode_integration(self, clear_all):
- """Test debug mode behavior across the system"""
- # Given
- global_obj = GlobalObject()
- original_debug = global_obj.debug
-
- try:
- # When - Enable debug mode
- global_obj.debug = True
-
- if not global_obj.stack:
- global_obj.instantiate_stack()
- global_obj.stack.enabled = True
-
- # Create and modify objects
- param = Parameter(name='debug_test', value=1.0)
-
- # This should trigger debug output in property_stack decorator
- with patch('builtins.print') as mock_print:
- param.value = 2.0
-
- # Should have printed debug info (if debug mode works)
- # Note: This depends on the debug implementation details
-
- # Test that operations still work normally
- assert param.value == 2.0
- assert global_obj.stack.canUndo()
-
- finally:
- # Restore original debug state
- global_obj.debug = original_debug
+ assert new_obj.display_name == 'test_obj'
+ assert new_obj.coefficients[0].value == param.value
def test_concurrent_access_simulation(self, clear_all):
"""Simulate concurrent access patterns"""
@@ -423,12 +257,15 @@ def test_concurrent_access_simulation(self, clear_all):
global_obj = GlobalObject()
results = []
errors = []
+ # The registry holds weak references, so keep the objects alive
+ params = []
def create_objects(thread_id, count=10):
"""Create objects in a thread"""
try:
for i in range(count):
- param = Parameter(name=f'thread_{thread_id}_param_{i}', value=float(i))
+ param = Parameter(display_name=f'thread_{thread_id}_param_{i}', value=float(i))
+ params.append(param)
results.append(param.unique_name)
time.sleep(0.001) # Small delay to encourage race conditions
except Exception as e:
@@ -451,6 +288,6 @@ def create_objects(thread_id, count=10):
assert len(results) == 15 # 3 threads * 5 objects each
assert len(set(results)) == 15 # All unique names should be unique
- # All objects should be in the map
+ # All objects should be in the registry
for unique_name in results:
- assert unique_name in global_obj.map.vertices()
+ assert unique_name in global_obj.registry
diff --git a/tests/unit/global_object/test_map.py b/tests/unit/global_object/test_map.py
deleted file mode 100644
index c68aa96f..00000000
--- a/tests/unit/global_object/test_map.py
+++ /dev/null
@@ -1,757 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import gc
-from unittest.mock import MagicMock
-
-import pytest
-
-from easyscience import ObjBase
-from easyscience import Parameter
-from easyscience import global_object
-from easyscience.global_object.map import Map
-from easyscience.global_object.map import _EntryList
-
-
-class TestEntryList:
- """Test the _EntryList helper class"""
-
- def test_init_default(self):
- """Test _EntryList initialization with defaults"""
- entry = _EntryList()
- assert entry._type == []
- assert entry.finalizer is None
-
- def test_init_with_known_type(self):
- """Test _EntryList initialization with known type"""
- entry = _EntryList(my_type='created')
- assert 'created' in entry._type
-
- def test_init_with_unknown_type(self):
- """Test _EntryList initialization with unknown type"""
- entry = _EntryList(my_type='unknown')
- assert entry._type == []
-
- def test_repr(self):
- """Test string representation"""
- entry = _EntryList()
- repr_str = str(entry)
- assert 'Undefined' in repr_str
- assert 'finalizer' in repr_str
-
- entry.type = 'created'
- repr_str = str(entry)
- assert 'created' in repr_str
- assert 'finalizer' in repr_str
-
- def test_type_property_getter(self):
- """Test type property getter"""
- entry = _EntryList()
- assert entry.type == []
-
- def test_type_property_setter_valid(self):
- """Test type property setter with valid types"""
- entry = _EntryList()
- entry.type = 'created'
- assert 'created' in entry.type
-
- entry.type = 'argument'
- assert 'argument' in entry.type
- assert 'created' in entry.type # Should be additive
-
- def test_type_property_setter_invalid(self):
- """Test type property setter with invalid type"""
- entry = _EntryList()
- entry.type = 'invalid'
- assert entry.type == []
-
- def test_type_property_setter_duplicate(self):
- """Test type property setter doesn't add duplicates"""
- entry = _EntryList()
- entry.type = 'created'
- entry.type = 'created' # Try to add again
- assert entry.type.count('created') == 1
-
- def test_remove_type(self):
- """Test removing a type"""
- entry = _EntryList()
- entry.type = 'created'
- entry.type = 'argument'
-
- entry.remove_type('created')
- assert 'created' not in entry.type
- assert 'argument' in entry.type
-
- def test_remove_type_not_present(self):
- """Test removing a type that's not present"""
- entry = _EntryList()
- entry.type = 'created'
- entry.remove_type('argument') # Not present
- assert 'created' in entry.type
-
- def test_reset_type(self):
- """Test resetting types"""
- entry = _EntryList()
- entry.type = 'created'
- entry.type = 'argument'
-
- entry.reset_type('returned')
- assert entry.type == ['returned']
-
- def test_reset_type_default_none(self):
- """Test resetting types with no default"""
- entry = _EntryList()
- entry.type = 'created'
-
- entry.reset_type()
- assert entry.type == []
-
- def test_boolean_properties(self):
- """Test boolean property helpers"""
- entry = _EntryList()
-
- # Test all false initially
- assert not entry.is_argument
- assert not entry.is_created
- assert not entry.is_created_internal
- assert not entry.is_returned
-
- # Test each property
- entry.type = 'argument'
- assert entry.is_argument
-
- entry.type = 'created'
- assert entry.is_created
-
- entry.type = 'created_internal'
- assert entry.is_created_internal
-
- entry.type = 'returned'
- assert entry.is_returned
-
-
-class TestMap:
- @pytest.fixture
- def clear(self):
- global_object.map._clear()
-
- @pytest.fixture
- def base_object(self):
- return ObjBase(name='test')
-
- @pytest.fixture
- def parameter_object(self):
- return Parameter(name='test2', value=2)
-
- def test_add_vertex(self, clear, base_object, parameter_object):
- # When Then Expect
- assert len(global_object.map._store) == 2
- assert len(global_object.map._Map__type_dict) == 2
-
- def test_clear(self, clear, base_object):
- # When
- assert len(global_object.map._store) == 1
- assert len(global_object.map._Map__type_dict) == 1
- # Then
- global_object.map._clear()
- # Expect
- assert len(global_object.map._store) == 0
- assert global_object.map._Map__type_dict == {}
-
- def test_weakref(self, clear):
- # When
- test_obj = ObjBase(name='test')
- assert len(global_object.map._store) == 1
- assert len(global_object.map._Map__type_dict) == 1
- # Then
- del test_obj
- gc.collect()
- # Expect
- assert len(global_object.map._store) == 0
- assert len(global_object.map._Map__type_dict) == 0
-
- def test_vertices(self, clear, base_object, parameter_object):
- # When Then Expect
- assert global_object.map.vertices() == [
- base_object.unique_name,
- parameter_object.unique_name,
- ]
-
- def test_get_item_by_key(self, clear, base_object, parameter_object):
- # When Then Expect
- assert global_object.map.get_item_by_key(base_object.unique_name) == base_object
- assert global_object.map.get_item_by_key(parameter_object.unique_name) == parameter_object
-
- @pytest.mark.parametrize('cls, kwargs', [(ObjBase, {}), (Parameter, {'value': 2.0})])
- def test_identical_unique_names_exception(self, clear, cls, kwargs):
- # When
- test_obj = cls(name='test', unique_name='test', **kwargs)
- # Then Expect
- with pytest.raises(ValueError):
- test_obj2 = cls(name='test2', unique_name='test', **kwargs)
-
- def test_unique_name_change_still_in_map(self, clear, base_object, parameter_object):
- # When
- assert global_object.map.get_item_by_key('ObjBase_0') == base_object
- assert global_object.map.get_item_by_key('Parameter_0') == parameter_object
- # Then
- base_object.unique_name = 'test3'
- parameter_object.unique_name = 'test4'
- # Expect
- assert global_object.map.get_item_by_key('ObjBase_0') == base_object
- assert global_object.map.get_item_by_key('Parameter_0') == parameter_object
- assert global_object.map.get_item_by_key('test3') == base_object
- assert global_object.map.get_item_by_key('test4') == parameter_object
-
- def test_add_vertex_duplicate_name_error(self, clear, base_object):
- """Test that adding vertex with duplicate name raises error"""
- # When/Then
- with pytest.raises(ValueError, match='already exists'):
- # Try to add another object with same unique_name
- duplicate_obj = ObjBase(name='duplicate', unique_name=base_object.unique_name)
-
- def test_add_vertex_with_object_type(self, clear):
- """Test adding vertex with specific object type"""
- # Given
- obj = ObjBase(name='test')
-
- # When - Object is automatically added during construction
- # Then
- assert global_object.map.is_known(obj)
- assert 'created' in global_object.map.find_type(obj)
-
- def test_is_known_object(self, clear, base_object):
- """Test is_known method"""
- # When/Then
- assert global_object.map.is_known(base_object) is True
-
- # Test with unknown object
- unknown_obj = MagicMock()
- unknown_obj.unique_name = 'unknown'
- assert global_object.map.is_known(unknown_obj) is False
-
- def test_find_type_known_object(self, clear, base_object):
- """Test find_type method"""
- # When/Then
- types = global_object.map.find_type(base_object)
- assert isinstance(types, list)
- assert 'created' in types
-
- def test_find_type_unknown_object(self, clear):
- """Test find_type with unknown object"""
- # Given
- unknown_obj = MagicMock()
- unknown_obj.unique_name = 'unknown'
-
- # When/Then
- result = global_object.map.find_type(unknown_obj)
- assert result is None
-
- def test_returned_objs_access_safe_under_modification(self, clear):
- """Ensure accessing returned_objs doesn't raise when entries change size during iteration."""
- objs = [ObjBase(name=f'race_{i}') for i in range(8)]
- # Mark all as returned
- for o in objs:
- global_object.map.change_type(o, 'returned')
-
- # Repeatedly access returned_objs while deleting objects and forcing GC to
- # try to trigger concurrent modification. This used to raise RuntimeError.
- for _ in range(200):
- _ = global_object.map.returned_objs # should not raise
- if _ and objs:
- # delete one object and collect to trigger finalizer/prune
- del objs[0]
- gc.collect()
- # If we got here without exceptions, consider the access safe
- assert True
-
- def test_reset_type(self, clear, base_object):
- """Test resetting object type"""
- # Given
- original_types = global_object.map.find_type(base_object)
-
- # When
- global_object.map.reset_type(base_object, 'argument')
-
- # Then
- new_types = global_object.map.find_type(base_object)
- assert new_types == ['argument']
- assert new_types != original_types
-
- def test_change_type(self, clear, base_object):
- """Test changing object type"""
- # Given
- original_types = global_object.map.find_type(base_object)
- original_count = len(original_types)
-
- # When
- global_object.map.change_type(base_object, 'argument')
-
- # Then
- new_types = global_object.map.find_type(base_object)
- assert 'argument' in new_types
- assert len(new_types) >= original_count # Should be at least the same or more
-
- def test_add_edge(self, clear, base_object, parameter_object):
- """Test adding edges between objects"""
- # When
- global_object.map.add_edge(base_object, parameter_object)
-
- # Then
- edges = global_object.map.get_edges(base_object)
- assert parameter_object.unique_name in edges
-
- def test_add_edge_unknown_start_object(self, clear, parameter_object):
- """Test adding edge with unknown start object"""
- # Given
- unknown_obj = MagicMock()
- unknown_obj.unique_name = 'unknown'
-
- # When/Then
- with pytest.raises(AttributeError, match='Start object not in map'):
- global_object.map.add_edge(unknown_obj, parameter_object)
-
- def test_get_edges(self, clear, base_object, parameter_object):
- """Test getting edges for an object"""
- # Given
- global_object.map.add_edge(base_object, parameter_object)
-
- # When
- edges = global_object.map.get_edges(base_object)
-
- # Then
- assert isinstance(edges, list)
- assert parameter_object.unique_name in edges
-
- def test_get_edges_unknown_object(self, clear):
- """Test getting edges for unknown object"""
- # Given
- unknown_obj = MagicMock()
- unknown_obj.unique_name = 'unknown'
-
- # When/Then
- with pytest.raises(AttributeError):
- global_object.map.get_edges(unknown_obj)
-
- def test_prune_vertex_from_edge(self, clear, base_object, parameter_object):
- """Test removing edge between objects"""
- # Given
- global_object.map.add_edge(base_object, parameter_object)
- assert parameter_object.unique_name in global_object.map.get_edges(base_object)
-
- # When
- global_object.map.prune_vertex_from_edge(base_object, parameter_object)
-
- # Then
- edges = global_object.map.get_edges(base_object)
- assert parameter_object.unique_name not in edges
-
- def test_prune_vertex_from_edge_none_child(self, clear, base_object):
- """Test pruning edge with None child"""
- # When/Then - Should not raise error
- global_object.map.prune_vertex_from_edge(base_object, None)
-
- def test_prune_vertex(self, clear, base_object):
- """Test pruning a vertex completely"""
- # Given
- unique_name = base_object.unique_name
- assert global_object.map.is_known(base_object)
-
- # When
- global_object.map.prune(unique_name)
-
- # Then
- assert not global_object.map.is_known(base_object)
- assert unique_name not in global_object.map.vertices()
-
- def test_edges_generation(self, clear, base_object, parameter_object):
- """Test edge generation"""
- # Given
- global_object.map.add_edge(base_object, parameter_object)
-
- # When
- edges = global_object.map.edges()
-
- # Then
- assert isinstance(edges, list)
- expected_edge = {base_object.unique_name, parameter_object.unique_name}
- assert expected_edge in edges
-
- def test_type_filtering_properties(self, clear):
- """Test type filtering properties"""
- # Given
- obj1 = ObjBase(name='obj1') # 'created' type
- obj2 = Parameter(name='obj2', value=1) # 'created' type
-
- global_object.map.change_type(obj1, 'argument')
- global_object.map.change_type(obj2, 'returned')
-
- # When/Then
- argument_objs = global_object.map.argument_objs
- created_objs = global_object.map.created_objs
- returned_objs = global_object.map.returned_objs
-
- assert obj1.unique_name in argument_objs
- assert obj1.unique_name in created_objs # Should still be there
- assert obj2.unique_name in created_objs
- assert obj2.unique_name in returned_objs
-
- def test_find_path_simple(self, clear, base_object, parameter_object):
- """Test finding path between objects"""
- # Given
- global_object.map.add_edge(base_object, parameter_object)
-
- # When
- path = global_object.map.find_path(base_object.unique_name, parameter_object.unique_name)
-
- # Then
- assert path == [base_object.unique_name, parameter_object.unique_name]
-
- def test_find_path_same_vertex(self, clear, base_object):
- """Test finding path to same vertex"""
- # When
- path = global_object.map.find_path(base_object.unique_name, base_object.unique_name)
-
- # Then
- assert path == [base_object.unique_name]
-
- def test_find_path_no_path(self, clear, base_object, parameter_object):
- """Test finding path when no path exists"""
- # When - No edge added
- path = global_object.map.find_path(base_object.unique_name, parameter_object.unique_name)
-
- # Then
- assert path == []
-
- def test_find_all_paths(self, clear, base_object, parameter_object):
- """Test finding all paths between objects"""
- # Given
- global_object.map.add_edge(base_object, parameter_object)
-
- # When
- paths = global_object.map.find_all_paths(
- base_object.unique_name, parameter_object.unique_name
- )
-
- # Then
- assert len(paths) == 1
- assert paths[0] == [base_object.unique_name, parameter_object.unique_name]
-
- def test_reverse_route_with_start(self, clear, base_object, parameter_object):
- """Test reverse route with specified start"""
- # Given
- global_object.map.add_edge(base_object, parameter_object)
-
- # When
- route = global_object.map.reverse_route(
- parameter_object.unique_name, base_object.unique_name
- )
-
- # Then
- assert route == [parameter_object.unique_name, base_object.unique_name]
-
- def test_reverse_route_without_start(self, clear, base_object, parameter_object):
- """Test reverse route without specified start"""
- # Given
- global_object.map.add_edge(base_object, parameter_object)
-
- # When
- route = global_object.map.reverse_route(parameter_object.unique_name)
-
- # Then
- assert len(route) >= 1
- assert route[0] == parameter_object.unique_name
-
- def test_is_connected_single_vertex(self, clear, base_object):
- """Test connectivity with single vertex"""
- # When/Then
- assert global_object.map.is_connected() is True
-
- def test_is_connected_multiple_vertices(self, clear, base_object, parameter_object):
- """Test connectivity with multiple connected vertices"""
- # Given
- global_object.map.add_edge(base_object, parameter_object)
-
- # When/Then
- assert global_object.map.is_connected() is True
-
- def test_map_repr(self, clear, base_object, parameter_object):
- """Test map string representation"""
- # When
- repr_str = str(global_object.map)
-
- # Then
- assert 'Map object' in repr_str
- assert '2' in repr_str # Should show vertex count
-
- def test_get_item_by_key_not_found(self, clear):
- """Test getting item by non-existent key"""
- # When/Then
- with pytest.raises(ValueError, match='Item not in map'):
- global_object.map.get_item_by_key('non_existent')
-
- def test_clear_with_finalizers(self, clear):
- """Test clearing map properly calls finalizers"""
- # Given
- obj = ObjBase(name='test')
- original_count = len(global_object.map.vertices())
-
- # When
- global_object.map._clear()
-
- # Then
- assert len(global_object.map.vertices()) == 0
-
- def test_map_initialization(self):
- """Test Map initialization"""
- # When
- test_map = Map()
-
- # Then
- assert len(test_map.vertices()) == 0
- assert test_map.edges() == []
-
- def test_vertices_retry_on_runtime_error(self, clear):
- """Test that vertices() retries when RuntimeError occurs during iteration.
-
- This tests the thread-safety fix for WeakValueDictionary modification
- during iteration (e.g., by garbage collection).
- """
- # Given
- test_map = Map()
-
- # Create a mock _store that raises RuntimeError on first iteration attempt
- call_count = 0
- original_store = test_map._store
-
- class MockWeakValueDict:
- def __init__(self):
- self.data = {}
- self.iteration_count = 0
-
- def __iter__(self):
- self.iteration_count += 1
- if self.iteration_count == 1:
- # First iteration raises RuntimeError (simulates GC interference)
- raise RuntimeError('dictionary changed size during iteration')
- # Subsequent iterations succeed
- return iter(self.data)
-
- def __len__(self):
- return len(self.data)
-
- mock_store = MockWeakValueDict()
- test_map._store = mock_store
-
- # When
- vertices = test_map.vertices()
-
- # Then
- assert vertices == []
- assert mock_store.iteration_count == 2 # Should have retried once
-
- def test_add_vertex_cleans_stale_type_dict_entry(self, clear):
- """Test that add_vertex cleans up stale __type_dict entries.
-
- This can happen when a weak reference was collected but the finalizer
- hasn't run yet, and a new object is created with the same unique_name.
- """
- # Given
- test_map = Map()
-
- # Manually add a stale entry to __type_dict (simulating GC collected but finalizer not run)
- stale_name = 'StaleObject_0'
- test_map._Map__type_dict[stale_name] = _EntryList()
-
- # Create a mock object with the same unique_name
- mock_obj = MagicMock()
- mock_obj.unique_name = stale_name
-
- # When - Adding the object should clean up the stale entry first
- test_map.add_vertex(mock_obj, 'created')
-
- # Then - Object should be added successfully
- assert stale_name in test_map._store
- assert stale_name in test_map._Map__type_dict
- assert test_map._Map__type_dict[stale_name].type == ['created']
-
- def test_prune_key_not_in_store(self, clear):
- """Test that prune handles case when key is not in _store.
-
- This defensive check prevents KeyError when the weak reference has
- already been garbage collected but __type_dict entry remains.
- """
- # Given
- test_map = Map()
-
- # Manually add entry to __type_dict without corresponding _store entry
- orphan_key = 'OrphanObject_0'
- test_map._Map__type_dict[orphan_key] = _EntryList()
-
- # When - Pruning should not raise error
- test_map.prune(orphan_key)
-
- # Then - Entry should be removed from __type_dict
- assert orphan_key not in test_map._Map__type_dict
-
- def test_prune_key_in_both_dicts(self, clear, base_object):
- """Test that prune removes key from both _store and __type_dict."""
- # Given
- unique_name = base_object.unique_name
- assert unique_name in global_object.map._store
- assert unique_name in global_object.map._Map__type_dict
-
- # When
- global_object.map.prune(unique_name)
-
- # Then
- assert unique_name not in global_object.map._Map__type_dict
- # Note: _store entry may or may not exist depending on weak ref state
-
- def test_prune_nonexistent_key(self, clear):
- """Test that prune handles nonexistent key gracefully."""
- # When/Then - Should not raise error
- global_object.map.prune('nonexistent_key')
-
- def test_reset_type_unknown_object(self, clear):
- """Test reset_type with object not in map."""
- # Given
- unknown_obj = MagicMock()
- unknown_obj.unique_name = 'unknown'
-
- # When/Then - Should not raise error
- global_object.map.reset_type(unknown_obj, 'argument')
-
- def test_change_type_unknown_object(self, clear):
- """Test change_type with object not in map."""
- # Given
- unknown_obj = MagicMock()
- unknown_obj.unique_name = 'unknown'
-
- # When/Then - Should not raise error
- global_object.map.change_type(unknown_obj, 'argument')
-
- def test_find_path_start_not_in_graph(self, clear):
- """Test find_path when start vertex is not in graph."""
- # When
- path = global_object.map.find_path('nonexistent', 'also_nonexistent')
-
- # Then
- assert path == []
-
- def test_find_all_paths_start_not_in_graph(self, clear):
- """Test find_all_paths when start vertex is not in graph."""
- # When
- paths = global_object.map.find_all_paths('nonexistent', 'also_nonexistent')
-
- # Then
- assert paths == []
-
- def test_find_isolated_vertices(self, clear, base_object, parameter_object):
- """Test finding isolated vertices (vertices with no outgoing edges)."""
- # Given - No edges added, both objects are isolated
-
- # When
- isolated = global_object.map.find_isolated_vertices()
-
- # Then
- assert base_object.unique_name in isolated
- assert parameter_object.unique_name in isolated
-
- def test_find_isolated_vertices_with_edges(self, clear, base_object, parameter_object):
- """Test finding isolated vertices when some have edges."""
- # Given
- global_object.map.add_edge(base_object, parameter_object)
-
- # When
- isolated = global_object.map.find_isolated_vertices()
-
- # Then
- # base_object has an edge, so it's not isolated
- assert base_object.unique_name not in isolated
- # parameter_object has no outgoing edges, so it's isolated
- assert parameter_object.unique_name in isolated
-
- def test_prune_vertex_from_edge_edge_not_exists(self, clear, base_object, parameter_object):
- """Test pruning edge that doesn't exist."""
- # Given - No edge added between objects
-
- # When/Then - Should not raise error
- global_object.map.prune_vertex_from_edge(base_object, parameter_object)
-
- def test_prune_vertex_from_edge_parent_not_in_map(self, clear, parameter_object):
- """Test pruning edge when parent is not in map."""
- # Given
- unknown_obj = MagicMock()
- unknown_obj.unique_name = 'unknown'
-
- # When/Then - Should not raise error (vertex1 not in type_dict)
- global_object.map.prune_vertex_from_edge(unknown_obj, parameter_object)
-
- def test_created_internal_property(self, clear):
- """Test created_internal property."""
- # Given
- obj = ObjBase(name='internal_obj')
- global_object.map.change_type(obj, 'created_internal')
-
- # When
- internal_objs = global_object.map.created_internal
-
- # Then
- assert obj.unique_name in internal_objs
-
- def test_clear_empties_both_dicts(self, clear, base_object, parameter_object):
- """Test that _clear() properly empties both _store and __type_dict."""
- # Given
- assert len(global_object.map._store) == 2
- assert len(global_object.map._Map__type_dict) == 2
-
- # When
- global_object.map._clear()
-
- # Then
- assert len(global_object.map._store) == 0
- assert len(global_object.map._Map__type_dict) == 0
-
- def test_entry_list_delitem(self):
- """Test _EntryList __delitem__ method."""
- # Given
- entry = _EntryList()
- entry.append('item1')
- entry.append('item2')
- entry.append('item3')
-
- # When
- del entry[1]
-
- # Then
- assert len(entry) == 2
- assert 'item2' not in entry
- assert 'item1' in entry
- assert 'item3' in entry
-
- def test_entry_list_repr_with_finalizer(self):
- """Test _EntryList repr when finalizer is set."""
- # Given
- entry = _EntryList()
- entry.type = 'created'
- entry.finalizer = MagicMock() # Non-None finalizer
-
- # When
- repr_str = str(entry)
-
- # Then
- assert 'created' in repr_str
- assert 'With a finalizer' in repr_str
-
- def test_entry_list_remove_type_unknown(self):
- """Test removing a type that's not in known types."""
- # Given
- entry = _EntryList()
- entry.type = 'created'
-
- # When - Try to remove unknown type
- entry.remove_type('unknown_type')
-
- # Then - Should not change anything
- assert 'created' in entry.type
diff --git a/tests/unit/global_object/test_object_registry.py b/tests/unit/global_object/test_object_registry.py
new file mode 100644
index 00000000..ccf60d3e
--- /dev/null
+++ b/tests/unit/global_object/test_object_registry.py
@@ -0,0 +1,262 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import gc
+
+import pytest
+
+from easyscience import Parameter
+from easyscience import global_object
+from easyscience.base_classes import EasyBase
+from easyscience.global_object.object_registry import ObjectRegistry
+
+
+class _Named:
+ """Minimal weak-referenceable object that can be registered in an ObjectRegistry."""
+
+ def __init__(self, unique_name):
+ self.unique_name = unique_name
+
+
+class TestObjectRegistry:
+ @pytest.fixture
+ def clear(self):
+ global_object.registry.clear()
+
+ @pytest.fixture
+ def base_object(self):
+ return EasyBase(display_name='test')
+
+ @pytest.fixture
+ def parameter_object(self):
+ return Parameter(display_name='test2', value=2)
+
+ def test_add(self, clear, base_object, parameter_object):
+ # When Then Expect
+ assert global_object.registry.objects() == [base_object, parameter_object]
+
+ def test_clear(self, clear, base_object):
+ # When
+ assert global_object.registry.names() == [base_object.unique_name]
+ # Then
+ global_object.registry.clear()
+ # Expect
+ assert global_object.registry.names() == []
+ assert global_object.registry.objects() == []
+
+ def test_weakref(self, clear):
+ # When
+ test_obj = EasyBase(display_name='test')
+ assert global_object.registry.names() == [test_obj.unique_name]
+ # Then
+ del test_obj
+ gc.collect()
+ # Expect
+ assert global_object.registry.names() == []
+
+ def test_names(self, clear, base_object, parameter_object):
+ # When Then Expect
+ assert global_object.registry.names() == [
+ base_object.unique_name,
+ parameter_object.unique_name,
+ ]
+
+ def test_getitem(self, clear, base_object, parameter_object):
+ # When Then Expect
+ assert global_object.registry[base_object.unique_name] == base_object
+ assert global_object.registry[parameter_object.unique_name] == parameter_object
+
+ def test_getitem_not_found(self, clear):
+ """Test getting item by non-existent key"""
+ # When/Then
+ with pytest.raises(KeyError):
+ global_object.registry['non_existent']
+
+ def test_contains_len_iter(self, clear, base_object, parameter_object):
+ # When Then Expect
+ assert base_object.unique_name in global_object.registry
+ assert 'non_existent' not in global_object.registry
+ assert len(global_object.registry) == 2
+ assert list(global_object.registry) == [
+ base_object.unique_name,
+ parameter_object.unique_name,
+ ]
+
+ def test_iter_survives_collection_mid_loop(self):
+ """Iteration uses a snapshot, so collecting an object mid-loop is safe."""
+ # Given
+ test_registry = ObjectRegistry()
+ objs = [_Named('a'), _Named('b')]
+ for obj in objs:
+ test_registry.add(obj)
+ del obj
+
+ # When
+ seen = []
+ for name in test_registry:
+ seen.append(name)
+ objs.clear()
+ gc.collect()
+
+ # Then
+ assert seen == ['a', 'b']
+ assert len(test_registry) == 0
+
+ @pytest.mark.parametrize(
+ 'cls, kwargs', [(EasyBase, {'display_name': 'test'}), (Parameter, {'value': 2.0})]
+ )
+ def test_identical_unique_names_exception(self, clear, cls, kwargs):
+ # When
+ test_obj = cls(unique_name='test', **kwargs)
+ # Then Expect
+ with pytest.raises(ValueError):
+ test_obj2 = cls(unique_name='test', **kwargs)
+
+ def test_add_duplicate_name_error(self, clear, base_object):
+ """Test that adding an object with a duplicate name raises error"""
+ # When/Then
+ with pytest.raises(ValueError, match='already exists'):
+ # Try to add another object with same unique_name
+ duplicate_obj = EasyBase(display_name='duplicate', unique_name=base_object.unique_name)
+
+ def test_unique_name_change_replaces_registry_entry(
+ self, clear, base_object, parameter_object
+ ):
+ # When
+ assert global_object.registry['EasyBase_0'] == base_object
+ assert global_object.registry['Parameter_0'] == parameter_object
+ # Then
+ base_object.unique_name = 'test3'
+ parameter_object.unique_name = 'test4'
+ # Expect
+ assert 'EasyBase_0' not in global_object.registry
+ assert 'Parameter_0' not in global_object.registry
+ assert global_object.registry['test3'] == base_object
+ assert global_object.registry['test4'] == parameter_object
+
+ def test_unique_name_change_to_taken_name_keeps_old_entry(
+ self, clear, base_object, parameter_object
+ ):
+ # When Then
+ with pytest.raises(ValueError, match='already exists'):
+ base_object.unique_name = parameter_object.unique_name
+ # Expect
+ assert base_object.unique_name == 'EasyBase_0'
+ assert global_object.registry['EasyBase_0'] is base_object
+ assert global_object.registry['Parameter_0'] is parameter_object
+
+ def test_unique_name_change_to_same_name(self, clear, base_object):
+ # When
+ base_object.unique_name = 'EasyBase_0'
+ # Then
+ assert global_object.registry['EasyBase_0'] is base_object
+ assert len(global_object.registry) == 1
+
+ def test_unique_name_reusable_after_rename(self, clear):
+ # When
+ first = EasyBase(unique_name='original')
+ first.unique_name = 'renamed'
+ # Then
+ second = EasyBase(unique_name='original')
+ # Expect
+ assert second.unique_name == 'original'
+ assert global_object.registry['original'] is second
+ assert global_object.registry['renamed'] is first
+
+ def test_discard(self, clear, base_object):
+ """Test discarding a name completely"""
+ # Given
+ unique_name = base_object.unique_name
+ assert unique_name in global_object.registry
+
+ # When
+ global_object.registry.discard(unique_name)
+
+ # Then
+ assert unique_name not in global_object.registry
+ assert base_object not in global_object.registry.objects()
+
+ def test_discard_nonexistent_key(self, clear):
+ """Test that discard handles nonexistent key gracefully."""
+ # When/Then - Should not raise error
+ global_object.registry.discard('nonexistent_key')
+
+ def test_registry_repr(self, clear, base_object, parameter_object):
+ """Test registry string representation"""
+ # When
+ repr_str = str(global_object.registry)
+
+ # Then
+ assert 'ObjectRegistry object' in repr_str
+ assert '2' in repr_str # Should show entry count
+
+ def test_registry_initialization(self):
+ """Test ObjectRegistry initialization"""
+ # When
+ test_registry = ObjectRegistry()
+
+ # Then
+ assert len(test_registry) == 0
+
+ def test_discard_then_reuse_name(self):
+ """Discarding a reused name removes the new object, even after the old one is collected."""
+ # Given
+ test_registry = ObjectRegistry()
+ obj_a = _Named('n')
+ test_registry.add(obj_a)
+ test_registry.discard('n')
+ obj_b = _Named('n')
+ test_registry.add(obj_b)
+
+ # When
+ del obj_a
+ gc.collect()
+ test_registry.discard('n')
+
+ # Then
+ assert 'n' not in test_registry
+ assert obj_b not in test_registry.objects()
+
+ def test_clear_then_reuse_name(self):
+ """Discarding a name reused after clearing removes the new object."""
+ # Given
+ test_registry = ObjectRegistry()
+ obj_a = _Named('n')
+ test_registry.add(obj_a)
+ test_registry.clear()
+ obj_b = _Named('n')
+ test_registry.add(obj_b)
+
+ # When
+ del obj_a
+ gc.collect()
+ test_registry.discard('n')
+
+ # Then
+ assert 'n' not in test_registry
+ assert obj_b not in test_registry.objects()
+
+ def test_objects_contains_live_objects_only(self):
+ """Objects that have been collected are not returned."""
+ # Given
+ test_registry = ObjectRegistry()
+ kept = _Named('kept')
+ released = _Named('released')
+ test_registry.add(kept)
+ test_registry.add(released)
+
+ # When
+ del released
+ gc.collect()
+
+ # Then
+ assert test_registry.objects() == [kept]
+
+ def test_objects_lists_renamed_object_once(self, clear, base_object):
+ """A renamed object drops its old name, so it is returned once."""
+ # When
+ base_object.unique_name = 'renamed'
+
+ # Then
+ assert global_object.registry.objects() == [base_object]
+ assert global_object.registry.names() == ['renamed']
diff --git a/tests/unit/global_object/test_undo_redo.py b/tests/unit/global_object/test_undo_redo.py
deleted file mode 100644
index 0171a0e6..00000000
--- a/tests/unit/global_object/test_undo_redo.py
+++ /dev/null
@@ -1,449 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import math
-
-import numpy as np
-import pytest
-
-from easyscience import DescriptorNumber
-from easyscience import Fitter
-from easyscience import ObjBase
-from easyscience import Parameter
-from easyscience.base_classes import CollectionBase
-from easyscience.variable import DescriptorBool
-from easyscience.variable import DescriptorStr
-
-
-def createSingleObjs(idx):
- alphabet = 'abcdefghijklmnopqrstuvwxyz'
- reps = math.floor(idx / len(alphabet)) + 1
- name = alphabet[idx % len(alphabet)] * reps
- if idx % 2:
- return Parameter(name, idx, unit='m/s')
- else:
- return DescriptorNumber(name, idx, unit='m/s')
-
-
-def createParam(option):
- return pytest.param(option, id=option[0])
-
-
-def doUndoRedo(obj, attr, future, additional=''):
- from easyscience import global_object
-
- global_object.stack.enabled = True
- e = False
-
- def getter(_obj, _attr):
- value = getattr(_obj, _attr)
- if additional:
- value = getattr(value, additional)
- return value
-
- try:
- previous = getter(obj, attr)
- if attr == 'unit' and hasattr(obj, 'convert_unit'):
- obj.convert_unit(future)
- else:
- setattr(obj, attr, future)
- assert getter(obj, attr) == future
- assert global_object.stack.canUndo()
- global_object.stack.undo()
- assert getter(obj, attr) == previous
- assert global_object.stack.canRedo()
- global_object.stack.redo()
- assert getter(obj, attr) == future
- except Exception as err:
- e = err
- finally:
- global_object.stack.enabled = False
- return e
-
-
-@pytest.mark.parametrize(
- 'test',
- [
- createParam(option)
- for option in [
- ('value', 500),
- ('error', 5),
- ('unit', 'km/s'),
- ('display_name', 'boom'),
- ]
- ],
-)
-def test_DescriptorNumberUndoRedo(test):
- obj = DescriptorNumber('DescriptorNumber', 1, unit='m/s')
- attr = test[0]
- value = test[1]
-
- e = doUndoRedo(obj, attr, value)
- assert not e
-
-
-def test_DescriptorBoolUndoRedo():
- obj = DescriptorBool('DescriptorBool', False)
- attr = 'value'
- value = True
-
- e = doUndoRedo(obj, attr, value)
- assert not e
-
- obj = DescriptorBool('DescriptorBool', True)
- attr = 'value'
- value = False
-
- e = doUndoRedo(obj, attr, value)
- assert not e
-
-
-def test_DescriptorStrUndoRedo():
- obj = DescriptorStr('DescriptorStr', 'Foo')
- attr = 'value'
- value = 'Bar'
-
- e = doUndoRedo(obj, attr, value)
- assert not e
-
-
-@pytest.mark.parametrize(
- 'test',
- [
- createParam(option)
- for option in [
- ('value', 500),
- ('error', 5),
- ('unit', 'km/s'),
- ('display_name', 'boom'),
- ('fixed', False),
- ('max', 505),
- ('min', -1),
- ]
- ],
-)
-def test_ParameterUndoRedo(test):
- obj = Parameter('Parameter', 1, unit='m/s')
- attr = test[0]
- value = test[1]
-
- e = doUndoRedo(obj, attr, value)
- assert not e
-
-
-def test_Parameter_Bounds_UndoRedo():
- from easyscience import global_object
-
- global_object.stack.enabled = True
- parameter = Parameter('test', 1)
- assert parameter.min == -np.inf
- assert parameter.max == np.inf
-
- parameter.min = 0
- parameter.max = 2
- assert parameter.min == 0
- assert parameter.max == 2
-
- global_object.stack.undo()
- global_object.stack.undo()
- assert parameter.min == -np.inf
- assert parameter.max == np.inf
-
-
-def test_ObjBaseUndoRedo():
- objs = {obj.name: obj for obj in [createSingleObjs(idx) for idx in range(5)]}
- name = 'test'
- obj = ObjBase(name, **objs)
- name2 = 'best'
-
- # Test name
- # assert not doUndoRedo(obj, 'name', name2)
-
- # Test setting value
- for b_obj in objs.values():
- e = doUndoRedo(obj, b_obj.name, b_obj.value + 1, 'value')
- assert not e
-
-
-def test_CollectionBaseUndoRedo():
- objs = [createSingleObjs(idx) for idx in range(5)]
- name = 'test'
- obj = CollectionBase(name, *objs)
- name2 = 'best'
-
- # assert not doUndoRedo(obj, 'name', name2)
-
- from easyscience import global_object
-
- global_object.stack.enabled = True
-
- original_length = len(obj)
- p = Parameter('slip_in', 50)
- idx = 2
- obj.insert(idx, p)
- assert len(obj) == original_length + 1
- objs.insert(idx, p)
- for item, obj_r in zip(obj, objs):
- assert item == obj_r
-
- # Test inserting items
- global_object.stack.undo()
- assert len(obj) == original_length
- _ = objs.pop(idx)
- for item, obj_r in zip(obj, objs):
- assert item == obj_r
- global_object.stack.redo()
- assert len(obj) == original_length + 1
- objs.insert(idx, p)
- for item, obj_r in zip(obj, objs):
- assert item == obj_r
-
- # Test Del Items
- del obj[idx]
- del objs[idx]
- assert len(obj) == original_length
- for item, obj_r in zip(obj, objs):
- assert item == obj_r
- global_object.stack.undo()
- assert len(obj) == original_length + 1
- objs.insert(idx, p)
- for item, obj_r in zip(obj, objs):
- assert item == obj_r
- del objs[idx]
- global_object.stack.redo()
- assert len(obj) == original_length
- for item, obj_r in zip(obj, objs):
- assert item == obj_r
-
- # Test Place Item
- old_item = objs[idx]
- objs[idx] = p
- obj[idx] = p
- assert len(obj) == original_length
- for item, obj_r in zip(obj, objs):
- assert item == obj_r
- global_object.stack.undo()
- for i in range(len(obj)):
- if i == idx:
- item = old_item
- else:
- item = objs[i]
- assert obj[i] == item
- global_object.stack.redo()
- for item, obj_r in zip(obj, objs):
- assert item == obj_r
-
- global_object.stack.enabled = False
-
-
-def test_UndoRedoMacros():
- items = [createSingleObjs(idx) for idx in range(5)]
- offset = 5
- undo_text = 'test_macro'
- from easyscience import global_object
-
- global_object.stack.enabled = True
- global_object.stack.beginMacro(undo_text)
- values = [item.value for item in items]
-
- for item, value in zip(items, values):
- item.value = value + offset
- global_object.stack.endMacro()
-
- for item, old_value in zip(items, values):
- assert item.value == old_value + offset
- assert global_object.stack.undoText() == undo_text
-
- global_object.stack.undo()
-
- for item, old_value in zip(items, values):
- assert item.value == old_value
- assert global_object.stack.redoText() == undo_text
-
- global_object.stack.redo()
- for item, old_value in zip(items, values):
- assert item.value == old_value + offset
-
-
-@pytest.mark.parametrize('fit_engine', ['LMFit', 'Bumps', 'DFO'])
-def test_fittingUndoRedo(fit_engine):
- m_value = 6
- c_value = 2
- x = np.linspace(-5, 5, 100)
- weights = np.ones_like(x)
- dy = np.random.rand(*x.shape)
-
- class Line(ObjBase):
- def __init__(self, m: Parameter, c: Parameter):
- super(Line, self).__init__('basic_line', m=m, c=c)
-
- @classmethod
- def default(cls):
- m = Parameter('m', m_value)
- c = Parameter('c', c_value)
- return cls(m=m, c=c)
-
- @classmethod
- def from_pars(cls, m_value: float, c_value: float):
- m = Parameter('m', m_value)
- c = Parameter('c', c_value)
- return cls(m=m, c=c)
-
- def __call__(self, x: np.ndarray) -> np.ndarray:
- return self.m.value * x + self.c.value
-
- l1 = Line.default()
- m_sp = 4
- c_sp = -3
-
- l2 = Line.from_pars(m_sp, c_sp)
- l2.m.fixed = False
- l2.c.fixed = False
-
- y = l1(x) + 0.125 * (dy - 0.5)
-
- f = Fitter(l2, l2)
- try:
- f.switch_minimizer(fit_engine)
- except AttributeError:
- pytest.skip(f'{fit_engine} is not installed')
-
- from easyscience import global_object
-
- global_object.stack.enabled = True
- res = f.fit(x, y, weights=weights)
-
- # assert l1.c.value == pytest.approx(l2.c.value, rel=l2.c.error * 3)
- # assert l1.m.value == pytest.approx(l2.m.value, rel=l2.m.error * 3)
- assert global_object.stack.undoText() == 'Fitting routine'
-
- global_object.stack.undo()
- assert l2.m.value == m_sp
- assert l2.c.value == c_sp
- assert global_object.stack.redoText() == 'Fitting routine'
-
- global_object.stack.redo()
- assert l2.m.value == res.p[f'p{l2.m.unique_name}']
- assert l2.c.value == res.p[f'p{l2.c.unique_name}']
-
-
-# TODO: Check if this test is needed
-# @pytest.mark.parametrize('math_funcs', [pytest.param([Parameter.__add__, float.__add__], id='Addition'),
-# pytest.param([Parameter.__sub__, float.__sub__], id='Subtraction')])
-# def test_parameter_maths_basic(math_funcs):
-# a = 1.0
-# b = 2.0
-# sa = 0.1
-# sb = 0.2
-
-# p_fun = math_funcs[0]
-# f_fun = math_funcs[1]
-
-# result_value = f_fun(a, b)
-# result_error = (sa ** 2 + sb ** 2) ** 0.5
-
-# from easyscience import global_object
-# global_object.stack.enabled = True
-
-# # Perform basic test
-# p1 = Parameter('a', a)
-# p2 = Parameter('b', b)
-
-# p1 = p_fun(p1, p2)
-
-# assert p1.value == result_value
-# global_object.stack.undo()
-# assert p1.value == a
-# global_object.stack.redo()
-# assert p1.value == result_value
-
-# # Perform basic + error
-# p1 = Parameter('a', a, error=sa)
-# p2 = Parameter('b', b, error=sb)
-# p1 = p_fun(p1, p2)
-# assert p1.value == result_value
-# assert p1.error == result_error
-# global_object.stack.undo()
-# assert p1.value == a
-# assert p1.error == sa
-# global_object.stack.redo()
-# assert p1.value == result_value
-# assert p1.error == result_error
-
-# # Perform basic + units
-# p1 = Parameter('a', a, error=sa, units='m/s')
-# p2 = Parameter('b', b, error=sb, units='m/s')
-# p1 = p_fun(p1, p2)
-# assert p1.value == result_value
-# assert p1.error == result_error
-# assert str(p1.unit) == 'meter / second'
-# global_object.stack.undo()
-# assert p1.value == a
-# assert p1.error == sa
-# assert str(p1.unit) == 'meter / second'
-# global_object.stack.redo()
-# assert p1.value == result_value
-# assert p1.error == result_error
-# assert str(p1.unit) == 'meter / second'
-
-
-# @pytest.mark.parametrize('math_funcs', [pytest.param([Parameter.__imul__, float.__mul__,
-# 'meter ** 2 / second ** 2'], id='Multiplication'),
-# pytest.param([Parameter.__itruediv__, float.__truediv__,
-# 'dimensionless'], id='Division')])
-# def test_parameter_maths_advanced(math_funcs):
-# a = 4.0
-# b = 2.0
-# sa = 0.1
-# sb = 0.2
-# unit = 'meter / second'
-
-# p_fun = math_funcs[0]
-# f_fun = math_funcs[1]
-# u_str = math_funcs[2]
-
-# result_value = f_fun(a, b)
-# result_error = ((sa / a) ** 2 + (sb / b) ** 2) ** 0.5 * result_value
-
-# from easyscience import global_object
-# global_object.stack.enabled = True
-
-# # Perform basic test
-# p1 = Parameter('a', a)
-# p2 = Parameter('b', b)
-
-# p1 = p_fun(p1, p2)
-# assert p1.value == result_value
-# global_object.stack.undo()
-# assert p1.value == a
-# global_object.stack.redo()
-# assert p1.value == result_value
-
-# # Perform basic + error
-# p1 = Parameter('a', a, error=sa)
-# p2 = Parameter('b', b, error=sb)
-# p1 = p_fun(p1, p2)
-# assert p1.value == result_value
-# assert p1.error == result_error
-# global_object.stack.undo()
-# assert p1.value == a
-# assert p1.error == sa
-# global_object.stack.redo()
-# assert p1.value == result_value
-# assert p1.error == result_error
-
-# # Perform basic + units
-# p1 = Parameter('a', a, error=sa, units=unit)
-# p2 = Parameter('b', b, error=sb, units=unit)
-# p1 = p_fun(p1, p2)
-# assert p1.value == result_value
-# assert p1.error == result_error
-# assert str(p1.unit) == u_str
-# global_object.stack.undo()
-# assert p1.value == a
-# assert p1.error == sa
-# assert str(p1.unit) == unit
-# global_object.stack.redo()
-# assert p1.value == result_value
-# assert p1.error == result_error
-# assert str(p1.unit) == u_str
diff --git a/tests/unit/global_object/test_undo_redo_comprehensive.py b/tests/unit/global_object/test_undo_redo_comprehensive.py
deleted file mode 100644
index 05264c1b..00000000
--- a/tests/unit/global_object/test_undo_redo_comprehensive.py
+++ /dev/null
@@ -1,705 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from unittest.mock import MagicMock
-from unittest.mock import patch
-
-import pytest
-
-from easyscience.global_object.undo_redo import CommandHolder
-from easyscience.global_object.undo_redo import DictStack
-from easyscience.global_object.undo_redo import DictStackReCreate
-from easyscience.global_object.undo_redo import FunctionStack
-from easyscience.global_object.undo_redo import NotarizedDict
-from easyscience.global_object.undo_redo import PropertyStack
-from easyscience.global_object.undo_redo import UndoCommand
-from easyscience.global_object.undo_redo import UndoStack
-from easyscience.global_object.undo_redo import dict_stack_deco
-from easyscience.global_object.undo_redo import property_stack
-
-
-class MockUndoCommand(UndoCommand):
- """Mock implementation for testing"""
-
- def __init__(self, obj, text=None):
- super().__init__(obj)
- if text:
- self.text = text
- self.undo_called = False
- self.redo_called = False
-
- def undo(self):
- self.undo_called = True
-
- def redo(self):
- self.redo_called = True
-
-
-class TestUndoCommand:
- """Test abstract UndoCommand base class"""
-
- def test_init(self):
- """Test UndoCommand initialization"""
- obj = MagicMock()
- cmd = MockUndoCommand(obj)
-
- assert cmd._obj is obj
- assert cmd._text is None
-
- def test_text_property(self):
- """Test text property getter/setter"""
- cmd = MockUndoCommand(MagicMock())
-
- # Test getter
- assert cmd.text is None
-
- # Test setter
- cmd.text = 'test text'
- assert cmd.text == 'test text'
-
- def test_abstract_methods(self):
- """Test that abstract methods are implemented"""
- cmd = MockUndoCommand(MagicMock())
-
- # Should not raise NotImplementedError
- cmd.undo()
- cmd.redo()
- assert cmd.undo_called
- assert cmd.redo_called
-
-
-class TestCommandHolder:
- """Test CommandHolder class"""
-
- def test_init_default(self):
- """Test CommandHolder initialization"""
- holder = CommandHolder()
- assert len(holder) == 0
- assert holder.text == ''
- assert not holder.is_macro
-
- def test_init_with_text(self):
- """Test CommandHolder initialization with text"""
- holder = CommandHolder('test text')
- assert holder._text == 'test text'
-
- def test_append_and_pop(self):
- """Test appending and popping commands"""
- holder = CommandHolder()
- cmd = MockUndoCommand(MagicMock())
-
- holder.append(cmd)
- assert len(holder) == 1
- assert holder.current is cmd
-
- popped = holder.pop()
- assert popped is cmd
- assert len(holder) == 0
-
- def test_iteration(self):
- """Test iteration over commands"""
- holder = CommandHolder()
- cmd1 = MockUndoCommand(MagicMock(), 'cmd1')
- cmd2 = MockUndoCommand(MagicMock(), 'cmd2')
-
- holder.append(cmd1)
- holder.append(cmd2)
-
- commands = list(holder)
- assert len(commands) == 2
- assert cmd2 in commands # Last appended should be first (LIFO)
-
- def test_is_macro_property(self):
- """Test is_macro property"""
- holder = CommandHolder()
- assert not holder.is_macro
-
- holder.append(MockUndoCommand(MagicMock()))
- assert not holder.is_macro
-
- holder.append(MockUndoCommand(MagicMock()))
- assert holder.is_macro
-
- def test_text_property_with_commands(self):
- """Test text property with commands"""
- holder = CommandHolder()
- cmd = MockUndoCommand(MagicMock(), 'command text')
- holder.append(cmd)
-
- assert holder.text == 'command text'
-
- def test_text_property_override(self):
- """Test text property with override"""
- holder = CommandHolder('override text')
- cmd = MockUndoCommand(MagicMock(), 'command text')
- holder.append(cmd)
-
- assert holder.text == 'override text'
-
-
-class TestUndoStack:
- """Test UndoStack class"""
-
- @pytest.fixture
- def stack(self):
- """Create a fresh UndoStack for each test"""
- return UndoStack()
-
- def test_init_default(self, stack):
- """Test UndoStack initialization"""
- assert stack.enabled is False
- assert len(stack.history) == 0
- assert len(stack.future) == 0
- assert not stack._macro_running
- assert not stack._command_running
-
- def test_init_with_max_history(self):
- """Test UndoStack initialization with max history"""
- stack = UndoStack(max_history=5)
- assert stack._max_history == 5
- assert stack.history.maxlen == 5
- assert stack.future.maxlen == 5
-
- def test_enabled_property(self, stack):
- """Test enabled property getter/setter"""
- assert not stack.enabled
-
- stack.enabled = True
- assert stack.enabled
-
- stack.enabled = False
- assert not stack.enabled
-
- def test_enabled_setter_with_running_macro(self, stack):
- """Test that setting enabled=False ends running macro"""
- stack.enabled = True
- stack.beginMacro('test')
- assert stack._macro_running
-
- stack.enabled = False
- assert not stack._macro_running
-
- def test_force_state(self, stack):
- """Test force_state method"""
- stack.force_state(True)
- assert stack.enabled
-
- stack.force_state(False)
- assert not stack.enabled
-
- def test_push_disabled_stack(self, stack):
- """Test pushing command to disabled stack"""
- cmd = MockUndoCommand(MagicMock())
-
- stack.push(cmd)
-
- assert cmd.redo_called
- assert len(stack.history) == 0
-
- def test_push_enabled_stack(self, stack):
- """Test pushing command to enabled stack"""
- stack.enabled = True
- cmd = MockUndoCommand(MagicMock())
-
- stack.push(cmd)
-
- assert cmd.redo_called
- assert len(stack.history) == 1
- assert len(stack.future) == 0
-
- def test_push_with_macro(self, stack):
- """Test pushing command during macro"""
- stack.enabled = True
- stack.beginMacro('test macro')
-
- cmd1 = MockUndoCommand(MagicMock())
- cmd2 = MockUndoCommand(MagicMock())
-
- stack.push(cmd1)
- stack.push(cmd2)
-
- assert len(stack.history) == 1 # Only the macro holder
- assert len(stack.history[0]) == 2 # Two commands in the macro
-
- def test_push_clears_future(self, stack):
- """Test that push clears future stack"""
- stack.enabled = True
- cmd1 = MockUndoCommand(MagicMock())
- cmd2 = MockUndoCommand(MagicMock())
-
- stack.push(cmd1)
- stack.undo()
- assert len(stack.future) == 1
-
- stack.push(cmd2)
- assert len(stack.future) == 0
-
- def test_pop(self, stack):
- """Test popping last command"""
- stack.enabled = True
- cmd = MockUndoCommand(MagicMock())
-
- stack.push(cmd)
- popped = stack.pop()
-
- assert popped is cmd
- assert len(stack.history) == 0
-
- def test_pop_from_macro(self, stack):
- """Test popping from macro"""
- stack.enabled = True
- stack.beginMacro('test')
-
- cmd1 = MockUndoCommand(MagicMock())
- cmd2 = MockUndoCommand(MagicMock())
-
- stack.push(cmd1)
- stack.push(cmd2)
-
- popped = stack.pop()
- assert popped is cmd2
- assert len(stack.history[0]) == 1 # One command left in macro
-
- def test_clear(self, stack):
- """Test clearing the stack"""
- stack.enabled = True
- stack.push(MockUndoCommand(MagicMock()))
- stack.beginMacro('test')
-
- stack.clear()
-
- assert len(stack.history) == 0
- assert len(stack.future) == 0
- assert not stack._macro_running
-
- def test_undo_success(self, stack):
- """Test successful undo operation"""
- stack.enabled = True
- cmd = MockUndoCommand(MagicMock())
-
- stack.push(cmd)
- stack.undo()
-
- assert cmd.undo_called
- assert len(stack.history) == 0
- assert len(stack.future) == 1
-
- def test_undo_cannot_undo(self, stack):
- """Test undo when cannot undo"""
- assert not stack.canUndo()
- stack.undo() # Should not raise error
-
- def test_undo_with_exception(self, stack):
- """Test undo with exception in command"""
- stack.enabled = True
-
- class FailingCommand(UndoCommand):
- def undo(self):
- raise Exception('test error')
-
- def redo(self):
- pass
-
- cmd = FailingCommand(MagicMock())
- stack.push(cmd)
-
- # Should not raise, just print error
- stack.undo()
-
- def test_redo_success(self, stack):
- """Test successful redo operation"""
- stack.enabled = True
- cmd = MockUndoCommand(MagicMock())
-
- stack.push(cmd)
- stack.undo()
- stack.redo()
-
- assert cmd.redo_called
- assert len(stack.history) == 1
- assert len(stack.future) == 0
-
- def test_redo_cannot_redo(self, stack):
- """Test redo when cannot redo"""
- assert not stack.canRedo()
- stack.redo() # Should not raise error
-
- def test_macro_operations(self, stack):
- """Test macro begin/end operations"""
- stack.enabled = True
-
- stack.beginMacro('test macro')
- assert stack._macro_running
-
- cmd1 = MockUndoCommand(MagicMock())
- cmd2 = MockUndoCommand(MagicMock())
- stack.push(cmd1)
- stack.push(cmd2)
-
- stack.endMacro()
- assert not stack._macro_running
-
- # Undo should undo both commands
- stack.undo()
- assert cmd1.undo_called
- assert cmd2.undo_called
-
- def test_macro_nested_error(self, stack):
- """Test error on nested macro"""
- stack.enabled = True
- stack.beginMacro('test1')
-
- with pytest.raises(AssertionError, match='already running'):
- stack.beginMacro('test2')
-
- def test_end_macro_without_begin_error(self, stack):
- """Test error on ending macro without begin"""
- stack.enabled = True
-
- with pytest.raises(AssertionError, match='not running'):
- stack.endMacro()
-
- def test_can_undo_redo_states(self, stack):
- """Test canUndo/canRedo state checking"""
- assert not stack.canUndo()
- assert not stack.canRedo()
-
- stack.enabled = True
- cmd = MockUndoCommand(MagicMock())
- stack.push(cmd)
-
- assert stack.canUndo()
- assert not stack.canRedo()
-
- stack.undo()
- assert not stack.canUndo()
- assert stack.canRedo()
-
- def test_can_undo_redo_during_macro(self, stack):
- """Test canUndo/canRedo during macro"""
- stack.enabled = True
- stack.push(MockUndoCommand(MagicMock()))
-
- stack.beginMacro('test')
- assert not stack.canUndo() # Cannot undo during macro
- assert not stack.canRedo() # Cannot redo during macro
-
- def test_undo_redo_text(self, stack):
- """Test undo/redo text retrieval"""
- stack.enabled = True
- cmd = MockUndoCommand(MagicMock(), 'test command')
-
- stack.push(cmd)
- assert stack.undoText() == 'test command'
- assert stack.redoText() == ''
-
- stack.undo()
- assert stack.undoText() == ''
- assert stack.redoText() == 'test command'
-
-
-class TestPropertyStack:
- """Test PropertyStack command"""
-
- def test_init(self):
- """Test PropertyStack initialization"""
- parent = MagicMock()
- func = MagicMock()
- old_value = 10
- new_value = 20
-
- cmd = PropertyStack(parent, func, old_value, new_value)
-
- assert cmd._parent is parent
- assert cmd._set_func is func
- assert cmd._old_value == old_value
- assert cmd._new_value == new_value
- assert '10' in cmd.text and '20' in cmd.text
-
- def test_init_with_custom_text(self):
- """Test PropertyStack with custom text"""
- cmd = PropertyStack(MagicMock(), MagicMock(), 10, 20, 'custom text')
- assert cmd.text == 'custom text'
-
- def test_undo_redo(self):
- """Test PropertyStack undo/redo"""
- parent = MagicMock()
- func = MagicMock()
-
- cmd = PropertyStack(parent, func, 10, 20)
-
- cmd.undo()
- func.assert_called_once_with(parent, 10)
-
- func.reset_mock()
- cmd.redo()
- func.assert_called_once_with(parent, 20)
-
-
-class TestFunctionStack:
- """Test FunctionStack command"""
-
- def test_init(self):
- """Test FunctionStack initialization"""
- parent = MagicMock()
- set_func = MagicMock()
- unset_func = MagicMock()
-
- cmd = FunctionStack(parent, set_func, unset_func)
-
- assert cmd._parent is parent
- assert cmd._old_fn is set_func
- assert cmd._new_fn is unset_func
-
- def test_undo_redo(self):
- """Test FunctionStack undo/redo"""
- set_func = MagicMock()
- unset_func = MagicMock()
-
- cmd = FunctionStack(MagicMock(), set_func, unset_func)
-
- cmd.undo()
- unset_func.assert_called_once()
-
- cmd.redo()
- set_func.assert_called_once()
-
-
-class TestNotarizedDict:
- """Test NotarizedDict class"""
-
- def test_init(self):
- """Test NotarizedDict initialization"""
- nd = NotarizedDict(a=1, b=2)
-
- assert nd['a'] == 1
- assert nd['b'] == 2
- assert not nd._stack_enabled
-
- def test_setitem_without_stack(self):
- """Test setitem without stack enabled"""
- nd = NotarizedDict()
- nd['key'] = 'value'
-
- assert nd['key'] == 'value'
-
- def test_delitem_without_stack(self):
- """Test delitem without stack enabled"""
- nd = NotarizedDict(key='value')
- del nd['key']
-
- assert 'key' not in nd
-
- def test_reorder_without_stack(self):
- """Test reorder without stack enabled"""
- nd = NotarizedDict(a=1, b=2)
- nd.reorder(b=2, a=1)
-
- assert list(nd.keys()) == ['b', 'a']
-
- def test_repr(self):
- """Test string representation"""
- nd = NotarizedDict(a=1)
- repr_str = repr(nd)
-
- assert 'NotarizedDict' in repr_str
- assert "'a': 1" in repr_str
-
-
-class TestDictStack:
- """Test DictStack command"""
-
- def test_init_deletion(self):
- """Test DictStack initialization for deletion"""
- nd = NotarizedDict(key='value')
- cmd = DictStack(nd, 'key')
-
- assert cmd._deletion is True
- assert cmd._creation is False
- assert cmd._key == 'key'
- assert cmd._old_value == 'value'
- assert cmd._index == 0
-
- def test_init_creation(self):
- """Test DictStack initialization for creation"""
- nd = NotarizedDict()
- cmd = DictStack(nd, 'key', 'value')
-
- assert cmd._creation is True
- assert cmd._deletion is False
- assert cmd._key == 'key'
- assert cmd._new_value == 'value'
-
- def test_init_modification(self):
- """Test DictStack initialization for modification"""
- nd = NotarizedDict(key='old_value')
- cmd = DictStack(nd, 'key', 'new_value')
-
- assert not cmd._creation
- assert not cmd._deletion
- assert cmd._old_value == 'old_value'
- assert cmd._new_value == 'new_value'
-
- def test_undo_redo_creation(self):
- """Test undo/redo for creation"""
- nd = NotarizedDict()
- cmd = DictStack(nd, 'key', 'value')
-
- # Simulate the initial operation (like UndoStack.push() does)
- cmd.redo()
- assert nd.data['key'] == 'value'
-
- # After creation, undo should delete the key
- cmd.undo()
- assert 'key' not in nd.data
-
- # After undo, redo should create/set new value
- cmd.redo()
- assert nd.data['key'] == 'value'
-
- def test_undo_redo_modification(self):
- """Test undo/redo for modification"""
- nd = NotarizedDict()
- nd['key'] = 'old_value'
- cmd = DictStack(nd, 'key', 'new_value')
-
- # Simulate the initial operation (like UndoStack.push() does)
- cmd.redo()
- assert nd.data['key'] == 'new_value'
-
- # After modification, undo should revert to old value
- cmd.undo()
- assert nd.data['key'] == 'old_value'
-
- # After undo, redo should set new value
- cmd.redo()
- assert nd.data['key'] == 'new_value'
-
- def test_undo_redo_deletion(self):
- """Test undo/redo for deletion"""
- nd = NotarizedDict(key='value')
- cmd = DictStack(nd, 'key')
-
- # Simulate the initial operation (like UndoStack.push() does)
- cmd.redo()
- assert 'key' not in nd.data
-
- # After deletion, undo should restore
- cmd.undo()
- assert nd.data['key'] == 'value'
-
- # After undo, redo should delete
- cmd.redo()
- assert 'key' not in nd.data
-
-
-class TestDictStackReCreate:
- """Test DictStackReCreate command"""
-
- def test_init(self):
- """Test DictStackReCreate initialization"""
- nd = NotarizedDict(a=1, b=2)
- new_dict = {'c': 3, 'd': 4}
-
- cmd = DictStackReCreate(nd, **new_dict)
-
- assert cmd._old_value == {'a': 1, 'b': 2}
- assert cmd._new_value == new_dict
-
- def test_undo_redo(self):
- """Test undo/redo operations"""
- nd = NotarizedDict(a=1, b=2)
- old_data = nd.data.copy()
- new_dict = {'c': 3, 'd': 4}
-
- cmd = DictStackReCreate(nd, **new_dict)
-
- cmd.undo()
- assert nd.data == old_data
-
- cmd.redo()
- assert nd.data == new_dict
-
-
-class TestPropertyStackDecorator:
- """Test property_stack decorator"""
-
- def test_decorator_without_args(self):
- """Test decorator applied without arguments"""
-
- @property_stack
- def test_func(obj, value):
- obj.value = value
-
- assert hasattr(test_func, 'func')
-
- def test_decorator_with_string_arg(self):
- """Test decorator applied with string argument"""
-
- @property_stack('Custom text')
- def test_func(obj, value):
- obj.value = value
-
- assert hasattr(test_func, 'func')
-
- def test_decorator_no_change_no_push(self):
- """Test decorator doesn't push when value unchanged"""
-
- class TestObj:
- def __init__(self):
- self.value = 10
-
- @property_stack
- def value(obj, new_value):
- obj.value = new_value
-
- obj = TestObj()
-
- with patch('easyscience.global_object') as mock_global:
- mock_stack = MagicMock()
- mock_global.stack = mock_stack
-
- value(obj, 10) # Same value
-
- # Should NOT have pushed anything since value didn't change
- mock_stack.push.assert_not_called()
-
-
-class TestDictStackDecorator:
- """Test dict_stack_deco decorator"""
-
- def test_decorator_with_stack_disabled(self):
- """Test decorator when stack is disabled"""
- nd = NotarizedDict()
- nd._stack_enabled = False
-
- @dict_stack_deco
- def test_func(obj, key, value):
- obj[key] = value
-
- # Should call the function directly
- with patch('easyscience.global_object') as mock_global:
- mock_stack = MagicMock()
- mock_global.stack = mock_stack
- test_func(nd, 'key', 'value')
- mock_stack.push.assert_not_called()
-
- def test_decorator_with_stack_enabled(self):
- """Test decorator when stack is enabled"""
- nd = NotarizedDict()
- nd._stack_enabled = True
-
- @dict_stack_deco
- def test_func(obj, key, value):
- pass # Don't actually modify
-
- with patch('easyscience.global_object') as mock_global:
- mock_stack = MagicMock()
- mock_global.stack = mock_stack
-
- test_func(nd, 'key', 'value')
-
- # Should push a DictStack command
- mock_stack.push.assert_called_once()
- args = mock_stack.push.call_args[0]
- assert isinstance(args[0], DictStack)
diff --git a/tests/unit/io/test_serializer_component.py b/tests/unit/io/test_serializer_component.py
index c4e21f49..08cc3405 100644
--- a/tests/unit/io/test_serializer_component.py
+++ b/tests/unit/io/test_serializer_component.py
@@ -19,7 +19,6 @@
'@module': DescriptorNumber.__module__,
'@class': DescriptorNumber.__name__,
'@version': easyscience.__version__,
- 'name': 'test',
'value': 1.0,
'variance': None,
'unit': 'dimensionless',
@@ -34,7 +33,6 @@
'@module': Parameter.__module__,
'@class': Parameter.__name__,
'@version': easyscience.__version__,
- 'name': 'test',
'unit': 'km',
'value': 1.0,
'variance': 0.0,
@@ -71,7 +69,7 @@ def check_dict(check, item):
@pytest.mark.parametrize(**skip_dict)
@pytest.mark.parametrize(**dp_param_dict)
-def test_variable_as_dict_methods(dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip):
+def test_variable_to_dict_methods(dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip):
data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'}
obj = dp_cls(**data_dict)
@@ -84,7 +82,7 @@ def test_variable_as_dict_methods(dp_kwargs: dict, dp_cls: Type[DescriptorNumber
if not isinstance(skip, list):
skip = [skip]
- enc = obj.as_dict(skip=skip)
+ enc = obj.to_dict(skip=skip)
expected_keys = set(dp_kwargs.keys())
obtained_keys = set(enc.keys())
diff --git a/tests/unit/io/test_serializer_dict.py b/tests/unit/io/test_serializer_dict.py
deleted file mode 100644
index 1ddf21ef..00000000
--- a/tests/unit/io/test_serializer_dict.py
+++ /dev/null
@@ -1,121 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-from copy import deepcopy
-from typing import Type
-
-import pytest
-
-from easyscience import DescriptorNumber
-from easyscience import ObjBase
-from easyscience import global_object
-from easyscience.io.serializer_dict import SerializerDict
-
-from .test_serializer_component import check_dict
-from .test_serializer_component import dp_param_dict
-from .test_serializer_component import skip_dict
-
-
-def recursive_remove(d, remove_keys: list) -> dict:
- """
- Remove keys from a dictionary.
- """
- if not isinstance(remove_keys, list):
- remove_keys = [remove_keys]
- if isinstance(d, dict):
- dd = {}
- for k in d.keys():
- if k not in remove_keys:
- dd[k] = recursive_remove(d[k], remove_keys)
- return dd
- else:
- return d
-
-
-########################################################################################################################
-# TESTING ENCODING
-########################################################################################################################
-@pytest.mark.parametrize(**skip_dict)
-@pytest.mark.parametrize(**dp_param_dict)
-def test_variable_SerializerDict(dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip):
- data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'}
-
- obj = dp_cls(**data_dict)
-
- dp_kwargs = deepcopy(dp_kwargs)
-
- if isinstance(skip, str):
- del dp_kwargs[skip]
-
- if not isinstance(skip, list):
- skip = [skip]
-
- enc = obj.encode(skip=skip, encoder=SerializerDict)
-
- expected_keys = set(dp_kwargs.keys())
- obtained_keys = set(enc.keys())
-
- dif = expected_keys.difference(obtained_keys)
-
- assert len(dif) == 0
-
- check_dict(dp_kwargs, enc)
-
-
-########################################################################################################################
-# TESTING DECODING
-########################################################################################################################
-@pytest.mark.parametrize(**dp_param_dict)
-def test_variable_SerializerDict_decode(dp_kwargs: dict, dp_cls: Type[DescriptorNumber]):
- data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'}
-
- obj = dp_cls(**data_dict)
-
- enc = obj.encode(encoder=SerializerDict)
- global_object.map._clear()
- dec = dp_cls.decode(enc, decoder=SerializerDict)
-
- for k in data_dict.keys():
- if hasattr(obj, k) and hasattr(dec, k):
- assert getattr(obj, k) == getattr(dec, k)
- else:
- raise AttributeError(f'{k} not found in decoded object')
-
-
-@pytest.mark.parametrize(**dp_param_dict)
-def test_variable_SerializerDict_from_dict(dp_kwargs: dict, dp_cls: Type[DescriptorNumber]):
- data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'}
-
- obj = dp_cls(**data_dict)
-
- enc = obj.encode(encoder=SerializerDict)
- global_object.map._clear()
- dec = dp_cls.from_dict(enc)
-
- for k in data_dict.keys():
- if hasattr(obj, k) and hasattr(dec, k):
- assert getattr(obj, k) == getattr(dec, k)
- else:
- raise AttributeError(f'{k} not found in decoded object')
-
-
-def test_group_encode():
- d0 = DescriptorNumber('a', 0)
- d1 = DescriptorNumber('b', 1)
-
- from easyscience.base_classes import CollectionBase
-
- b = CollectionBase('test', d0, d1)
- d = b.as_dict()
- assert isinstance(d['data'], list)
-
-
-def test_group_encode2():
- d0 = DescriptorNumber('a', 0)
- d1 = DescriptorNumber('b', 1)
-
- from easyscience.base_classes import CollectionBase
-
- b = ObjBase('outer', b=CollectionBase('test', d0, d1))
- d = b.as_dict()
- assert isinstance(d['b'], dict)
diff --git a/tests/unit/models/__init__.py b/tests/unit/job/__init__.py
similarity index 99%
rename from tests/unit/models/__init__.py
rename to tests/unit/job/__init__.py
index b1990ec8..4e798e20 100644
--- a/tests/unit/models/__init__.py
+++ b/tests/unit/job/__init__.py
@@ -1,3 +1,2 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
-
diff --git a/tests/unit/job/test_job.py b/tests/unit/job/test_job.py
new file mode 100644
index 00000000..950b77cc
--- /dev/null
+++ b/tests/unit/job/test_job.py
@@ -0,0 +1,141 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+import pytest
+
+from easyscience import global_object
+from easyscience.base_classes import ModelBase
+from easyscience.job import AnalysisBase
+from easyscience.job import ExperimentBase
+from easyscience.job import JobBase
+from easyscience.job import TheoreticalModelBase
+
+
+@pytest.fixture(autouse=True)
+def clear():
+ global_object.registry.clear()
+ yield
+ global_object.registry.clear()
+
+
+class Analysis(AnalysisBase):
+ def calculate_theory(self, x, **kwargs):
+ return super().calculate_theory(x, **kwargs)
+
+ def fit(self, x, y, e, **kwargs):
+ return super().fit(x, y, e, **kwargs)
+
+
+class Job(JobBase):
+ @JobBase.theoretical_model.setter
+ def theoretical_model(self, theory):
+ JobBase.theoretical_model.fset(self, theory)
+
+ @JobBase.experiment.setter
+ def experiment(self, experiment):
+ JobBase.experiment.fset(self, experiment)
+
+ @JobBase.analysis.setter
+ def analysis(self, analysis):
+ JobBase.analysis.fset(self, analysis)
+
+ def calculate_theory(self, *args, **kwargs):
+ return super().calculate_theory(*args, **kwargs)
+
+ def fit(self, *args, **kwargs):
+ return super().fit(*args, **kwargs)
+
+
+@pytest.mark.parametrize('cls', [Analysis, ExperimentBase, Job, TheoreticalModelBase])
+def test_job_classes_are_model_bases(cls):
+ obj = cls(display_name='name', unique_name=f'{cls.__name__}_unique')
+
+ assert isinstance(obj, ModelBase)
+ assert obj.display_name == 'name'
+ assert obj.unique_name == f'{cls.__name__}_unique'
+ assert obj.get_all_variables() == []
+
+
+class TestAnalysisBase:
+ def test_defaults(self):
+ analysis = Analysis(display_name='analysis')
+
+ assert analysis.minimizer is None
+ assert str(analysis) == 'Analysis: analysis'
+
+ def test_arguments_are_keyword_only(self):
+ with pytest.raises(TypeError):
+ Analysis('analysis')
+
+ def test_minimizer_setter(self):
+ analysis = Analysis()
+ minimizer = object()
+
+ analysis.minimizer = minimizer
+
+ assert analysis.minimizer is minimizer
+
+ def test_abstract_methods_raise(self):
+ analysis = Analysis()
+ x = np.array([1.0])
+
+ with pytest.raises(NotImplementedError, match='calculate_theory'):
+ analysis.calculate_theory(x)
+ with pytest.raises(NotImplementedError, match='fit'):
+ analysis.fit(x, x, x)
+
+ def test_cannot_instantiate_abstract_base(self):
+ with pytest.raises(TypeError):
+ AnalysisBase()
+
+
+class TestExperimentBase:
+ def test_str(self):
+ assert str(ExperimentBase(display_name='experiment')) == 'Experiment: experiment'
+
+ def test_str_falls_back_to_unique_name(self):
+ experiment = ExperimentBase(unique_name='exp_1')
+
+ assert str(experiment) == 'Experiment: exp_1'
+
+
+class TestTheoreticalModelBase:
+ def test_str_not_implemented(self):
+ with pytest.raises(NotImplementedError):
+ str(TheoreticalModelBase())
+
+
+class TestJobBase:
+ def test_defaults(self):
+ job = Job(display_name='job')
+
+ assert job.theoretical_model is None
+ assert job.experiment is None
+ assert job.analysis is None
+
+ @pytest.mark.parametrize(
+ ('attribute', 'value_factory'),
+ [
+ ('theoretical_model', TheoreticalModelBase),
+ ('experiment', ExperimentBase),
+ ('analysis', Analysis),
+ ],
+ )
+ def test_base_setters_are_abstract(self, attribute, value_factory):
+ job = Job()
+
+ with pytest.raises(NotImplementedError, match='setter not implemented'):
+ setattr(job, attribute, value_factory())
+
+ def test_abstract_methods_raise(self):
+ job = Job()
+
+ with pytest.raises(NotImplementedError, match='calculate_theory'):
+ job.calculate_theory()
+ with pytest.raises(NotImplementedError, match='fit'):
+ job.fit()
+
+ def test_cannot_instantiate_abstract_base(self):
+ with pytest.raises(TypeError):
+ JobBase()
diff --git a/tests/unit/legacy/test_collection_base.py b/tests/unit/legacy/test_collection_base.py
deleted file mode 100644
index 152a0a37..00000000
--- a/tests/unit/legacy/test_collection_base.py
+++ /dev/null
@@ -1,155 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-"""Tests for easyscience.legacy.collection_base.CollectionBase.
-
-These tests cover methods not exercised by the existing
-tests/unit/base_classes/test_collection_base.py suite.
-"""
-
-import logging
-
-import pytest
-
-from easyscience import DescriptorNumber
-from easyscience import Parameter
-from easyscience import global_object
-from easyscience.legacy.collection_base import CollectionBase
-
-
-@pytest.fixture(autouse=True)
-def _clear_map():
- global_object.map._clear()
- yield
- global_object.map._clear()
-
-
-@pytest.fixture
-def setup_pars():
- return {
- 'name': 'test',
- 'par1': Parameter('p1', 0.1, fixed=True),
- 'des1': DescriptorNumber('d1', 0.1),
- 'par2': Parameter('p2', 0.2),
- 'des2': DescriptorNumber('d2', 0.2),
- 'par3': Parameter('p3', 0.3),
- }
-
-
-# ---------------------------------------------------------------------------
-# Deprecation warning on instantiation
-# ---------------------------------------------------------------------------
-
-def test_instantiation_emits_deprecation_warning(caplog: "pytest.LogCaptureFixture"):
- """Instantiating CollectionBase should log a deprecation warning."""
- with caplog.at_level(logging.WARNING, logger='easyscience.legacy'):
- CollectionBase('test')
- assert len(caplog.records) >= 1
- messages = [r.message for r in caplog.records]
- combined = ' '.join(messages)
- assert 'deprecated' in combined.lower()
-
-
-# ---------------------------------------------------------------------------
-# sort
-# ---------------------------------------------------------------------------
-
-def test_sort_ascending(setup_pars):
- """Sort by parameter value in ascending order."""
- name = setup_pars.pop('name')
- coll = CollectionBase(name, **setup_pars)
- coll.sort(mapping=lambda item: item.value, reverse=False)
- values = [item.value for item in coll]
- assert values == sorted(values)
-
-
-def test_sort_descending(setup_pars):
- """Sort by parameter value in descending order."""
- name = setup_pars.pop('name')
- coll = CollectionBase(name, **setup_pars)
- coll.sort(mapping=lambda item: item.value, reverse=True)
- values = [item.value for item in coll]
- assert values == sorted(values, reverse=True)
-
-
-# ---------------------------------------------------------------------------
-# data property
-# ---------------------------------------------------------------------------
-
-def test_data_property(setup_pars):
- """The data property returns a tuple of stored items."""
- name = setup_pars.pop('name')
- coll = CollectionBase(name, **setup_pars)
- d = coll.data
- assert isinstance(d, tuple)
- assert len(d) == 5
- # Items should be the same objects
- for item_from_data, item_from_iter in zip(d, coll):
- assert item_from_data is item_from_iter
-
-
-# ---------------------------------------------------------------------------
-# __setitem__ with an EasyScience object (not a Number)
-# ---------------------------------------------------------------------------
-
-def test_setitem_with_easyscience_object(setup_pars):
- """Replace an item at an index with another EasyScience object."""
- name = setup_pars.pop('name')
- coll = CollectionBase(name, **setup_pars)
-
- n_before = len(coll)
- old_item = coll[0]
- new_item = Parameter('replacement', 99.0)
-
- coll[0] = new_item
-
- assert len(coll) == n_before
- assert coll[0].name == 'replacement'
- assert coll[0].value == 99.0
- # Old item should be removed from the graph
- assert old_item.unique_name not in global_object.map.get_edges(coll)
-
-
-# ---------------------------------------------------------------------------
-# __getitem__ with duplicate names returns a new CollectionBase
-# ---------------------------------------------------------------------------
-
-def test_getitem_duplicate_names_returns_collection(setup_pars):
- """When multiple items share the same name, __getitem__ returns a sub-collection."""
- name = setup_pars.pop('name')
- # Add two items with the same display name
- p1 = Parameter('same_name', 1.0)
- p2 = Parameter('same_name', 2.0)
- coll = CollectionBase(name, p1, p2)
-
- result = coll['same_name']
- assert isinstance(result, CollectionBase)
- assert len(result) == 2
-
-
-def test_getitem_nonexistent_name_raises(setup_pars):
- """Looking up a nonexistent name raises IndexError."""
- name = setup_pars.pop('name')
- coll = CollectionBase(name, **setup_pars)
-
- with pytest.raises(IndexError, match='Given index does not exist'):
- _ = coll['nonexistent']
-
-
-# ---------------------------------------------------------------------------
-# insert method (called by MutableSequence.append, but test edge case)
-# ---------------------------------------------------------------------------
-
-def test_insert_at_specific_index(setup_pars):
- """Insert an item at a specific index (not just via append)."""
- name = setup_pars.pop('name')
- coll = CollectionBase(name, **setup_pars)
-
- n_before = len(coll)
- new_item = Parameter('inserted', 42.0)
-
- coll.insert(2, new_item)
-
- assert len(coll) == n_before + 1
- assert coll[2].name == 'inserted'
- assert coll[2].value == 42.0
diff --git a/tests/unit/legacy/test_dict.py b/tests/unit/legacy/test_dict.py
deleted file mode 100644
index b1990ec8..00000000
--- a/tests/unit/legacy/test_dict.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
diff --git a/tests/unit/legacy/test_json.py b/tests/unit/legacy/test_json.py
deleted file mode 100644
index b1990ec8..00000000
--- a/tests/unit/legacy/test_json.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
diff --git a/tests/unit/legacy/test_obj_base.py b/tests/unit/legacy/test_obj_base.py
deleted file mode 100644
index fc33c424..00000000
--- a/tests/unit/legacy/test_obj_base.py
+++ /dev/null
@@ -1,119 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-"""Tests for easyscience.legacy.obj_base.ObjBase.
-
-These tests cover methods not exercised by the existing
-tests/unit/base_classes/test_obj_base.py suite.
-"""
-
-import logging
-from typing import ClassVar
-
-import pytest
-
-from easyscience import Parameter
-from easyscience import global_object
-from easyscience.legacy.obj_base import ObjBase
-
-
-@pytest.fixture(autouse=True)
-def _clear_map():
- global_object.map._clear()
- yield
- global_object.map._clear()
-
-
-# ---------------------------------------------------------------------------
-# Deprecation warning on instantiation
-# ---------------------------------------------------------------------------
-
-def test_instantiation_emits_deprecation_warning(caplog: "pytest.LogCaptureFixture"):
- """Instantiating ObjBase should log a deprecation warning."""
- with caplog.at_level(logging.WARNING, logger='easyscience.legacy'):
- ObjBase('test')
- assert len(caplog.records) >= 1
- messages = [r.message for r in caplog.records]
- combined = ' '.join(messages)
- assert 'deprecated' in combined.lower()
-
-
-# ---------------------------------------------------------------------------
-# __repr__
-# ---------------------------------------------------------------------------
-
-def test_repr():
- """Verify __repr__ returns the expected format."""
- obj = ObjBase('my_name')
- r = repr(obj)
- assert "ObjBase" in r
- assert "my_name" in r
-
-
-# ---------------------------------------------------------------------------
-# __setattr__ with interface bindings regeneration
-# ---------------------------------------------------------------------------
-
-def test_setattr_with_interface_calls_generate_bindings():
- """When replacing an attribute that has an interface, generate_bindings is called."""
- from unittest.mock import Mock
-
- class A(ObjBase):
- a: ClassVar[Parameter]
-
- def __init__(self, a: Parameter):
- super().__init__('A', a=a)
-
- p1 = Parameter('a', 1.0)
- a = A(p1)
-
- # Attach a mock interface
- mock_iface = Mock()
- a.interface = mock_iface
-
- # Replace the parameter β should trigger generate_bindings via __setattr__
- p2 = Parameter('a', 2.0)
- a.a = p2
-
- mock_iface.generate_bindings.assert_called()
-
-
-# ---------------------------------------------------------------------------
-# __setattr__ without annotation (the else branch)
-# ---------------------------------------------------------------------------
-
-def test_setattr_without_annotation():
- """Setting a non-annotated BasedBase/DescriptorBase attribute updates graph."""
-
- class A(ObjBase):
- def __init__(self, p: Parameter):
- super().__init__('A', p=p)
-
- p1 = Parameter('p', 1.0)
- a = A(p1)
-
- graph = global_object.map
- edges_before = set(graph.get_edges(a))
-
- # Replace the parameter with a new one
- p2 = Parameter('p', 2.0)
- a.p = p2
-
- edges_after = set(graph.get_edges(a))
- assert edges_before != edges_after
- assert p2.unique_name in edges_after
- assert p1.unique_name not in edges_after
-
-
-# ---------------------------------------------------------------------------
-# __setter with DescriptorBase path
-# ---------------------------------------------------------------------------
-
-def test_setter_sets_descriptor_value():
- """When setting a Descriptor via the logged property, the descriptor's value is updated."""
- from easyscience import DescriptorNumber
-
- d = DescriptorNumber('d1', 0.5)
- obj = ObjBase('test', d1=d)
- obj.d1 = 3.14
- assert obj.d1.value == 3.14
diff --git a/tests/unit/legacy/test_xml.py b/tests/unit/legacy/test_xml.py
deleted file mode 100644
index b1990ec8..00000000
--- a/tests/unit/legacy/test_xml.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
diff --git a/tests/unit/models/test_polynomial.py b/tests/unit/models/test_polynomial.py
deleted file mode 100644
index 5376d03e..00000000
--- a/tests/unit/models/test_polynomial.py
+++ /dev/null
@@ -1,192 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import numpy as np
-import pytest
-
-from easyscience import global_object
-from easyscience.base_classes import CollectionBase
-from easyscience.models.polynomial import Polynomial
-from easyscience.variable import Parameter
-
-
-@pytest.fixture
-def clear():
- """Clear global object map before each test."""
- global_object.map._clear()
-
-
-poly_test_cases = (
- (1.0,),
- (
- 1.0,
- 2.0,
- ),
- (1.0, 2.0, 3.0),
- (-1.0, -2.0, -3.0),
- (0.72, 6.48, -0.48),
-)
-
-
-@pytest.mark.parametrize('coo', poly_test_cases)
-def test_Polynomial_pars(clear, coo):
- poly = Polynomial(coefficients=coo)
-
- vals = {coo.value for coo in poly.coefficients}
- assert len(vals.difference(set(coo))) == 0
-
- x = np.linspace(0, 10, 100)
- y = np.polyval(coo, x)
- assert np.allclose(poly(x), y)
-
-
-def test_Polynomial_default_initialization(clear):
- """Test Polynomial with no coefficients."""
- poly = Polynomial(name='test_poly')
-
- assert poly.name == 'test_poly'
- assert len(poly.coefficients) == 0
-
- # Test that calling the polynomial with empty coefficients works
- x = np.array([1, 2, 3])
- result = poly(x)
- assert len(result) == len(x)
-
-
-def test_Polynomial_with_Parameter_objects(clear):
- """Test Polynomial with Parameter objects as coefficients."""
- p0 = Parameter('c0', value=1.0)
- p1 = Parameter('c1', value=2.0)
- p2 = Parameter('c2', value=3.0)
-
- poly = Polynomial(coefficients=[p0, p1, p2])
-
- assert len(poly.coefficients) == 3
- assert poly.coefficients[0].name == 'c0'
- assert poly.coefficients[1].name == 'c1'
- assert poly.coefficients[2].name == 'c2'
- assert poly.coefficients[0].value == 1.0
- assert poly.coefficients[1].value == 2.0
- assert poly.coefficients[2].value == 3.0
-
- # Test evaluation - coefficients passed directly to polyval
- # polyval([1.0, 2.0, 3.0], x) = 1.0*x^2 + 2.0*x + 3.0
- x = np.array([0, 1, 2])
- expected = np.polyval([1.0, 2.0, 3.0], x)
- assert np.allclose(poly(x), expected)
-
-
-def test_Polynomial_with_mixed_coefficients(clear):
- """Test Polynomial with mixed float and Parameter coefficients."""
- p0 = Parameter('c0', value=5.0)
-
- poly = Polynomial(coefficients=[p0, 2.0, 1.0])
-
- assert len(poly.coefficients) == 3
- assert poly.coefficients[0].name == 'c0'
- assert poly.coefficients[1].name == 'c1'
- assert poly.coefficients[2].name == 'c2'
-
- # polyval([5.0, 2.0, 1.0], x) = 5.0*x^2 + 2.0*x + 1.0
- x = np.array([1, 2, 3])
- expected = np.polyval([5.0, 2.0, 1.0], x)
- assert np.allclose(poly(x), expected)
-
-
-def test_Polynomial_with_CollectionBase(clear):
- """Test Polynomial initialized with a CollectionBase."""
- collection = CollectionBase('coeffs')
- collection.append(Parameter('c0', value=1.0))
- collection.append(Parameter('c1', value=2.0))
- collection.append(Parameter('c2', value=3.0))
-
- poly = Polynomial(coefficients=collection)
-
- assert poly.coefficients is collection
- assert len(poly.coefficients) == 3
-
- # polyval([1.0, 2.0, 3.0], x) = 1.0*x^2 + 2.0*x + 3.0
- x = np.array([0, 1, 2])
- expected = np.polyval([1.0, 2.0, 3.0], x)
- assert np.allclose(poly(x), expected)
-
-
-def test_Polynomial_invalid_coefficient_type(clear):
- """Test that invalid coefficient types raise TypeError."""
- with pytest.raises(TypeError, match='Coefficients must be floats or Parameters'):
- Polynomial(coefficients=[1.0, 'invalid', 3.0])
-
- with pytest.raises(TypeError, match='Coefficients must be floats or Parameters'):
- Polynomial(coefficients=[1, 2, 3]) # integers, not floats
-
-
-def test_Polynomial_invalid_coefficients_type(clear):
- """Test that invalid coefficients argument type raises TypeError."""
- # String is iterable, so it will iterate over characters and fail with first error
- with pytest.raises(TypeError, match='Coefficients must be floats or Parameters'):
- Polynomial(coefficients='invalid')
-
- # Integer is not iterable, so it will fail with second error
- with pytest.raises(TypeError, match='coefficients must be a list or a CollectionBase'):
- Polynomial(coefficients=42)
-
-
-def test_Polynomial_repr_no_coefficients(clear):
- """Test __repr__ with no coefficients."""
- poly = Polynomial(name='empty')
-
- repr_str = repr(poly)
- assert 'Polynomial(empty, )' == repr_str
-
-
-def test_Polynomial_repr_one_coefficient(clear):
- """Test __repr__ with one coefficient."""
- poly = Polynomial(coefficients=[5.0])
-
- repr_str = repr(poly)
- assert 'Polynomial(polynomial, 5.0)' == repr_str
-
-
-def test_Polynomial_repr_two_coefficients(clear):
- """Test __repr__ with two coefficients."""
- poly = Polynomial(coefficients=[3.0, 2.0])
-
- repr_str = repr(poly)
- assert 'Polynomial(polynomial, 2.0x + 3.0)' == repr_str
-
-
-def test_Polynomial_repr_three_coefficients(clear):
- """Test __repr__ with three coefficients."""
- poly = Polynomial(coefficients=[1.0, 2.0, 3.0])
-
- repr_str = repr(poly)
- assert 'Polynomial(polynomial, 3.0x^2 + 2.0x + 1.0)' == repr_str
-
-
-def test_Polynomial_repr_with_zero_coefficients(clear):
- """Test __repr__ with some zero coefficients."""
- poly = Polynomial(coefficients=[1.0, 0.0, 3.0, 0.0, 5.0])
-
- repr_str = repr(poly)
- # Zero coefficients for higher powers should be excluded
- assert 'Polynomial(polynomial, 5.0x^4 + 3.0x^2 + 0.0x + 1.0)' == repr_str
-
-
-def test_Polynomial_repr_negative_coefficients(clear):
- """Test __repr__ with negative coefficients."""
- poly = Polynomial(coefficients=[-1.0, -2.0, -3.0])
-
- repr_str = repr(poly)
- assert 'Polynomial(polynomial, -3.0x^2 + -2.0x + -1.0)' == repr_str
-
-
-def test_Polynomial_call_with_args_kwargs(clear):
- """Test that __call__ accepts *args and **kwargs."""
- poly = Polynomial(coefficients=[1.0, 2.0, 3.0])
-
- x = np.array([0, 1, 2])
- # These additional args/kwargs should be ignored
- result = poly(x, 'extra_arg', extra_kwarg='value')
- # polyval([1.0, 2.0, 3.0], x) = 1.0*x^2 + 2.0*x + 3.0
- expected = np.polyval([1.0, 2.0, 3.0], x)
- assert np.allclose(result, expected)
diff --git a/tests/unit/variable/test_descriptor_any_type.py b/tests/unit/variable/test_descriptor_any_type.py
deleted file mode 100644
index 5dc28b58..00000000
--- a/tests/unit/variable/test_descriptor_any_type.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# SPDX-FileCopyrightText: 2026 EasyScience contributors
-# SPDX-License-Identifier: BSD-3-Clause
-
-import numpy as np
-import pytest
-
-from easyscience import global_object
-from easyscience.variable import DescriptorAnyType
-
-
-class TestDescriptorAnyType:
- @pytest.fixture
- def descriptor(self):
- descriptor = DescriptorAnyType(
- name='name',
- value='string',
- description='description',
- url='url',
- display_name='display_name',
- parent=None,
- )
- return descriptor
-
- @pytest.fixture
- def clear(self):
- global_object.map._clear()
-
- def test_init(self, descriptor: DescriptorAnyType):
- # When Then Expect
- assert descriptor._value == 'string'
-
- # From super
- assert descriptor._name == 'name'
- assert descriptor._description == 'description'
- assert descriptor._url == 'url'
- assert descriptor._display_name == 'display_name'
-
- def test_value(self, descriptor: DescriptorAnyType):
- # When Then Expect
- assert descriptor.value == 'string'
-
- @pytest.mark.parametrize(
- 'value', [True, 'new_string', 1.0, np.array([1, 2, 3]), {'key': 'value'}]
- )
- def test_set_value(self, descriptor: DescriptorAnyType, value):
- # When Then
- descriptor.value = value
-
- # Expect
- if isinstance(value, np.ndarray):
- assert np.array_equal(descriptor._value, value)
- else:
- assert descriptor._value == value
-
- @pytest.mark.parametrize(
- 'value, expected',
- [
- (True, 'True'),
- ('new_string', "'new_string'"),
- (1.0, '1.0'),
- (np.array([1, 2, 3]), 'array([1, 2, 3])'),
- ({'key': 'value'}, "{'key': 'value'}"),
- ],
- )
- def test_repr(self, descriptor: DescriptorAnyType, value, expected):
- # Set the descriptor value
- descriptor.value = value
-
- # When Then
- repr_str = str(descriptor)
-
- print(repr_str)
-
- # Expect
- assert repr_str == f""
-
- def test_copy(self, descriptor: DescriptorAnyType):
- # When Then
- descriptor_copy = descriptor.__copy__()
-
- # Expect
- assert type(descriptor_copy) == DescriptorAnyType
- assert descriptor_copy._value == descriptor._value
diff --git a/tests/unit/variable/test_descriptor_array.py b/tests/unit/variable/test_descriptor_array.py
index 73bb0857..a7520c63 100644
--- a/tests/unit/variable/test_descriptor_array.py
+++ b/tests/unit/variable/test_descriptor_array.py
@@ -16,34 +16,30 @@ class TestDescriptorArray:
@pytest.fixture
def descriptor(self):
descriptor = DescriptorArray(
- name='name',
value=[[1.0, 2.0], [3.0, 4.0]],
unit='m',
variance=[[0.1, 0.2], [0.3, 0.4]],
description='description',
url='url',
display_name='display_name',
- parent=None,
)
return descriptor
@pytest.fixture
def descriptor_dimensionless(self):
descriptor = DescriptorArray(
- name='name',
value=[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
unit='dimensionless',
variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
description='description',
url='url',
display_name='display_name',
- parent=None,
)
return descriptor
@pytest.fixture
def clear(self):
- global_object.map._clear()
+ global_object.registry.clear()
def test_init(self, descriptor: DescriptorArray):
# When Then Expect
@@ -52,7 +48,6 @@ def test_init(self, descriptor: DescriptorArray):
assert np.array_equal(descriptor._array.variances, np.array([[0.1, 0.2], [0.3, 0.4]]))
# From super
- assert descriptor._name == 'name'
assert descriptor._description == 'description'
assert descriptor._url == 'url'
assert descriptor._display_name == 'display_name'
@@ -60,14 +55,12 @@ def test_init(self, descriptor: DescriptorArray):
def test_init_sc_unit(self):
# When Then
descriptor = DescriptorArray(
- name='name',
value=[[1.0, 2.0], [3.0, 4.0]],
unit=sc.units.Unit('m'),
variance=[[0.1, 0.2], [0.3, 0.4]],
description='description',
url='url',
display_name='display_name',
- parent=None,
)
# Expect
@@ -79,14 +72,12 @@ def test_init_sc_unit_unknown(self):
# When Then Expect
with pytest.raises(UnitError):
DescriptorArray(
- name='name',
value=[[1.0, 2.0], [3.0, 4.0]],
unit='unknown',
variance=[[0.1, 0.2], [0.3, 0.4]],
description='description',
url='url',
display_name='display_name',
- parent=None,
)
@pytest.mark.parametrize('value', [True, 'string'])
@@ -96,14 +87,12 @@ def test_init_value_type_exception(self, value):
# Then Expect
with pytest.raises(TypeError):
DescriptorArray(
- name='name',
value=value,
unit='m',
variance=[[0.1, 0.2], [0.3, 0.4]],
description='description',
url='url',
display_name='display_name',
- parent=None,
)
def test_init_variance_exception(self):
@@ -112,14 +101,12 @@ def test_init_variance_exception(self):
# Then Expect
with pytest.raises(ValueError):
DescriptorArray(
- name='name',
value=[[1.0, 2.0], [3.0, 4.0]],
unit='m',
variance=variance,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
# test from_scipp
@@ -127,7 +114,7 @@ def test_from_scipp(self):
# When
full_value = sc.array(dims=['row', 'column'], values=[[1, 2], [3, 4]], unit='m')
# Then
- descriptor = DescriptorArray.from_scipp(name='name', full_value=full_value)
+ descriptor = DescriptorArray.from_scipp(display_name='name', full_value=full_value)
# Expect
assert np.array_equal(descriptor._array.values, [[1, 2], [3, 4]])
@@ -185,6 +172,14 @@ def test_set_variance(self, descriptor: DescriptorArray):
assert np.array_equal(descriptor.variance, np.array([[0.2, 0.3], [0.4, 0.5]]))
assert np.array_equal(descriptor.error, np.sqrt(np.array([[0.2, 0.3], [0.4, 0.5]])))
+ def test_set_variance_none(self, descriptor: DescriptorArray):
+ # When Then
+ descriptor.variance = None
+
+ # Expect
+ assert descriptor.variance is None
+ assert descriptor.error is None
+
def test_error(self, descriptor: DescriptorArray):
# When Then Expect
assert np.array_equal(descriptor.error, np.sqrt(np.array([[0.1, 0.2], [0.3, 0.4]])))
@@ -213,7 +208,7 @@ def test_repr(self, descriptor: DescriptorArray):
# Expect
assert (
repr_str
- == ""
+ == ""
)
def test_copy(self, descriptor: DescriptorArray):
@@ -232,7 +227,9 @@ def test_copy(self, descriptor: DescriptorArray):
)
def test_base_unit(self, unit_string, expected):
# When
- descriptor = DescriptorArray(name='name', value=[[1.0, 2.0], [3.0, 4.0]], unit=unit_string)
+ descriptor = DescriptorArray(
+ display_name='name', value=[[1.0, 2.0], [3.0, 4.0]], unit=unit_string
+ )
# Then
base_unit = descriptor._base_unit()
@@ -244,36 +241,47 @@ def test_base_unit(self, unit_string, expected):
'test, expected, raises_warning',
[
(
- DescriptorNumber('test', 2, 'm', 0.01),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
DescriptorArray(
- 'test + name', [[3.0, 4.0], [5.0, 6.0]], 'm', [[0.11, 0.21], [0.31, 0.41]]
+ [[3.0, 4.0], [5.0, 6.0]],
+ unit='m',
+ variance=[[0.11, 0.21], [0.31, 0.41]],
+ display_name='test + name',
),
True,
),
(
- DescriptorNumber('test', 1, 'cm', 10),
+ DescriptorNumber(1, unit='cm', variance=10, display_name='test'),
DescriptorArray(
- 'test + name',
[[1.01, 2.01], [3.01, 4.01]],
- 'm',
- [[0.1010, 0.2010], [0.3010, 0.4010]],
+ unit='m',
+ variance=[[0.1010, 0.2010], [0.3010, 0.4010]],
+ display_name='test + name',
),
True,
),
(
- DescriptorArray('test', [[2.0, 3.0], [4.0, -5.0]], 'cm', [[1.0, 2.0], [3.0, 4.0]]),
DescriptorArray(
- 'test + name',
+ [[2.0, 3.0], [4.0, -5.0]],
+ unit='cm',
+ variance=[[1.0, 2.0], [3.0, 4.0]],
+ display_name='test',
+ ),
+ DescriptorArray(
[[1.02, 2.03], [3.04, 3.95]],
- 'm',
- [[0.1001, 0.2002], [0.3003, 0.4004]],
+ unit='m',
+ variance=[[0.1001, 0.2002], [0.3003, 0.4004]],
+ display_name='test + name',
),
False,
),
(
- DescriptorArray('test', [[2, 3], [4, -5]], 'cm'),
+ DescriptorArray([[2, 3], [4, -5]], unit='cm', display_name='test'),
DescriptorArray(
- 'test + name', [[1.02, 2.03], [3.04, 3.95]], 'm', [[0.1, 0.2], [0.3, 0.4]]
+ [[1.02, 2.03], [3.04, 3.95]],
+ unit='m',
+ variance=[[0.1, 0.2], [0.3, 0.4]],
+ display_name='test + name',
),
False,
),
@@ -296,7 +304,6 @@ def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warn
result = descriptor + test
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -308,19 +315,19 @@ def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warn
(
[[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]],
DescriptorArray(
- 'test',
[[3.0, 5.0], [7.0, -1.0], [11.0, -2.0]],
- 'dimensionless',
- [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ unit='dimensionless',
+ variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ display_name='test',
),
),
(
1,
DescriptorArray(
- 'test',
[[2.0, 3.0], [4.0, 5.0], [6.0, 7.0]],
- 'dimensionless',
- [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ unit='dimensionless',
+ variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ display_name='test',
),
),
],
@@ -341,39 +348,47 @@ def test_addition_dimensionless(
'test, expected, raises_warning',
[
(
- DescriptorNumber('test', 2, 'm', 0.01),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
DescriptorArray(
- 'test + name', [[3.0, 4.0], [5.0, 6.0]], 'm', [[0.11, 0.21], [0.31, 0.41]]
+ [[3.0, 4.0], [5.0, 6.0]],
+ unit='m',
+ variance=[[0.11, 0.21], [0.31, 0.41]],
+ display_name='test + name',
),
True,
),
(
- DescriptorNumber('test', 1, 'cm', 10),
+ DescriptorNumber(1, unit='cm', variance=10, display_name='test'),
DescriptorArray(
- 'test + name',
[[101.0, 201.0], [301.0, 401.0]],
- 'cm',
- [[1010.0, 2010.0], [3010.0, 4010.0]],
+ unit='cm',
+ variance=[[1010.0, 2010.0], [3010.0, 4010.0]],
+ display_name='test + name',
),
True,
),
(
- DescriptorArray('test', [[2.0, 3.0], [4.0, -5.0]], 'cm', [[1.0, 2.0], [3.0, 4.0]]),
DescriptorArray(
- 'test + name',
+ [[2.0, 3.0], [4.0, -5.0]],
+ unit='cm',
+ variance=[[1.0, 2.0], [3.0, 4.0]],
+ display_name='test',
+ ),
+ DescriptorArray(
[[102.0, 203.0], [304.0, 395.0]],
- 'cm',
- [[1001.0, 2002.0], [3003.0, 4004.0]],
+ unit='cm',
+ variance=[[1001.0, 2002.0], [3003.0, 4004.0]],
+ display_name='test + name',
),
False,
),
(
- DescriptorArray('test', [[2, 3], [4, -5]], 'cm'),
+ DescriptorArray([[2, 3], [4, -5]], unit='cm', display_name='test'),
DescriptorArray(
- 'test + name',
[[102.0, 203.0], [304.0, 395.0]],
- 'cm',
- [[1000.0, 2000.0], [3000.0, 4000.0]],
+ unit='cm',
+ variance=[[1000.0, 2000.0], [3000.0, 4000.0]],
+ display_name='test + name',
),
False,
),
@@ -396,7 +411,6 @@ def test_reverse_addition(self, descriptor: DescriptorArray, test, expected, rai
result = test + descriptor
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -408,19 +422,19 @@ def test_reverse_addition(self, descriptor: DescriptorArray, test, expected, rai
(
[[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]],
DescriptorArray(
- 'test',
[[3.0, 5.0], [7.0, -1.0], [11.0, -2.0]],
- 'dimensionless',
- [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ unit='dimensionless',
+ variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ display_name='test',
),
),
(
1,
DescriptorArray(
- 'test',
[[2.0, 3.0], [4.0, 5.0], [6.0, 7.0]],
- 'dimensionless',
- [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ unit='dimensionless',
+ variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ display_name='test',
),
),
],
@@ -441,39 +455,47 @@ def test_reverse_addition_dimensionless(
'test, expected, raises_warning',
[
(
- DescriptorNumber('test', 2, 'm', 0.01),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
DescriptorArray(
- 'test + name', [[-1.0, 0.0], [1.0, 2.0]], 'm', [[0.11, 0.21], [0.31, 0.41]]
+ [[-1.0, 0.0], [1.0, 2.0]],
+ unit='m',
+ variance=[[0.11, 0.21], [0.31, 0.41]],
+ display_name='test + name',
),
True,
),
(
- DescriptorNumber('test', 1, 'cm', 10),
+ DescriptorNumber(1, unit='cm', variance=10, display_name='test'),
DescriptorArray(
- 'test + name',
[[0.99, 1.99], [2.99, 3.99]],
- 'm',
- [[0.1010, 0.2010], [0.3010, 0.4010]],
+ unit='m',
+ variance=[[0.1010, 0.2010], [0.3010, 0.4010]],
+ display_name='test + name',
),
True,
),
(
- DescriptorArray('test', [[2.0, 3.0], [4.0, -5.0]], 'cm', [[1.0, 2.0], [3.0, 4.0]]),
DescriptorArray(
- 'test + name',
+ [[2.0, 3.0], [4.0, -5.0]],
+ unit='cm',
+ variance=[[1.0, 2.0], [3.0, 4.0]],
+ display_name='test',
+ ),
+ DescriptorArray(
[[0.98, 1.97], [2.96, 4.05]],
- 'm',
- [[0.1001, 0.2002], [0.3003, 0.4004]],
+ unit='m',
+ variance=[[0.1001, 0.2002], [0.3003, 0.4004]],
+ display_name='test + name',
),
False,
),
(
- DescriptorArray('test', [[2, 3], [4, -5]], 'cm'),
+ DescriptorArray([[2, 3], [4, -5]], unit='cm', display_name='test'),
DescriptorArray(
- 'test + name',
[[0.98, 1.97], [2.96, 4.05]],
- 'm',
- [[0.100, 0.200], [0.300, 0.400]],
+ unit='m',
+ variance=[[0.100, 0.200], [0.300, 0.400]],
+ display_name='test + name',
),
False,
),
@@ -496,7 +518,6 @@ def test_subtraction(self, descriptor: DescriptorArray, test, expected, raises_w
result = descriptor - test
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -508,19 +529,19 @@ def test_subtraction(self, descriptor: DescriptorArray, test, expected, raises_w
(
[[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]],
DescriptorArray(
- 'test',
[[-1.0, -1.0], [-1.0, 9.0], [-1, 14.0]],
- 'dimensionless',
- [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ unit='dimensionless',
+ variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ display_name='test',
),
),
(
1,
DescriptorArray(
- 'test',
[[0.0, 1.0], [2.0, 3.0], [4.0, 5.0]],
- 'dimensionless',
- [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ unit='dimensionless',
+ variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ display_name='test',
),
),
],
@@ -541,39 +562,47 @@ def test_subtraction_dimensionless(
'test, expected, raises_warning',
[
(
- DescriptorNumber('test', 2, 'm', 0.01),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
DescriptorArray(
- 'test + name', [[1.0, 0.0], [-1.0, -2.0]], 'm', [[0.11, 0.21], [0.31, 0.41]]
+ [[1.0, 0.0], [-1.0, -2.0]],
+ unit='m',
+ variance=[[0.11, 0.21], [0.31, 0.41]],
+ display_name='test + name',
),
True,
),
(
- DescriptorNumber('test', 1, 'cm', 10),
+ DescriptorNumber(1, unit='cm', variance=10, display_name='test'),
DescriptorArray(
- 'test + name',
[[-99.0, -199.0], [-299.0, -399.0]],
- 'cm',
- [[1010.0, 2010.0], [3010.0, 4010.0]],
+ unit='cm',
+ variance=[[1010.0, 2010.0], [3010.0, 4010.0]],
+ display_name='test + name',
),
True,
),
(
- DescriptorArray('test', [[2.0, 3.0], [4.0, -5.0]], 'cm', [[1.0, 2.0], [3.0, 4.0]]),
DescriptorArray(
- 'test + name',
+ [[2.0, 3.0], [4.0, -5.0]],
+ unit='cm',
+ variance=[[1.0, 2.0], [3.0, 4.0]],
+ display_name='test',
+ ),
+ DescriptorArray(
[[-98.0, -197.0], [-296.0, -405.0]],
- 'cm',
- [[1001.0, 2002.0], [3003.0, 4004.0]],
+ unit='cm',
+ variance=[[1001.0, 2002.0], [3003.0, 4004.0]],
+ display_name='test + name',
),
False,
),
(
- DescriptorArray('test', [[2, 3], [4, -5]], 'cm'),
+ DescriptorArray([[2, 3], [4, -5]], unit='cm', display_name='test'),
DescriptorArray(
- 'test + name',
[[-98.0, -197.0], [-296.0, -405.0]],
- 'cm',
- [[1000.0, 2000.0], [3000.0, 4000.0]],
+ unit='cm',
+ variance=[[1000.0, 2000.0], [3000.0, 4000.0]],
+ display_name='test + name',
),
False,
),
@@ -598,7 +627,6 @@ def test_reverse_subtraction(
result = test - descriptor
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -610,19 +638,19 @@ def test_reverse_subtraction(
(
[[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]],
DescriptorArray(
- 'test',
[[1.0, 1.0], [1.0, -9.0], [1.0, -14.0]],
- 'dimensionless',
- [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ unit='dimensionless',
+ variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ display_name='test',
),
),
(
1,
DescriptorArray(
- 'test',
[[0.0, -1.0], [-2.0, -3.0], [-4.0, -5.0]],
- 'dimensionless',
- [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ unit='dimensionless',
+ variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
+ display_name='test',
),
),
],
@@ -643,69 +671,80 @@ def test_reverse_subtraction_dimensionless(
'test, expected, raises_warning',
[
(
- DescriptorNumber('test', 2, 'm', 0.01),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
DescriptorArray(
- 'test * name', [[2.0, 4.0], [6.0, 8.0]], 'm^2', [[0.41, 0.84], [1.29, 1.76]]
+ [[2.0, 4.0], [6.0, 8.0]],
+ unit='m^2',
+ variance=[[0.41, 0.84], [1.29, 1.76]],
+ display_name='test * name',
),
True,
),
(
- DescriptorNumber('test', 1, 'cm', 10),
+ DescriptorNumber(1, unit='cm', variance=10, display_name='test'),
DescriptorArray(
- 'test * name',
[[0.01, 0.02], [0.03, 0.04]],
- 'm^2',
- [[0.00101, 0.00402], [0.00903, 0.01604]],
+ unit='m^2',
+ variance=[[0.00101, 0.00402], [0.00903, 0.01604]],
+ display_name='test * name',
),
True,
),
(
- DescriptorNumber('test', 1, 'kg', 10),
+ DescriptorNumber(1, unit='kg', variance=10, display_name='test'),
DescriptorArray(
- 'test * name', [[1.0, 2.0], [3.0, 4.0]], 'kg*m', [[10.1, 40.2], [90.3, 160.4]]
+ [[1.0, 2.0], [3.0, 4.0]],
+ unit='kg*m',
+ variance=[[10.1, 40.2], [90.3, 160.4]],
+ display_name='test * name',
),
True,
),
(
- DescriptorArray('test', [[2.0, 3.0], [4.0, -5.0]], 'cm', [[1.0, 2.0], [3.0, 4.0]]),
DescriptorArray(
- 'test * name',
+ [[2.0, 3.0], [4.0, -5.0]],
+ unit='cm',
+ variance=[[1.0, 2.0], [3.0, 4.0]],
+ display_name='test',
+ ),
+ DescriptorArray(
[[0.02, 0.06], [0.12, -0.2]],
- 'm^2',
- [[0.00014, 0.00098], [0.00318, 0.0074]],
+ unit='m^2',
+ variance=[[0.00014, 0.00098], [0.00318, 0.0074]],
+ display_name='test * name',
),
False,
),
(
- DescriptorArray('test', [[2, 3], [4, -5]], 'cm'),
+ DescriptorArray([[2, 3], [4, -5]], unit='cm', display_name='test'),
DescriptorArray(
- 'test * name',
[[0.02, 0.06], [0.12, -0.2]],
- 'm^2',
- [
+ unit='m^2',
+ variance=[
[0.1 * 2**2 * 1e-4, 0.2 * 3**2 * 1e-4],
[0.3 * 4**2 * 1e-4, 0.4 * 5**2 * 1e-4],
],
+ display_name='test * name',
),
False,
),
(
[[2.0, 3.0], [4.0, -5.0]],
DescriptorArray(
- 'test * name',
[[2.0, 6.0], [12.0, -20.0]],
- 'm',
- [[0.1 * 2**2, 0.2 * 3**2], [0.3 * 4**2, 0.4 * 5**2]],
+ unit='m',
+ variance=[[0.1 * 2**2, 0.2 * 3**2], [0.3 * 4**2, 0.4 * 5**2]],
+ display_name='test * name',
),
False,
),
(
2.0,
DescriptorArray(
- 'test * name',
[[2.0, 4.0], [6.0, 8.0]],
- 'm',
- [[0.1 * 2**2, 0.2 * 2**2], [0.3 * 2**2, 0.4 * 2**2]],
+ unit='m',
+ variance=[[0.1 * 2**2, 0.2 * 2**2], [0.3 * 2**2, 0.4 * 2**2]],
+ display_name='test * name',
),
False,
),
@@ -731,7 +770,6 @@ def test_multiplication(self, descriptor: DescriptorArray, test, expected, raise
result = descriptor * test
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -743,19 +781,19 @@ def test_multiplication(self, descriptor: DescriptorArray, test, expected, raise
(
[[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]],
DescriptorArray(
- 'test',
[[2.0, 6.0], [12.0, -20.0], [30.0, -48.0]],
- 'dimensionless',
- [[0.4, 1.8], [4.8, 10.0], [18.0, 38.4]],
+ unit='dimensionless',
+ variance=[[0.4, 1.8], [4.8, 10.0], [18.0, 38.4]],
+ display_name='test',
),
),
(
1.5,
DescriptorArray(
- 'test',
[[1.5, 3.0], [4.5, 6.0], [7.5, 9.0]],
- 'dimensionless',
- [[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]],
+ unit='dimensionless',
+ variance=[[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]],
+ display_name='test',
),
),
],
@@ -776,66 +814,80 @@ def test_multiplication_dimensionless(
'test, expected, raises_warning',
[
(
- DescriptorNumber('test', 2, 'm', 0.01),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
DescriptorArray(
- 'test * name', [[2.0, 4.0], [6.0, 8.0]], 'm^2', [[0.41, 0.84], [1.29, 1.76]]
+ [[2.0, 4.0], [6.0, 8.0]],
+ unit='m^2',
+ variance=[[0.41, 0.84], [1.29, 1.76]],
+ display_name='test * name',
),
True,
),
(
- DescriptorNumber('test', 1, 'cm', 10),
+ DescriptorNumber(1, unit='cm', variance=10, display_name='test'),
DescriptorArray(
- 'test * name',
[[100.0, 200.0], [300.0, 400.0]],
- 'cm^2',
- [[101000.0, 402000.0], [903000.0, 1604000.0]],
+ unit='cm^2',
+ variance=[[101000.0, 402000.0], [903000.0, 1604000.0]],
+ display_name='test * name',
),
True,
),
(
- DescriptorNumber('test', 1, 'kg', 10),
+ DescriptorNumber(1, unit='kg', variance=10, display_name='test'),
DescriptorArray(
- 'test * name', [[1.0, 2.0], [3.0, 4.0]], 'kg*m', [[10.1, 40.2], [90.3, 160.4]]
+ [[1.0, 2.0], [3.0, 4.0]],
+ unit='kg*m',
+ variance=[[10.1, 40.2], [90.3, 160.4]],
+ display_name='test * name',
),
True,
),
(
- DescriptorArray('test', [[2.0, 3.0], [4.0, -5.0]], 'cm', [[1.0, 2.0], [3.0, 4.0]]),
DescriptorArray(
- 'test * name',
+ [[2.0, 3.0], [4.0, -5.0]],
+ unit='cm',
+ variance=[[1.0, 2.0], [3.0, 4.0]],
+ display_name='test',
+ ),
+ DescriptorArray(
[[200.0, 600.0], [1200.0, -2000.0]],
- 'cm^2',
- [[14000.0, 98000.0], [318000.0, 740000.0]],
+ unit='cm^2',
+ variance=[[14000.0, 98000.0], [318000.0, 740000.0]],
+ display_name='test * name',
),
False,
),
(
- DescriptorArray('test', [[2, 3], [4, -5]], 'cm'),
+ DescriptorArray([[2, 3], [4, -5]], unit='cm', display_name='test'),
DescriptorArray(
- 'test * name',
[[200.0, 600.0], [1200.0, -2000.0]],
- 'cm^2',
- [[0.1 * 2**2 * 1e4, 0.2 * 3**2 * 1e4], [0.3 * 4**2 * 1e4, 0.4 * 5**2 * 1e4]],
+ unit='cm^2',
+ variance=[
+ [0.1 * 2**2 * 1e4, 0.2 * 3**2 * 1e4],
+ [0.3 * 4**2 * 1e4, 0.4 * 5**2 * 1e4],
+ ],
+ display_name='test * name',
),
False,
),
(
[[2.0, 3.0], [4.0, -5.0]],
DescriptorArray(
- 'test * name',
[[2.0, 6.0], [12.0, -20.0]],
- 'm',
- [[0.1 * 2**2, 0.2 * 3**2], [0.3 * 4**2, 0.4 * 5**2]],
+ unit='m',
+ variance=[[0.1 * 2**2, 0.2 * 3**2], [0.3 * 4**2, 0.4 * 5**2]],
+ display_name='test * name',
),
False,
),
(
2.0,
DescriptorArray(
- 'test * name',
[[2.0, 4.0], [6.0, 8.0]],
- 'm',
- [[0.1 * 2**2, 0.2 * 2**2], [0.3 * 2**2, 0.4 * 2**2]],
+ unit='m',
+ variance=[[0.1 * 2**2, 0.2 * 2**2], [0.3 * 2**2, 0.4 * 2**2]],
+ display_name='test * name',
),
False,
),
@@ -863,7 +915,6 @@ def test_reverse_multiplication(
result = test * descriptor
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -875,19 +926,19 @@ def test_reverse_multiplication(
(
[[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]],
DescriptorArray(
- 'test',
[[2.0, 6.0], [12.0, -20.0], [30.0, -48.0]],
- 'dimensionless',
- [[0.4, 1.8], [4.8, 10.0], [18.0, 38.4]],
+ unit='dimensionless',
+ variance=[[0.4, 1.8], [4.8, 10.0], [18.0, 38.4]],
+ display_name='test',
),
),
(
1.5,
DescriptorArray(
- 'test',
[[1.5, 3.0], [4.5, 6.0], [7.5, 9.0]],
- 'dimensionless',
- [[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]],
+ unit='dimensionless',
+ variance=[[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]],
+ display_name='test',
),
),
],
@@ -908,12 +959,11 @@ def test_reverse_multiplication_dimensionless(
'test, expected, raises_warning',
[
(
- DescriptorNumber('test', 2, 'm', 0.01),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
DescriptorArray(
- 'name / test',
[[1.0 / 2.0, 2.0 / 2.0], [3.0 / 2.0, 4.0 / 2.0]],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
[
(0.1 + 0.01 * 1.0**2 / 2.0**2) / 2.0**2,
(0.2 + 0.01 * 2.0**2 / 2.0**2) / 2.0**2,
@@ -923,16 +973,16 @@ def test_reverse_multiplication_dimensionless(
(0.4 + 0.01 * 4.0**2 / 2.0**2) / 2.0**2,
],
],
+ display_name='name / test',
),
True,
),
(
- DescriptorNumber('test', 1, 'cm', 10),
+ DescriptorNumber(1, unit='cm', variance=10, display_name='test'),
DescriptorArray(
- 'name / test',
[[100.0, 200.0], [300.0, 400.0]],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
[
(0.1 + 10 * 1.0**2 / 1.0**2) / 1.0**2 * 1e4,
(0.2 + 10 * 2.0**2 / 1.0**2) / 1.0**2 * 1e4,
@@ -942,16 +992,16 @@ def test_reverse_multiplication_dimensionless(
(0.4 + 10 * 4.0**2 / 1.0**2) / 1.0**2 * 1e4,
],
],
+ display_name='name / test',
),
True,
),
(
- DescriptorNumber('test', 1, 'kg', 10),
+ DescriptorNumber(1, unit='kg', variance=10, display_name='test'),
DescriptorArray(
- 'name / test',
[[1.0, 2.0], [3.0, 4.0]],
- 'm/kg',
- [
+ unit='m/kg',
+ variance=[
[
(0.1 + 10 * 1.0**2 / 1.0**2) / 1.0**2,
(0.2 + 10 * 2.0**2 / 1.0**2) / 1.0**2,
@@ -961,18 +1011,21 @@ def test_reverse_multiplication_dimensionless(
(0.4 + 10 * 4.0**2 / 1.0**2) / 1.0**2,
],
],
+ display_name='name / test',
),
True,
),
(
DescriptorArray(
- 'test', [[2.0, 3.0], [4.0, -5.0]], 'cm^2', [[1.0, 2.0], [3.0, 4.0]]
+ [[2.0, 3.0], [4.0, -5.0]],
+ unit='cm^2',
+ variance=[[1.0, 2.0], [3.0, 4.0]],
+ display_name='test',
),
DescriptorArray(
- 'name / test',
[[1 / 2 * 1e4, 2 / 3 * 1e4], [3.0 / 4.0 * 1e4, -4.0 / 5.0 * 1e4]],
- '1/m',
- [
+ unit='1/m',
+ variance=[
[
(0.1 + 1.0 * 1.0**2 / 2.0**2) / 2.0**2 * 1e8,
(0.2 + 2.0 * 2.0**2 / 3.0**2) / 3.0**2 * 1e8,
@@ -982,39 +1035,40 @@ def test_reverse_multiplication_dimensionless(
(0.4 + 4.0 * 4.0**2 / 5.0**2) / 5.0**2 * 1e8,
],
],
+ display_name='name / test',
),
False,
),
(
- DescriptorArray('test', [[2, 3], [4, -5]], 'cm^2'),
+ DescriptorArray([[2, 3], [4, -5]], unit='cm^2', display_name='test'),
DescriptorArray(
- 'name / test',
[[1 / 2 * 1e4, 2 / 3 * 1e4], [3.0 / 4.0 * 1e4, -4.0 / 5.0 * 1e4]],
- '1/m',
- [
+ unit='1/m',
+ variance=[
[(0.1) / 2.0**2 * 1e8, (0.2) / 3.0**2 * 1e8],
[(0.3) / 4.0**2 * 1e8, (0.4) / 5.0**2 * 1e8],
],
+ display_name='name / test',
),
False,
),
(
[[2.0, 3.0], [4.0, -5.0]],
DescriptorArray(
- 'name / name',
[[0.5, 2.0 / 3.0], [3.0 / 4.0, -4 / 5]],
- 'm',
- [[0.1 / 2**2, 0.2 / 3.0**2], [0.3 / 4**2, 0.4 / 5.0**2]],
+ unit='m',
+ variance=[[0.1 / 2**2, 0.2 / 3.0**2], [0.3 / 4**2, 0.4 / 5.0**2]],
+ display_name='name / name',
),
False,
),
(
2.0,
DescriptorArray(
- 'name / test',
[[0.5, 1.0], [3.0 / 2.0, 2.0]],
- 'm',
- [[0.1 / 2.0**2, 0.2 / 2.0**2], [0.3 / 2.0**2, 0.4 / 2.0**2]],
+ unit='m',
+ variance=[[0.1 / 2.0**2, 0.2 / 2.0**2], [0.3 / 2.0**2, 0.4 / 2.0**2]],
+ display_name='name / test',
),
False,
),
@@ -1040,7 +1094,6 @@ def test_division(self, descriptor: DescriptorArray, test, expected, raises_warn
result = descriptor / test
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.allclose(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -1052,27 +1105,27 @@ def test_division(self, descriptor: DescriptorArray, test, expected, raises_warn
(
[[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]],
DescriptorArray(
- 'test',
[[1.0 / 2.0, 2.0 / 3.0], [3.0 / 4.0, -4.0 / 5.0], [5.0 / 6.0, -6.0 / 8.0]],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
[0.1 / 2.0**2, 0.2 / 3.0**2],
[0.3 / 4.0**2, 0.4 / 5.0**2],
[0.5 / 6.0**2, 0.6 / 8.0**2],
],
+ display_name='test',
),
),
(
2,
DescriptorArray(
- 'test',
[[1.0 / 2.0, 2.0 / 2.0], [3.0 / 2.0, 4.0 / 2.0], [5.0 / 2.0, 6.0 / 2.0]],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
[0.1 / 2.0**2, 0.2 / 2.0**2],
[0.3 / 2.0**2, 0.4 / 2.0**2],
[0.5 / 2.0**2, 0.6 / 2.0**2],
],
+ display_name='test',
),
),
],
@@ -1093,75 +1146,77 @@ def test_division_dimensionless(
'test, expected, raises_warning',
[
(
- DescriptorNumber('test', 2, 'm', 0.01),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
DescriptorArray(
- 'test / name',
[[2.0, 1.0], [2.0 / 3.0, 0.5]],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
[0.41, 0.0525],
[
(0.01 + 0.3 * 2**2 / 3.0**2) / 3.0**2,
(0.01 + 0.4 * 2**2 / 4.0**2) / 4.0**2,
],
],
+ display_name='test / name',
),
True,
),
(
- DescriptorNumber('test', 1, 'cm', 10),
+ DescriptorNumber(1, unit='cm', variance=10, display_name='test'),
DescriptorArray(
- 'test / name',
[[1.0 / 100.0, 1.0 / 200.0], [1.0 / 300.0, 1.0 / 400.0]],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
[1.01e-3, (1e-3 + 0.2 * 0.01**2 / 2**2) / 2**2],
[
(1e-3 + 0.3 * 0.01**2 / 3**2) / 3**2,
(1e-3 + 0.4 * 0.01**2 / 4**2) / 4**2,
],
],
+ display_name='test / name',
),
True,
),
(
- DescriptorNumber('test', 1, 'kg', 10),
+ DescriptorNumber(1, unit='kg', variance=10, display_name='test'),
DescriptorArray(
- 'test / name',
[[1.0, 0.5], [1.0 / 3.0, 0.25]],
- 'kg/m',
- [
+ unit='kg/m',
+ variance=[
[10.1, (10 + 0.2 * 1 / 2**2) / 2**2],
[(10 + 0.3 * 1 / 3**2) / 3**2, (10 + 0.4 * 1 / 4**2) / 4**2],
],
+ display_name='test / name',
),
True,
),
(
DescriptorArray(
- 'test', [[2.0, 3.0], [4.0, -5.0]], 'cm^2', [[1.0, 2.0], [3.0, 4.0]]
+ [[2.0, 3.0], [4.0, -5.0]],
+ unit='cm^2',
+ variance=[[1.0, 2.0], [3.0, 4.0]],
+ display_name='test',
),
DescriptorArray(
- 'test / name',
[[2e-4, 1.5e-4], [4.0 / 3.0 * 1e-4, -1.25e-4]],
- 'm',
- [
+ unit='m',
+ variance=[
[1.4e-8, 6.125e-9],
[
(3.0e-8 + 0.3 * (0.0004) ** 2 / 3**2) / 3**2,
(4.0e-8 + 0.4 * (0.0005) ** 2 / 4**2) / 4**2,
],
],
+ display_name='test / name',
),
False,
),
(
- DescriptorArray('test', [[2, 3], [4, -5]], 'cm^2'),
+ DescriptorArray([[2, 3], [4, -5]], unit='cm^2', display_name='test'),
DescriptorArray(
- 'test / name',
[[2e-4, 1.5e-4], [4.0 / 3.0 * 1e-4, -1.25e-4]],
- 'm',
- [
+ unit='m',
+ variance=[
[
(0.1 * 2.0**2 / 1.0**2) / 1.0**2 * 1e-8,
(0.2 * 3.0**2 / 2.0**2) / 2.0**2 * 1e-8,
@@ -1171,32 +1226,33 @@ def test_division_dimensionless(
(0.4 * 5.0**2 / 4.0**2) / 4.0**2 * 1e-8,
],
],
+ display_name='test / name',
),
False,
),
(
[[2.0, 3.0], [4.0, -5.0]],
DescriptorArray(
- 'test / name',
[[2, 1.5], [4.0 / 3.0, -1.25]],
- '1/m',
- [
+ unit='1/m',
+ variance=[
[0.1 * 2**2 / 1**4, 0.2 * 3.0**2 / 2.0**4],
[0.3 * 4**2 / 3**4, 0.4 * 5.0**2 / 4.0**4],
],
+ display_name='test / name',
),
False,
),
(
2.0,
DescriptorArray(
- 'test / name',
[[2, 1.0], [2.0 / 3.0, 0.5]],
- '1/m',
- [
+ unit='1/m',
+ variance=[
[0.1 * 2**2 / 1**4, 0.2 * 2.0**2 / 2.0**4],
[0.3 * 2**2 / 3**4, 0.4 * 2.0**2 / 4.0**4],
],
+ display_name='test / name',
),
False,
),
@@ -1222,7 +1278,6 @@ def test_reverse_division(self, descriptor: DescriptorArray, test, expected, rai
result = test / descriptor
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.allclose(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -1234,27 +1289,27 @@ def test_reverse_division(self, descriptor: DescriptorArray, test, expected, rai
(
[[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]],
DescriptorArray(
- 'test',
[[2.0 / 1.0, 3.0 / 2.0], [4.0 / 3.0, -5.0 / 4.0], [6.0 / 5.0, -8.0 / 6.0]],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
[0.1 * 2.0**2, 0.2 * 3.0**2 / 2**4],
[0.3 * 4.0**2 / 3.0**4, 0.4 * 5.0**2 / 4**4],
[0.5 * 6.0**2 / 5**4, 0.6 * 8.0**2 / 6**4],
],
+ display_name='test',
),
),
(
2,
DescriptorArray(
- 'test',
[[2.0, 1.0], [2.0 / 3.0, 0.5], [2.0 / 5.0, 1.0 / 3.0]],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
[0.1 * 2.0**2, 0.2 / 2**2],
[0.3 * 2**2 / 3**4, 0.4 * 2**2 / 4**4],
[0.5 * 2**2 / 5**4, 0.6 * 2**2 / 6**4],
],
+ display_name='test',
),
),
],
@@ -1276,12 +1331,12 @@ def test_reverse_division_dimensionless(
[
[[2.0, 3.0], [4.0, -5.0], [6.0, 0.0]],
0.0,
- DescriptorNumber('test', 0, 'cm', 10),
+ DescriptorNumber(0, unit='cm', variance=10, display_name='test'),
DescriptorArray(
- 'test',
[[1.5, 0.0], [4.5, 6.0], [7.5, 9.0]],
- 'dimensionless',
- [[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]],
+ unit='dimensionless',
+ variance=[[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]],
+ display_name='test',
),
],
ids=['list', 'number', 'DescriptorNumber', 'DescriptorArray'],
@@ -1293,10 +1348,10 @@ def test_division_exception(self, descriptor_dimensionless: DescriptorArray, tes
# Also test reverse division where `self` is a DescriptorArray with a zero
zero_descriptor = DescriptorArray(
- 'test',
[[1.5, 0.0], [4.5, 6.0], [7.5, 0.0]],
- 'dimensionless',
- [[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]],
+ unit='dimensionless',
+ variance=[[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]],
+ display_name='test',
)
with pytest.raises(ZeroDivisionError):
test / zero_descriptor
@@ -1305,39 +1360,39 @@ def test_division_exception(self, descriptor_dimensionless: DescriptorArray, tes
'test, expected',
[
(
- DescriptorNumber('test', 2, 'dimensionless'),
+ DescriptorNumber(2, unit='dimensionless', display_name='test'),
DescriptorArray(
- 'test ** name',
[[1.0, 4.0], [9.0, 16.0]],
- 'm^2',
- [[4 * 0.1 * 1, 4 * 0.2 * 2**2], [4 * 0.3 * 3**2, 4 * 0.4 * 4**2]],
+ unit='m^2',
+ variance=[[4 * 0.1 * 1, 4 * 0.2 * 2**2], [4 * 0.3 * 3**2, 4 * 0.4 * 4**2]],
+ display_name='test ** name',
),
),
(
- DescriptorNumber('test', 3, 'dimensionless'),
+ DescriptorNumber(3, unit='dimensionless', display_name='test'),
DescriptorArray(
- 'test ** name',
[[1.0, 8.0], [27, 64.0]],
- 'm^3',
- [[9 * 0.1, 9 * 0.2 * 2**4], [9 * 0.3 * 3**4, 9 * 0.4 * 4**4]],
+ unit='m^3',
+ variance=[[9 * 0.1, 9 * 0.2 * 2**4], [9 * 0.3 * 3**4, 9 * 0.4 * 4**4]],
+ display_name='test ** name',
),
),
(
- DescriptorNumber('test', 0.0, 'dimensionless'),
+ DescriptorNumber(0.0, unit='dimensionless', display_name='test'),
DescriptorArray(
- 'test ** name',
[[1.0, 1.0], [1.0, 1.0]],
- 'dimensionless',
- [[0.0, 0.0], [0.0, 0.0]],
+ unit='dimensionless',
+ variance=[[0.0, 0.0], [0.0, 0.0]],
+ display_name='test ** name',
),
),
(
0.0,
DescriptorArray(
- 'test ** name',
[[1.0, 1.0], [1.0, 1.0]],
- 'dimensionless',
- [[0.0, 0.0], [0.0, 0.0]],
+ unit='dimensionless',
+ variance=[[0.0, 0.0], [0.0, 0.0]],
+ display_name='test ** name',
),
),
],
@@ -1353,7 +1408,6 @@ def test_power(self, descriptor: DescriptorArray, test, expected):
result = descriptor**test
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -1363,25 +1417,25 @@ def test_power(self, descriptor: DescriptorArray, test, expected):
'test, expected',
[
(
- DescriptorNumber('test', 0.1, 'dimensionless'),
+ DescriptorNumber(0.1, unit='dimensionless', display_name='test'),
DescriptorArray(
- 'test ** name',
[[1, 2**0.1], [3**0.1, 4**0.1], [5**0.1, 6**0.1]],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
[0.1**2 * 0.1 * 1, 0.1**2 * 0.2 * 2 ** (-1.8)],
[0.1**2 * 0.3 * 3 ** (-1.8), 0.1**2 * 0.4 * 4 ** (-1.8)],
[0.1**2 * 0.5 * 5 ** (-1.8), 0.1**2 * 0.6 * 6 ** (-1.8)],
],
+ display_name='test ** name',
),
),
(
- DescriptorNumber('test', 2.0, 'dimensionless'),
+ DescriptorNumber(2.0, unit='dimensionless', display_name='test'),
DescriptorArray(
- 'test ** name',
[[1.0, 4.0], [9.0, 16.0], [25.0, 36.0]],
- 'dimensionless',
- [[0.4, 3.2], [10.8, 25.6], [50.0, 86.4]],
+ unit='dimensionless',
+ variance=[[0.4, 3.2], [10.8, 25.6], [50.0, 86.4]],
+ display_name='test ** name',
),
),
],
@@ -1392,7 +1446,6 @@ def test_power_dimensionless(self, descriptor_dimensionless: DescriptorArray, te
result = descriptor_dimensionless**test
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.allclose(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -1401,13 +1454,16 @@ def test_power_dimensionless(self, descriptor_dimensionless: DescriptorArray, te
@pytest.mark.parametrize(
'test, exception',
[
- (DescriptorNumber('test', 2, 'm'), UnitError),
- (DescriptorNumber('test', 2, 'dimensionless', 10), ValueError),
- (DescriptorNumber('test', np.nan, 'dimensionless'), UnitError),
- (DescriptorNumber('test', np.nan, 'dimensionless'), UnitError),
- (DescriptorNumber('test', 1.5, 'dimensionless'), UnitError),
+ (DescriptorNumber(2, unit='m', display_name='test'), UnitError),
+ (
+ DescriptorNumber(2, unit='dimensionless', variance=10, display_name='test'),
+ ValueError,
+ ),
+ (DescriptorNumber(np.nan, unit='dimensionless', display_name='test'), UnitError),
+ (DescriptorNumber(np.nan, unit='dimensionless', display_name='test'), UnitError),
+ (DescriptorNumber(1.5, unit='dimensionless', display_name='test'), UnitError),
(
- DescriptorNumber('test', 0.5, 'dimensionless'),
+ DescriptorNumber(0.5, unit='dimensionless', display_name='test'),
UnitError,
), # Square roots are not legal
],
@@ -1430,7 +1486,10 @@ def test_power_exception(self, descriptor: DescriptorArray, test, exception):
@pytest.mark.parametrize(
'test',
- [DescriptorNumber('test', 2, 's'), DescriptorArray('test', [[1, 2], [3, 4]], 's')],
+ [
+ DescriptorNumber(2, unit='s', display_name='test'),
+ DescriptorArray([[1, 2], [3, 4]], unit='s', display_name='test'),
+ ],
ids=['add_array_to_unit', 'incompatible_units'],
)
def test_addition_exception(self, descriptor: DescriptorArray, test):
@@ -1442,7 +1501,10 @@ def test_addition_exception(self, descriptor: DescriptorArray, test):
@pytest.mark.parametrize(
'test',
- [DescriptorNumber('test', 2, 's'), DescriptorArray('test', [[1, 2], [3, 4]], 's')],
+ [
+ DescriptorNumber(2, unit='s', display_name='test'),
+ DescriptorArray([[1, 2], [3, 4]], unit='s', display_name='test'),
+ ],
ids=['add_array_to_unit', 'incompatible_units'],
)
def test_sub_exception(self, descriptor: DescriptorArray, test):
@@ -1474,17 +1536,14 @@ def test_negation(self, descriptor):
# Expect
expected = DescriptorArray(
- name='name',
value=[[-1.0, -2.0], [-3.0, -4.0]],
unit='m',
variance=[[0.1, 0.2], [0.3, 0.4]],
description='description',
url='url',
display_name='display_name',
- parent=None,
)
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -1493,14 +1552,12 @@ def test_negation(self, descriptor):
def test_abs(self, descriptor):
# When
negated = DescriptorArray(
- name='name',
value=[[-1.0, -2.0], [-3.0, -4.0]],
unit='m',
variance=[[0.1, 0.2], [0.3, 0.4]],
description='description',
url='url',
display_name='display_name',
- parent=None,
)
# Then
@@ -1508,7 +1565,6 @@ def test_abs(self, descriptor):
# Expect
assert type(result) == DescriptorArray
- assert result.name == result.unique_name
assert np.array_equal(result.value, descriptor.value)
assert result.unit == descriptor.unit
assert np.allclose(result.variance, descriptor.variance)
@@ -1519,40 +1575,55 @@ def test_abs(self, descriptor):
[
(
DescriptorArray(
- 'test + name', [[3.0, 4.0], [5.0, 6.0]], 'm', [[0.11, 0.21], [0.31, 0.41]]
+ [[3.0, 4.0], [5.0, 6.0]],
+ unit='m',
+ variance=[[0.11, 0.21], [0.31, 0.41]],
+ display_name='test + name',
),
- DescriptorNumber('test', 9, 'm', 0.52),
+ DescriptorNumber(9, unit='m', variance=0.52, display_name='test'),
),
(
DescriptorArray(
- 'test + name',
[[101.0, 201.0], [301.0, 401.0]],
- 'dimensionless',
- [[1010.0, 2010.0], [3010.0, 4010.0]],
+ unit='dimensionless',
+ variance=[[1010.0, 2010.0], [3010.0, 4010.0]],
+ display_name='test + name',
+ ),
+ DescriptorNumber(
+ 502.0, unit='dimensionless', variance=5020.0, display_name='test'
),
- DescriptorNumber('test', 502.0, 'dimensionless', 5020.0),
),
(
- DescriptorArray('test', np.ones((9, 9)), 'dimensionless', np.ones((9, 9))),
- DescriptorNumber('test', 9.0, 'dimensionless', 9.0),
+ DescriptorArray(
+ np.ones((9, 9)),
+ unit='dimensionless',
+ variance=np.ones((9, 9)),
+ display_name='test',
+ ),
+ DescriptorNumber(9.0, unit='dimensionless', variance=9.0, display_name='test'),
),
(
- DescriptorArray('test', np.ones((3, 3, 3)), 'dimensionless', np.ones((3, 3, 3))),
DescriptorArray(
- 'test',
+ np.ones((3, 3, 3)),
+ unit='dimensionless',
+ variance=np.ones((3, 3, 3)),
+ display_name='test',
+ ),
+ DescriptorArray(
[3.0, 3.0, 3.0],
- 'dimensionless',
- [
+ unit='dimensionless',
+ variance=[
3.0,
3.0,
3.0,
],
dimensions=['dim2'],
+ display_name='test',
),
),
(
- DescriptorArray('test', [[2.0]], 'dimensionless'),
- DescriptorNumber('test', 2.0, 'dimensionless'),
+ DescriptorArray([[2.0]], unit='dimensionless', display_name='test'),
+ DescriptorNumber(2.0, unit='dimensionless', display_name='test'),
),
],
ids=['2d_unit', '2d_dimensionless', '2d_large', '3d_dimensionless', '1d_dimensionless'],
@@ -1560,7 +1631,6 @@ def test_abs(self, descriptor):
def test_trace(self, test: DescriptorArray, expected: DescriptorNumber):
result = test.trace()
assert type(result) == type(expected)
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
if test.variance is not None:
@@ -1573,14 +1643,17 @@ def test_trace(self, test: DescriptorArray, expected: DescriptorNumber):
[
(
DescriptorArray(
- 'test', np.ones((3, 3, 4, 5)), 'dimensionless', np.ones((3, 3, 4, 5))
+ np.ones((3, 3, 4, 5)),
+ unit='dimensionless',
+ variance=np.ones((3, 3, 4, 5)),
+ display_name='test',
),
DescriptorArray(
- 'test',
- 3 * np.ones((3, 4)),
- 'dimensionless',
3 * np.ones((3, 4)),
+ unit='dimensionless',
+ variance=3 * np.ones((3, 4)),
dimensions=['dim0', 'dim2'],
+ display_name='test',
),
('dim1', 'dim3'),
)
@@ -1592,7 +1665,6 @@ def test_trace_select_dimensions(
):
result = test.trace(dimension1=dimensions[0], dimension2=dimensions[1])
assert type(result) == type(expected)
- assert result.name == result.unique_name
assert np.array_equal(result.value.shape, expected.value.shape)
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
@@ -1602,17 +1674,32 @@ def test_trace_select_dimensions(
'test,dimensions,message',
[
(
- DescriptorArray('test', np.ones((3, 3, 3)), 'dimensionless', np.ones((3, 3, 3))),
+ DescriptorArray(
+ np.ones((3, 3, 3)),
+ unit='dimensionless',
+ variance=np.ones((3, 3, 3)),
+ display_name='test',
+ ),
('dim0', None),
'Either both or none',
),
(
- DescriptorArray('test', np.ones((3, 3, 3)), 'dimensionless', np.ones((3, 3, 3))),
+ DescriptorArray(
+ np.ones((3, 3, 3)),
+ unit='dimensionless',
+ variance=np.ones((3, 3, 3)),
+ display_name='test',
+ ),
('dim0', 'dim0'),
'must be different',
),
(
- DescriptorArray('test', np.ones((3, 3, 3)), 'dimensionless', np.ones((3, 3, 3))),
+ DescriptorArray(
+ np.ones((3, 3, 3)),
+ unit='dimensionless',
+ variance=np.ones((3, 3, 3)),
+ display_name='test',
+ ),
('dim0', 'dim1337'),
'does not exist',
),
@@ -1635,9 +1722,10 @@ def test_slicing(self, descriptor: DescriptorArray):
assert type(last_value) == DescriptorArray
assert type(second_array) == DescriptorArray
- assert first_value.name != descriptor.unique_name
- assert last_value.name != descriptor.unique_name
- assert second_array.name != descriptor.unique_name
+ # Slices are fresh objects with their own identity, not the sliced parent.
+ assert first_value.unique_name != descriptor.unique_name
+ assert last_value.unique_name != descriptor.unique_name
+ assert second_array.unique_name != descriptor.unique_name
assert np.array_equal(
first_value.full_value.values, descriptor.full_value['dim0', 0].values
@@ -1680,26 +1768,31 @@ def test_slice_assignment_exception(self, descriptor_dimensionless: DescriptorAr
[
(
DescriptorArray(
- 'test + name', [[3.0, 4.0], [5.0, 6.0]], 'm', [[0.11, 0.21], [0.31, 0.41]]
+ [[3.0, 4.0], [5.0, 6.0]],
+ unit='m',
+ variance=[[0.11, 0.21], [0.31, 0.41]],
+ display_name='test + name',
),
- DescriptorNumber('test', 18, 'm', 1.04),
+ DescriptorNumber(18, unit='m', variance=1.04, display_name='test'),
),
(
DescriptorArray(
- 'test + name',
[[101.0, 201.0], [301.0, 401.0]],
- 'cm',
- [[1010.0, 2010.0], [3010.0, 4010.0]],
+ unit='cm',
+ variance=[[1010.0, 2010.0], [3010.0, 4010.0]],
+ display_name='test + name',
),
- DescriptorNumber('test', 1004.0, 'cm', 10040.0),
+ DescriptorNumber(1004.0, unit='cm', variance=10040.0, display_name='test'),
),
(
- DescriptorArray('test', [[2.0, 3.0]], 'dimensionless', [[1.0, 2.0]]),
- DescriptorNumber('test', 5.0, 'dimensionless', 3.0),
+ DescriptorArray(
+ [[2.0, 3.0]], unit='dimensionless', variance=[[1.0, 2.0]], display_name='test'
+ ),
+ DescriptorNumber(5.0, unit='dimensionless', variance=3.0, display_name='test'),
),
(
- DescriptorArray('test', [[2.0, 3.0]], 'dimensionless'),
- DescriptorNumber('test', 5.0, 'dimensionless'),
+ DescriptorArray([[2.0, 3.0]], unit='dimensionless', display_name='test'),
+ DescriptorNumber(5.0, unit='dimensionless', display_name='test'),
),
],
ids=[
@@ -1712,7 +1805,6 @@ def test_slice_assignment_exception(self, descriptor_dimensionless: DescriptorAr
def test_sum(self, test, expected):
result = test.sum()
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
if test.variance is not None:
@@ -1721,15 +1813,20 @@ def test_sum(self, test, expected):
@pytest.mark.parametrize(
'expected, dim',
[
- (DescriptorArray('test', [4.0, 6.0], 'm', [0.4, 0.6]), 'dim0'),
- (DescriptorArray('test', [3.0, 7.0], 'm', [0.3, 0.7]), 'dim1'),
+ (
+ DescriptorArray([4.0, 6.0], unit='m', variance=[0.4, 0.6], display_name='test'),
+ 'dim0',
+ ),
+ (
+ DescriptorArray([3.0, 7.0], unit='m', variance=[0.3, 0.7], display_name='test'),
+ 'dim1',
+ ),
],
ids=['descriptor_array_dim0', 'descriptor_array_dim1'],
)
def test_sum_over_subset(self, descriptor, expected, dim):
result = descriptor.sum(dim)
assert type(result) == type(expected)
- assert result.name == result.unique_name
assert np.array_equal(result.value, expected.value)
assert result.unit == expected.unit
assert np.allclose(result.variance, expected.variance)
@@ -1737,25 +1834,40 @@ def test_sum_over_subset(self, descriptor, expected, dim):
@pytest.mark.parametrize(
'test, dimensions',
[
- (DescriptorArray('test', [1.0], 'dimensionless', [1.0]), ['dim0']),
(
- DescriptorArray('test', [[1.0, 1.0]], 'dimensionless', [[1.0, 1.0]]),
+ DescriptorArray([1.0], unit='dimensionless', variance=[1.0], display_name='test'),
+ ['dim0'],
+ ),
+ (
+ DescriptorArray(
+ [[1.0, 1.0]], unit='dimensionless', variance=[[1.0, 1.0]], display_name='test'
+ ),
['dim0', 'dim1'],
),
(
- DescriptorArray('test', [[1.0], [1.0]], 'dimensionless', [[1.0], [1.0]]),
+ DescriptorArray(
+ [[1.0], [1.0]],
+ unit='dimensionless',
+ variance=[[1.0], [1.0]],
+ display_name='test',
+ ),
['dim0', 'dim1'],
),
(
- DescriptorArray('test', [[[1.0, 1.0, 1.0]]], 'dimensionless', [[[1.0, 1.0, 1.0]]]),
+ DescriptorArray(
+ [[[1.0, 1.0, 1.0]]],
+ unit='dimensionless',
+ variance=[[[1.0, 1.0, 1.0]]],
+ display_name='test',
+ ),
['dim0', 'dim1', 'dim2'],
),
(
DescriptorArray(
- 'test',
- [[[1.0]], [[1.0]], [[1.0]]],
- 'dimensionless',
[[[1.0]], [[1.0]], [[1.0]]],
+ unit='dimensionless',
+ variance=[[[1.0]], [[1.0]], [[1.0]]],
+ display_name='test',
),
['dim0', 'dim1', 'dim2'],
),
@@ -1770,7 +1882,9 @@ def test_array_set_dimensions_exception(self, descriptor):
descriptor.dimensions = ['too_few']
assert 'must have the same shape'
with pytest.raises(ValueError) as e:
- DescriptorArray('test', [[1.0]], 'm', [[1.0]], dimensions=['dim'])
+ DescriptorArray(
+ [[1.0]], unit='m', variance=[[1.0]], dimensions=['dim'], display_name='test'
+ )
assert 'Length of dimensions' in str(e)
def test_array_set_integer_value(self, descriptor):
@@ -1793,7 +1907,9 @@ def test_array_create_with_mixed_integers_and_floats(self):
value = [[1, 2], [3, 4]]
variance = [[0.1, 0.2], [0.3, 0.4]]
# Then Expect
- descriptor = DescriptorArray('test', value, 'dimensionless', variance) # Should not raise
+ descriptor = DescriptorArray(
+ value, unit='dimensionless', variance=variance, display_name='test'
+ ) # Should not raise
assert isinstance(descriptor.value[0][0], float)
assert isinstance(descriptor.variance[0][0], float)
diff --git a/tests/unit/variable/test_descriptor_base.py b/tests/unit/variable/test_descriptor_base.py
index 24c342dc..df69218f 100644
--- a/tests/unit/variable/test_descriptor_base.py
+++ b/tests/unit/variable/test_descriptor_base.py
@@ -1,9 +1,14 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
+import gc
+
import pytest
from easyscience import global_object
+from easyscience.base_classes import EasyBase
+from easyscience.base_classes import EasyList
+from easyscience.variable import DescriptorNumber
from easyscience.variable.descriptor_base import DescriptorBase
@@ -13,34 +18,34 @@ def descriptor(self):
# This avoids the error: TypeError: Can't instantiate abstract class DescriptorBase with abstract methods __init__
DescriptorBase.__abstractmethods__ = set()
DescriptorBase.__repr__ = lambda x: 'DescriptorBase'
- self.objs_before_new_descriptor = len(global_object.map.created_objs)
+ # The registry holds weak references; collect objects left over from
+ # earlier tests so they cannot vanish between the count and the assert
+ gc.collect()
+ self.objs_before_new_descriptor = len(global_object.registry)
descriptor = DescriptorBase(
- name='name',
description='description',
url='url',
display_name='display_name',
- parent=None,
)
return descriptor
@pytest.fixture
def clear(self):
- global_object.map._clear()
+ global_object.registry.clear()
@pytest.mark.parametrize(
- 'name',
- [1, True, 1.0, [], {}, (), None, object()],
- ids=['int', 'bool', 'float', 'list', 'dict', 'tuple', 'None', 'object'],
+ 'unique_name',
+ [1, True, 1.0, [], {}, (), object()],
+ ids=['int', 'bool', 'float', 'list', 'dict', 'tuple', 'object'],
)
- def test_init_name_type_error(self, name):
+ def test_init_unique_name_type_error(self, unique_name):
# When Then
with pytest.raises(TypeError):
DescriptorBase(
- name=name,
+ unique_name=unique_name,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
@pytest.mark.parametrize(
@@ -52,11 +57,9 @@ def test_init_display_name_type_error(self, display_name):
# When Then
with pytest.raises(TypeError):
DescriptorBase(
- name='name',
description='description',
url='url',
display_name=display_name,
- parent=None,
)
@pytest.mark.parametrize(
@@ -68,11 +71,9 @@ def test_init_description_type_error(self, description):
# When Then
with pytest.raises(TypeError):
DescriptorBase(
- name='name',
description=description,
url='url',
display_name='display_name',
- parent=None,
)
@pytest.mark.parametrize(
@@ -84,19 +85,16 @@ def test_init_url_type_error(self, url):
# When Then
with pytest.raises(TypeError):
DescriptorBase(
- name='name',
description='description',
url=url,
display_name='display_name',
- parent=None,
)
def test_init(self, descriptor: DescriptorBase):
- assert descriptor._name == 'name'
assert descriptor._description == 'description'
assert descriptor._url == 'url'
assert descriptor._display_name == 'display_name'
- assert len(global_object.map.created_objs) - self.objs_before_new_descriptor == 1
+ assert len(global_object.registry) - self.objs_before_new_descriptor == 1
def test_display_name(self, descriptor: DescriptorBase):
# When Then Expect
@@ -106,7 +104,7 @@ def test_display_name_none(self, descriptor: DescriptorBase):
# When
descriptor._display_name = None
# Then Expect
- assert descriptor.display_name == 'name'
+ assert descriptor.display_name == descriptor.unique_name
def test_display_name_setter(self, descriptor: DescriptorBase):
# When
@@ -124,22 +122,6 @@ def test_display_name_setter_type_error(self, descriptor: DescriptorBase, displa
with pytest.raises(TypeError):
descriptor.display_name = display_name
- def test_name_setter(self, descriptor: DescriptorBase):
- # When
- descriptor.name = 'new_name'
- # Then Expect
- assert descriptor.name == 'new_name'
-
- @pytest.mark.parametrize(
- 'name',
- [1, True, 1.0, [], {}, (), object(), None],
- ids=['int', 'bool', 'float', 'list', 'dict', 'tuple', 'object', 'None'],
- )
- def test_name_setter_type_error(self, descriptor: DescriptorBase, name):
- # When Then
- with pytest.raises(TypeError):
- descriptor.name = name
-
def test_description_setter(self, descriptor: DescriptorBase):
# When
descriptor.description = 'new_description'
@@ -172,21 +154,12 @@ def test_url_setter_type_error(self, descriptor: DescriptorBase, url):
with pytest.raises(TypeError):
descriptor.url = url
- @pytest.mark.parametrize('stack_enabled,stack_elements', [(False, 0), (True, 1)])
- def test_set_display_name_without_global_object_stack(
- self, descriptor: DescriptorBase, stack_enabled, stack_elements
- ):
- # When
- descriptor.__repr__ = lambda x: 'DescriptorBase'
- global_object.stack.clear()
- global_object.stack._enabled = stack_enabled
-
- # Then
+ def test_set_display_name(self, descriptor: DescriptorBase):
+ # When Then
descriptor.display_name = 'new_display_name'
# Expect
assert descriptor.display_name == 'new_display_name'
- assert len(global_object.stack.history) == stack_elements
def test_copy(self, descriptor: DescriptorBase):
# When Then
@@ -194,18 +167,17 @@ def test_copy(self, descriptor: DescriptorBase):
# Expect
assert type(descriptor_copy) == DescriptorBase
- assert descriptor_copy._name == descriptor._name
assert descriptor_copy._description == descriptor._description
assert descriptor_copy._url == descriptor._url
assert descriptor_copy._display_name == descriptor._display_name
def test_unique_name_generator(self, clear, descriptor: DescriptorBase):
# When
- second_descriptor = DescriptorBase(name='test', unique_name='DescriptorBase_2')
+ second_descriptor = DescriptorBase(display_name='test', unique_name='DescriptorBase_2')
# Then
- third_descriptor = DescriptorBase(name='test2')
- fourth_descriptor = DescriptorBase(name='test3')
+ third_descriptor = DescriptorBase(display_name='test2')
+ fourth_descriptor = DescriptorBase(display_name='test3')
# Expect
assert descriptor.unique_name == 'DescriptorBase_0'
diff --git a/tests/unit/variable/test_descriptor_bool.py b/tests/unit/variable/test_descriptor_bool.py
index 3c181f0e..8aa6befe 100644
--- a/tests/unit/variable/test_descriptor_bool.py
+++ b/tests/unit/variable/test_descriptor_bool.py
@@ -11,25 +11,22 @@ class TestDescriptorBool:
@pytest.fixture
def descriptor(self):
descriptor = DescriptorBool(
- name='name',
value=True,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
return descriptor
@pytest.fixture
def clear(self):
- global_object.map._clear()
+ global_object.registry.clear()
def test_init(self, descriptor: DescriptorBool):
# When Then Expect
assert descriptor._bool_value == True
# From super
- assert descriptor._name == 'name'
assert descriptor._description == 'description'
assert descriptor._url == 'url'
assert descriptor._display_name == 'display_name'
@@ -40,12 +37,10 @@ def test_init_bool_value_type_exception(self, bool_value):
# When Then Expect
with pytest.raises(ValueError):
DescriptorBool(
- name='name',
value=bool_value,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
def test_value(self, descriptor: DescriptorBool):
@@ -70,7 +65,7 @@ def test_repr(self, descriptor: DescriptorBool):
repr_str = str(descriptor)
# Expect
- assert repr_str == ""
+ assert repr_str == ""
def test_copy(self, descriptor: DescriptorBool):
# When Then
diff --git a/tests/unit/variable/test_descriptor_number.py b/tests/unit/variable/test_descriptor_number.py
index 6abd3029..381c1db0 100644
--- a/tests/unit/variable/test_descriptor_number.py
+++ b/tests/unit/variable/test_descriptor_number.py
@@ -13,20 +13,18 @@ class TestDescriptorNumber:
@pytest.fixture
def descriptor(self):
descriptor = DescriptorNumber(
- name='name',
value=1,
unit='m',
variance=0.1,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
return descriptor
@pytest.fixture
def clear(self):
- global_object.map._clear()
+ global_object.registry.clear()
def test_init(self, descriptor: DescriptorNumber):
# When Then Expect
@@ -36,7 +34,6 @@ def test_init(self, descriptor: DescriptorNumber):
assert descriptor._observers == []
# From super
- assert descriptor._name == 'name'
assert descriptor._description == 'description'
assert descriptor._url == 'url'
assert descriptor._display_name == 'display_name'
@@ -44,14 +41,12 @@ def test_init(self, descriptor: DescriptorNumber):
def test_init_sc_unit(self):
# When Then
descriptor = DescriptorNumber(
- name='name',
value=1,
unit=sc.units.Unit('m'),
variance=0.1,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
# Expect
@@ -63,14 +58,12 @@ def test_init_sc_unit_unknown(self):
# When Then Expect
with pytest.raises(UnitError):
DescriptorNumber(
- name='name',
value=1,
unit='unknown',
variance=0.1,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
@pytest.mark.parametrize('value', [True, 'string'])
@@ -80,14 +73,12 @@ def test_init_value_type_exception(self, value):
# Then Expect
with pytest.raises(TypeError):
DescriptorNumber(
- name='name',
value=value,
unit='m',
variance=0.1,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
def test_init_variance_exception(self):
@@ -97,14 +88,12 @@ def test_init_variance_exception(self):
# Then Expect
with pytest.raises(ValueError):
DescriptorNumber(
- name='name',
value=1,
unit='m',
variance=variance,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
# test from_scipp
@@ -113,7 +102,7 @@ def test_from_scipp(self):
full_value = sc.scalar(1, unit='m')
# Then
- descriptor = DescriptorNumber.from_scipp(name='name', full_value=full_value)
+ descriptor = DescriptorNumber.from_scipp(display_name='name', full_value=full_value)
# Expect
assert descriptor._scalar.value == 1
@@ -134,7 +123,7 @@ def test_from_scipp(self):
def test_from_scipp_type_exception(self, full_value):
# When Then Expect
with pytest.raises(TypeError):
- DescriptorNumber.from_scipp(name='name', full_value=full_value)
+ DescriptorNumber.from_scipp(display_name='name', full_value=full_value)
def test_full_value(self, descriptor: DescriptorNumber):
# When Then Expect
@@ -201,7 +190,7 @@ def test_repr(self, descriptor: DescriptorNumber):
repr_str = str(descriptor)
# Expect
- assert repr_str == ""
+ assert repr_str == ""
def test_copy(self, descriptor: DescriptorNumber):
# When Then
@@ -219,7 +208,7 @@ def test_copy(self, descriptor: DescriptorNumber):
)
def test_base_unit(self, unit_string, expected):
# When
- descriptor = DescriptorNumber(name='name', value=1, unit=unit_string)
+ descriptor = DescriptorNumber(display_name='name', value=1, unit=unit_string)
# Then
base_unit = descriptor._base_unit()
@@ -231,17 +220,12 @@ def test_base_unit(self, unit_string, expected):
'test, expected',
[
(
- DescriptorNumber(
- 'test',
- 2,
- 'm',
- 0.01,
- ),
- DescriptorNumber('test + name', 3, 'm', 0.11),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
+ DescriptorNumber(3, unit='m', variance=0.11, display_name='test + name'),
),
(
- DescriptorNumber('test', 2, 'cm', 0.01),
- DescriptorNumber('test + name', 102, 'cm', 1000.01),
+ DescriptorNumber(2, unit='cm', variance=0.01, display_name='test'),
+ DescriptorNumber(102, unit='cm', variance=1000.01, display_name='test + name'),
),
],
ids=['regular', 'unit_conversion'],
@@ -252,7 +236,6 @@ def test_addition(self, descriptor: DescriptorNumber, test, expected):
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -261,7 +244,7 @@ def test_addition(self, descriptor: DescriptorNumber, test, expected):
def test_addition_with_scalar(self):
# When
- descriptor = DescriptorNumber(name='name', value=1, variance=0.1)
+ descriptor = DescriptorNumber(display_name='name', value=1, variance=0.1)
# Then
result = descriptor + 1.0
@@ -269,13 +252,11 @@ def test_addition_with_scalar(self):
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == 2.0
assert result.unit == 'dimensionless'
assert result.variance == 0.1
assert type(result_reverse) == DescriptorNumber
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == 2.0
assert result_reverse.unit == 'dimensionless'
assert result_reverse.variance == 0.1
@@ -284,11 +265,7 @@ def test_addition_with_scalar(self):
'test',
[
1.0,
- DescriptorNumber(
- 'test',
- 2,
- 's',
- ),
+ DescriptorNumber(2, unit='s', display_name='test'),
],
ids=['add_scalar_to_unit', 'incompatible_units'],
)
@@ -303,17 +280,12 @@ def test_addition_exception(self, descriptor: DescriptorNumber, test):
'test, expected',
[
(
- DescriptorNumber(
- 'test',
- 2,
- 'm',
- 0.01,
- ),
- DescriptorNumber('test - name', 1, 'm', 0.11),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
+ DescriptorNumber(1, unit='m', variance=0.11, display_name='test - name'),
),
(
- DescriptorNumber('test', 2, 'cm', 0.01),
- DescriptorNumber('test - name', -98, 'cm', 1000.01),
+ DescriptorNumber(2, unit='cm', variance=0.01, display_name='test'),
+ DescriptorNumber(-98, unit='cm', variance=1000.01, display_name='test - name'),
),
],
ids=['regular', 'unit_conversion'],
@@ -324,7 +296,6 @@ def test_subtraction(self, descriptor: DescriptorNumber, test, expected):
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -333,7 +304,7 @@ def test_subtraction(self, descriptor: DescriptorNumber, test, expected):
def test_subtraction_with_scalar(self):
# When
- descriptor = DescriptorNumber(name='name', value=2, variance=0.1)
+ descriptor = DescriptorNumber(display_name='name', value=2, variance=0.1)
# Then
result = descriptor - 1.0
@@ -341,13 +312,11 @@ def test_subtraction_with_scalar(self):
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == 1.0
assert result.unit == 'dimensionless'
assert result.variance == 0.1
assert type(result_reverse) == DescriptorNumber
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == -1.0
assert result_reverse.unit == 'dimensionless'
assert result_reverse.variance == 0.1
@@ -356,11 +325,7 @@ def test_subtraction_with_scalar(self):
'test',
[
1.0,
- DescriptorNumber(
- 'test',
- 2,
- 's',
- ),
+ DescriptorNumber(2, unit='s', display_name='test'),
],
ids=['sub_scalar_to_unit', 'incompatible_units'],
)
@@ -375,17 +340,12 @@ def test_subtraction_exception(self, descriptor: DescriptorNumber, test):
'test, expected',
[
(
- DescriptorNumber(
- 'test',
- 2,
- 'm',
- 0.01,
- ),
- DescriptorNumber('test * name', 2, 'm^2', 0.41),
+ DescriptorNumber(2, unit='m', variance=0.01, display_name='test'),
+ DescriptorNumber(2, unit='m^2', variance=0.41, display_name='test * name'),
),
(
- DescriptorNumber('test', 2, 'dm', 0.01),
- DescriptorNumber('test * name', 0.2, 'm^2', 0.0041),
+ DescriptorNumber(2, unit='dm', variance=0.01, display_name='test'),
+ DescriptorNumber(0.2, unit='m^2', variance=0.0041, display_name='test * name'),
),
],
ids=['regular', 'base_unit_conversion'],
@@ -396,7 +356,6 @@ def test_multiplication(self, descriptor: DescriptorNumber, test, expected):
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == pytest.approx(expected.variance)
@@ -408,13 +367,11 @@ def test_multiplication_with_scalar(self, descriptor: DescriptorNumber):
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == 2.0
assert result.unit == 'm'
assert result.variance == 0.4
assert type(result_reverse) == DescriptorNumber
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == 2.0
assert result_reverse.unit == 'm'
assert result_reverse.variance == 0.4
@@ -423,19 +380,14 @@ def test_multiplication_with_scalar(self, descriptor: DescriptorNumber):
'test, expected, expected_reverse',
[
(
- DescriptorNumber(
- 'test',
- 2,
- 'm^2',
- 0.01,
- ),
- DescriptorNumber('name / test', 0.5, '1/m', 0.025625),
- DescriptorNumber('test / name', 2, 'm', 0.41),
+ DescriptorNumber(2, unit='m^2', variance=0.01, display_name='test'),
+ DescriptorNumber(0.5, unit='1/m', variance=0.025625, display_name='name / test'),
+ DescriptorNumber(2, unit='m', variance=0.41, display_name='test / name'),
),
(
2,
- DescriptorNumber('name / 2', 0.5, 'm', 0.025),
- DescriptorNumber('2 / name', 2, '1/m', 0.4),
+ DescriptorNumber(0.5, unit='m', variance=0.025, display_name='name / 2'),
+ DescriptorNumber(2, unit='1/m', variance=0.4, display_name='2 / name'),
),
],
ids=['descriptorNumber', 'scalar'],
@@ -447,19 +399,19 @@ def test_division(self, descriptor: DescriptorNumber, test, expected, expected_r
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == pytest.approx(expected.variance)
assert type(result_reverse) == DescriptorNumber
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == expected_reverse.value
assert result_reverse.unit == expected_reverse.unit
assert result_reverse.variance == pytest.approx(expected_reverse.variance)
@pytest.mark.parametrize(
- 'test', [0, DescriptorNumber('test', 0, 'm', 0.01)], ids=['zero', 'zero_descriptor']
+ 'test',
+ [0, DescriptorNumber(0, unit='m', variance=0.01, display_name='test')],
+ ids=['zero', 'zero_descriptor'],
)
def test_division_exception(self, descriptor: DescriptorNumber, test):
# When Then Expect
@@ -468,7 +420,7 @@ def test_division_exception(self, descriptor: DescriptorNumber, test):
def test_division_exception_reverse(self):
# When
- descriptor = DescriptorNumber(name='name', value=0, variance=0.1)
+ descriptor = DescriptorNumber(display_name='name', value=0, variance=0.1)
# Then Expect
with pytest.raises(ZeroDivisionError):
@@ -478,38 +430,41 @@ def test_division_exception_reverse(self):
'test, expected',
[
(
- DescriptorNumber('test', 2),
- DescriptorNumber('name ** test', 4, unit='m^2', variance=1.6),
+ DescriptorNumber(2, display_name='test'),
+ DescriptorNumber(4, unit='m^2', variance=1.6, display_name='name ** test'),
+ ),
+ (2, DescriptorNumber(4, unit='m^2', variance=1.6, display_name='name ** 2')),
+ (
+ -2,
+ DescriptorNumber(0.25, unit='1/m^2', variance=0.00625, display_name='name ** -2'),
),
- (2, DescriptorNumber('name ** 2', 4, unit='m^2', variance=1.6)),
- (-2, DescriptorNumber('name ** -2', 0.25, unit='1/m^2', variance=0.00625)),
],
ids=['descriptorNumber', 'scalar', 'negative_scalar'],
)
def test_power_of_descriptor(self, test, expected):
# When
- descriptor = DescriptorNumber(name='name', value=2, unit='m', variance=0.1)
+ descriptor = DescriptorNumber(display_name='name', value=2, unit='m', variance=0.1)
# Then
result = descriptor**test
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
def test_power_of_dimensionless_descriptor(self):
# When
- descriptor = DescriptorNumber(name='name', value=2, unit='dimensionless', variance=0.1)
+ descriptor = DescriptorNumber(
+ display_name='name', value=2, unit='dimensionless', variance=0.1
+ )
# Then
result = descriptor**0.5
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == 1.4142135623730951
assert result.unit == 'dimensionless'
assert result.variance == pytest.approx(0.0125)
@@ -517,10 +472,18 @@ def test_power_of_dimensionless_descriptor(self):
@pytest.mark.parametrize(
'descriptor, exponent, exception',
[
- (DescriptorNumber('name', 2), DescriptorNumber('test', 2, unit='m'), UnitError),
- (DescriptorNumber('name', 2), DescriptorNumber('test', 2, variance=0.1), ValueError),
- (DescriptorNumber('name', 2, unit='m'), 0.5, UnitError),
- (DescriptorNumber('name', -2), 0.5, ValueError),
+ (
+ DescriptorNumber(2, display_name='name'),
+ DescriptorNumber(2, unit='m', display_name='test'),
+ UnitError,
+ ),
+ (
+ DescriptorNumber(2, display_name='name'),
+ DescriptorNumber(2, variance=0.1, display_name='test'),
+ ValueError,
+ ),
+ (DescriptorNumber(2, unit='m', display_name='name'), 0.5, UnitError),
+ (DescriptorNumber(-2, display_name='name'), 0.5, ValueError),
],
ids=[
'descriptor_unit',
@@ -536,7 +499,7 @@ def test_power_of_descriptor_exceptions(self, descriptor, exponent, exception):
def test_descriptor_as_exponentiation(self):
# When
- descriptor = DescriptorNumber(name='name', value=2)
+ descriptor = DescriptorNumber(display_name='name', value=2)
# Then
result = 2**descriptor
@@ -547,8 +510,8 @@ def test_descriptor_as_exponentiation(self):
@pytest.mark.parametrize(
'exponent, exception',
[
- (DescriptorNumber('test', 2, unit='m'), UnitError),
- (DescriptorNumber('test', 2, variance=0.1), ValueError),
+ (DescriptorNumber(2, unit='m', display_name='test'), UnitError),
+ (DescriptorNumber(2, variance=0.1, display_name='test'), ValueError),
],
ids=['descriptor_unit', 'descriptor_variance'],
)
@@ -559,28 +522,26 @@ def test_descriptor_as_exponentiation_exception(self, exponent, exception):
def test_negation(self):
# When
- descriptor = DescriptorNumber(name='name', unit='m', value=2, variance=0.1)
+ descriptor = DescriptorNumber(display_name='name', unit='m', value=2, variance=0.1)
# Then
result = -descriptor
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == -2
assert result.unit == 'm'
assert result.variance == 0.1
def test_abs(self):
# When
- descriptor = DescriptorNumber(name='name', unit='m', value=-2, variance=0.1)
+ descriptor = DescriptorNumber(display_name='name', unit='m', value=-2, variance=0.1)
# Then
result = abs(descriptor)
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == 2
assert result.unit == 'm'
assert result.variance == 0.1
diff --git a/tests/unit/variable/test_descriptor_str.py b/tests/unit/variable/test_descriptor_str.py
index 46acc847..bc9533e7 100644
--- a/tests/unit/variable/test_descriptor_str.py
+++ b/tests/unit/variable/test_descriptor_str.py
@@ -11,25 +11,22 @@ class TestDescriptorStr:
@pytest.fixture
def descriptor(self):
descriptor = DescriptorStr(
- name='name',
value='string',
description='description',
url='url',
display_name='display_name',
- parent=None,
)
return descriptor
@pytest.fixture
def clear(self):
- global_object.map._clear()
+ global_object.registry.clear()
def test_init(self, descriptor: DescriptorStr):
# When Then Expect
assert descriptor._string == 'string'
# From super
- assert descriptor._name == 'name'
assert descriptor._description == 'description'
assert descriptor._url == 'url'
assert descriptor._display_name == 'display_name'
@@ -39,12 +36,10 @@ def test_init_string_type_exception(self, string):
# When Then Expect
with pytest.raises(ValueError):
DescriptorStr(
- name='name',
value=string,
description='description',
url='url',
display_name='display_name',
- parent=None,
)
def test_value(self, descriptor: DescriptorStr):
@@ -69,7 +64,7 @@ def test_repr(self, descriptor: DescriptorStr):
repr_str = str(descriptor)
# Expect
- assert repr_str == ""
+ assert repr_str == ""
def test_copy(self, descriptor: DescriptorStr):
# When Then
diff --git a/tests/unit/variable/test_parameter.py b/tests/unit/variable/test_parameter.py
index 1ac05b45..9e1bcbc7 100644
--- a/tests/unit/variable/test_parameter.py
+++ b/tests/unit/variable/test_parameter.py
@@ -9,7 +9,7 @@
from scipp import UnitError
from easyscience import DescriptorNumber
-from easyscience import ObjBase
+from easyscience import ModelBase
from easyscience import Parameter
from easyscience import global_object
@@ -17,9 +17,7 @@
class TestParameter:
@pytest.fixture
def parameter(self) -> Parameter:
- self.mock_callback = MagicMock()
parameter = Parameter(
- name='name',
value=1,
unit='m',
variance=0.01,
@@ -28,26 +26,17 @@ def parameter(self) -> Parameter:
description='description',
url='url',
display_name='display_name',
- callback=self.mock_callback,
- parent=None,
)
return parameter
@pytest.fixture
def normal_parameter(self) -> Parameter:
- parameter = Parameter(
- name='name',
- value=1,
- unit='m',
- variance=0.01,
- min=0,
- max=10,
- )
+ parameter = Parameter(display_name='name', value=1, unit='m', variance=0.01, min=0, max=10)
return parameter
@pytest.fixture
def clear(self):
- global_object.map._clear()
+ global_object.registry.clear()
def compare_parameters(self, parameter1: Parameter, parameter2: Parameter):
assert parameter1.value == parameter2.value
@@ -64,14 +53,12 @@ def test_init(self, parameter: Parameter):
assert parameter._min.unit == 'm'
assert parameter._max.value == 10
assert parameter._max.unit == 'm'
- assert parameter._callback == self.mock_callback
assert parameter._independent == True
# From super
assert parameter._scalar.value == 1
assert parameter._scalar.unit == 'm'
assert parameter._scalar.variance == 0.01
- assert parameter._name == 'name'
assert parameter._description == 'description'
assert parameter._url == 'url'
assert parameter._display_name == 'display_name'
@@ -80,13 +67,11 @@ def test_init(self, parameter: Parameter):
def test_init_value_min_exception(self):
# When
- mock_callback = MagicMock()
value = -1
# Then Expect
with pytest.raises(ValueError):
Parameter(
- name='name',
value=value,
unit='m',
variance=0.01,
@@ -95,19 +80,15 @@ def test_init_value_min_exception(self):
description='description',
url='url',
display_name='display_name',
- callback=mock_callback,
- parent=None,
)
def test_init_value_max_exception(self):
# When
- mock_callback = MagicMock()
value = 100
# Then Expect
with pytest.raises(ValueError):
Parameter(
- name='name',
value=value,
unit='m',
variance=0.01,
@@ -116,14 +97,12 @@ def test_init_value_max_exception(self):
description='description',
url='url',
display_name='display_name',
- callback=mock_callback,
- parent=None,
)
def test_make_dependent_on(self, normal_parameter: Parameter):
# When
independent_parameter = Parameter(
- name='independent', value=1, unit='m', variance=0.01, min=0, max=10
+ display_name='independent', value=1, unit='m', variance=0.01, min=0, max=10
)
# Then
@@ -144,13 +123,10 @@ def test_make_dependent_on(self, normal_parameter: Parameter):
normal_parameter.value == 4
self.compare_parameters(normal_parameter, 2 * independent_parameter)
- def test_dependent_parameter_update_pushes_value_to_callback(self, parameter: Parameter):
- # When # Simulate a calculator bound to the parameter via the callback (issue #281)
- calculator = {'value': parameter._scalar.value}
- self.mock_callback.fget.side_effect = lambda: calculator['value']
- self.mock_callback.fset.side_effect = lambda value: calculator.update(value=value)
+ def test_dependent_parameter_update_follows_independent(self, parameter: Parameter):
+ # When
independent_parameter = Parameter(
- name='independent', value=1, unit='m', variance=0.01, min=0, max=10
+ display_name='independent', value=1, unit='m', variance=0.01, min=0, max=10
)
# Then
@@ -158,30 +134,23 @@ def test_dependent_parameter_update_pushes_value_to_callback(self, parameter: Pa
dependency_expression='2*a', dependency_map={'a': independent_parameter}
)
- # Expect # Making the parameter dependent pushes the computed value to the calculator
- assert calculator['value'] == 2.0
- self.mock_callback.fset.assert_called_with(2.0)
- assert self.mock_callback.fset.call_count == 1
+ # Expect # Making the parameter dependent computes its value
+ assert parameter.value == 2.0
+ assert parameter.unit == 'm'
# Then
independent_parameter.value = 3
- # Expect # The change of the independent parameter propagates to the calculator
- assert calculator['value'] == 6.0
- self.mock_callback.fset.assert_called_with(6.0)
- assert self.mock_callback.fset.call_count == 2
- # Reading the value must not return a stale calculator value
+ # Expect # The change of the independent parameter propagates (issue #281)
assert parameter.value == 6.0
+ assert parameter.unit == 'm'
- def test_dependent_parameter_update_pushes_value_to_callback_with_desired_unit(
+ def test_dependent_parameter_update_follows_independent_with_desired_unit(
self, parameter: Parameter
):
- # When # Simulate a calculator bound to the parameter via the callback (issue #281)
- calculator = {'value': parameter._scalar.value}
- self.mock_callback.fget.side_effect = lambda: calculator['value']
- self.mock_callback.fset.side_effect = lambda value: calculator.update(value=value)
+ # When
independent_parameter = Parameter(
- name='independent', value=1, unit='m', variance=0.01, min=0, max=10
+ display_name='independent', value=1, unit='m', variance=0.01, min=0, max=10
)
# Then
@@ -191,23 +160,23 @@ def test_dependent_parameter_update_pushes_value_to_callback_with_desired_unit(
desired_unit='cm',
)
- # Expect # The calculator receives the value converted to the desired unit
- assert calculator['value'] == 200.0
- self.mock_callback.fset.assert_called_with(200.0)
+ # Expect # The value is converted to the desired unit
+ assert parameter.value == 200.0
+ assert parameter.unit == 'cm'
# Then
independent_parameter.value = 2
# Expect
- assert calculator['value'] == 400.0
assert parameter.value == 400.0
+ assert parameter.unit == 'cm'
def test_dependent_parameter_make_dependent_on_with_desired_unit(
self, normal_parameter: Parameter
):
# When
independent_parameter = Parameter(
- name='independent', value=1, unit='m', variance=0.01, min=0, max=10
+ display_name='independent', value=1, unit='m', variance=0.01, min=0, max=10
)
# Then
@@ -287,7 +256,7 @@ def test_dependent_parameter_make_dependent_on_with_desired_unit_incompatible_un
):
# When
independent_parameter = Parameter(
- name='independent', value=1, unit='m', variance=0.01, min=0, max=10
+ display_name='independent', value=1, unit='m', variance=0.01, min=0, max=10
)
# Then Expect
@@ -303,7 +272,7 @@ def test_dependent_parameter_make_dependent_on_with_incorrect_unit_raises(
):
# When
independent_parameter = Parameter(
- name='independent', value=1, unit='m', variance=0.01, min=0, max=10
+ display_name='independent', value=1, unit='m', variance=0.01, min=0, max=10
)
# Then Expect
@@ -319,7 +288,6 @@ def test_dependent_parameter_make_dependent_on_with_incorrect_unit_raises(
def test_parameter_from_dependency(self, normal_parameter: Parameter):
# When Then
dependent_parameter = Parameter.from_dependency(
- name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
display_name='display_name',
@@ -329,7 +297,6 @@ def test_parameter_from_dependency(self, normal_parameter: Parameter):
assert dependent_parameter._independent == False
assert dependent_parameter.dependency_expression == '2*a'
assert dependent_parameter.dependency_map == {'a': normal_parameter}
- assert dependent_parameter.name == 'dependent'
assert dependent_parameter.display_name == 'display_name'
self.compare_parameters(dependent_parameter, 2 * normal_parameter)
@@ -342,7 +309,6 @@ def test_parameter_from_dependency(self, normal_parameter: Parameter):
def test_parameter_from_dependency_with_desired_unit(self, normal_parameter: Parameter):
# When Then
dependent_parameter = Parameter.from_dependency(
- name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
display_name='display_name',
@@ -353,7 +319,6 @@ def test_parameter_from_dependency_with_desired_unit(self, normal_parameter: Par
assert dependent_parameter._independent == False
assert dependent_parameter.dependency_expression == '2*a'
assert dependent_parameter.dependency_map == {'a': normal_parameter}
- assert dependent_parameter.name == 'dependent'
assert dependent_parameter.display_name == 'display_name'
assert dependent_parameter.value == 200 * normal_parameter.value
@@ -386,7 +351,6 @@ def test_parameter_from_dependency_with_desired_unit_incompatible_unit_raises(
# When Then Expect
with pytest.raises(UnitError):
dependent_parameter = Parameter.from_dependency(
- name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
display_name='display_name',
@@ -396,8 +360,7 @@ def test_parameter_from_dependency_with_desired_unit_incompatible_unit_raises(
def test_dependent_parameter_with_unique_name(self, clear, normal_parameter: Parameter):
# When Then
dependent_parameter = Parameter.from_dependency(
- name='dependent',
- dependency_expression='2*"Parameter_0"',
+ display_name='dependent', dependency_expression='2*"Parameter_0"'
)
# Expect
@@ -416,7 +379,7 @@ def test_process_dependency_unique_names_double_quotes(
):
# When
independent_parameter = Parameter(
- name='independent',
+ display_name='independent',
value=1,
unit='m',
variance=0.01,
@@ -438,7 +401,7 @@ def test_process_dependency_unique_names_single_quotes(
):
# When
independent_parameter = Parameter(
- name='independent',
+ display_name='independent',
value=1,
unit='m',
variance=0.01,
@@ -447,7 +410,7 @@ def test_process_dependency_unique_names_single_quotes(
unique_name='Special_name',
)
independent_parameter_2 = Parameter(
- name='independent_2',
+ display_name='independent_2',
value=1,
unit='m',
variance=0.01,
@@ -489,7 +452,7 @@ def test_process_dependency_unique_names_exception_not_a_descriptorNumber(
):
# When
normal_parameter._dependency_map = {}
- base_obj = ObjBase(name='ObjBase', unique_name='base_obj')
+ base_obj = ModelBase(unique_name='base_obj')
# Then Expect
with pytest.raises(
@@ -501,10 +464,10 @@ def test_process_dependency_unique_names_exception_not_a_descriptorNumber(
@pytest.mark.parametrize(
'dependency_expression, dependency_map',
[
- (2, {'a': Parameter(name='a', value=1)}),
- ('2*a', ['a', Parameter(name='a', value=1)]),
- ('2*a', {4: Parameter(name='a', value=1)}),
- ('2*a', {'a': ObjBase(name='a')}),
+ (2, {'a': Parameter(display_name='a', value=1)}),
+ ('2*a', ['a', Parameter(display_name='a', value=1)]),
+ ('2*a', {4: Parameter(display_name='a', value=1)}),
+ ('2*a', {'a': ModelBase(display_name='a')}),
],
ids=[
'dependency_expression_not_a_string',
@@ -519,7 +482,7 @@ def test_parameter_from_dependency_input_exceptions(
# When Then Expect
with pytest.raises(TypeError):
Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression=dependency_expression,
dependency_map=dependency_map,
)
@@ -543,9 +506,11 @@ def test_parameter_make_dependent_on_exceptions_cleanup_previously_dependent(
self, normal_parameter, dependency_expression, error
):
# When
- independent_parameter = Parameter(name='independent', value=10, unit='s', variance=0.02)
+ independent_parameter = Parameter(
+ display_name='independent', value=10, unit='s', variance=0.02
+ )
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='best',
dependency_map={'best': independent_parameter},
)
@@ -568,7 +533,9 @@ def test_parameter_make_dependent_on_exceptions_cleanup_previously_independent(
self, normal_parameter
):
# When
- independent_parameter = Parameter(name='independent', value=10, unit='s', variance=0.02)
+ independent_parameter = Parameter(
+ display_name='independent', value=10, unit='s', variance=0.02
+ )
# Then Expect
# Check that the correct error is raised
with pytest.raises(NameError):
@@ -585,7 +552,7 @@ def test_parameter_make_dependent_on_exceptions_cleanup_previously_independent(
def test_dependent_parameter_updates(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -612,17 +579,17 @@ def test_dependent_parameter_updates(self, normal_parameter: Parameter):
def test_dependent_parameter_indirect_updates(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
dependent_parameter_2 = Parameter.from_dependency(
- name='dependent_2',
+ display_name='dependent_2',
dependency_expression='10*a',
dependency_map={'a': normal_parameter},
)
dependent_parameter_3 = Parameter.from_dependency(
- name='dependent_3',
+ display_name='dependent_3',
dependency_expression='b+c',
dependency_map={'b': dependent_parameter, 'c': dependent_parameter_2},
)
@@ -639,12 +606,12 @@ def test_dependent_parameter_indirect_updates(self, normal_parameter: Parameter)
def test_dependent_parameter_cyclic_dependencies(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
dependent_parameter_2 = Parameter.from_dependency(
- name='dependent_2',
+ display_name='dependent_2',
dependency_expression='2*b',
dependency_map={'b': dependent_parameter},
)
@@ -664,7 +631,7 @@ def test_dependent_parameter_cyclic_dependencies(self, normal_parameter: Paramet
def test_dependent_parameter_logical_dependency(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='a if a.value > 0 else -a',
dependency_map={'a': normal_parameter},
)
@@ -678,11 +645,13 @@ def test_dependent_parameter_logical_dependency(self, normal_parameter: Paramete
def test_dependent_parameter_return_is_descriptor_number(self):
# When
- descriptor_number = DescriptorNumber(name='descriptor', value=1, unit='m', variance=0.01)
+ descriptor_number = DescriptorNumber(
+ display_name='descriptor', value=1, unit='m', variance=0.01
+ )
# Then
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*descriptor',
dependency_map={'descriptor': descriptor_number},
)
@@ -702,12 +671,9 @@ def test_dependent_parameter_division_expression_order(self):
would work correctly.
"""
# When
- angstrom = DescriptorNumber('angstrom', 1e-10, unit='m')
+ angstrom = DescriptorNumber(1e-10, unit='m', display_name='angstrom')
jump_length = Parameter(
- name='jump_length',
- value=float(1.0),
- fixed=False,
- unit='angstrom',
+ display_name='jump_length', value=float(1.0), fixed=False, unit='angstrom'
)
expression = 'jump_length / angstrom'
@@ -721,7 +687,7 @@ def test_dependent_parameter_division_expression_order(self):
expected_value = expected_result.value
# Then - This should not raise an error
- dependent_param = Parameter(name='a', value=1.0)
+ dependent_param = Parameter(display_name='a', value=1.0)
dependent_param.make_dependent_on(
dependency_expression=expression,
dependency_map=dependency_map,
@@ -732,7 +698,7 @@ def test_dependent_parameter_division_expression_order(self):
# Also test the alternative expression that previously worked
expression_alt = '1/angstrom * jump_length'
- dependent_param_alt = Parameter(name='b', value=1.0)
+ dependent_param_alt = Parameter(display_name='b', value=1.0)
dependent_param_alt.make_dependent_on(
dependency_expression=expression_alt,
dependency_map=dependency_map,
@@ -742,7 +708,7 @@ def test_dependent_parameter_division_expression_order(self):
def test_dependent_parameter_overwrite_dependency(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -750,7 +716,7 @@ def test_dependent_parameter_overwrite_dependency(self, normal_parameter: Parame
# Then
normal_parameter_2 = Parameter(
- name='a2', value=-2, unit='m', variance=0.01, min=-10, max=0
+ display_name='a2', value=-2, unit='m', variance=0.01, min=-10, max=0
)
dependent_parameter.make_dependent_on(
dependency_expression='3*a2', dependency_map={'a2': normal_parameter_2}
@@ -766,7 +732,7 @@ def test_dependent_parameter_overwrite_dependency(self, normal_parameter: Parame
def test_make_independent(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -800,7 +766,7 @@ def test_independent_parameter_dependency_expression(self, normal_parameter: Par
def test_dependent_parameter_dependency_expression_setter(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -817,7 +783,7 @@ def test_independent_parameter_dependency_map(self, normal_parameter: Parameter)
def test_dependent_parameter_dependency_map_setter(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -832,8 +798,6 @@ def test_min(self, parameter: Parameter):
def test_set_min(self, parameter: Parameter):
# When Then
- self.mock_callback.fget.return_value = 1.0 # Ensure fget returns a scalar value
-
parameter.min = 0.1
# Expect
@@ -842,7 +806,7 @@ def test_set_min(self, parameter: Parameter):
def test_set_min_dependent_parameter(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -866,7 +830,7 @@ def test_set_max(self, parameter: Parameter):
def test_set_max_dependent_parameter(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -893,7 +857,6 @@ def test_convert_unit(self, parameter: Parameter):
def test_set_desired_unit(self, normal_parameter: Parameter):
# When Then
dependent_parameter = Parameter.from_dependency(
- name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
display_name='display_name',
@@ -931,7 +894,6 @@ def test_set_desired_unit(self, normal_parameter: Parameter):
def test_set_desired_unit_incompatible_units_raises(self, normal_parameter: Parameter):
# When Then
dependent_parameter = Parameter.from_dependency(
- name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
display_name='display_name',
@@ -947,7 +909,6 @@ def test_set_desired_unit_incompatible_units_raises(self, normal_parameter: Para
def test_set_desired_unit_None(self, normal_parameter: Parameter):
# When Then
dependent_parameter = Parameter.from_dependency(
- name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
display_name='display_name',
@@ -975,7 +936,6 @@ def test_set_desired_unit_independent_parameter_raises(self, normal_parameter: P
def test_set_desired_unit_incorrect_unit_type_raises(self, normal_parameter: Parameter):
# When Then
dependent_parameter = Parameter.from_dependency(
- name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
display_name='display_name',
@@ -995,7 +955,7 @@ def test_set_fixed(self, parameter: Parameter):
def test_set_fixed_dependent_parameter(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -1034,7 +994,9 @@ def test_set_error_exception(self, parameter: Parameter):
def test_repr(self, parameter: Parameter):
# When Then Expect
- assert repr(parameter) == ""
+ assert (
+ repr(parameter) == ""
+ )
def test_repr_fixed(self, parameter: Parameter):
# When
@@ -1042,43 +1004,33 @@ def test_repr_fixed(self, parameter: Parameter):
# Then Expect
assert (
- repr(parameter) == ""
+ repr(parameter)
+ == ""
)
- def test_value_match_callback(self, parameter: Parameter):
+ def test_value_read_is_repeatable_and_has_no_side_effects(self, parameter: Parameter):
# When
- self.mock_callback.fget.return_value = 1.0
+ observer = MagicMock()
+ parameter._attach_observer(observer)
- # Then Expect
- assert parameter.value == 1.0
- assert parameter._callback.fget.call_count == 1
-
- def test_value_no_match_callback(self, parameter: Parameter):
- # When
- self.mock_callback.fget.return_value = 2.0
+ # Then
+ values = [parameter.value for _ in range(3)]
- # Then Expect
- assert parameter.value == 2.0
- assert parameter._callback.fget.call_count == 1
+ # Expect
+ assert values == [1.0, 1.0, 1.0]
+ assert observer.mock_calls == []
def test_set_value(self, parameter: Parameter):
- # When
- # First call returns 1.0 that is used to enforce the undo/redo functionality to register the value has changed
- # Second and third call returns 2.0 is used in the constraint check
- self.mock_callback.fget.side_effect = [1.0, 2.0, 2.0]
-
- # Then
+ # When Then
parameter.value = 2
# Expect
- parameter._callback.fset.assert_called_with(2)
- assert parameter._callback.fset.call_count == 1
assert parameter._scalar == sc.scalar(2, unit='m')
def test_set_value_dependent_parameter(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -1095,7 +1047,7 @@ def test_set_full_value(self, parameter: Parameter):
def test_set_variance_dependent_parameter(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -1107,7 +1059,7 @@ def test_set_variance_dependent_parameter(self, normal_parameter: Parameter):
def test_set_error_dependent_parameter(self, normal_parameter: Parameter):
# When
dependent_parameter = Parameter.from_dependency(
- name='dependent',
+ display_name='dependent',
dependency_expression='2*a',
dependency_map={'a': normal_parameter},
)
@@ -1118,15 +1070,12 @@ def test_set_error_dependent_parameter(self, normal_parameter: Parameter):
def test_copy(self, parameter: Parameter):
# When Then
- self.mock_callback.fget.return_value = 1.0 # Ensure fget returns a scalar value
parameter_copy = parameter.__copy__()
# Expect
assert type(parameter_copy) == Parameter
assert id(parameter_copy._scalar) != id(parameter._scalar)
- assert isinstance(parameter_copy._callback, property)
- assert parameter_copy._name == parameter._name
assert parameter_copy._scalar == parameter._scalar
assert parameter_copy._min == parameter._min
assert parameter_copy._max == parameter._max
@@ -1140,19 +1089,32 @@ def test_copy(self, parameter: Parameter):
'test, expected, expected_reverse',
[
(
- Parameter('test', 2, 'm', 0.01, -10, 20),
- Parameter('name + test', 3, 'm', 0.02, -10, 30),
- Parameter('test + name', 3, 'm', 0.02, -10, 30),
+ Parameter(2, unit='m', variance=0.01, min=-10, max=20, display_name='test'),
+ Parameter(3, unit='m', variance=0.02, min=-10, max=30, display_name='name + test'),
+ Parameter(3, unit='m', variance=0.02, min=-10, max=30, display_name='test + name'),
),
(
- Parameter('test', 2, 'm', 0.01),
- Parameter('name + test', 3, 'm', 0.02, min=-np.inf, max=np.inf),
- Parameter('test + name', 3, 'm', 0.02, min=-np.inf, max=np.inf),
+ Parameter(2, unit='m', variance=0.01, display_name='test'),
+ Parameter(
+ 3, unit='m', variance=0.02, min=-np.inf, max=np.inf, display_name='name + test'
+ ),
+ Parameter(
+ 3, unit='m', variance=0.02, min=-np.inf, max=np.inf, display_name='test + name'
+ ),
),
(
- Parameter('test', 2, 'cm', 0.01, -10, 10),
- Parameter('name + test', 1.02, 'm', 0.010001, -0.1, 10.1),
- Parameter('test + name', 102, 'cm', 100.01, -10, 1010),
+ Parameter(2, unit='cm', variance=0.01, min=-10, max=10, display_name='test'),
+ Parameter(
+ 1.02,
+ unit='m',
+ variance=0.010001,
+ min=-0.1,
+ max=10.1,
+ display_name='name + test',
+ ),
+ Parameter(
+ 102, unit='cm', variance=100.01, min=-10, max=1010, display_name='test + name'
+ ),
),
],
ids=['regular', 'no_bounds', 'unit_conversion'],
@@ -1164,22 +1126,17 @@ def test_addition_with_parameter(
expected: Parameter,
expected_reverse: Parameter,
):
- # When
- parameter._callback = property()
-
- # Then
+ # When Then
result = parameter + test
result_reverse = test + parameter
# Expect
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
assert result.min == expected.min
assert result.max == expected.max
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == expected_reverse.value
assert result_reverse.unit == expected_reverse.unit
assert result_reverse.variance == expected_reverse.variance
@@ -1190,21 +1147,19 @@ def test_addition_with_parameter(
def test_addition_with_scalar(self):
# When
- parameter = Parameter(name='name', value=1, variance=0.01, min=0, max=10)
+ parameter = Parameter(display_name='name', value=1, variance=0.01, min=0, max=10)
# Then
result = parameter + 1.0
result_reverse = 1.0 + parameter
# Expect
- assert result.name == result.unique_name
assert result.value == 2.0
assert result.unit == 'dimensionless'
assert result.variance == 0.01
assert result.min == 1.0
assert result.max == 11.0
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == 2.0
assert result_reverse.unit == 'dimensionless'
assert result_reverse.variance == 0.01
@@ -1213,8 +1168,7 @@ def test_addition_with_scalar(self):
def test_addition_with_descriptor_number(self, parameter: Parameter):
# When
- parameter._callback = property()
- descriptor_number = DescriptorNumber(name='test', value=1, variance=0.1, unit='cm')
+ descriptor_number = DescriptorNumber(display_name='test', value=1, variance=0.1, unit='cm')
# Then
result = parameter + descriptor_number
@@ -1222,7 +1176,6 @@ def test_addition_with_descriptor_number(self, parameter: Parameter):
# Expect
assert type(result) == Parameter
- assert result.name == result.unique_name
assert result.value == 1.01
assert result.unit == 'm'
assert result.variance == 0.01001
@@ -1230,7 +1183,6 @@ def test_addition_with_descriptor_number(self, parameter: Parameter):
assert result.max == 10.01
assert type(result_reverse) == Parameter
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == 101.0
assert result_reverse.unit == 'cm'
assert result_reverse.variance == 100.1
@@ -1244,11 +1196,7 @@ def test_addition_with_descriptor_number(self, parameter: Parameter):
'test',
[
1.0,
- Parameter(
- 'test',
- 2,
- 's',
- ),
+ Parameter(2, unit='s', display_name='test'),
],
ids=['add_scalar_to_unit', 'incompatible_units'],
)
@@ -1263,19 +1211,39 @@ def test_addition_exception(self, parameter: Parameter, test):
'test, expected, expected_reverse',
[
(
- Parameter('test', 2, 'm', 0.01, -20, 20),
- Parameter('name - test', -1, 'm', 0.02, -20, 30),
- Parameter('test - name', 1, 'm', 0.02, -30, 20),
+ Parameter(2, unit='m', variance=0.01, min=-20, max=20, display_name='test'),
+ Parameter(
+ -1, unit='m', variance=0.02, min=-20, max=30, display_name='name - test'
+ ),
+ Parameter(1, unit='m', variance=0.02, min=-30, max=20, display_name='test - name'),
),
(
- Parameter('test', 2, 'm', 0.01),
- Parameter('name - test', -1, 'm', 0.02, min=-np.inf, max=np.inf),
- Parameter('test - name', 1, 'm', 0.02, min=-np.inf, max=np.inf),
+ Parameter(2, unit='m', variance=0.01, display_name='test'),
+ Parameter(
+ -1,
+ unit='m',
+ variance=0.02,
+ min=-np.inf,
+ max=np.inf,
+ display_name='name - test',
+ ),
+ Parameter(
+ 1, unit='m', variance=0.02, min=-np.inf, max=np.inf, display_name='test - name'
+ ),
),
(
- Parameter('test', 2, 'cm', 0.01, -10, 10),
- Parameter('name - test', 0.98, 'm', 0.010001, -0.1, 10.1),
- Parameter('test - name', -98, 'cm', 100.01, -1010, 10),
+ Parameter(2, unit='cm', variance=0.01, min=-10, max=10, display_name='test'),
+ Parameter(
+ 0.98,
+ unit='m',
+ variance=0.010001,
+ min=-0.1,
+ max=10.1,
+ display_name='name - test',
+ ),
+ Parameter(
+ -98, unit='cm', variance=100.01, min=-1010, max=10, display_name='test - name'
+ ),
),
],
ids=['regular', 'no_bounds', 'unit_conversion'],
@@ -1287,22 +1255,17 @@ def test_subtraction_with_parameter(
expected: Parameter,
expected_reverse: Parameter,
):
- # When
- parameter._callback = property()
-
- # Then
+ # When Then
result = parameter - test
result_reverse = test - parameter
# Expect
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
assert result.min == expected.min
assert result.max == expected.max
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == expected_reverse.value
assert result_reverse.unit == expected_reverse.unit
assert result_reverse.variance == expected_reverse.variance
@@ -1313,22 +1276,20 @@ def test_subtraction_with_parameter(
def test_subtraction_with_parameter_nan_cases(self):
# When
- parameter = Parameter(name='name', value=1, variance=0.01, min=-np.inf, max=np.inf)
- test = Parameter(name='test', value=2, variance=0.01, min=-np.inf, max=np.inf)
+ parameter = Parameter(display_name='name', value=1, variance=0.01, min=-np.inf, max=np.inf)
+ test = Parameter(display_name='test', value=2, variance=0.01, min=-np.inf, max=np.inf)
# Then
result = parameter - test
result_reverse = test - parameter
# Expect
- assert result.name == result.unique_name
assert result.value == -1.0
assert result.unit == 'dimensionless'
assert result.variance == 0.02
assert result.min == -np.inf
assert result.max == np.inf
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == 1.0
assert result_reverse.unit == 'dimensionless'
assert result_reverse.variance == 0.02
@@ -1337,21 +1298,19 @@ def test_subtraction_with_parameter_nan_cases(self):
def test_subtraction_with_scalar(self):
# When
- parameter = Parameter(name='name', value=2, variance=0.01, min=0, max=10)
+ parameter = Parameter(display_name='name', value=2, variance=0.01, min=0, max=10)
# Then
result = parameter - 1.0
result_reverse = 1.0 - parameter
# Expect
- assert result.name == result.unique_name
assert result.value == 1.0
assert result.unit == 'dimensionless'
assert result.variance == 0.01
assert result.min == -1.0
assert result.max == 9.0
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == -1.0
assert result_reverse.unit == 'dimensionless'
assert result_reverse.variance == 0.01
@@ -1360,8 +1319,7 @@ def test_subtraction_with_scalar(self):
def test_subtraction_with_descriptor_number(self, parameter: Parameter):
# When
- parameter._callback = property()
- descriptor_number = DescriptorNumber(name='test', value=1, variance=0.1, unit='cm')
+ descriptor_number = DescriptorNumber(display_name='test', value=1, variance=0.1, unit='cm')
# Then
result = parameter - descriptor_number
@@ -1369,7 +1327,6 @@ def test_subtraction_with_descriptor_number(self, parameter: Parameter):
# Expect
assert type(result) == Parameter
- assert result.name == result.unique_name
assert result.value == 0.99
assert result.unit == 'm'
assert result.variance == 0.01001
@@ -1377,7 +1334,6 @@ def test_subtraction_with_descriptor_number(self, parameter: Parameter):
assert result.max == 9.99
assert type(result_reverse) == Parameter
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == -99.0
assert result_reverse.unit == 'cm'
assert result_reverse.variance == 100.1
@@ -1391,11 +1347,7 @@ def test_subtraction_with_descriptor_number(self, parameter: Parameter):
'test',
[
1.0,
- Parameter(
- 'test',
- 2,
- 's',
- ),
+ Parameter(2, unit='s', display_name='test'),
],
ids=['sub_scalar_to_unit', 'incompatible_units'],
)
@@ -1410,19 +1362,41 @@ def test_subtraction_exception(self, parameter: Parameter, test):
'test, expected, expected_reverse',
[
(
- Parameter('test', 2, 'm', 0.01, -10, 20),
- Parameter('name * test', 2, 'm^2', 0.05, -100, 200),
- Parameter('test * name', 2, 'm^2', 0.05, -100, 200),
+ Parameter(2, unit='m', variance=0.01, min=-10, max=20, display_name='test'),
+ Parameter(
+ 2, unit='m^2', variance=0.05, min=-100, max=200, display_name='name * test'
+ ),
+ Parameter(
+ 2, unit='m^2', variance=0.05, min=-100, max=200, display_name='test * name'
+ ),
),
(
- Parameter('test', 2, 'm', 0.01),
- Parameter('name * test', 2, 'm^2', 0.05, min=-np.inf, max=np.inf),
- Parameter('test * name', 2, 'm^2', 0.05, min=-np.inf, max=np.inf),
+ Parameter(2, unit='m', variance=0.01, display_name='test'),
+ Parameter(
+ 2,
+ unit='m^2',
+ variance=0.05,
+ min=-np.inf,
+ max=np.inf,
+ display_name='name * test',
+ ),
+ Parameter(
+ 2,
+ unit='m^2',
+ variance=0.05,
+ min=-np.inf,
+ max=np.inf,
+ display_name='test * name',
+ ),
),
(
- Parameter('test', 2, 'dm', 0.01, -10, 20),
- Parameter('name * test', 0.2, 'm^2', 0.0005, -10, 20),
- Parameter('test * name', 0.2, 'm^2', 0.0005, -10, 20),
+ Parameter(2, unit='dm', variance=0.01, min=-10, max=20, display_name='test'),
+ Parameter(
+ 0.2, unit='m^2', variance=0.0005, min=-10, max=20, display_name='name * test'
+ ),
+ Parameter(
+ 0.2, unit='m^2', variance=0.0005, min=-10, max=20, display_name='test * name'
+ ),
),
],
ids=['regular', 'no_bounds', 'base_unit_conversion'],
@@ -1434,22 +1408,17 @@ def test_multiplication_with_parameter(
expected: Parameter,
expected_reverse: Parameter,
):
- # When
- parameter._callback = property()
-
- # Then
+ # When Then
result = parameter * test
result_reverse = test * parameter
# Expect
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == pytest.approx(expected.variance)
assert result.min == expected.min
assert result.max == expected.max
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == expected_reverse.value
assert result_reverse.unit == expected_reverse.unit
assert result_reverse.variance == pytest.approx(expected_reverse.variance)
@@ -1460,35 +1429,61 @@ def test_multiplication_with_parameter(
'test, expected, expected_reverse',
[
(
- Parameter('test', 0, '', 0.01, -10, 0),
- Parameter('name * test', 0.0, 'dimensionless', 0.01, -np.inf, 0),
- Parameter('test * name', 0, 'dimensionless', 0.01, -np.inf, 0),
+ Parameter(0, unit='', variance=0.01, min=-10, max=0, display_name='test'),
+ Parameter(
+ 0.0,
+ unit='dimensionless',
+ variance=0.01,
+ min=-np.inf,
+ max=0,
+ display_name='name * test',
+ ),
+ Parameter(
+ 0,
+ unit='dimensionless',
+ variance=0.01,
+ min=-np.inf,
+ max=0,
+ display_name='test * name',
+ ),
),
(
- Parameter('test', 0, '', 0.01, 0, 10),
- Parameter('name * test', 0.0, 'dimensionless', 0.01, 0, np.inf),
- Parameter('test * name', 0, 'dimensionless', 0.01, 0, np.inf),
+ Parameter(0, unit='', variance=0.01, min=0, max=10, display_name='test'),
+ Parameter(
+ 0.0,
+ unit='dimensionless',
+ variance=0.01,
+ min=0,
+ max=np.inf,
+ display_name='name * test',
+ ),
+ Parameter(
+ 0,
+ unit='dimensionless',
+ variance=0.01,
+ min=0,
+ max=np.inf,
+ display_name='test * name',
+ ),
),
],
ids=['zero_min', 'zero_max'],
)
def test_multiplication_with_parameter_nan_cases(self, test, expected, expected_reverse):
# When
- parameter = Parameter(name='name', value=1, variance=0.01, min=1, max=np.inf)
+ parameter = Parameter(display_name='name', value=1, variance=0.01, min=1, max=np.inf)
# Then
result = parameter * test
result_reverse = test * parameter
# Expect
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
assert result.min == expected.min
assert result.max == expected.max
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == expected_reverse.value
assert result_reverse.unit == expected_reverse.unit
assert result_reverse.variance == expected_reverse.variance
@@ -1499,14 +1494,18 @@ def test_multiplication_with_parameter_nan_cases(self, test, expected, expected_
'test, expected, expected_reverse',
[
(
- DescriptorNumber(name='test', value=2, variance=0.1, unit='cm'),
- Parameter('name * test', 2, 'dm^2', 0.14, 0, 20),
- Parameter('test * name', 2, 'dm^2', 0.14, 0, 20),
+ DescriptorNumber(display_name='test', value=2, variance=0.1, unit='cm'),
+ Parameter(
+ 2, unit='dm^2', variance=0.14, min=0, max=20, display_name='name * test'
+ ),
+ Parameter(
+ 2, unit='dm^2', variance=0.14, min=0, max=20, display_name='test * name'
+ ),
),
(
- DescriptorNumber(name='test', value=0, variance=0.1, unit='cm'),
- DescriptorNumber('name * test', 0, 'dm^2', 0.1),
- DescriptorNumber('test * name', 0, 'dm^2', 0.1),
+ DescriptorNumber(display_name='test', value=0, variance=0.1, unit='cm'),
+ DescriptorNumber(0, unit='dm^2', variance=0.1, display_name='name * test'),
+ DescriptorNumber(0, unit='dm^2', variance=0.1, display_name='test * name'),
),
],
ids=['regular', 'zero_value'],
@@ -1514,16 +1513,12 @@ def test_multiplication_with_parameter_nan_cases(self, test, expected, expected_
def test_multiplication_with_descriptor_number(
self, parameter: Parameter, test, expected, expected_reverse
):
- # When
- parameter._callback = property()
-
- # Then
+ # When Then
result = parameter * test
result_reverse = test * parameter
# Expect
assert type(result) == type(expected)
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -1532,7 +1527,6 @@ def test_multiplication_with_descriptor_number(
assert result.max == expected.max
assert type(result_reverse) == type(expected_reverse)
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == expected_reverse.value
assert result_reverse.unit == expected_reverse.unit
assert result_reverse.variance == expected_reverse.variance
@@ -1545,13 +1539,13 @@ def test_multiplication_with_descriptor_number(
[
(
2,
- Parameter('name * 2', 2, 'm', 0.04, 0, 20),
- Parameter('2 * name', 2, 'm', 0.04, 0, 20),
+ Parameter(2, unit='m', variance=0.04, min=0, max=20, display_name='name * 2'),
+ Parameter(2, unit='m', variance=0.04, min=0, max=20, display_name='2 * name'),
),
(
0,
- DescriptorNumber('name * 0', 0, 'm', 0),
- DescriptorNumber('0 * name', 0, 'm', 0),
+ DescriptorNumber(0, unit='m', variance=0, display_name='name * 0'),
+ DescriptorNumber(0, unit='m', variance=0, display_name='0 * name'),
),
],
ids=['regular', 'zero_value'],
@@ -1559,16 +1553,12 @@ def test_multiplication_with_descriptor_number(
def test_multiplication_with_scalar(
self, parameter: Parameter, test, expected, expected_reverse
):
- # When
- parameter._callback = property()
-
- # Then
+ # When Then
result = parameter * test
result_reverse = test * parameter
# Expect
assert type(result) == type(expected)
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -1576,7 +1566,6 @@ def test_multiplication_with_scalar(
assert result.min == expected.min
assert result.max == expected.max
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == expected_reverse.value
assert result_reverse.unit == expected_reverse.unit
assert result_reverse.variance == expected_reverse.variance
@@ -1588,34 +1577,62 @@ def test_multiplication_with_scalar(
'test, expected, expected_reverse',
[
(
- Parameter('test', 2, 's', 0.01, -10, 20),
- Parameter('name / test', 0.5, 'm/s', 0.003125, -np.inf, np.inf),
- Parameter('test / name', 2, 's/m', 0.05, -np.inf, np.inf),
+ Parameter(2, unit='s', variance=0.01, min=-10, max=20, display_name='test'),
+ Parameter(
+ 0.5,
+ unit='m/s',
+ variance=0.003125,
+ min=-np.inf,
+ max=np.inf,
+ display_name='name / test',
+ ),
+ Parameter(
+ 2,
+ unit='s/m',
+ variance=0.05,
+ min=-np.inf,
+ max=np.inf,
+ display_name='test / name',
+ ),
),
(
- Parameter('test', 2, 's', 0.01, 0, 20),
- Parameter('name / test', 0.5, 'm/s', 0.003125, 0.0, np.inf),
- Parameter('test / name', 2, 's/m', 0.05, 0.0, np.inf),
+ Parameter(2, unit='s', variance=0.01, min=0, max=20, display_name='test'),
+ Parameter(
+ 0.5,
+ unit='m/s',
+ variance=0.003125,
+ min=0.0,
+ max=np.inf,
+ display_name='name / test',
+ ),
+ Parameter(
+ 2, unit='s/m', variance=0.05, min=0.0, max=np.inf, display_name='test / name'
+ ),
),
(
- Parameter('test', -2, 's', 0.01, -10, 0),
- Parameter('name / test', -0.5, 'm/s', 0.003125, -np.inf, 0.0),
- Parameter('test / name', -2, 's/m', 0.05, -np.inf, 0.0),
+ Parameter(-2, unit='s', variance=0.01, min=-10, max=0, display_name='test'),
+ Parameter(
+ -0.5,
+ unit='m/s',
+ variance=0.003125,
+ min=-np.inf,
+ max=0.0,
+ display_name='name / test',
+ ),
+ Parameter(
+ -2, unit='s/m', variance=0.05, min=-np.inf, max=0.0, display_name='test / name'
+ ),
),
],
ids=['crossing_zero', 'only_positive', 'only_negative'],
)
def test_division_with_parameter(self, parameter: Parameter, test, expected, expected_reverse):
- # When
- parameter._callback = property()
-
- # Then
+ # When Then
result = parameter / test
result_reverse = test / parameter
# Expect
assert type(result) == Parameter
- assert result.name == result.unique_name
assert result.value == pytest.approx(expected.value)
assert result.unit == expected.unit
assert result.variance == pytest.approx(expected.variance)
@@ -1623,7 +1640,6 @@ def test_division_with_parameter(self, parameter: Parameter, test, expected, exp
assert result.max == expected.max
assert type(result) == Parameter
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == pytest.approx(expected_reverse.value)
assert result_reverse.unit == expected_reverse.unit
assert result_reverse.variance == pytest.approx(expected_reverse.variance)
@@ -1634,19 +1650,35 @@ def test_division_with_parameter(self, parameter: Parameter, test, expected, exp
'first, second, expected',
[
(
- Parameter('name', 1, 'm', 0.01, -10, 20),
- Parameter('test', -2, 's', 0.01, -10, 0),
- Parameter('name / test', -0.5, 'm/s', 0.003125, -np.inf, np.inf),
+ Parameter(1, unit='m', variance=0.01, min=-10, max=20, display_name='name'),
+ Parameter(-2, unit='s', variance=0.01, min=-10, max=0, display_name='test'),
+ Parameter(
+ -0.5,
+ unit='m/s',
+ variance=0.003125,
+ min=-np.inf,
+ max=np.inf,
+ display_name='name / test',
+ ),
),
(
- Parameter('name', -10, 'm', 0.01, -20, -10),
- Parameter('test', -2, 's', 0.01, -10, 0),
- Parameter('name / test', 5.0, 'm/s', 0.065, 1, np.inf),
+ Parameter(-10, unit='m', variance=0.01, min=-20, max=-10, display_name='name'),
+ Parameter(-2, unit='s', variance=0.01, min=-10, max=0, display_name='test'),
+ Parameter(
+ 5.0, unit='m/s', variance=0.065, min=1, max=np.inf, display_name='name / test'
+ ),
),
(
- Parameter('name', 10, 'm', 0.01, 10, 20),
- Parameter('test', -20, 's', 0.01, -20, -10),
- Parameter('name / test', -0.5, 'm/s', 3.125e-5, -2, -0.5),
+ Parameter(10, unit='m', variance=0.01, min=10, max=20, display_name='name'),
+ Parameter(-20, unit='s', variance=0.01, min=-20, max=-10, display_name='test'),
+ Parameter(
+ -0.5,
+ unit='m/s',
+ variance=3.125e-5,
+ min=-2,
+ max=-0.5,
+ display_name='name / test',
+ ),
),
],
ids=[
@@ -1660,7 +1692,6 @@ def test_division_with_parameter_remaining_cases(self, first, second, expected):
result = first / second
# Expect
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -1671,14 +1702,20 @@ def test_division_with_parameter_remaining_cases(self, first, second, expected):
'test, expected, expected_reverse',
[
(
- DescriptorNumber(name='test', value=2, variance=0.1, unit='s'),
- Parameter('name / test', 0.5, 'm/s', 0.00875, 0, 5),
- Parameter('test / name', 2, 's/m', 0.14, 0.2, np.inf),
+ DescriptorNumber(display_name='test', value=2, variance=0.1, unit='s'),
+ Parameter(
+ 0.5, unit='m/s', variance=0.00875, min=0, max=5, display_name='name / test'
+ ),
+ Parameter(
+ 2, unit='s/m', variance=0.14, min=0.2, max=np.inf, display_name='test / name'
+ ),
),
(
2,
- Parameter('name / 2', 0.5, 'm', 0.0025, 0, 5),
- Parameter('2 / name', 2, 'm**-1', 0.04, 0.2, np.inf),
+ Parameter(0.5, unit='m', variance=0.0025, min=0, max=5, display_name='name / 2'),
+ Parameter(
+ 2, unit='m**-1', variance=0.04, min=0.2, max=np.inf, display_name='2 / name'
+ ),
),
],
ids=['descriptor_number', 'number'],
@@ -1686,16 +1723,12 @@ def test_division_with_parameter_remaining_cases(self, first, second, expected):
def test_division_with_descriptor_number_and_number(
self, parameter: Parameter, test, expected, expected_reverse
):
- # When
- parameter._callback = property()
-
- # Then
+ # When Then
result = parameter / test
result_reverse = test / parameter
# Expect
assert type(result) == Parameter
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -1703,7 +1736,6 @@ def test_division_with_descriptor_number_and_number(
assert result.max == expected.max
assert type(result_reverse) == Parameter
- assert result_reverse.name == result_reverse.unique_name
assert result_reverse.value == expected_reverse.value
assert result_reverse.unit == expected_reverse.unit
assert result_reverse.variance == expected_reverse.variance
@@ -1714,23 +1746,19 @@ def test_division_with_descriptor_number_and_number(
'test, expected',
[
(
- DescriptorNumber(name='test', value=0, variance=0.1, unit='s'),
- DescriptorNumber('test / name', 0.0, 's/m', 0.1),
+ DescriptorNumber(display_name='test', value=0, variance=0.1, unit='s'),
+ DescriptorNumber(0.0, unit='s/m', variance=0.1, display_name='test / name'),
),
- (0, DescriptorNumber('0 / name', 0.0, '1/m', 0.0)),
+ (0, DescriptorNumber(0.0, unit='1/m', variance=0.0, display_name='0 / name')),
],
ids=['descriptor_number', 'number'],
)
def test_zero_value_divided_by_parameter(self, parameter: Parameter, test, expected):
- # When
- parameter._callback = property()
-
- # Then
+ # When Then
result = test / parameter
# Expect
assert type(result) == DescriptorNumber
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -1739,29 +1767,59 @@ def test_zero_value_divided_by_parameter(self, parameter: Parameter, test, expec
'first, second, expected',
[
(
- DescriptorNumber('name', 1, 'm', 0.01),
- Parameter('test', 2, 's', 0.1, -10, 10),
- Parameter('name / test', 0.5, 'm/s', 0.00875, -np.inf, np.inf),
+ DescriptorNumber(1, unit='m', variance=0.01, display_name='name'),
+ Parameter(2, unit='s', variance=0.1, min=-10, max=10, display_name='test'),
+ Parameter(
+ 0.5,
+ unit='m/s',
+ variance=0.00875,
+ min=-np.inf,
+ max=np.inf,
+ display_name='name / test',
+ ),
),
(
- DescriptorNumber('name', -1, 'm', 0.01),
- Parameter('test', 2, 's', 0.1, 0, 10),
- Parameter('name / test', -0.5, 'm/s', 0.00875, -np.inf, -0.1),
+ DescriptorNumber(-1, unit='m', variance=0.01, display_name='name'),
+ Parameter(2, unit='s', variance=0.1, min=0, max=10, display_name='test'),
+ Parameter(
+ -0.5,
+ unit='m/s',
+ variance=0.00875,
+ min=-np.inf,
+ max=-0.1,
+ display_name='name / test',
+ ),
),
(
- DescriptorNumber('name', 1, 'm', 0.01),
- Parameter('test', -2, 's', 0.1, -10, 0),
- Parameter('name / test', -0.5, 'm/s', 0.00875, -np.inf, -0.1),
+ DescriptorNumber(1, unit='m', variance=0.01, display_name='name'),
+ Parameter(-2, unit='s', variance=0.1, min=-10, max=0, display_name='test'),
+ Parameter(
+ -0.5,
+ unit='m/s',
+ variance=0.00875,
+ min=-np.inf,
+ max=-0.1,
+ display_name='name / test',
+ ),
),
(
- DescriptorNumber('name', -1, 'm', 0.01),
- Parameter('test', -2, 's', 0.1, -10, 0),
- Parameter('name / test', 0.5, 'm/s', 0.00875, 0.1, np.inf),
+ DescriptorNumber(-1, unit='m', variance=0.01, display_name='name'),
+ Parameter(-2, unit='s', variance=0.1, min=-10, max=0, display_name='test'),
+ Parameter(
+ 0.5,
+ unit='m/s',
+ variance=0.00875,
+ min=0.1,
+ max=np.inf,
+ display_name='name / test',
+ ),
),
(
- DescriptorNumber('name', 1, 'm', 0.01),
- Parameter('test', 2, 's', 0.1, 1, 10),
- Parameter('name / test', 0.5, 'm/s', 0.00875, 0.1, 1),
+ DescriptorNumber(1, unit='m', variance=0.01, display_name='name'),
+ Parameter(2, unit='s', variance=0.1, min=1, max=10, display_name='test'),
+ Parameter(
+ 0.5, unit='m/s', variance=0.00875, min=0.1, max=1, display_name='name / test'
+ ),
),
],
ids=[
@@ -1777,7 +1835,6 @@ def test_division_with_descriptor_number_missing_cases(self, first, second, expe
result = first / second
# Expect
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -1786,12 +1843,11 @@ def test_division_with_descriptor_number_missing_cases(self, first, second, expe
@pytest.mark.parametrize(
'test',
- [0, DescriptorNumber('test', 0, 's', 0.1)],
+ [0, DescriptorNumber(0, unit='s', variance=0.1, display_name='test')],
ids=['number', 'descriptor_number'],
)
def test_divide_parameter_by_zero(self, parameter: Parameter, test):
# When
- parameter._callback = property()
# Then Expect
with pytest.raises(ZeroDivisionError):
@@ -1799,8 +1855,8 @@ def test_divide_parameter_by_zero(self, parameter: Parameter, test):
def test_divide_by_zero_value_parameter(self):
# When
- descriptor = DescriptorNumber('test', 1, 's', 0.1)
- parameter = Parameter('name', 0, 'm', 0.01)
+ descriptor = DescriptorNumber(1, unit='s', variance=0.1, display_name='test')
+ parameter = Parameter(0, unit='m', variance=0.01, display_name='name')
# Then Expect
with pytest.raises(ZeroDivisionError):
@@ -1809,14 +1865,41 @@ def test_divide_by_zero_value_parameter(self):
@pytest.mark.parametrize(
'test, expected',
[
- (3, Parameter('name ** 3', 125, 'm^3', 281.25, -125, 1000)),
- (2, Parameter('name ** 2', 25, 'm^2', 5.0, 0, 100)),
- (-1, Parameter('name ** -1', 0.2, '1/m', 8e-5, -np.inf, np.inf)),
- (-2, Parameter('name ** -2', 0.04, '1/m^2', 1.28e-5, 0, np.inf)),
- (0, DescriptorNumber('name ** 0', 1, 'dimensionless', 0)),
(
- DescriptorNumber('test', 2),
- Parameter('name ** test', 25, 'm^2', 5.0, 0, 100),
+ 3,
+ Parameter(
+ 125, unit='m^3', variance=281.25, min=-125, max=1000, display_name='name ** 3'
+ ),
+ ),
+ (2, Parameter(25, unit='m^2', variance=5.0, min=0, max=100, display_name='name ** 2')),
+ (
+ -1,
+ Parameter(
+ 0.2,
+ unit='1/m',
+ variance=8e-5,
+ min=-np.inf,
+ max=np.inf,
+ display_name='name ** -1',
+ ),
+ ),
+ (
+ -2,
+ Parameter(
+ 0.04,
+ unit='1/m^2',
+ variance=1.28e-5,
+ min=0,
+ max=np.inf,
+ display_name='name ** -2',
+ ),
+ ),
+ (0, DescriptorNumber(1, unit='dimensionless', variance=0, display_name='name ** 0')),
+ (
+ DescriptorNumber(2, display_name='test'),
+ Parameter(
+ 25, unit='m^2', variance=5.0, min=0, max=100, display_name='name ** test'
+ ),
),
],
ids=[
@@ -1830,14 +1913,13 @@ def test_divide_by_zero_value_parameter(self):
)
def test_power_of_parameter(self, test, expected):
# When
- parameter = Parameter('name', 5, 'm', 0.05, -5, 10)
+ parameter = Parameter(5, unit='m', variance=0.05, min=-5, max=10, display_name='name')
# Then
result = parameter**test
# Expect
assert type(result) == type(expected)
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -1849,46 +1931,73 @@ def test_power_of_parameter(self, test, expected):
'test, exponent, expected',
[
(
- Parameter('name', 5, 'm', 0.05, 0, 10),
+ Parameter(5, unit='m', variance=0.05, min=0, max=10, display_name='name'),
-1,
- Parameter('name ** -1', 0.2, '1/m', 8e-5, 0.1, np.inf),
+ Parameter(
+ 0.2, unit='1/m', variance=8e-5, min=0.1, max=np.inf, display_name='name ** -1'
+ ),
),
(
- Parameter('name', -5, 'm', 0.05, -5, 0),
+ Parameter(-5, unit='m', variance=0.05, min=-5, max=0, display_name='name'),
-1,
- Parameter('name ** -1', -0.2, '1/m', 8e-5, -np.inf, -0.2),
+ Parameter(
+ -0.2,
+ unit='1/m',
+ variance=8e-5,
+ min=-np.inf,
+ max=-0.2,
+ display_name='name ** -1',
+ ),
),
(
- Parameter('name', 5, 'm', 0.05, 5, 10),
+ Parameter(5, unit='m', variance=0.05, min=5, max=10, display_name='name'),
-1,
- Parameter('name ** -1', 0.2, '1/m', 8e-5, 0.1, 0.2),
+ Parameter(
+ 0.2, unit='1/m', variance=8e-5, min=0.1, max=0.2, display_name='name ** -1'
+ ),
),
(
- Parameter('name', -5, 'm', 0.05, -10, -5),
+ Parameter(-5, unit='m', variance=0.05, min=-10, max=-5, display_name='name'),
-1,
- Parameter('name ** -1', -0.2, '1/m', 8e-5, -0.2, -0.1),
+ Parameter(
+ -0.2, unit='1/m', variance=8e-5, min=-0.2, max=-0.1, display_name='name ** -1'
+ ),
),
(
- Parameter('name', -5, 'm', 0.05, -10, -5),
+ Parameter(-5, unit='m', variance=0.05, min=-10, max=-5, display_name='name'),
-2,
- Parameter('name ** -2', 0.04, '1/m^2', 1.28e-5, 0.01, 0.04),
+ Parameter(
+ 0.04,
+ unit='1/m^2',
+ variance=1.28e-5,
+ min=0.01,
+ max=0.04,
+ display_name='name ** -2',
+ ),
),
(
- Parameter('name', 5, '', 0.1, 1, 10),
+ Parameter(5, unit='', variance=0.1, min=1, max=10, display_name='name'),
0.3,
Parameter(
- 'name ** 0.3',
1.6206565966927624,
- '',
- 0.0009455500095853564,
- 1,
- 1.9952623149688795,
+ unit='',
+ variance=0.0009455500095853564,
+ min=1,
+ max=1.9952623149688795,
+ display_name='name ** 0.3',
),
),
(
- Parameter('name', 5, '', 0.1),
+ Parameter(5, unit='', variance=0.1, display_name='name'),
0.5,
- Parameter('name ** 0.5', 2.23606797749979, '', 0.005, 0, np.inf),
+ Parameter(
+ 2.23606797749979,
+ unit='',
+ variance=0.005,
+ min=0,
+ max=np.inf,
+ display_name='name ** 0.5',
+ ),
),
],
ids=[
@@ -1906,7 +2015,6 @@ def test_power_of_diffent_parameters(self, test, exponent, expected):
result = test**exponent
# Expect
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
@@ -1917,17 +2025,17 @@ def test_power_of_diffent_parameters(self, test, exponent, expected):
'parameter, exponent, expected',
[
(
- Parameter('name', 5, 'm'),
- DescriptorNumber('test', 2, unit='s'),
+ Parameter(5, unit='m', display_name='name'),
+ DescriptorNumber(2, unit='s', display_name='test'),
UnitError,
),
(
- Parameter('name', 5, 'm'),
- DescriptorNumber('test', 2, variance=0.01),
+ Parameter(5, unit='m', display_name='name'),
+ DescriptorNumber(2, variance=0.01, display_name='test'),
ValueError,
),
- (Parameter('name', 5, 'm'), 0.5, UnitError),
- (Parameter('name', -5, ''), 0.5, ValueError),
+ (Parameter(5, unit='m', display_name='name'), 0.5, UnitError),
+ (Parameter(-5, unit='', display_name='name'), 0.5, ValueError),
],
ids=[
'exponent_unit',
@@ -1943,13 +2051,12 @@ def test_power_exceptions(self, parameter, exponent, expected):
def test_negation(self):
# When
- parameter = Parameter('name', 5, 'm', 0.05, -5, 10)
+ parameter = Parameter(5, unit='m', variance=0.05, min=-5, max=10, display_name='name')
# Then
result = -parameter
# Expect
- assert result.name == result.unique_name
assert result.value == -5
assert result.unit == 'm'
assert result.variance == 0.05
@@ -1960,12 +2067,12 @@ def test_negation(self):
'test, expected',
[
(
- Parameter('name', -5, 'm', 0.05, -10, -5),
- Parameter('abs(name)', 5, 'm', 0.05, 5, 10),
+ Parameter(-5, unit='m', variance=0.05, min=-10, max=-5, display_name='name'),
+ Parameter(5, unit='m', variance=0.05, min=5, max=10, display_name='abs(name)'),
),
(
- Parameter('name', 5, 'm', 0.05, -10, 10),
- Parameter('abs(name)', 5, 'm', 0.05, 0, 10),
+ Parameter(5, unit='m', variance=0.05, min=-10, max=10, display_name='name'),
+ Parameter(5, unit='m', variance=0.05, min=0, max=10, display_name='abs(name)'),
),
],
ids=['pure_negative', 'crossing_zero'],
@@ -1975,9 +2082,27 @@ def test_abs(self, test, expected):
result = abs(test)
# Expect
- assert result.name == result.unique_name
assert result.value == expected.value
assert result.unit == expected.unit
assert result.variance == expected.variance
assert result.min == expected.min
assert result.max == expected.max
+
+
+@pytest.mark.parametrize('label', [None, '', 'label'], ids=['unset', 'empty', 'explicit'])
+def test_display_name_serialization_preserves_unset_state(label):
+ # Given
+ p = Parameter(2, display_name=label)
+
+ # When
+ encoded = p.to_dict()
+
+ # Then Expect: an unset label is omitted rather than stored as the fallback
+ if label is None:
+ assert 'display_name' not in encoded
+ else:
+ assert encoded['display_name'] == label
+
+ restored = Parameter.from_dict(p.to_dict(skip=['unique_name']))
+ assert restored.unique_name != p.unique_name
+ assert restored.display_name == (restored.unique_name if label is None else label)
diff --git a/tests/unit/variable/test_parameter_dependency_serialization.py b/tests/unit/variable/test_parameter_dependency_serialization.py
index 62feb5f3..8e855d48 100644
--- a/tests/unit/variable/test_parameter_dependency_serialization.py
+++ b/tests/unit/variable/test_parameter_dependency_serialization.py
@@ -18,9 +18,9 @@ class TestParameterDependencySerialization:
@pytest.fixture
def clear_global_map(self):
"""This fixture pattern:
- - Clears the map before each test (clean slate)
+ - Clears the registry before each test (clean slate)
- Yields control to the test
- - Clears the map after each test (cleanup)
+ - Clears the registry after each test (cleanup)
Dependency serialization tests require more robust
setup-yield-cleanup pattern because they involve complex
@@ -28,19 +28,17 @@ def clear_global_map(self):
and dependency resolution that are particularly sensitive
to global state contamination.
"""
- # The global map uses weakref.WeakValueDictionary() for object storage,
- # but also maintains strong references in __type_dict that need explicit cleanup.
- global_object.map._clear()
+ global_object.registry.clear()
yield
# final cleanup after test
- global_object.map._clear()
+ global_object.registry.clear()
def test_independent_parameter_serialization(self, clear_global_map):
"""Test that independent parameters serialize normally without dependency info."""
- param = Parameter(name='test', value=5.0, unit='m', min=0, max=10)
+ param = Parameter(display_name='test', value=5.0, unit='m', min=0, max=10)
# Serialize
- serialized = param.as_dict()
+ serialized = param.to_dict()
# Should not contain dependency fields
assert '_dependency_string' not in serialized
@@ -48,11 +46,11 @@ def test_independent_parameter_serialization(self, clear_global_map):
assert '_independent' not in serialized
# Deserialize
- global_object.map._clear()
+ global_object.registry.clear()
new_param = Parameter.from_dict(serialized)
# Should be identical
- assert new_param.name == param.name
+ assert new_param.display_name == param.display_name
assert new_param.value == param.value
assert new_param.unit == param.unit
assert new_param.independent is True
@@ -60,15 +58,15 @@ def test_independent_parameter_serialization(self, clear_global_map):
def test_dependent_parameter_serialization(self, clear_global_map):
"""Test serialization of parameters with dependencies."""
# Create independent parameter
- a = Parameter(name='a', value=2.0, unit='m', min=0, max=10)
+ a = Parameter(display_name='a', value=2.0, unit='m', min=0, max=10)
# Create dependent parameter
b = Parameter.from_dependency(
- name='b', dependency_expression='2 * a', dependency_map={'a': a}, unit='m'
+ display_name='b', dependency_expression='2 * a', dependency_map={'a': a}, unit='m'
)
# Serialize dependent parameter
- serialized = b.as_dict()
+ serialized = b.to_dict()
# Should contain dependency information
assert serialized['_dependency_string'] == '2 * a'
@@ -78,7 +76,7 @@ def test_dependent_parameter_serialization(self, clear_global_map):
assert serialized['_independent'] is False
# Deserialize
- global_object.map._clear()
+ global_object.registry.clear()
new_b = Parameter.from_dict(serialized)
# Should have pending dependency info
@@ -92,11 +90,11 @@ def test_dependent_parameter_serialization(self, clear_global_map):
def test_dependency_resolution_after_deserialization(self, clear_global_map):
"""Test that dependencies are properly resolved after deserialization."""
# Create test parameters with dependencies
- a = Parameter(name='a', value=2.0, unit='m', min=0, max=10)
- b = Parameter(name='b', value=3.0, unit='m', min=0, max=10)
+ a = Parameter(display_name='a', value=2.0, unit='m', min=0, max=10)
+ b = Parameter(display_name='b', value=3.0, unit='m', min=0, max=10)
c = Parameter.from_dependency(
- name='c',
+ display_name='c',
dependency_expression='a + b',
dependency_map={'a': a, 'b': b},
unit='m',
@@ -106,10 +104,10 @@ def test_dependency_resolution_after_deserialization(self, clear_global_map):
assert c.value == 5.0 # 2 + 3
# Serialize all parameters
- params_data = {'a': a.as_dict(), 'b': b.as_dict(), 'c': c.as_dict()}
+ params_data = {'a': a.to_dict(), 'b': b.to_dict(), 'c': c.to_dict()}
# Clear and deserialize (manual approach)
- global_object.map._clear()
+ global_object.registry.clear()
new_params = {}
for name, data in params_data.items():
new_params[name] = Parameter.from_dict(data)
@@ -122,7 +120,7 @@ def test_dependency_resolution_after_deserialization(self, clear_global_map):
resolve_all_parameter_dependencies(new_params)
# Alternative simplified approach using the helper function:
- # global_object.map._clear()
+ # global_object.registry.clear()
# new_params = deserialize_and_resolve_parameters(params_data)
# After resolution, c should be dependent and functional
@@ -136,11 +134,11 @@ def test_dependency_resolution_after_deserialization(self, clear_global_map):
def test_dependency_resolution_after_deserialization_desired_unit(self, clear_global_map):
"""Test that dependencies are properly resolved after deserialization."""
# Create test parameters with dependencies
- a = Parameter(name='a', value=2.0, unit='m', min=0, max=10)
- b = Parameter(name='b', value=3.0, unit='m', min=0, max=10)
+ a = Parameter(display_name='a', value=2.0, unit='m', min=0, max=10)
+ b = Parameter(display_name='b', value=3.0, unit='m', min=0, max=10)
c = Parameter.from_dependency(
- name='c',
+ display_name='c',
dependency_expression='a + b',
dependency_map={'a': a, 'b': b},
desired_unit='cm',
@@ -151,10 +149,10 @@ def test_dependency_resolution_after_deserialization_desired_unit(self, clear_gl
assert c.unit == 'cm'
# Serialize all parameters
- params_data = {'a': a.as_dict(), 'b': b.as_dict(), 'c': c.as_dict()}
+ params_data = {'a': a.to_dict(), 'b': b.to_dict(), 'c': c.to_dict()}
# Clear and deserialize (manual approach)
- global_object.map._clear()
+ global_object.registry.clear()
new_params = {}
for name, data in params_data.items():
new_params[name] = Parameter.from_dict(data)
@@ -167,7 +165,7 @@ def test_dependency_resolution_after_deserialization_desired_unit(self, clear_gl
resolve_all_parameter_dependencies(new_params)
# Alternative simplified approach using the helper function:
- # global_object.map._clear()
+ # global_object.registry.clear()
# new_params = deserialize_and_resolve_parameters(params_data)
# After resolution, c should be dependent and functional
@@ -182,18 +180,16 @@ def test_dependency_resolution_after_deserialization_desired_unit(self, clear_gl
def test_unique_name_dependency_serialization(self, clear_global_map):
"""Test serialization of dependencies using unique names."""
- a = Parameter(name='a', value=3.0, unit='m', min=0, max=10)
+ a = Parameter(display_name='a', value=3.0, unit='m', min=0, max=10)
# Create dependent parameter using unique name
b = Parameter.from_dependency(
- name='b',
- dependency_expression='2 * "Parameter_0"', # Using unique name
- unit='m',
+ display_name='b', dependency_expression='2 * "Parameter_0"', unit='m'
)
# Serialize both parameters
- a_serialized = a.as_dict()
- b_serialized = b.as_dict()
+ a_serialized = a.to_dict()
+ b_serialized = b.to_dict()
# Should contain unique name mapping
assert b_serialized['_dependency_string'] == '2 * __Parameter_0__'
@@ -204,9 +200,9 @@ def test_unique_name_dependency_serialization(self, clear_global_map):
)
# Deserialize both and resolve
- global_object.map._clear()
+ global_object.registry.clear()
c = Parameter(
- name='c', value=0.0
+ display_name='c', value=0.0
) # Dummy to occupy unique name, to force new unique_names
# Remove unique_name from serialized data to force generation of new unique names
@@ -225,11 +221,11 @@ def test_unique_name_dependency_serialization(self, clear_global_map):
def test_json_serialization_roundtrip(self, clear_global_map):
"""Test that parameter dependencies survive JSON serialization."""
# Create parameters with dependencies
- length = Parameter(name='length', value=10.0, unit='m', min=0, max=100)
- width = Parameter(name='width', value=5.0, unit='m', min=0, max=50)
+ length = Parameter(display_name='length', value=10.0, unit='m', min=0, max=100)
+ width = Parameter(display_name='width', value=5.0, unit='m', min=0, max=50)
area = Parameter.from_dependency(
- name='area',
+ display_name='area',
dependency_expression='length * width',
dependency_map={'length': length, 'width': width},
unit='m^2',
@@ -237,14 +233,14 @@ def test_json_serialization_roundtrip(self, clear_global_map):
# Serialize to JSON
params_data = {
- 'length': length.as_dict(),
- 'width': width.as_dict(),
- 'area': area.as_dict(),
+ 'length': length.to_dict(),
+ 'width': width.to_dict(),
+ 'area': area.to_dict(),
}
json_str = json.dumps(params_data, default=str)
# Deserialize from JSON
- global_object.map._clear()
+ global_object.registry.clear()
loaded_data = json.loads(json_str)
new_params = {}
for name, data in loaded_data.items():
@@ -263,14 +259,14 @@ def test_json_serialization_roundtrip(self, clear_global_map):
def test_multiple_dependent_parameters(self, clear_global_map):
"""Test serialization with multiple dependent parameters."""
# Create a chain of dependencies
- x = Parameter(name='x', value=2.0, unit='m', min=0, max=10)
+ x = Parameter(display_name='x', value=2.0, unit='m', min=0, max=10)
y = Parameter.from_dependency(
- name='y', dependency_expression='2 * x', dependency_map={'x': x}, unit='m'
+ display_name='y', dependency_expression='2 * x', dependency_map={'x': x}, unit='m'
)
z = Parameter.from_dependency(
- name='z',
+ display_name='z',
dependency_expression='y + x',
dependency_map={'y': y, 'x': x},
unit='m',
@@ -281,10 +277,10 @@ def test_multiple_dependent_parameters(self, clear_global_map):
assert z.value == 6.0 # 4 + 2
# Serialize all
- params_data = {'x': x.as_dict(), 'y': y.as_dict(), 'z': z.as_dict()}
+ params_data = {'x': x.to_dict(), 'y': y.to_dict(), 'z': z.to_dict()}
# Deserialize and resolve
- global_object.map._clear()
+ global_object.registry.clear()
new_params = {}
for name, data in params_data.items():
new_params[name] = Parameter.from_dict(data)
@@ -306,12 +302,10 @@ def test_dependency_with_descriptor_number(self, clear_global_map):
# When
- x = DescriptorNumber(name='x', value=3.0, unit='m')
- y = Parameter(name='y', value=4.0, unit='m')
+ x = DescriptorNumber(display_name='x', value=3.0, unit='m')
+ y = Parameter(display_name='y', value=4.0, unit='m')
z = Parameter.from_dependency(
- name='z',
- dependency_expression='x + y',
- dependency_map={'x': x, 'y': y},
+ display_name='z', dependency_expression='x + y', dependency_map={'x': x, 'y': y}
)
# Verify original functionality
@@ -319,9 +313,9 @@ def test_dependency_with_descriptor_number(self, clear_global_map):
# Then
# Serialize all
- params_data = {'x': x.as_dict(), 'y': y.as_dict(), 'z': z.as_dict()}
+ params_data = {'x': x.to_dict(), 'y': y.to_dict(), 'z': z.to_dict()}
# Deserialize and resolve
- global_object.map._clear()
+ global_object.registry.clear()
new_params = {}
for name, data in params_data.items():
if name == 'x':
@@ -344,14 +338,14 @@ def test_dependency_with_descriptor_number(self, clear_global_map):
def test_get_parameters_with_pending_dependencies(self, clear_global_map):
"""Test utility function for finding parameters with pending dependencies."""
# Create parameters
- a = Parameter(name='a', value=1.0, unit='m')
+ a = Parameter(display_name='a', value=1.0, unit='m')
b = Parameter.from_dependency(
- name='b', dependency_expression='2 * a', dependency_map={'a': a}, unit='m'
+ display_name='b', dependency_expression='2 * a', dependency_map={'a': a}, unit='m'
)
# Serialize and deserialize
- params_data = {'a': a.as_dict(), 'b': b.as_dict()}
- global_object.map._clear()
+ params_data = {'a': a.to_dict(), 'b': b.to_dict()}
+ global_object.registry.clear()
new_params = {}
for name, data in params_data.items():
new_params[name] = Parameter.from_dict(data)
@@ -360,7 +354,7 @@ def test_get_parameters_with_pending_dependencies(self, clear_global_map):
pending = get_parameters_with_pending_dependencies(new_params)
assert len(pending) == 1
- assert pending[0].name == 'b'
+ assert pending[0].display_name == 'b'
assert hasattr(pending[0], '_pending_dependency_string')
# After resolution, should be empty
@@ -370,44 +364,22 @@ def test_get_parameters_with_pending_dependencies(self, clear_global_map):
def test_error_handling_missing_dependency(self, clear_global_map):
"""Test error handling when dependency cannot be resolved."""
- a = Parameter(name='a', value=1.0, unit='m')
+ a = Parameter(display_name='a', value=1.0, unit='m')
b = Parameter.from_dependency(
- name='b', dependency_expression='2 * a', dependency_map={'a': a}, unit='m'
+ display_name='b', dependency_expression='2 * a', dependency_map={'a': a}, unit='m'
)
# Serialize b but not a
- b_data = b.as_dict()
+ b_data = b.to_dict()
- # Deserialize without a in the global map
- global_object.map._clear()
+ # Deserialize without a in the global registry
+ global_object.registry.clear()
new_b = Parameter.from_dict(b_data)
# Should raise error when trying to resolve
with pytest.raises(ValueError, match='Cannot find parameter with serializer_id'):
new_b.resolve_pending_dependencies()
- def test_backward_compatibility_base_deserializer(self, clear_global_map):
- """Test that the base deserializer path still works for dependent parameters."""
- from easyscience.io.serializer_dict import SerializerDict
-
- # Create dependent parameter
- a = Parameter(name='a', value=2.0, unit='m')
- b = Parameter.from_dependency(
- name='b', dependency_expression='3 * a', dependency_map={'a': a}, unit='m'
- )
-
- # Use base serializer path (SerializerDict.decode)
- serialized = b.encode(encoder=SerializerDict)
- global_object.map._clear()
-
- # This should not raise the "_independent" error anymore
- deserialized = SerializerDict.decode(serialized)
-
- # Should be a valid Parameter (but without dependency resolution)
- assert isinstance(deserialized, Parameter)
- assert deserialized.name == 'b'
- assert deserialized.independent is True # Base path doesn't handle dependencies
-
@pytest.mark.parametrize(
'order',
[['x', 'y', 'z'], ['z', 'x', 'y'], ['y', 'z', 'x'], ['z', 'y', 'x']],
@@ -422,11 +394,11 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order):
"""Test that dependency IDs allow parameters to be loaded in any order."""
# WHEN
# Create parameters with dependencies
- x = Parameter(name='x', value=5.0, unit='m', min=0, max=20)
- y = Parameter(name='y', value=10.0, unit='m', min=0, max=30)
+ x = Parameter(display_name='x', value=5.0, unit='m', min=0, max=20)
+ y = Parameter(display_name='y', value=10.0, unit='m', min=0, max=30)
z = Parameter.from_dependency(
- name='z',
+ display_name='z',
dependency_expression='x * y',
dependency_map={'x': x, 'y': y},
unit='m^2',
@@ -440,7 +412,7 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order):
y_dep_id = y._DescriptorNumber__serializer_id
# Serialize all parameters
- params_data = {'x': x.as_dict(), 'y': y.as_dict(), 'z': z.as_dict()}
+ params_data = {'x': x.to_dict(), 'y': y.to_dict(), 'z': z.to_dict()}
# Verify dependency IDs are in serialized data
assert params_data['x']['__serializer_id'] == x_dep_id
@@ -449,7 +421,7 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order):
assert '_dependency_map_serializer_ids' in params_data['z']
# THEN
- global_object.map._clear()
+ global_object.registry.clear()
new_params = {}
# Load in the specified order
@@ -478,11 +450,11 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order):
def test_deserialize_and_resolve_parameters_helper(self, clear_global_map):
"""Test the convenience helper function for deserialization and dependency resolution."""
# Create test parameters with dependencies
- a = Parameter(name='a', value=2.0, unit='m', min=0, max=10)
- b = Parameter(name='b', value=3.0, unit='m', min=0, max=10)
+ a = Parameter(display_name='a', value=2.0, unit='m', min=0, max=10)
+ b = Parameter(display_name='b', value=3.0, unit='m', min=0, max=10)
c = Parameter.from_dependency(
- name='c',
+ display_name='c',
dependency_expression='a + b',
dependency_map={'a': a, 'b': b},
unit='m',
@@ -492,10 +464,10 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map):
assert c.value == 5.0 # 2 + 3
# Serialize all parameters
- params_data = {'a': a.as_dict(), 'b': b.as_dict(), 'c': c.as_dict()}
+ params_data = {'a': a.to_dict(), 'b': b.to_dict(), 'c': c.to_dict()}
- # Clear global map
- global_object.map._clear()
+ # Clear global registry
+ global_object.registry.clear()
# Use the helper function instead of manual deserialization + resolution
new_params = deserialize_and_resolve_parameters(params_data)
@@ -507,16 +479,16 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map):
assert 'c' in new_params
# Check that independent parameters work
- assert new_params['a'].name == 'a'
+ assert new_params['a'].display_name == 'a'
assert new_params['a'].value == 2.0
assert new_params['a'].independent is True
- assert new_params['b'].name == 'b'
+ assert new_params['b'].display_name == 'b'
assert new_params['b'].value == 3.0
assert new_params['b'].independent is True
# Check that dependent parameter is properly resolved
- assert new_params['c'].name == 'c'
+ assert new_params['c'].display_name == 'c'
assert new_params['c'].value == 5.0 # 2 + 3
assert new_params['c'].independent is False
@@ -527,3 +499,29 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map):
# Verify no pending dependencies remain
pending = get_parameters_with_pending_dependencies(new_params)
assert len(pending) == 0
+
+ def test_nested_dependent_parameter_round_trip(self, clear_global_map):
+ """A dependent parameter held by a model keeps its dependency through to_dict/from_dict."""
+ from easy_test_models import PairModel
+
+ a = Parameter(display_name='a', value=2.0, unit='m')
+ b = Parameter.from_dependency(
+ display_name='b', dependency_expression='3 * a', dependency_map={'a': a}, unit='m'
+ )
+ model = PairModel(a=a, b=b)
+
+ model_dict = model.to_dict()
+
+ # The nested parameter was serialized by its own to_dict
+ assert model_dict['b']['_independent'] is False
+ assert model_dict['b']['_dependency_string'] == '3 * a'
+ assert '_dependency_map_serializer_ids' in model_dict['b']
+
+ global_object.registry.clear()
+ new_model = PairModel.from_dict(model_dict)
+ new_model.b.resolve_pending_dependencies()
+
+ assert new_model.b.independent is False
+ assert new_model.b.value == 6.0
+ new_model.a.value = 5.0
+ assert new_model.b.value == 15.0