diff --git a/EasyReflectometryApp/Backends/Mock/Sample.qml b/EasyReflectometryApp/Backends/Mock/Sample.qml index d6780caf..43d86575 100644 --- a/EasyReflectometryApp/Backends/Mock/Sample.qml +++ b/EasyReflectometryApp/Backends/Mock/Sample.qml @@ -451,6 +451,28 @@ QtObject { } } + // Model links (contrasts tied to a reference model) + function planModelLink(modelIndices, referenceIndex, tieMaterials) { + console.debug(`planModelLink ${modelIndices} ${referenceIndex} ${tieMaterials}`) + const reference = referenceIndex >= 0 ? referenceIndex : Math.min.apply(null, modelIndices) + var rows = [] + for (const follower of modelIndices) { + if (follower === reference) continue + rows.push({ key: `${follower}:sample/1/layers/0/thickness`, path: 'sample/1/layers/0/thickness', + name: 'Film thickness', action: 'tie', reason: '', selectable: true, selected: true }) + rows.push({ key: `${follower}:sample/1/layers/0/material/sld`, path: 'sample/1/layers/0/material/sld', + name: 'Film sld', action: 'undecided', reason: 'material chemistry', selectable: true, selected: tieMaterials }) + rows.push({ key: `${follower}:scale`, path: 'scale', + name: 'scale', action: 'skip', reason: 'per contrast', selectable: false, selected: false }) + } + return { reference: reference, rows: rows, messages: [] } + } + function linkModels(modelIndices, referenceIndex, selectedKeys) { + console.debug(`linkModels ${modelIndices} ${referenceIndex} ${selectedKeys}`) + return { tied: selectedKeys.length, messages: [] } + } + function removeLinkedParameter(uniqueName) { console.debug(`removeLinkedParameter ${uniqueName}`) } + // Inequality constraints (BUMPS-only fit penalties) readonly property int inequalityConstraintsCount: 0 readonly property var violatedInequalityConstraints: [] diff --git a/EasyReflectometryApp/Backends/Py/sample.py b/EasyReflectometryApp/Backends/Py/sample.py index d6368ec1..f5823084 100644 --- a/EasyReflectometryApp/Backends/Py/sample.py +++ b/EasyReflectometryApp/Backends/Py/sample.py @@ -11,6 +11,7 @@ from easyreflectometry import Project as ProjectLib from easyreflectometry.constraints import USER_CONSTRAINT_FLAG from easyreflectometry.constraints import constrain +from easyreflectometry.contrasts import follows from easyreflectometry.inequality_constraints import InequalitySpec from easyreflectometry.inequality_constraints import check_units from easyreflectometry.inequality_constraints import evaluate_spec @@ -1492,6 +1493,8 @@ def constraintsList(self) -> list[dict[str, str]]: display_by_object = {id(entry['object']): entry['display_name'] for entry in context} owned = self._physics_constraints_logic.owned_parameters() recipe_rows: dict[tuple, dict[str, Any]] = {} + linked = self._linked_followers() + link_rows: dict[int, dict[str, Any]] = {} for entry in context: parameter_obj = entry['object'] @@ -1522,6 +1525,32 @@ def constraintsList(self) -> list[dict[str, str]]: } recipe_rows[key]['members'].append(entry['display_name']) continue + link_index = linked.get(id(parameter_obj)) + if link_index is not None: + # One row per model link instead of one per tied parameter. + if link_index not in link_rows: + record = self._project_lib.links[link_index] + link_rows[link_index] = { + 'dependentName': record.follower.name, + 'uniqueName': f'link:{link_index}', + 'linkIndex': link_index, + 'referenceName': record.reference.name, + 'expression': '', + 'rawExpression': '', + 'relation': '', + 'type': 'link', + 'enabled': True, + 'satisfied': True, + 'count': 0, + 'members': [], + 'pairs': [], + } + link_row = link_rows[link_index] + link_row['members'].append(entry['display_name']) + link_row['pairs'].append( + {'name': entry['display_name'], 'uniqueName': getattr(parameter_obj, 'unique_name', '') or ''} + ) + continue state = self._resolve_constraint_state(parameter_obj, display_lookup, display_by_object) if state is None: continue @@ -1555,10 +1584,27 @@ def constraintsList(self) -> list[dict[str, str]]: } ) + for link_index in sorted(link_rows): + link_row = link_rows[link_index] + count = len(link_row['pairs']) + link_row['count'] = count + link_row['expression'] = f"linked to {link_row['referenceName']} ({count} tied parameter(s))" + link_row['rawExpression'] = link_row['expression'] + constraints.append(link_row) constraints.extend(recipe_rows.values()) constraints.extend(self._inequality_constraint_rows(display_lookup)) return constraints + def _linked_followers(self) -> Dict[int, int]: + """``{id(follower parameter): index in Project.links}`` for the ties model links made + that still hold; a follower the user constrained otherwise since is listed on its own.""" + linked: Dict[int, int] = {} + for link_index, record in enumerate(self._project_lib.links): + for follower, reference, _ in record.pairs: + if follows(follower, reference): + linked[id(follower)] = link_index + return linked + # ----- physics-constraint recipes ----- @Property('QVariantList', notify=constraintsChanged) @@ -1639,6 +1685,16 @@ def removeConstraintByIndex(self, index: int) -> None: 'Removing physics constraint %s failed: %s', row.get('recipeId'), result.get('message', '') ) return + if row.get('type') == 'link': + links = self._project_lib.links + link_index = int(row['linkIndex']) + if 0 <= link_index < len(links): + for pair in row.get('pairs', []): + self._constraint_states.pop(pair.get('uniqueName'), None) + # Every follower gets back its state from before the link. + self._project_lib.unlink(links[link_index]) + self._emit_constraints_changed() + return # Resolve by unique_name (parameter identity), not display name, so two # parameters sharing a display name don't collide (issue #328). @@ -1661,6 +1717,17 @@ def removeConstraintByIndex(self, index: int) -> None: self._ensure_parameter_independent(param_obj) self._emit_constraints_changed() + @Slot(str) + def removeLinkedParameter(self, unique_name: str) -> None: + """Remove one tie of a model link (a ``pairs`` entry of a ``'link'`` row); the parameter + gets back its state from before the link and the rest of the link stays.""" + param_obj = self._find_parameter_object_by_unique_name(unique_name) + if param_obj is None or id(param_obj) not in self._linked_followers(): + return + self._constraint_states.pop(unique_name, None) + self._project_lib.detach(param_obj) + self._emit_constraints_changed() + def _find_parameter_object_by_unique_name(self, unique_name: str): """Find a parameter object by its unique_name (stable identity). @@ -1974,31 +2041,97 @@ def addConstraint(self, dependent_index: int, relation: str, expression: str): 'type': mode, } - @Slot('QVariantList', bool, result='QVariant') def constrainModelsParameters(self, model_indices: list, tie_materials: bool = False) -> dict: - """Tie the parameters of the selected models to those of the first one (the lowest index). + """Tie the default rows of :meth:`planModelLink` to the first selected model (the lowest index). - Corresponding parameters are tied by the library (``Project.plan_link``/``apply_link``): - scale and background stay per model; material chemistry (SLDs, densities, scattering - lengths) is tied only with `tie_materials`, since contrasts usually differ in it. - Ties are listed with the other constraints; removing one restores the parameter. + The one-step form of :meth:`linkModels`, without a reference choice or a deselection. :return: ``{'tied': n, 'messages': [...]}`` -- what could not be linked, and why. """ + plan = self.planModelLink(model_indices, -1, tie_materials) + return self.linkModels(model_indices, -1, [row['key'] for row in plan['rows'] if row['selected']]) + + def _plan_model_links(self, model_indices: list, reference_index: int) -> Tuple[int, list, list[str]]: + """``(reference, [(follower, plan), ...], messages)`` for tying the selected models to one. + + A `reference_index` below 0 picks the lowest selected index. Chemistry rows are left + ``'undecided'``, so the caller decides them; nothing is changed. + """ indices = sorted({int(index) for index in model_indices}) models = self._project_lib.models if len(indices) < 2 or not all(0 <= index < len(models) for index in indices): - return {'tied': 0, 'messages': [self.tr('Select at least two models.')]} - reference, plans, messages = indices[0], [], [] - materials = 'tie' if tie_materials else 'skip' - # One action: every follower is planned first, and linked only if all of them can be. - for follower in indices[1:]: + return -1, [], [self.tr('Select at least two models.')] + reference = int(reference_index) + if reference < 0: + reference = indices[0] + if reference not in indices: + return -1, [], [self.tr('The reference model must be one of the selected models.')] + plans, messages = [], [] + for follower in indices: + if follower == reference: + continue try: - plans.append((follower, self._project_lib.plan_link(follower, reference, materials=materials))) + plans.append((follower, self._project_lib.plan_link(follower, reference))) except ValueError as error: messages.append(str(error)) + return reference, plans, messages + + @staticmethod + def _link_row_key(follower: int, path: str) -> str: + return f'{follower}:{path}' + + @Slot('QVariantList', int, bool, result='QVariant') + def planModelLink(self, model_indices: list, reference_index: int, tie_materials: bool = False) -> dict: + """What :meth:`linkModels` would tie, row by row, for the GUI to preview; nothing is changed. + + Each row is one parameter of a follower model: ``selectable`` rows can be ticked or + unticked, ``selected`` is the default (every tieable row; chemistry rows only with + `tie_materials`), and the others carry the ``reason`` they cannot be tied. + + :return: ``{'reference': index, 'rows': [...], 'messages': [...]}``. + """ + reference, plans, messages = self._plan_model_links(model_indices, reference_index) + display = {id(entry['object']): entry['display_name'] for entry in self._parameters_logic.constraint_context()} + rows = [] + for follower, plan in plans: + for row in plan.rows: + selectable = row.action in ('tie', 'undecided') + rows.append( + { + 'key': self._link_row_key(follower, row.path), + 'path': row.path, + 'name': display.get(id(row.follower), row.path), + 'action': row.action, + 'reason': row.reason, + 'selectable': selectable, + 'selected': row.action == 'tie' or (row.action == 'undecided' and bool(tie_materials)), + } + ) + return {'reference': reference, 'rows': rows, 'messages': messages} + + @Slot('QVariantList', int, 'QVariantList', result='QVariant') + def linkModels(self, model_indices: list, reference_index: int, selected_keys: list) -> dict: + """Tie the parameters of the selected models to those of the reference model. + + Corresponding parameters are tied by the library (``Project.plan_link``/``apply_link``). + Exactly the rows listed in `selected_keys` (the ``key`` values of :meth:`planModelLink` + rows) are tied and every other one is skipped. + Each link is listed as one constraint row; removing it restores the parameters. + + :return: ``{'tied': n, 'messages': [...]}`` -- what could not be linked, and why. + """ + reference, plans, messages = self._plan_model_links(model_indices, reference_index) if messages: - return {'tied': 0, 'messages': messages + [self.tr('No model was linked.')]} + return {'tied': 0, 'messages': messages + ([self.tr('No model was linked.')] if reference >= 0 else [])} + selected = {str(key) for key in selected_keys} + for follower, plan in plans: + for row in plan.rows: + if row.action in ('tie', 'undecided'): + row.action = 'tie' if self._link_row_key(follower, row.path) in selected else 'skip' + plans = [(follower, plan) for follower, plan in plans if any(row.action == 'tie' for row in plan.rows)] + if not plans: + return {'tied': 0, 'messages': [self.tr('No parameter is selected to tie.')]} + # One action: every follower was planned first, and is linked only if all of them can be. records = [] try: for _, plan in plans: @@ -2007,6 +2140,7 @@ def constrainModelsParameters(self, model_indices: list, tie_materials: bool = F for record in records: self._project_lib.unlink(record) return {'tied': 0, 'messages': [str(error), self.tr('No model was linked.')]} + models = self._project_lib.models for follower, plan in plans: conflicts = [row for row in plan.rows if row.action == 'conflict'] messages.extend(f"'{models[follower].name}' {row.path}: {row.reason}" for row in conflicts) diff --git a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml index 51adf58d..e6b4e9e2 100644 --- a/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml +++ b/EasyReflectometryApp/Gui/Globals/BackendWrapper.qml @@ -321,7 +321,9 @@ QtObject { function sampleValidateConstraintExpression(index, relation, expression) { return activeBackend.sample.validateConstraintExpression(index, relation, expression) } function sampleAddConstraint(index, relation, expression) { return activeBackend.sample.addConstraint(index, relation, expression) } function sampleRemoveConstraintByIndex(value) { activeBackend.sample.removeConstraintByIndex(value) } - function sampleConstrainModelsParameters(modelIndices, tieMaterials) { return activeBackend.sample.constrainModelsParameters(modelIndices, tieMaterials) } + function samplePlanModelLink(modelIndices, referenceIndex, tieMaterials) { return activeBackend.sample.planModelLink(modelIndices, referenceIndex, tieMaterials) } + function sampleLinkModels(modelIndices, referenceIndex, selectedKeys) { return activeBackend.sample.linkModels(modelIndices, referenceIndex, selectedKeys) } + function sampleRemoveLinkedParameter(uniqueName) { activeBackend.sample.removeLinkedParameter(uniqueName) } // Inequality constraints (BUMPS-only fit penalties) and physics-constraint recipes readonly property int sampleInequalityConstraintsCount: activeBackend.sample.inequalityConstraintsCount diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml index ec5a2049..3a1c5273 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/Constraints.qml @@ -34,6 +34,7 @@ EaElements.GroupBox { switch (type) { case 'inequality': return qsTr("≤ ≥") case 'recipe': return qsTr("physics") + case 'link': return qsTr("link") case 'lower_bound': case 'upper_bound': return qsTr("bound") case 'static': return qsTr("value") @@ -242,7 +243,7 @@ EaElements.GroupBox { EaElements.TextArea { id: expressionEditor width: parent.width - placeholderText: qsTr("Enter expression, e.g. np.sqrt(1 / sld_ni) + 4") + placeholderText: qsTr("Enter expression, e.g. 2 * model_d2o_roughness") wrapMode: TextEdit.WrapAnywhere selectByMouse: true onTextChanged: constraintsGroup.scheduleValidation() @@ -458,6 +459,12 @@ EaElements.GroupBox { ? "\n" + constraint.members.join(", ") : "" return qsTr("Physics constraint: %1 tied parameter(s)").arg(constraint.count || 0) + members } + if (constraint.type === 'link') { + // Single ties of a link are removed under "Link contrasts". + const linked = constraint.members && constraint.members.length + ? "\n" + constraint.members.join(", ") : "" + return qsTr("Model link: %1 tied parameter(s)").arg(constraint.count || 0) + linked + } return qsTr("Equality constraint") } } diff --git a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml index 032d1d57..cb940c4b 100644 --- a/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml +++ b/EasyReflectometryApp/Gui/Pages/Sample/Sidebar/Advanced/Groups/ModelConstraints.qml @@ -14,6 +14,95 @@ EaElements.GroupBox { property var selectedModelIndices: [] property int selectedModelsCount: selectedModelIndices.length + readonly property var sortedSelection: selectedModelIndices.slice().sort((a, b) => a - b) + + // The model the others are tied to: the user's choice while it is selected, else the lowest selected. + property int referenceModelIndex: -1 + readonly property int effectiveReferenceIndex: sortedSelection.indexOf(referenceModelIndex) >= 0 + ? referenceModelIndex + : (sortedSelection.length ? sortedSelection[0] : -1) + + // Rows of the link preview (backend planModelLink). A row is ticked by its default unless + // the user changed it: `rowOverrides` maps a row key to the user's tick. + property var planRows: [] + property var planMessages: [] + property var rowOverrides: ({}) + readonly property var visiblePlanRows: planRows.filter( + row => showUntieableCheckBox.checked || row.selectable || row.action === 'conflict') + readonly property int tickedCount: planRows.filter(row => row.selectable && isRowTicked(row)).length + + // One row per model link, with its index in the full constraints list (for removal). + readonly property var linkRows: { + const rows = [] + const constraints = Globals.BackendWrapper.sampleConstraintsList || [] + for (let i = 0; i < constraints.length; i++) { + if (constraints[i].type === 'link') { + rows.push({ row: constraints[i], index: i }) + } + } + return rows + } + readonly property var currentLinkPairs: { + const i = linksTable.currentIndex + return i >= 0 && i < linkRows.length ? linkRows[i].row.pairs : [] + } + + function isRowTicked(row) { + return rowOverrides.hasOwnProperty(row.key) ? rowOverrides[row.key] : row.selected + } + + function setRowTicked(row, ticked) { + const overrides = Object.assign({}, rowOverrides) + overrides[row.key] = ticked + rowOverrides = overrides + } + + function setAllTicked(ticked) { + const overrides = {} + for (const row of planRows) { + if (row.selectable) { + overrides[row.key] = ticked + } + } + rowOverrides = overrides + } + + function tickedKeys() { + return planRows.filter(row => row.selectable && isRowTicked(row)).map(row => row.key) + } + + function refreshPlan() { + if (selectedModelsCount < 2) { + planRows = [] + planMessages = [] + return + } + const plan = Globals.BackendWrapper.samplePlanModelLink( + selectedModelIndices, effectiveReferenceIndex, tieMaterialsCheckBox.checked) + planRows = plan ? plan.rows : [] + planMessages = plan ? plan.messages : [] + } + + // Selection, reference and the project can change together; plan once for all of them. + Timer { + id: planTimer + interval: 0 + onTriggered: modelConstraintsGroup.refreshPlan() + } + + onSelectedModelIndicesChanged: planTimer.restart() + onEffectiveReferenceIndexChanged: planTimer.restart() + + Connections { + target: Globals.BackendWrapper + // Linking, unlinking or editing constraints changes what can still be tied. + function onSampleConstraintsListChanged() { planTimer.restart() } + // Indices of a selection made before models were added or removed mean other models now. + function onSampleModelNamesChanged() { + modelConstraintsGroup.selectedModelIndices = [] + modelConstraintsGroup.rowOverrides = ({}) + } + } Column { spacing: EaStyle.Sizes.fontPixelSize * 0.75 @@ -21,8 +110,8 @@ EaElements.GroupBox { EaElements.Label { width: parent.width - text: qsTr("Tie the parameters of the selected models to those of the first one, e.g. contrasts " - + "of one sample. Scale and background stay per model.") + text: qsTr("Tie the parameters of the selected models to those of the reference model, e.g. contrasts " + + "of one sample. Scale and background stay per model. Untick the parameters to leave free.") wrapMode: Text.Wrap color: EaStyle.Colors.themeForegroundMinor } @@ -66,7 +155,7 @@ EaElements.GroupBox { EaElements.CheckBox { id: modelCheckBox width: EaStyle.Sizes.fontPixelSize * 3 - checked: false + checked: modelConstraintsGroup.selectedModelIndices.indexOf(index) >= 0 topPadding: 0 bottomPadding: 0 anchors.verticalCenter: parent.verticalCenter @@ -81,7 +170,7 @@ EaElements.GroupBox { } } modelConstraintsGroup.selectedModelIndices = newIndices // Reassign to trigger update - // console.debug("Model", index, "checked state:", checked, "Selected indices:", modelConstraintsGroup.selectedModelIndices) + checked = Qt.binding(() => modelConstraintsGroup.selectedModelIndices.indexOf(index) >= 0) } } @@ -91,7 +180,9 @@ EaElements.GroupBox { horizontalAlignment: Text.AlignLeft text: { const modelName = Globals.BackendWrapper.sampleModelNames[index] - return modelName ? modelName : "" + const reference = index === modelConstraintsGroup.effectiveReferenceIndex + && modelConstraintsGroup.selectedModelsCount > 1 + return modelName ? modelName + (reference ? qsTr(" (reference)") : "") : "" } elide: Text.ElideRight } @@ -105,9 +196,26 @@ EaElements.GroupBox { } } - Item { - height: EaStyle.Sizes.fontPixelSize * 0.5 - width: 1 + Row { + spacing: EaStyle.Sizes.fontPixelSize * 0.5 + + EaElements.Label { + anchors.verticalCenter: parent.verticalCenter + enabled: modelConstraintsGroup.selectedModelsCount > 1 + text: qsTr("Reference model") + } + + EaElements.ComboBox { + id: referenceBox + width: EaStyle.Sizes.fontPixelSize * 20 + enabled: modelConstraintsGroup.selectedModelsCount > 1 + model: modelConstraintsGroup.sortedSelection.map(index => Globals.BackendWrapper.sampleModelNames[index] || '') + currentIndex: modelConstraintsGroup.sortedSelection.indexOf(modelConstraintsGroup.effectiveReferenceIndex) + onActivated: { + modelConstraintsGroup.referenceModelIndex = modelConstraintsGroup.sortedSelection[currentIndex] + currentIndex = Qt.binding(() => modelConstraintsGroup.sortedSelection.indexOf(modelConstraintsGroup.effectiveReferenceIndex)) + } + } } // Contrasts differ in their materials, so their SLDs are tied only on request. @@ -116,6 +224,115 @@ EaElements.GroupBox { checked: false text: qsTr("Also tie material SLDs and densities") ToolTip.text: qsTr("Leave unticked when the models are contrasts, i.e. differ in their materials") + onToggled: { + // The checkbox sets the material rows again; the user's other ticks stay. + const overrides = Object.assign({}, modelConstraintsGroup.rowOverrides) + for (const row of modelConstraintsGroup.planRows) { + if (row.action === 'undecided') { + delete overrides[row.key] + } + } + modelConstraintsGroup.rowOverrides = overrides + planTimer.restart() + } + } + + // Link preview: what linking would tie, row by row + EaElements.Label { + visible: modelConstraintsGroup.selectedModelsCount > 1 + text: qsTr("Parameters to tie (%1 of %2 ticked)") + .arg(modelConstraintsGroup.tickedCount) + .arg(modelConstraintsGroup.planRows.filter(row => row.selectable).length) + } + + EaComponents.TableView { + id: planTable + width: parent.width + visible: modelConstraintsGroup.selectedModelsCount > 1 + maxRowCountShow: 8 + + defaultInfoText: modelConstraintsGroup.planMessages.length ? qsTr("These models cannot be linked") + : qsTr("Nothing to tie") + + model: modelConstraintsGroup.visiblePlanRows.length + + header: EaComponents.TableViewHeader { + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: qsTr("Tie") + } + + EaComponents.TableViewLabel { + flexibleWidth: true + horizontalAlignment: Text.AlignLeft + text: qsTr("Parameter") + } + + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 12 + horizontalAlignment: Text.AlignLeft + text: qsTr("Note") + } + } + + delegate: EaComponents.TableViewDelegate { + property var planRow: modelConstraintsGroup.visiblePlanRows[index] + + EaComponents.TableViewCheckBox { + enabled: planRow !== undefined && planRow.selectable + checked: planRow !== undefined && planRow.selectable && modelConstraintsGroup.isRowTicked(planRow) + onToggled: { + modelConstraintsGroup.setRowTicked(planRow, checked) + checked = Qt.binding(() => planRow !== undefined && planRow.selectable + && modelConstraintsGroup.isRowTicked(planRow)) + } + } + + EaComponents.TableViewLabel { + horizontalAlignment: Text.AlignLeft + text: planRow ? planRow.name : "" + color: planRow && planRow.selectable ? EaStyle.Colors.themeForeground : EaStyle.Colors.themeForegroundMinor + elide: Text.ElideRight + ToolTip.text: planRow ? planRow.path : "" + } + + EaComponents.TableViewLabel { + horizontalAlignment: Text.AlignLeft + text: planRow ? planRow.reason : "" + color: planRow && planRow.action === 'conflict' ? EaStyle.Colors.themeAccent : EaStyle.Colors.themeForegroundMinor + elide: Text.ElideRight + ToolTip.text: planRow ? planRow.reason : "" + } + } + } + + Row { + visible: modelConstraintsGroup.selectedModelsCount > 1 + spacing: EaStyle.Sizes.fontPixelSize + + EaElements.SideBarButton { + width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 2 + enabled: modelConstraintsGroup.planRows.some(row => row.selectable) + fontIcon: "check-square" + text: qsTr("Tick all") + onClicked: modelConstraintsGroup.setAllTicked(true) + } + + EaElements.SideBarButton { + width: (EaStyle.Sizes.sideBarContentWidth - EaStyle.Sizes.fontPixelSize) / 2 + enabled: modelConstraintsGroup.tickedCount > 0 + fontIcon: "square" + text: qsTr("Untick all") + onClicked: modelConstraintsGroup.setAllTicked(false) + } + } + + EaElements.CheckBox { + id: showUntieableCheckBox + visible: modelConstraintsGroup.selectedModelsCount > 1 + checked: false + text: qsTr("Show the parameters that cannot be tied") } EaElements.SideBarButton { @@ -123,17 +340,29 @@ EaElements.GroupBox { wide: true fontIcon: "link" text: qsTr("Link selected models") - enabled: modelConstraintsGroup.selectedModelsCount > 1 + enabled: modelConstraintsGroup.selectedModelsCount > 1 && modelConstraintsGroup.tickedCount > 0 onClicked: { - const result = Globals.BackendWrapper.sampleConstrainModelsParameters( - modelConstraintsGroup.selectedModelIndices, tieMaterialsCheckBox.checked) + const result = Globals.BackendWrapper.sampleLinkModels( + modelConstraintsGroup.selectedModelIndices, + modelConstraintsGroup.effectiveReferenceIndex, + modelConstraintsGroup.tickedKeys()) linkResult.text = result ? qsTr("%1 parameter(s) tied.").arg(result.tied) + (result.messages.length ? '\n' + result.messages.join('\n') : '') : '' + modelConstraintsGroup.rowOverrides = ({}) } } + // Why the selected models cannot be linked (layouts differ, ...) + EaElements.Label { + width: parent.width + visible: modelConstraintsGroup.selectedModelsCount > 1 && text !== '' + wrapMode: Text.Wrap + color: EaStyle.Colors.themeAccent + text: modelConstraintsGroup.planMessages.join('\n') + } + EaElements.Label { id: linkResult width: parent.width @@ -142,7 +371,7 @@ EaElements.GroupBox { color: EaStyle.Colors.themeForegroundMinor } - // Model constraints table + // Model links: one row per link; selecting one lists its ties below Item { height: EaStyle.Sizes.fontPixelSize * 0.5 width: 1 @@ -150,107 +379,111 @@ EaElements.GroupBox { EaElements.Label { enabled: true - text: qsTr("Model Constraints") + text: qsTr("Model links") } - Item { - id: modelConstraintsTableContainer + EaComponents.TableView { + id: linksTable width: parent.width - height: modelConstraintsTable.height - EaComponents.TableView { - id: modelConstraintsTable - width: parent.width - maxRowCountShow: 1000 + defaultInfoText: qsTr("No Model Links") - defaultInfoText: qsTr("No Model Constraints") + model: modelConstraintsGroup.linkRows.length - // Table model - use backend data directly - model: Globals.BackendWrapper.sampleConstraintsList.length + header: EaComponents.TableViewHeader { - // Header row - header: EaComponents.TableViewHeader { + EaComponents.TableViewLabel { + width: EaStyle.Sizes.fontPixelSize * 2.5 + text: qsTr("No.") + } - EaComponents.TableViewLabel { - width: EaStyle.Sizes.fontPixelSize * 2.5 - text: qsTr("No.") - } + EaComponents.TableViewLabel { + flexibleWidth: true + horizontalAlignment: Text.AlignLeft + text: qsTr("Link") + } - EaComponents.TableViewLabel { - id: modelConstraintNameHeaderColumn - width: EaStyle.Sizes.fontPixelSize * 31 - horizontalAlignment: Text.AlignHCenter - text: qsTr("Constraint") - } + // Placeholder for row delete button + EaComponents.TableViewLabel { + width: EaStyle.Sizes.tableRowHeight + } + } - // Placeholder for row delete button - EaComponents.TableViewLabel { - width: EaStyle.Sizes.tableRowHeight - } + delegate: EaComponents.TableViewDelegate { + property var link: modelConstraintsGroup.linkRows[index] + + EaComponents.TableViewLabel { + text: index + 1 + color: EaStyle.Colors.themeForegroundMinor } - // Table rows - delegate: EaComponents.TableViewDelegate { + EaComponents.TableViewLabel { + horizontalAlignment: Text.AlignLeft + text: link ? qsTr("%1 → %2 (%3 tied)").arg(link.row.dependentName).arg(link.row.referenceName).arg(link.row.count) : "" + elide: Text.ElideRight + ToolTip.text: link ? link.row.members.join(", ") : "" + } - EaComponents.TableViewLabel { - id: modelConstraintNumberColumn - width: EaStyle.Sizes.fontPixelSize * 2.5 - text: index + 1 - color: EaStyle.Colors.themeForegroundMinor - } + EaComponents.TableViewButton { + fontIcon: "minus-circle" + ToolTip.text: qsTr("Remove this link: every tied parameter gets back its value from before the link") + onClicked: Globals.BackendWrapper.sampleRemoveConstraintByIndex(link.index) + } - EaComponents.TableViewLabel { - id: modelConstraintColumn - width: EaStyle.Sizes.fontPixelSize * 31 - horizontalAlignment: Text.AlignLeft - text: { - const constraint = Globals.BackendWrapper.sampleConstraintsList[index] - if (!constraint) { - return "" - } - const prefix = constraint.relation ? constraint.relation + ' ' : '' - return constraint.dependentName + ' ' + prefix + constraint.expression - } - elide: Text.ElideRight + mouseArea.onPressed: { + if (linksTable.currentIndex !== index) { + linksTable.currentIndex = index } + } + } + } - // Placeholder for delete button space - Item { - width: EaStyle.Sizes.tableRowHeight - } + EaElements.Label { + visible: modelConstraintsGroup.currentLinkPairs.length > 0 + text: { + const i = linksTable.currentIndex + return i >= 0 && i < modelConstraintsGroup.linkRows.length + ? qsTr("Tied parameters of link %1").arg(i + 1) : "" + } + } - mouseArea.onPressed: { - if (modelConstraintsTable.currentIndex !== index) { - modelConstraintsTable.currentIndex = index - } - } + EaComponents.TableView { + id: linkPairsTable + width: parent.width + visible: modelConstraintsGroup.currentLinkPairs.length > 0 + maxRowCountShow: 8 + + model: modelConstraintsGroup.currentLinkPairs.length + + header: EaComponents.TableViewHeader { + + EaComponents.TableViewLabel { + flexibleWidth: true + horizontalAlignment: Text.AlignLeft + text: qsTr("Parameter") + } + + // Placeholder for row delete button + EaComponents.TableViewLabel { + width: EaStyle.Sizes.tableRowHeight } } - // Delete buttons - separate from table content but positioned at row level - Column { - id: modelDeleteButtonsColumn - anchors.right: parent.right - anchors.top: modelConstraintsTable.top - anchors.topMargin: modelConstraintsTable.headerItem ? modelConstraintsTable.headerItem.height : 0 - spacing: 0 - - Repeater { - model: Globals.BackendWrapper.sampleConstraintsList.length - - EaElements.SideBarButton { - width: 35 - height: EaStyle.Sizes.tableRowHeight - fontIcon: "minus-circle" - ToolTip.text: qsTr("Remove this constraint") - - onClicked: { - Globals.BackendWrapper.sampleRemoveConstraintByIndex(index) - } - } + delegate: EaComponents.TableViewDelegate { + property var pair: modelConstraintsGroup.currentLinkPairs[index] + + EaComponents.TableViewLabel { + horizontalAlignment: Text.AlignLeft + text: pair ? pair.name : "" + elide: Text.ElideRight + } + + EaComponents.TableViewButton { + fontIcon: "minus-circle" + ToolTip.text: qsTr("Untie this parameter: it gets back its value from before the link") + onClicked: Globals.BackendWrapper.sampleRemoveLinkedParameter(pair.uniqueName) } } } } } - \ No newline at end of file diff --git a/docs/src/tutorials/model_def.md b/docs/src/tutorials/model_def.md index 4c9167dc..00b14c42 100644 --- a/docs/src/tutorials/model_def.md +++ b/docs/src/tutorials/model_def.md @@ -167,6 +167,9 @@ so selecting `refnx` while the sample has magnetic layers is refused - see Below these sit three constraint groups, from the most specific to the most general: `Physics constraints`, `Single constraints` and `Link contrasts`. +All constraints are saved with the project. The groups are disabled while a fit is +running. + ### Physics constraints The `Physics constraints` group applies physically motivated constraints to an assembly with one click. The list is per assembly of the current model, and only shows the recipes that @@ -196,10 +199,12 @@ parameters. 1. Pick the **dependent parameter** from the drop-down. 2. Pick the relation: `=`, `≤` or `≥`. -3. Type the **expression**, for example `np.sqrt(1 / sld_ni) + 4`. Use - **Insert parameter alias…** to paste the alias of another parameter rather than typing - it, and **Insert total film thickness** to use the read-only sum of all layer thicknesses - between superphase and subphase. +3. Type the **expression**, for example `2 * model_d2o_roughness`. Parameters are referred + to by their alias: the display name in lower case, with underscores, such as + `model_d2o_thickness` for `Model D2O thickness`. Use **Insert parameter alias…** to + paste the alias of another parameter rather than typing it, and **Insert total film + thickness** to use the read-only sum of all layer thicknesses between superphase and + subphase. 4. Check the **Preview** line, which shows how the constraint will read - and, for an inequality, that numeric literals are interpreted in the dependent parameter's unit. 5. Press **Add constraint**. @@ -208,6 +213,25 @@ parameters. parameters become **inequality constraints**, which are enforced as penalties during fitting. +What an `=` expression can contain: + +- `+`, `-`, `*`, `/` and `**` between parameters, e.g. + `model_film_thickness + model_sio2_thickness`. The expression must have the unit of the + dependent parameter: a thickness can be set to a sum of thicknesses but not to an SLD. +- Plain numbers, which have no unit. They can scale a parameter + (`0.5 * model_film_thickness`) but cannot be added to one that has a unit: + `model_film_thickness / 2 + 4` is refused for a thickness, because 4 is not a length. + This differs from `≤` and `≥`, where numbers are read in the dependent parameter's unit. +- The constants `pi` and `e`, and functions such as `sqrt` and `exp` applied to numbers, + e.g. `sqrt(2) * model_d2o_roughness`. + +```{note} +Mathematical functions of a parameter, such as `sqrt(model_film_thickness)` or +`exp(1 / model_film_thickness)`, are not supported yet. Write `sqrt`, `exp` and `pi` +without a `np.` or `math.` prefix: with a prefix, an expression that refers to a parameter +passes the preview but is not added. +``` + ```{warning} Inequality constraints need a BUMPS minimizer (`Analysis` › `Minimization method`). With `lmfit` or `DFO-LS` selected the group shows a warning and fits are refused until the @@ -218,8 +242,9 @@ parameter values violate an inequality, and again fits will not start until they Existing constraints are listed in the `Active Constraints` table, with columns `No.`, `Type`, `Parameter` and `Expression`. The `Type` column tells the kinds apart - `expr` for a plain equality, `≤ ≥` for an inequality, `physics` for a recipe from the group above, -`bound` for an upper bound and `value` for a fixed value - and each row can be removed -individually. +`link` for a model link from the group below, `bound` for an upper bound and `value` for a +fixed value - and each row can be removed individually. Hover the `Type` of a `physics` or +`link` row to see which parameters it ties. ### Link contrasts The `Link contrasts` group ties whole models together rather than single parameters. It @@ -230,15 +255,28 @@ it shares the structure from the start. -1. Tick two or more models in the table. The ticked model highest in the list is the - reference; the others follow it. +1. Tick two or more models in the table. One of them is the **reference**, marked + `(reference)` in the table; the others follow it. By default it is the ticked model + highest in the list; pick another one in **Reference model**. 2. Leave **Also tie material SLDs and densities** unticked when the models are contrasts, - i.e. differ in their materials. Ticked, it ties every material's SLD, density and - scattering lengths to the reference's too, including the water that makes the - contrast. -3. Press **Link selected models**. The line below the button reports how many + i.e. differ in their materials. Ticked, it ticks every material's SLD, density and + scattering lengths in the preview too, including the water that makes the contrast. +3. Check the preview table, **Parameters to tie (n of m ticked)**. It lists every + parameter of the following models that can be tied, ticked if it will be. Untick the + ones that should stay free in the following model; tick a single material row to tie + only that material. **Tick all** and **Untick all** set every row at once, and + **Show the parameters that cannot be tied** adds the rows linking leaves alone, with + the reason in the `Note` column (see the list below). +4. Press **Link selected models**. The line below the button reports how many parameters were tied, and lists any it could not tie, with the reason. +The preview follows the project: it is updated when you change the ticked models, the +reference or the materials checkbox, and when constraints change elsewhere. After linking, +the tied rows move to the parameters that cannot be tied, as "already follows the +reference"; the ones you left unticked can still be ticked and linked later. Your ticks are +kept while the preview is updated, except that the materials checkbox resets the material +rows. Adding, removing or renaming a model clears the selection. + Parameters are matched by their position in the model, so linking needs the models to have the same layout: the same assemblies with the same layers, and, layer by layer, materials of the same kind (a density material and a plain-SLD material do not match). @@ -247,24 +285,34 @@ Models that do not match are reported, e.g. and nothing is tied. When several models are ticked, either all of them are linked or, if one cannot be, none. -What linking ties, and what it leaves alone: +What linking ties by default, and what it leaves alone: -- each model keeps its own **scale** and **background**; +- each model keeps its own **scale** and **background** (`per contrast`); - thicknesses, roughnesses and the other structural parameters of corresponding layers - are tied; + are tied, unless unticked in the preview; +- the superphase thickness and roughness and the subphase thickness play no part in the + calculation and are not tied (`not used`); - a material both models use is already one set of parameters and needs no tie. A material is shared only when both models' layers use the same entry of the `Material editor`. A model made with the duplicate button has its own copies of the materials, under the same names: to share the film SLD with the reference, pick its layers' materials again in the `Layer editor`; -- a material that differs between the models (the water) keeps its own SLD, unless - **Also tie material SLDs and densities** is ticked; +- a material that differs between the models (the water) keeps its own SLD + (`material chemistry`), unless its row is ticked; - a parameter that already follows the reference's is left as it is; one that follows any other parameter is not re-tied, and is listed. -Each tie is listed in the `Model Constraints` table below, and with the other constraints -in the `Active Constraints` table. Removing a row frees that parameter and restores the -value and bounds it had before the link. Links are saved with the project. +Each link is one row of the `Model links` table, e.g. +`Film in CMSi → Film in D2O (5 tied)`, and one row of type `link` in the +`Active Constraints` table. Select a link to see its parameters in **Tied parameters of +link n**: + +- the remove button next to a parameter unties only that one; the rest of the link stays; +- the remove button on the link row (in either table) removes the whole link. + +A parameter that is untied gets back the value and bounds it had before the link. Links are +saved with the project. Linking the same models again, e.g. for a parameter left unticked +the first time, adds a second link row. ### Trying the constraints out Four simulated datasets with a known ground truth are provided for exercising these diff --git a/examples/datasets/README.md b/examples/datasets/README.md index 50a2bba2..e6a38911 100644 --- a/examples/datasets/README.md +++ b/examples/datasets/README.md @@ -190,19 +190,21 @@ being added with **Add contrast**. `Si`, `SiO2` and `Film`, so that it shares them with the D2O model, and `CMSi` for the subphase. 4. *Sample › Advanced › Link contrasts*: tick the D2O model and `Film in CMSi`, - leave **Also tie material SLDs and densities** unticked, and press **Link - selected models**: "5 parameter(s) tied." These are the oxide and film - thicknesses and roughnesses, and the subphase roughness. The shared materials - need no tie, and the CMSi SLD stays its own. The five ties are listed in the - *Model Constraints* table. + keep the D2O model as **Reference model**, and leave **Also tie material SLDs + and densities** unticked. The preview ticks the oxide and film thicknesses and + roughnesses, and the subphase roughness; the water SLD is listed unticked. The + shared materials need no tie, and the CMSi SLD stays its own. Press **Link + selected models**: "5 parameter(s) tied." The link is one row of the *Model + links* table, and its five ties are listed below it. 5. Continue with steps 3 and 4 above, pairing the CMSi experiment with `Film in CMSi`. The fit is the same as with **Add contrast**: oxide 15.2 Å, film 80.0 Å, SLD 2.00, roughness 5.0 Å, scales 0.99 / 0.95 / 0.98, pooled reduced χ² 0.89. -6. Remove one of the rows from the *Model Constraints* table: the parameter it - names is free again in `Film in CMSi`, with the value and bounds it had before - the link. (The oxide and film rows read alike, since a layer parameter is - named after its assembly, `Loaded layer`.) +6. Select the link in *Model links* and remove one parameter from *Tied + parameters of link 1*: it is free again in `Film in CMSi`, with the value and + bounds it had before the link, and the other ties stay. (The oxide and film + rows read alike, since a layer parameter is named after its assembly, + `Loaded layer`.) ## 6. `dppc_contrasts.ort` — multiple contrasts of a monolayer (advanced) diff --git a/tests/test_multi_contrast.py b/tests/test_multi_contrast.py index 55823868..37a1bfb2 100644 --- a/tests/test_multi_contrast.py +++ b/tests/test_multi_contrast.py @@ -49,6 +49,15 @@ def _data(project, index, noise=0.01) -> DataSet1D: return DataSet1D(name=f'data {index}', x=Q, y=y, ye=(0.02 * y) ** 2, model=project.models[index], auto_background=False) +def _default_keys(sample, indices, reference=-1) -> list: + """The keys of the rows the link preview ticks by default.""" + return [row['key'] for row in sample.planModelLink(indices, reference, False)['rows'] if row['selected']] + + +def _link_rows(sample) -> list: + return [row for row in sample.constraintsList if row['type'] == 'link'] + + class TestContrast: def test_add_contrast_shares_the_untouched_structure(self, project_and_sample): project, sample = project_and_sample @@ -139,18 +148,120 @@ def test_ties_geometry_but_not_material_slds(self, project_and_sample): assert list(film.thickness.dependency_map.values()) == [reference.sample[1].layers[0].thickness] assert film.material.sld.independent - def test_removing_a_linked_row_restores_the_parameter(self, project_and_sample): + def test_a_link_is_listed_as_one_row(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + result = sample.constrainModelsParameters([0, 1], False) + + rows = sample.constraintsList + links = [row for row in rows if row['type'] == 'link'] + assert len(links) == 1 + assert links[0]['count'] == result['tied'] == len(links[0]['pairs']) + assert links[0]['dependentName'] == project.models[1].name + assert links[0]['referenceName'] == project.models[0].name + tied = {pair['uniqueName'] for pair in links[0]['pairs']} + assert not any(row.get('uniqueName') in tied for row in rows if row['type'] != 'link') + + def test_removing_the_link_row_restores_every_parameter(self, project_and_sample): project, sample = project_and_sample project.models.duplicate_model(0) film = project.models[1].sample[1].layers[0] film.thickness.value = 120.0 + film.roughness.value = 4.5 sample.constrainModelsParameters([0, 1], False) - index = next(i for i, row in enumerate(sample.constraintsList) if row.get('uniqueName') == film.thickness.unique_name) + index = next(i for i, row in enumerate(sample.constraintsList) if row['type'] == 'link') sample.removeConstraintByIndex(index) + assert film.thickness.independent and film.roughness.independent + assert film.thickness.value == pytest.approx(120.0) + assert film.roughness.value == pytest.approx(4.5) + assert project.links == [] + + def test_removing_one_tie_of_a_link_keeps_the_others(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + film = project.models[1].sample[1].layers[0] + film.thickness.value = 120.0 + sample.constrainModelsParameters([0, 1], False) + + sample.removeLinkedParameter(film.thickness.unique_name) + assert film.thickness.independent assert film.thickness.value == pytest.approx(120.0) + assert not film.roughness.independent + (link,) = [row for row in sample.constraintsList if row['type'] == 'link'] + assert film.thickness.unique_name not in {pair['uniqueName'] for pair in link['pairs']} + + def test_the_plan_lists_every_row_with_its_default(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + film = project.models[1].sample[1].layers[0] + + plan = sample.planModelLink([0, 1], -1, False) + + assert plan['reference'] == 0 and plan['messages'] == [] + rows = {row['path']: row for row in plan['rows']} + thickness = rows['sample/1/layers/0/thickness'] + assert thickness['selectable'] and thickness['selected'] and thickness['key'].startswith('1:') + sld = rows['sample/1/layers/0/material/sld'] + assert sld['action'] == 'undecided' and sld['selectable'] and not sld['selected'] + assert not rows['scale']['selectable'] and rows['scale']['reason'] == 'per contrast' + assert {row['path']: row['selected'] for row in sample.planModelLink([0, 1], -1, True)['rows']}[ + 'sample/1/layers/0/material/sld' + ] + assert film.thickness.independent # planning changes nothing + assert project.links == [] + + def test_unticked_rows_stay_free(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + film = project.models[1].sample[1].layers[0] + plan = sample.planModelLink([0, 1], -1, False) + keys = [row['key'] for row in plan['rows'] if row['selected'] and not row['path'].endswith('/thickness')] + + result = sample.linkModels([0, 1], -1, keys) + + assert result['tied'] == len(keys) + assert film.thickness.independent + assert not film.roughness.independent + + def test_a_ticked_material_row_is_tied_without_tie_materials(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + film = project.models[1].sample[1].layers[0] + plan = sample.planModelLink([0, 1], -1, False) + key = next(row['key'] for row in plan['rows'] if row['path'] == 'sample/1/layers/0/material/sld') + + sample.linkModels([0, 1], -1, [key]) + + assert list(film.material.sld.dependency_map.values()) == [project.models[0].sample[1].layers[0].material.sld] + assert film.thickness.independent + + def test_the_reference_model_can_be_chosen(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + first, second = (model.sample[1].layers[0] for model in project.models) + + assert sample.linkModels([0, 1], 1, _default_keys(sample, [0, 1], 1))['tied'] > 0 + + assert list(first.thickness.dependency_map.values()) == [second.thickness] + assert second.thickness.independent + + def test_the_reference_must_be_selected(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + project.models.duplicate_model(0) + result = sample.linkModels([0, 1], 2, []) + assert result['tied'] == 0 and 'reference' in result['messages'][0] + assert project.links == [] + + def test_nothing_ticked_links_nothing(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + result = sample.linkModels([0, 1], -1, []) + assert result['tied'] == 0 and result['messages'] + assert project.links == [] def test_different_layouts_are_reported(self, project_and_sample): project, sample = project_and_sample @@ -169,6 +280,81 @@ def test_linking_several_models_is_all_or_nothing(self, project_and_sample): assert project.links == [] assert project.models[1].sample[1].layers[0].thickness.independent + def test_removing_the_last_tie_removes_the_link(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + film = project.models[1].sample[1].layers[0] + key = next(key for key in _default_keys(sample, [0, 1]) if key.endswith('sample/1/layers/0/roughness')) + assert sample.linkModels([0, 1], -1, [key])['tied'] == 1 + + sample.removeLinkedParameter(film.roughness.unique_name) + + assert film.roughness.independent + assert _link_rows(sample) == [] + assert project.links == [] + + def test_three_models_with_partial_ticks(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + project.models.duplicate_model(0) + reference, second, third = (model.sample[1].layers[0] for model in project.models) + keys = [key for key in _default_keys(sample, [0, 1, 2]) if key != '2:sample/1/layers/0/thickness'] + + result = sample.linkModels([0, 1, 2], -1, keys) + + assert result['tied'] == len(keys) and result['messages'] == [] + assert [link.follower for link in project.links] == [project.models[1], project.models[2]] + assert list(second.thickness.dependency_map.values()) == [reference.thickness] + assert third.thickness.independent + assert list(third.roughness.dependency_map.values()) == [reference.roughness] + first, second_link = (row['count'] for row in _link_rows(sample)) + assert first == second_link + 1 and first + second_link == len(keys) + + def test_removing_the_second_of_two_links(self, project_and_sample): + project, sample = project_and_sample + project.models.duplicate_model(0) + project.models.duplicate_model(0) + assert sample.constrainModelsParameters([0, 1])['tied'] + assert sample.constrainModelsParameters([0, 2])['tied'] + index = [i for i, row in enumerate(sample.constraintsList) if row['type'] == 'link'][1] + + sample.removeConstraintByIndex(index) + + assert [link.follower for link in project.links] == [project.models[1]] + assert project.models[2].sample[1].layers[0].thickness.independent + assert not project.models[1].sample[1].layers[0].thickness.independent + (link,) = _link_rows(sample) + assert link['linkIndex'] == 0 + + def test_a_tie_constrained_otherwise_leaves_the_link(self, project_and_sample): + from easyreflectometry.constraints import constrain_equal + + project, sample = project_and_sample + project.models.duplicate_model(0) + film = project.models[1].sample[1].layers[0] + key = next(key for key in _default_keys(sample, [0, 1]) if key.endswith('sample/1/layers/0/thickness')) + sample.linkModels([0, 1], -1, [key]) + + constrain_equal(film.thickness, project.models[0].sample[1].layers[0].roughness) + + assert _link_rows(sample) == [] + assert any(row.get('uniqueName') == film.thickness.unique_name for row in sample.constraintsList) + + @pytest.mark.xfail(strict=True, reason='a link none of whose ties hold stays in Project.links (PR #363 review, finding B)') + def test_a_link_without_ties_is_dropped(self, project_and_sample): + from easyreflectometry.constraints import constrain_equal + + project, sample = project_and_sample + project.models.duplicate_model(0) + film = project.models[1].sample[1].layers[0] + key = next(key for key in _default_keys(sample, [0, 1]) if key.endswith('sample/1/layers/0/thickness')) + sample.linkModels([0, 1], -1, [key]) + + constrain_equal(film.thickness, project.models[0].sample[1].layers[0].roughness) + + assert _link_rows(sample) == [] # listing the constraints is where a dead link could be dropped + assert project.links == [] + class TestExperiments: def test_pairing_inclusion_and_removal(self, project_and_sample): diff --git a/tests/test_py_sample_constraints.py b/tests/test_py_sample_constraints.py index fe05ebf8..4980873f 100644 --- a/tests/test_py_sample_constraints.py +++ b/tests/test_py_sample_constraints.py @@ -502,16 +502,16 @@ def test_linked_models_survive_reload(self, project_and_backend): project, backend = project_and_backend project.models.duplicate_model(0) assert backend.constrainModelsParameters([0, 1])['tied'] - # The library's ties are listed from the parameters themselves - linked = [row for row in backend.constraintsList if row['type'] == 'dynamic'] - assert linked + # The library's ties are listed as one row per link + linked = [row for row in backend.constraintsList if row['type'] == 'link'] + assert len(linked) == 1 and linked[0]['count'] > 0 reloaded, reloaded_backend, _ = _save_and_reload(project, backend) - reloaded_linked = [row for row in reloaded_backend.constraintsList if row['type'] == 'dynamic'] - assert sorted((row['dependentName'], row['expression']) for row in reloaded_linked) == sorted( - (row['dependentName'], row['expression']) for row in linked - ) + reloaded_linked = [row for row in reloaded_backend.constraintsList if row['type'] == 'link'] + assert [(row['dependentName'], row['expression'], sorted(row['members'])) for row in reloaded_linked] == [ + (row['dependentName'], row['expression'], sorted(row['members'])) for row in linked + ] reloaded.models[0].sample[1].layers[0].thickness.value = 33.0 assert reloaded.models[1].sample[1].layers[0].thickness.value == pytest.approx(33.0)