Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Unreleased

- Added a **Structure** tab on the Model page: a schematic view of the layer stack with one
colored box per layer (colors per material, heights following thickness, "× N" badges for
collapsed repeating multilayers, legend and total-thickness caption). Boxes show tooltips
with material/SLD/thickness/roughness, clicking selects the layer in the sidebar editor,
and the view updates live on edits and after fits ([#242](https://github.com/easyscience/reflectometry-lib/issues/242)).
- Enabling magnetism no longer jumps to another page. Ticking a layer's
"Magn." box while the current engine cannot model magnetism now asks whether
to switch the project to refl1d and does both in one step.
Expand Down
18 changes: 18 additions & 0 deletions EasyReflectometryApp/Backends/Mock/Sample.qml
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,24 @@ QtObject {
},
]

// Structure view: same contract and value types as Backends/Py/logic/structure.py —
// thickness/indices/repetitions are numbers, everything else strings
readonly property var structure: [
{ 'label': 'Air', 'material': 'Air', 'color': '#0173B2', 'color_end': '', 'sld': '0.00', 'isld': '0.00', 'thickness': 0.0, 'roughness': '0.0', 'assembly': 'Superphase', 'assembly_index': 0, 'layer_index': 0, 'kind': 'superphase', 'repetitions': 1 },
{ 'label': 'TypeA', 'material': 'TypeA', 'color': '#DE8F05', 'color_end': '', 'sld': '1.00', 'isld': '0.01', 'thickness': 2.5, 'roughness': '1.0', 'assembly': 'Multi-layer', 'assembly_index': 1, 'layer_index': 0, 'kind': 'layer', 'repetitions': 1 },
{ 'label': 'TypeB', 'material': 'TypeB', 'color': '#029E73', 'color_end': '', 'sld': '2.07', 'isld': '0.00', 'thickness': 5.0, 'roughness': '1.0', 'assembly': 'Multi-layer', 'assembly_index': 1, 'layer_index': 1, 'kind': 'layer', 'repetitions': 1 },
{ 'label': 'TypeA', 'material': 'TypeA', 'color': '#DE8F05', 'color_end': '', 'sld': '1.00', 'isld': '0.01', 'thickness': 2.5, 'roughness': '1.0', 'assembly': 'Multi-layer', 'assembly_index': 1, 'layer_index': 0, 'kind': 'layer', 'repetitions': 1 },
{ 'label': 'TypeB', 'material': 'TypeB', 'color': '#029E73', 'color_end': '', 'sld': '2.07', 'isld': '0.00', 'thickness': 5.0, 'roughness': '1.0', 'assembly': 'Multi-layer', 'assembly_index': 1, 'layer_index': 1, 'kind': 'layer', 'repetitions': 1 },
{ 'label': 'Substrate', 'material': 'Si', 'color': '#D55E00', 'color_end': '', 'sld': '2.07', 'isld': '0.00', 'thickness': 0.0, 'roughness': '1.0', 'assembly': 'Substrate', 'assembly_index': 2, 'layer_index': 0, 'kind': 'subphase', 'repetitions': 1 },
]
readonly property var structureLegend: [
{ 'label': 'Air', 'color': '#0173B2' },
{ 'label': 'TypeA', 'color': '#DE8F05' },
{ 'label': 'TypeB', 'color': '#029E73' },
{ 'label': 'Si', 'color': '#D55E00' },
]
readonly property real structureTotalThickness: 15.0

// Setters
function setCurrentLayerIndex(value){
console.debug(`setCurrentLayerIndex ${value}`)
Expand Down
122 changes: 122 additions & 0 deletions EasyReflectometryApp/Backends/Py/logic/structure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from easyreflectometry import Project as ProjectLib
from easyreflectometry.model.model import COLORS

# An assembly whose expanded box count would exceed this collapses to its repeat unit
MAX_EXPANDED_BOXES_PER_ASSEMBLY = 12


def flatten(project_lib: ProjectLib) -> tuple[list[dict], list[dict], float]:
"""Flatten the current model's sample into drawable boxes for the Structure view.

Returns (structure, legend, total_thickness):
- structure: one dict per drawn box, top to bottom (contract in MD/VISUAL_LAYERS_PLAN.md §3.2)
- legend: distinct {label, color} pairs in stack order
- total_thickness: physical total in Angstrom (collapsed repeats counted n times, caps excluded)
"""
model_index = project_lib.current_model_index
if model_index is None or not 0 <= model_index < len(project_lib._models):
return [], [], 0.0
sample = project_lib._models[model_index].sample

colors = _ColorMap(project_lib._materials)
boxes = []
total_thickness = 0.0

for assembly_index, assembly in enumerate(sample):
if assembly.type == 'Gradient-layer':
boxes.append(_gradient_box(assembly, assembly_index, colors))
total_thickness += boxes[-1]['thickness']
continue

repetitions = 1
if assembly.type == 'Repeating Multi-layer':
repetitions = int(assembly.repetitions.value)
collapsed = repetitions * len(assembly.layers) > MAX_EXPANDED_BOXES_PER_ASSEMBLY
total_thickness += repetitions * sum(layer.thickness.value for layer in assembly.layers)

for _ in range(1 if collapsed else repetitions):
for layer_index, layer in enumerate(assembly.layers):
boxes.append(_layer_box(layer, assembly, assembly_index, layer_index, colors))
if collapsed:
boxes[-len(assembly.layers)]['repetitions'] = repetitions

# The first/last drawn layers are the semi-infinite superphase/subphase caps and are
# excluded from the total. Only a plain layer is retagged: a gradient assembly at either
# end keeps its own kind (and its thickness), and a lone box is a superphase only.
if boxes:
for box, kind in ((boxes[0], 'superphase'), (boxes[-1], 'subphase')):
if box['kind'] == 'layer':
box['kind'] = kind
total_thickness -= box['thickness']

legend = []
seen = set()
for box in boxes:
if box['material'] not in seen:
seen.add(box['material'])
legend.append({'label': box['material'], 'color': box['color']})

return boxes, legend, total_thickness


def _value(quantity) -> float:
# Material.sld is a Parameter, but MaterialSolvated.sld is a computed plain float
return float(getattr(quantity, 'value', quantity))


def _layer_box(layer, assembly, assembly_index: int, layer_index: int, colors: '_ColorMap') -> dict:
material = layer.material
return {
'label': layer.name,
'material': material.name,
'color': colors.get(material),
'color_end': '',
'sld': f'{_value(material.sld):.2f}',
'isld': f'{_value(material.isld):.2f}',
'thickness': float(layer.thickness.value),
'roughness': f'{layer.roughness.value:.1f}',
'assembly': assembly.name,
'assembly_index': assembly_index,
'layer_index': layer_index,
'kind': 'layer',
'repetitions': 1,
}


def _gradient_box(assembly, assembly_index: int, colors: '_ColorMap') -> dict:
# A gradient assembly is drawn as one box colored front->back; its internal
# discretization slices use anonymous materials and are never drawn.
front = assembly.front_material
back = assembly.back_material
return {
'label': assembly.name,
'material': f'{front.name} → {back.name}',
'color': colors.get(front),
'color_end': colors.get(back),
'sld': f'{_value(front.sld):.2f}',
'isld': f'{_value(front.isld):.2f}',
'thickness': float(assembly.thickness),
'roughness': f'{assembly.front_layer.roughness.value:.1f}',
'assembly': assembly.name,
'assembly_index': assembly_index,
'layer_index': 0,
'kind': 'gradient',
'repetitions': 1,
}


class _ColorMap:
"""Material name -> palette color; project materials by table position,
unknown (ad-hoc) materials by first-seen order. Solvated materials are
keyed on their inner dry material so the solvent does not change the color."""

def __init__(self, materials):
self._by_name = {material.name: COLORS[i % len(COLORS)] for i, material in enumerate(materials)}
self._next_index = len(materials)

def get(self, material) -> str:
name = getattr(material, 'material', material).name
if name not in self._by_name:
self._by_name[name] = COLORS[self._next_index % len(COLORS)]
self._next_index += 1
return self._by_name[name]
15 changes: 15 additions & 0 deletions EasyReflectometryApp/Backends/Py/py_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,18 @@ def _connect_sample_page(self) -> None:
self._sample.modelsTableChanged.connect(self._analysis.experimentsChanged)
# Connect sample changes to multi-experiment selection signal
self._sample.modelsTableChanged.connect(self.multiExperimentSelectionChanged)
# Structure view: refresh on any change to the stack. Signals overlap on some
# paths (harmless); assembliesTableChanged is the only signal a repetitions
# edit emits, modelsTableChanged the only one removeModel emits — keep both.
for signal in (
self._sample.layersChange,
self._sample.assembliesTableChanged,
self._sample.materialsTableChanged,
self._sample.modelsIndexChanged,
self._sample.modelsTableChanged,
self._sample.externalSampleChanged,
):
signal.connect(self._sample._clearStructureCacheAndEmit)
# Adding/removing/toggling an inequality constraint changes the
# engine-support notices shown on the Analysis and Sample pages.
self._sample.constraintsChanged.connect(self._analysis.inequalityContextChanged)
Expand Down Expand Up @@ -271,6 +283,9 @@ def _connect_analysis_page(self) -> None:
# A finished fit updates the goodness-of-fit; refresh the Summary tab's
# HTML binding so it stops showing the stale pre-fit value.
self._analysis.externalFittingChanged.connect(self._summary.summaryChanged)
# The fit path never emits a Sample signal; refresh the Structure view post-fit
self._analysis.externalParametersChanged.connect(self._sample._clearStructureCacheAndEmit)
self._analysis.externalFittingChanged.connect(self._sample._clearStructureCacheAndEmit)
self._analysis.externalExperimentChanged.connect(self._relay_experiment_page_experiment_changed)
self._analysis.externalExperimentChanged.connect(self._refresh_plots)
# Selecting another experiment changes the polarization state and the
Expand Down
27 changes: 27 additions & 0 deletions EasyReflectometryApp/Backends/Py/sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .logic.parameters import Parameters as ParametersLogic
from .logic.physics_constraints import PhysicsConstraints as PhysicsConstraintsLogic
from .logic.project import Project as ProjectLogic
from .logic.structure import flatten as flatten_structure

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -73,6 +74,7 @@ class Sample(QObject):
layersChange = Signal()
layersIndexChanged = Signal()

structureChanged = Signal()
# Per-layer magnetism (rho_m / theta_m) and calculator capability.
magnetismChanged = Signal()
magnetismFailed = Signal(str)
Expand Down Expand Up @@ -107,6 +109,7 @@ def __init__(self, project_lib: ProjectLib, parent=None):
self._parameters_logic = ParametersLogic(project_lib)

self._chached_layers = None
self._cached_structure = None
self._constraint_states: Dict[str, dict[str, Any]] = {}
# Child of self (not QTimer.singleShot) so a pending notification can
# never outlive this backend or keep it - and the project - alive.
Expand Down Expand Up @@ -744,6 +747,30 @@ def _emitMagnetismChanged(self) -> None:
self.externalRefreshPlot.emit()
self.externalSampleChanged.emit()

# # #
# Structure view (flattened whole-stack representation)
# # #
@Property('QVariantList', notify=structureChanged)
def structure(self) -> list[dict]:
return self._structure_parts()[0]

@Property('QVariantList', notify=structureChanged)
def structureLegend(self) -> list[dict]:
return self._structure_parts()[1]

@Property(float, notify=structureChanged)
def structureTotalThickness(self) -> float:
return self._structure_parts()[2]

def _structure_parts(self) -> tuple[list[dict], list[dict], float]:
if self._cached_structure is None:
self._cached_structure = flatten_structure(self._project_lib)
return self._cached_structure

def _clearStructureCacheAndEmit(self):
self._cached_structure = None
self.structureChanged.emit()

# # #
# Constraints
# # #
Expand Down
5 changes: 5 additions & 0 deletions EasyReflectometryApp/Gui/Globals/BackendWrapper.qml
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ QtObject {
function sampleSetCurrentAssemblyConformalRoughness(value) { activeBackend.sample.setCurrentAssemblyConformalRoughness(value) }
function sampleSetCurrentAssemblyRepeatedLayerReptitions(value) { activeBackend.sample.setCurrentAssemblyRepeatedLayerReptitions(value) }

// Structure view (flattened whole-stack representation)
readonly property var sampleStructure: activeBackend.sample.structure
readonly property var sampleStructureLegend: activeBackend.sample.structureLegend
readonly property real sampleStructureTotalThickness: activeBackend.sample.structureTotalThickness

// Layer
readonly property var sampleLayers: activeBackend.sample.layers
readonly property string sampleCurrentLayerName: activeBackend.sample.currentLayerName
Expand Down
7 changes: 6 additions & 1 deletion EasyReflectometryApp/Gui/Pages/Sample/Layout.qml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import Gui.Globals as Globals
EaComponents.ContentPage {
mainView: EaComponents.MainContent {
tabs: [
EaElements.TabButton { text: qsTr('Reflectivity') }
EaElements.TabButton { text: qsTr('Reflectivity') },
EaElements.TabButton { text: qsTr('Structure') }
]

items: [
Loader {
source: `MainContent/CombinedView.qml`
onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`)
},
Loader {
source: `MainContent/StructureView.qml`
onStatusChanged: if (status === Loader.Ready) console.debug(`${source} loaded`)
}
]
}
Expand Down
Loading
Loading