Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
43 changes: 16 additions & 27 deletions .github/workflows/installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,37 +114,26 @@ jobs:
# ${{ secrets.APPLE_CERT_DATA }} ${{ secrets.APPLE_CERT_PASSWORD }}
# ${{ secrets.APPLE_NOTARY_USER }} ${{ secrets.APPLE_NOTARY_PASSWORD }}

- name: Install DigiCert Client tools from Github Custom Actions marketplace
if: |
runner.os == 'windows' && github.event_name == 'push'
uses: digicert/ssm-code-signing@v1.0.1

- name: Set up P12 certificate
if: |
runner.os == 'windows' && github.event_name == 'push'
- name: Setup SM_CLIENT_CERT_FILE from base64 secret data
if: runner.os == 'Windows'
run: |
echo "${{ secrets.WINDOWS_CERT_DATA }}" | base64 --decode > /d/Certificate_pkcs12.p12
echo "${{ secrets.KEYLOCKER_CERT_DATA }}" | base64 --decode > /d/Certificate_pkcs12.p12
shell: bash

- name: Set keylocker variables
if: |
runner.os == 'windows' && github.event_name == 'push'
id: variables
run: |
echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
echo "SM_HOST=${{ secrets.KEYLOCKER_HOST }}" >> "$GITHUB_ENV"
echo "SM_API_KEY=${{ secrets.KEYLOCKER_API_KEY }}" >> "$GITHUB_ENV"
echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV"
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.WINDOWS_CERT_PASSWORD }}" >> "$GITHUB_ENV"
shell: bash

- name: Sign the binary using keypair alias
if: |
runner.os == 'windows' && github.event_name == 'push' && env.BRANCH_NAME == 'master'
run: |
smctl sign --keypair-alias key_911959544 --input ${{ env.SETUP_EXE_PATH }}
shell: cmd

- name: Setup Software Trust Manager
if: runner.os == 'Windows'
uses: digicert/code-signing-software-trust-action@v1
with:
simple-signing-mode: true
# If the below 2 parameters are supplied, then smctl executable is invoked to attempt the signing.
input: ${{ env.SETUP_EXE_PATH }}
keypair-alias: ${{ secrets.KEYLOCKER_KEYPAIR_ALIAS }}
env:
SM_HOST: ${{ secrets.KEYLOCKER_HOST }}
SM_API_KEY: ${{ secrets.KEYLOCKER_API_KEY }}
SM_CLIENT_CERT_FILE: D:\\Certificate_pkcs12.p12
SM_CLIENT_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}

- name: Create zip archive of offline app installer for distribution
run: >
Expand Down
7 changes: 7 additions & 0 deletions EasyReflectometryApp/Backends/Mock/Analysis.qml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ QtObject {
readonly property string fitErrorMessage: ''
readonly property int fitNumRefinedParams: 3
readonly property real fitChi2: 1.2345
readonly property int fitIteration: 0
readonly property real fitInterimChi2: 0.0
readonly property real fitInterimReducedChi2: 0.0
readonly property string fitProgressMessage: ''
readonly property bool fitHasInterimUpdate: false
readonly property bool fitHasPreviewUpdate: false
readonly property var fitPreviewParameterValues: ({})
readonly property var fitResults: ({ success: true, nvarys: 3, chi2: 1.2345 })

// Fit failure signal (mirrors Python backend)
Expand Down
45 changes: 41 additions & 4 deletions EasyReflectometryApp/Backends/Py/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,34 @@ def fitNumRefinedParams(self) -> int:
def fitChi2(self) -> float:
return self._fitting_logic.fit_chi2

@Property(int, notify=fittingChanged)
def fitIteration(self) -> int:
return self._fitting_logic.fit_iteration

@Property(float, notify=fittingChanged)
def fitInterimChi2(self) -> float:
return self._fitting_logic.fit_interim_chi2

@Property(float, notify=fittingChanged)
def fitInterimReducedChi2(self) -> float:
return self._fitting_logic.fit_interim_reduced_chi2

@Property(str, notify=fittingChanged)
def fitProgressMessage(self) -> str:
return self._fitting_logic.fit_progress_message

@Property(bool, notify=fittingChanged)
def fitHasInterimUpdate(self) -> bool:
return self._fitting_logic.fit_has_interim_update

@Property(bool, notify=fittingChanged)
def fitHasPreviewUpdate(self) -> bool:
return self._fitting_logic.fit_has_preview_update

@Property('QVariant', notify=fittingChanged)
def fitPreviewParameterValues(self) -> dict:
return self._fitting_logic.fit_preview_parameter_values

@Property('QVariant', notify=fittingChanged)
def fitResults(self) -> dict:
"""Return fit results as a dict for QML consumption."""
Expand Down Expand Up @@ -171,10 +199,17 @@ def _start_threaded_fit(self) -> None:
self._fitter_thread.setTerminationEnabled(True)
self._fitter_thread.finished.connect(self._on_fit_finished)
self._fitter_thread.failed.connect(self._on_fit_failed)
self._fitter_thread.progressDetail.connect(self._on_fit_progress)
self._fitter_thread.finished.connect(self._fitter_thread.deleteLater)
self._fitter_thread.failed.connect(self._fitter_thread.deleteLater)
self._fitter_thread.start()

@Slot(dict)
def _on_fit_progress(self, payload: dict) -> None:
"""Handle in-flight progress payloads emitted from the worker thread."""
self._fitting_logic.on_fit_progress(payload)
self.fittingChanged.emit()

@Slot(list)
def _on_fit_finished(self, results: list) -> None:
"""Handle successful completion of threaded fit."""
Expand All @@ -187,21 +222,23 @@ def _on_fit_finished(self, results: list) -> None:
@Slot(str)
def _on_fit_failed(self, error_message: str) -> None:
"""Handle failed threaded fit."""
is_user_cancel = self._fitting_logic.fit_cancelled and 'cancel' in error_message.lower()
if is_user_cancel:
error_message = 'Fitting cancelled by user'
self._fitting_logic.on_fit_failed(error_message)
self._fitter_thread = None
self.fittingChanged.emit()
self._clearCacheAndEmitParametersChanged()
self.externalFittingChanged.emit()
self.fitFailed.emit(error_message)
if not is_user_cancel:
self.fitFailed.emit(error_message)

@Slot()
def _onStopFit(self) -> None:
"""Stop fitting and clean up."""
self._fitting_logic.stop_fit()
if self._fitter_thread is not None:
self._fitter_thread.stop()
self._fitter_thread.deleteLater()
self._fitter_thread = None
self.fittingChanged.emit()
self.externalFittingChanged.emit()

Expand Down Expand Up @@ -455,7 +492,7 @@ def get_individual_experiment_data_list(self):
if exp_idx < len(self._experiments_logic.available())
else f'Experiment {exp_idx + 1}'
)
color = color_palette[idx % len(color_palette)]
color = color_palette[exp_idx % len(color_palette)]

experiment_data_list.append({'data': data, 'name': exp_name, 'color': color, 'index': exp_idx})
except (IndexError, AttributeError) as e:
Expand Down
66 changes: 57 additions & 9 deletions EasyReflectometryApp/Backends/Py/logic/assemblies.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ class Assemblies:
def __init__(self, project_lib: ProjectLib):
self._project_lib = project_lib

def _has_valid_assembly_index(self, index: int) -> bool:
return 0 <= index < len(self._assemblies)

def _target_insert_index(self, current_index: int, previous_length: int) -> int:
if previous_length <= 1:
return previous_length
return min(current_index + 1, previous_length - 1)

def _move_new_assembly_into_position(self, existing_ids: set[int], target_index: int) -> int | None:
new_index = next((idx for idx, assembly in enumerate(self._assemblies) if id(assembly) not in existing_ids), None)
if new_index is None:
return None

while new_index > target_index:
self._assemblies.move_up(new_index)
new_index -= 1

while new_index < target_index:
self._assemblies.move_down(new_index)
new_index += 1

return new_index

@property
def _assemblies(self) -> Sample:
return self._project_lib._models[self._project_lib.current_model_index].sample # Sample is a collection of assemblies
Expand Down Expand Up @@ -43,12 +66,23 @@ def remove_at_index(self, value: str) -> None:
self._assemblies.remove_assembly(int(value))

def add_new(self) -> None:
previous_length = len(self._assemblies)
target_index = self._target_insert_index(self.index, previous_length)
existing_ids = {id(assembly) for assembly in self._assemblies}
self._assemblies.add_assembly()
new_index = self._move_new_assembly_into_position(existing_ids, target_index)
index_si = self._project_lib.get_index_si()
self._assemblies[-1].layers[0].material = self._project_lib._materials[index_si]
if new_index is not None:
self._assemblies[new_index].layers[0].material = self._project_lib._materials[index_si]

def duplicate_selected(self) -> None:
if not self._has_valid_assembly_index(self.index):
return
previous_length = len(self._assemblies)
target_index = self._target_insert_index(self.index, previous_length)
existing_ids = {id(assembly) for assembly in self._assemblies}
self._assemblies.duplicate_assembly(self.index)
self._move_new_assembly_into_position(existing_ids, target_index)

def move_selected_up(self) -> None:
if self.index > 0:
Expand All @@ -60,31 +94,45 @@ def move_selected_down(self) -> None:
self._assemblies.move_down(self.index)
self.index = self.index + 1

def set_name_at_current_index(self, new_value: str) -> None:
self._assemblies[self.index].name = new_value
return True
def set_name_at_current_index(self, new_value: str) -> bool:
return self.set_name_at_index(self.index, new_value)

def set_name_at_index(self, index: int, new_value: str) -> bool:
if not self._has_valid_assembly_index(index):
return False
if self._assemblies[index].name != new_value:
self._assemblies[index].name = new_value
return True
return False

def set_type_at_current_index(self, new_value: str) -> bool:
if new_value == self._assemblies[self.index].type:
return self.set_type_at_index(self.index, new_value)

def set_type_at_index(self, index: int, new_value: str) -> bool:
if not self._has_valid_assembly_index(index):
return False
if new_value == self._assemblies[index].type:
return False

if new_value == 'Multi-layer':
new_assembly = Multilayer()
new_assembly.layers[0].material = self._assemblies[self.index].layers.data[0].material
new_assembly.layers[0].material = self._assemblies[index].layers.data[0].material
elif new_value == 'Repeating Multi-layer':
new_assembly = RepeatingMultilayer(repetitions=1, name=new_value)
new_assembly.layers[0].material = self._assemblies[self.index].layers.data[0].material
new_assembly.layers[0].material = self._assemblies[index].layers.data[0].material
elif new_value == 'Surfactant Layer':
index_air = self._project_lib.get_index_air()
index_d2o = self._project_lib.get_index_d2o()
new_assembly = SurfactantLayer()
new_assembly.layers[0].solvent = self._project_lib._materials[index_air]
new_assembly.layers[1].solvent = self._project_lib._materials[index_d2o]
else:
return False

if new_assembly.name is None:
new_assembly.name = self._assemblies[self.index].name
new_assembly.name = self._assemblies[index].name

self._assemblies[self.index] = new_assembly
self._assemblies[index] = new_assembly
return True

# Only for repeating multilayer
Expand Down
72 changes: 70 additions & 2 deletions EasyReflectometryApp/Backends/Py/logic/fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import cast

from easyreflectometry import Project as ProjectLib
from easyreflectometry.utils import count_free_parameters
from easyscience.fitting import FitResults
from easyscience.fitting.minimizers.utils import FitError

Expand All @@ -26,6 +27,13 @@ def __init__(self, project_lib: ProjectLib):
self._fit_error_message: Optional[str] = None
self._fit_cancelled = False
self._stop_requested = False
self._fit_iteration = 0
self._fit_interim_chi2 = 0.0
self._fit_interim_reduced_chi2 = 0.0
self._fit_running_message = ''
self._fit_preview_parameter_values: dict = {}
self._fit_has_preview_update = False
self._fit_has_interim_update = False

@property
def status(self) -> str:
Expand Down Expand Up @@ -68,6 +76,61 @@ def fit_cancelled(self) -> bool:
"""Return True if fit was cancelled by user."""
return self._fit_cancelled

@property
def fit_iteration(self) -> int:
return self._fit_iteration

@property
def fit_interim_chi2(self) -> float:
return self._fit_interim_chi2

@property
def fit_interim_reduced_chi2(self) -> float:
return self._fit_interim_reduced_chi2

@property
def fit_progress_message(self) -> str:
return self._fit_running_message

@property
def fit_preview_parameter_values(self) -> dict:
return dict(self._fit_preview_parameter_values)

@property
def fit_has_preview_update(self) -> bool:
return self._fit_has_preview_update

@property
def fit_has_interim_update(self) -> bool:
return self._fit_has_interim_update

def on_fit_progress(self, payload: dict) -> None:
"""Update transient state from an in-flight fit progress payload."""
self._fit_iteration = int(payload.get('iteration', 0) or 0)
self._fit_interim_chi2 = float(payload.get('chi2', 0.0) or 0.0)
self._fit_interim_reduced_chi2 = float(
payload.get('reduced_chi2', self._fit_interim_chi2) or self._fit_interim_chi2
)
self._fit_preview_parameter_values = dict(payload.get('parameter_values', {}) or {})
self._fit_has_preview_update = bool(payload.get('refresh_plots', False))
self._fit_has_interim_update = True

if self._fit_iteration > 0:
self._fit_running_message = (
f'Fitting... iter {self._fit_iteration}, Chi2 = {self._fit_interim_chi2:.6g}'
)
else:
self._fit_running_message = 'Fitting...'

def clear_fit_progress(self) -> None:
self._fit_iteration = 0
self._fit_interim_chi2 = 0.0
self._fit_interim_reduced_chi2 = 0.0
self._fit_running_message = ''
self._fit_preview_parameter_values = {}
self._fit_has_preview_update = False
self._fit_has_interim_update = False

def on_fit_failed(self, error_message: str) -> None:
"""Handle fitting failure callback.

Expand All @@ -79,6 +142,7 @@ def on_fit_failed(self, error_message: str) -> None:
self._running = False
self._finished = True
self._show_results_dialog = True
self.clear_fit_progress()

def stop_fit(self) -> None:
"""Request fitting to stop and clean up state."""
Expand All @@ -90,6 +154,7 @@ def stop_fit(self) -> None:
self._fit_cancelled = True
self._fit_error_message = 'Fitting cancelled by user'
self._show_results_dialog = True
self.clear_fit_progress()

def reset_stop_flag(self) -> None:
"""Reset the stop request flag before starting a new fit."""
Expand All @@ -108,6 +173,8 @@ def prepare_for_threaded_fit(self) -> None:
self._fit_error_message = None
self._result = None
self._results = []
self.clear_fit_progress()
self._fit_running_message = 'Fitting...'

def _ordered_experiments(self) -> list:
"""Return experiments as an ordered list of experiment objects.
Expand Down Expand Up @@ -213,6 +280,7 @@ def on_fit_finished(self, results: FitResults | List[FitResults]) -> None:
self._finished = True
self._show_results_dialog = True
self._fit_error_message = None
self.clear_fit_progress()

# Store result(s) - handle both single and multiple results
if isinstance(results, list) and len(results) > 0:
Expand All @@ -229,8 +297,8 @@ def on_fit_finished(self, results: FitResults | List[FitResults]) -> None:
@property
def fit_n_pars(self) -> int:
"""Return the global number of refined parameters for the fit."""
if self._results:
return sum(result.n_pars for result in self._results)
if len(self._results) > 1:
return count_free_parameters(self._project_lib)
if self._result is None:
return 0
return self._result.n_pars
Expand Down
Loading
Loading