diff --git a/core/io/template.py b/core/io/template.py new file mode 100644 index 0000000..a286eef --- /dev/null +++ b/core/io/template.py @@ -0,0 +1,85 @@ +"""Example input-data template generator. + +Emits a small measurement file that demonstrates the exact format the +app's readers accept, so a user can fill in their own numbers by example. +The output round-trips cleanly through :func:`core.io.load_measurements`. + +Format is chosen by file extension: + +* ``.csv`` → long-format ``concentration,signal,replica`` (read by + :class:`~core.io.formats.csv_reader.CsvReader`). +* anything else (``.txt``) → tab-separated repeated-header blocks, one + ``varsignal`` header per replica (read by + :class:`~core.io.formats.txt.TxtReader`). + +The example is **assay-agnostic**: it shows the (concentration → signal) +layout shared by every assay, not any particular binding model. Signal +values are obviously-synthetic placeholders, not real measurements. + +The reader-accepted column names are duplicated here rather than imported, +but :func:`tests.unit.test_io_template` round-trips the output through the +real readers, so any drift from the readers' contract fails the suite. +""" + +from __future__ import annotations + +from pathlib import Path + +# A few titration points (Molar) shared by every replica, plus three +# replicas of plausible rising signal with small per-replica variation. +# Hard-coded — no RNG — so the emitted template is reproducible. +_CONCENTRATIONS_M: tuple[float, ...] = (0.0, 1e-6, 2e-6, 5e-6, 1e-5, 2e-5) +_REPLICA_SIGNALS: tuple[tuple[float, ...], ...] = ( + (100.0, 255.0, 402.0, 651.0, 848.0, 1001.0), + (102.0, 249.0, 398.0, 640.0, 855.0, 996.0), + (98.0, 252.0, 405.0, 648.0, 851.0, 1004.0), +) + +_TXT_HEADER = ( + '# SupraSimFit input-data template — replace the example rows below with your own data.\n' + '#\n' + "# Tab-separated. Each replica is a 'varsignal' header followed by\n" + '# concentrationsignal rows. Concentrations are in Molar (M).\n' + "# Lines starting with '#' are ignored. Add or remove replica blocks as needed." +) + + +def _txt_template() -> str: + """Build a tab-separated, repeated-header TXT template (``TxtReader``).""" + lines = [_TXT_HEADER] + for signals in _REPLICA_SIGNALS: + lines.append('var\tsignal') + lines.extend(f'{conc:g}\t{sig:g}' for conc, sig in zip(_CONCENTRATIONS_M, signals)) + return '\n'.join(lines) + '\n' + + +def _csv_template() -> str: + """Build a long-format ``concentration,signal,replica`` CSV (``CsvReader``). + + No ``#`` comments: ``CsvReader`` parses with ``pandas.read_csv`` and no + comment character, so comment lines would be mis-read as data. + """ + lines = ['concentration,signal,replica'] + for replica, signals in enumerate(_REPLICA_SIGNALS): + lines.extend(f'{conc:g},{sig:g},{replica}' for conc, sig in zip(_CONCENTRATIONS_M, signals)) + return '\n'.join(lines) + '\n' + + +def write_data_template(path: str | Path) -> Path: + """Write an example input file demonstrating the reader-accepted format. + + Parameters + ---------- + path : str or Path + Destination. A ``.csv`` suffix emits long-format CSV; any other + suffix emits tab-separated TXT. + + Returns + ------- + Path + The path that was written. + """ + path = Path(path) + content = _csv_template() if path.suffix.lower() == '.csv' else _txt_template() + path.write_text(content) + return path diff --git a/gui/dialogs/simulator_dialog.py b/gui/dialogs/simulator_dialog.py new file mode 100644 index 0000000..ae11768 --- /dev/null +++ b/gui/dialogs/simulator_dialog.py @@ -0,0 +1,161 @@ +"""DBA forward-simulation dialog. + +A standalone parameter explorer for the Direct Binding Assay: the user sets +the host–dye binding constant, the signal coefficients, and the total host +and dye concentrations, and a PyQtGraph curve updates live with the predicted +titration signal. The curve is computed by the shared, tested forward model +:func:`core.models.equilibrium.dba_signal` — no model math lives here. + +Two titration directions match the two DBA assay subtypes: + +* **Host → Dye** (``HtoD``): dye fixed at ``[Dye]₀``; host titrated 0 → ``[Host]₀``. +* **Dye → Host** (``DtoH``): host fixed at ``[Host]₀``; dye titrated 0 → ``[Dye]₀``. + +Concentrations are entered and plotted in µM; they are converted to Molar at +the boundary because the forward model works in base units. +""" + +from __future__ import annotations + +import numpy as np +import pyqtgraph as pg +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) + +from core.models.equilibrium import dba_signal +from gui.widgets.numeric_inputs import NoScrollDoubleSpinBox + +# Number of points sampled along the simulated titration curve. +_N_POINTS = 200 + +# (combo label, dba_signal mode, titrated-species label for the x-axis) +_MODES: tuple[tuple[str, str, str], ...] = ( + ('Host → Dye (titrate host)', 'HtoD', 'Host'), + ('Dye → Host (titrate dye)', 'DtoH', 'Dye'), +) + +_INTRO = ( + 'Forward DBA simulation. Adjust the parameters to see the predicted ' + 'titration curve update live. Concentrations are entered in µM.' +) + + +class SimulatorDialog(QDialog): + """Live Direct-Binding-Assay titration-curve simulator. + + Parameter inputs drive a PyQtGraph curve computed by the shared forward + model :func:`core.models.equilibrium.dba_signal`. + """ + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle('DBA Simulator') + self.setMinimumSize(760, 480) + self._build_ui() + self._recompute() + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + def _build_ui(self) -> None: + root = QHBoxLayout(self) + + # --- left: parameter controls ----------------------------------- + side = QVBoxLayout() + intro = QLabel(_INTRO) + intro.setWordWrap(True) + side.addWidget(intro) + + form = QFormLayout() + form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + + self._mode = QComboBox() + for label, _mode, _x_species in _MODES: + self._mode.addItem(label) + + self._ka = self._spin(0.0, 1e12, 0, 5e5, ' M⁻¹') + self._i0 = self._spin(-1e9, 1e9, 2, 0.0, ' a.u.') + self._i_free = self._spin(0.0, 1e12, 0, 5e7, ' a.u./M') + self._i_bound = self._spin(0.0, 1e12, 0, 3e8, ' a.u./M') + self._h0 = self._spin(0.0, 1e6, 3, 50.0, ' µM') + self._d0 = self._spin(0.0, 1e6, 3, 5.0, ' µM') + + form.addRow('Titration', self._mode) + form.addRow('Ka (host–dye)', self._ka) + form.addRow('I₀ (baseline)', self._i0) + form.addRow('I (free dye)', self._i_free) + form.addRow('I (bound dye)', self._i_bound) + form.addRow('[Host]₀', self._h0) + form.addRow('[Dye]₀', self._d0) + side.addLayout(form) + side.addStretch(1) + + side_box = QWidget() + side_box.setLayout(side) + side_box.setFixedWidth(300) + root.addWidget(side_box) + + # --- right: live plot -------------------------------------------- + self._plot = pg.PlotWidget() + self._plot.setBackground('w') + self._plot.showGrid(x=True, y=True, alpha=0.3) + self._plot.setLabel('left', 'Signal (a.u.)') + self._curve = self._plot.plot([], [], pen=pg.mkPen('#1f77b4', width=2)) + root.addWidget(self._plot, stretch=1) + + # Wire every control once all widgets exist (avoids firing + # _recompute against half-built state during construction). + self._mode.currentIndexChanged.connect(self._recompute) + for sb in (self._ka, self._i0, self._i_free, self._i_bound, self._h0, self._d0): + sb.valueChanged.connect(self._recompute) + + @staticmethod + def _spin(lo: float, hi: float, decimals: int, value: float, suffix: str) -> NoScrollDoubleSpinBox: + sb = NoScrollDoubleSpinBox() + sb.setRange(lo, hi) + sb.setDecimals(decimals) + sb.setValue(value) + sb.setSuffix(suffix) + return sb + + # ------------------------------------------------------------------ + # Simulation + # ------------------------------------------------------------------ + def _recompute(self) -> None: + _label, mode, x_species = _MODES[self._mode.currentIndex()] + h0 = self._h0.value() * 1e-6 # µM → M + d0 = self._d0.value() * 1e-6 # µM → M + + if mode == 'HtoD': # titrate host into fixed dye + titrant_max, y_fixed = h0, d0 + else: # DtoH: titrate dye into fixed host + titrant_max, y_fixed = d0, h0 + + x_titrant = np.linspace(0.0, titrant_max, _N_POINTS) + signal = dba_signal( + self._i0.value(), + self._ka.value(), + self._i_free.value(), + self._i_bound.value(), + x_titrant, + y_fixed, + mode=mode, + ) + + self._curve.setData(x_titrant * 1e6, signal) # plot titrant in µM + self._plot.setLabel('bottom', f'[{x_species}] (µM)') + + # ------------------------------------------------------------------ + # Introspection (used by tests) + # ------------------------------------------------------------------ + def curve_xy(self) -> tuple[np.ndarray, np.ndarray]: + """Return the current plotted ``(x_µM, signal)`` arrays.""" + return self._curve.getData() diff --git a/gui/main_window.py b/gui/main_window.py index 7b5e338..28224d9 100644 --- a/gui/main_window.py +++ b/gui/main_window.py @@ -2,11 +2,14 @@ from __future__ import annotations +from pathlib import Path + from PyQt6.QtCore import QCoreApplication, QEvent, QLocale, QObject, Qt from PyQt6.QtGui import QAction, QIcon, QKeySequence from PyQt6.QtWidgets import ( QAbstractSpinBox, QApplication, + QFileDialog, QMainWindow, QMenu, QMessageBox, @@ -16,6 +19,7 @@ ) from _version import __version__ +from gui.dialogs.simulator_dialog import SimulatorDialog from gui.fitting_session import FittingSession from gui.preferences import APP_NAME, ORG_NAME from gui.update_check import UpdateCheckWorker, is_newer @@ -171,10 +175,16 @@ def _setup_toolbar(self) -> None: self._act_load_style.setToolTip('Load plot style settings from a JSON file') self._act_load_style.triggered.connect(self._on_load_style) + self._act_save_template = QAction('Save Data Template\u2026', self) + self._act_save_template.setToolTip('Save an example input file showing the data format the readers accept') + self._act_save_template.triggered.connect(self._on_save_template) + import_menu = QMenu(self) import_menu.addAction(self._act_load) import_menu.addAction(self._act_import) import_menu.addSeparator() + import_menu.addAction(self._act_save_template) + import_menu.addSeparator() import_menu.addAction(self._act_load_style) self._import_btn = self._make_menu_button('Import', import_menu, 'Import / Load options') @@ -185,6 +195,11 @@ def _setup_toolbar(self) -> None: self._act_demo.triggered.connect(self._on_load_demo) tb.addAction(self._act_demo) + self._act_simulate = QAction('DBA Simulator', self) + self._act_simulate.setToolTip('Open the DBA forward-simulation dialog (live titration curve)') + self._act_simulate.triggered.connect(self._on_simulate) + tb.addAction(self._act_simulate) + tb.addSeparator() self._act_fit = QAction('Run Fit', self) @@ -271,6 +286,7 @@ def _setup_menus(self) -> None: file_menu.addAction(self._act_new) file_menu.addAction(self._act_load) + file_menu.addAction(self._act_save_template) file_menu.addSeparator() file_menu.addAction(self._act_fit) file_menu.addSeparator() @@ -291,6 +307,10 @@ def _setup_menus(self) -> None: quit_act.triggered.connect(self.close) file_menu.addAction(quit_act) + # Tools menu + tools_menu = mb.addMenu('&Tools') + tools_menu.addAction(self._act_simulate) + # View menu view_menu = mb.addMenu('&View') close_tab_act = QAction('Close Tab', self) @@ -355,6 +375,39 @@ def _on_load_demo(self) -> None: if session: session.load_demo_ida() + def _on_simulate(self) -> None: + """Open (or re-raise) the modeless DBA forward-simulation dialog. + + A single instance is kept on the window so it survives the slot + returning and can be re-focused instead of stacking duplicates. + """ + dlg = getattr(self, '_simulator_dialog', None) + if dlg is None: + dlg = SimulatorDialog(self) + self._simulator_dialog = dlg + dlg.show() + dlg.raise_() + dlg.activateWindow() + + def _on_save_template(self) -> None: + """Write an example input file so users can see the expected format.""" + from core.io.template import write_data_template + + path, _filter = QFileDialog.getSaveFileName( + self, + 'Save Data Template', + str(Path.home() / 'data_template.txt'), + 'Text data (*.txt);;CSV data (*.csv)', + ) + if not path: + return + try: + written = write_data_template(path) + except Exception as exc: # surface any write failure — no silent fallback + QMessageBox.warning(self, 'Could not save template', f'Failed to write the template file:\n{exc}') + return + self._statusbar.showMessage(f'Saved data template → {written}') + def _on_run_fit(self) -> None: session = self.active_session() if session: diff --git a/tests/unit/gui/test_simulator_dialog.py b/tests/unit/gui/test_simulator_dialog.py new file mode 100644 index 0000000..50fd0d1 --- /dev/null +++ b/tests/unit/gui/test_simulator_dialog.py @@ -0,0 +1,76 @@ +"""Tests for the DBA forward-simulation dialog (#23). + +These verify the *wiring* between the parameter widgets and the shared +forward model — that the dialog reads its controls, converts units, picks +the titration direction, and plots ``dba_signal`` correctly. The model math +itself is covered by the equilibrium-model tests; here the independent check +is a direct ``dba_signal`` call with the same stated parameters plus a +model-agnostic monotonicity argument. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +pytest.importorskip('PyQt6') + +from core.models.equilibrium import dba_signal +from gui.dialogs.simulator_dialog import SimulatorDialog + + +def _set_params(dlg, *, ka, i0, i_free, i_bound, h0_uM, d0_uM, mode_index): + """Drive the dialog's widgets to a known state (triggers live recompute).""" + dlg._mode.setCurrentIndex(mode_index) + dlg._ka.setValue(ka) + dlg._i0.setValue(i0) + dlg._i_free.setValue(i_free) + dlg._i_bound.setValue(i_bound) + dlg._h0.setValue(h0_uM) + dlg._d0.setValue(d0_uM) + + +class TestSimulatorWiring: + """Plotted curve must equal the model called with the widgets' values.""" + + def test_curve_matches_dba_signal_host_to_dye(self, qapp): + dlg = SimulatorDialog() + _set_params(dlg, ka=5e5, i0=0.0, i_free=5e7, i_bound=3e8, h0_uM=50.0, d0_uM=5.0, mode_index=0) + + x_uM, y = dlg.curve_xy() + assert len(x_uM) > 1 + # HtoD titrates host 0 → [Host]₀ (50 µM); dye is the fixed species. + assert np.nanmax(x_uM) == pytest.approx(50.0) + expected = dba_signal(0.0, 5e5, 5e7, 3e8, np.asarray(x_uM) * 1e-6, 5e-6, mode='HtoD') + np.testing.assert_allclose(y, expected, rtol=1e-9, equal_nan=True) + + def test_mode_switch_titrates_dye_and_fixes_host(self, qapp): + dlg = SimulatorDialog() + _set_params(dlg, ka=5e5, i0=0.0, i_free=5e7, i_bound=3e8, h0_uM=50.0, d0_uM=5.0, mode_index=1) + + x_uM, y = dlg.curve_xy() + # DtoH titrates dye 0 → [Dye]₀ (5 µM); host (50 µM) is now fixed. + assert np.nanmax(x_uM) == pytest.approx(5.0) + expected = dba_signal(0.0, 5e5, 5e7, 3e8, np.asarray(x_uM) * 1e-6, 50e-6, mode='DtoH') + np.testing.assert_allclose(y, expected, rtol=1e-9, equal_nan=True) + + def test_live_update_on_parameter_change(self, qapp): + dlg = SimulatorDialog() + _set_params(dlg, ka=5e5, i0=0.0, i_free=5e7, i_bound=3e8, h0_uM=50.0, d0_uM=5.0, mode_index=0) + _x, y_before = dlg.curve_xy() + + dlg._ka.setValue(5e7) # 100× stronger binding reshapes the curve + _x, y_after = dlg.curve_xy() + + assert not np.allclose(y_before, y_after, equal_nan=True) + + def test_signal_rises_monotonically_when_bound_is_brighter(self, qapp): + """Model-agnostic physics check: with the host-dye complex brighter + than free dye, signal must rise monotonically as host saturates.""" + dlg = SimulatorDialog() + _set_params(dlg, ka=5e5, i0=0.0, i_free=5e7, i_bound=3e8, h0_uM=50.0, d0_uM=5.0, mode_index=0) + + _x, y = dlg.curve_xy() + y = np.asarray(y, dtype=float) + diffs = np.diff(y[np.isfinite(y)]) + assert (diffs >= -1e-6).all() diff --git a/tests/unit/test_io_template.py b/tests/unit/test_io_template.py new file mode 100644 index 0000000..ee8750b --- /dev/null +++ b/tests/unit/test_io_template.py @@ -0,0 +1,52 @@ +"""Round-trip tests for the example data template (#24). + +The template's whole job is to demonstrate a *reader-accepted* format, so +the meaningful check is that it loads back through the real loader with the +expected shape and values — not that any specific bytes were written. +""" + +from __future__ import annotations + +import pytest + +from core.io import load_measurements +from core.io.template import write_data_template + +_N_REPLICAS = 3 +_N_POINTS = 6 + + +class TestDataTemplateRoundTrip: + """The emitted template parses through the app's existing readers.""" + + def test_txt_template_round_trips(self, tmp_path): + path = write_data_template(tmp_path / 'template.txt') + + df = load_measurements(path) + assert list(df.columns) == ['concentration', 'signal', 'replica'] + assert df['replica'].nunique() == _N_REPLICAS + assert len(df) == _N_REPLICAS * _N_POINTS + + def test_csv_template_round_trips(self, tmp_path): + path = write_data_template(tmp_path / 'template.csv') + + df = load_measurements(path) + assert {'concentration', 'signal', 'replica'}.issubset(df.columns) + assert df['replica'].nunique() == _N_REPLICAS + assert len(df) == _N_REPLICAS * _N_POINTS + + def test_extension_selects_format(self, tmp_path): + """A .txt file uses repeated 'var\\tsignal' headers; .csv does not.""" + txt = (write_data_template(tmp_path / 't.txt')).read_text() + csv = (write_data_template(tmp_path / 't.csv')).read_text() + assert 'var\tsignal' in txt and '\t' in txt + assert 'concentration,signal,replica' in csv and '\t' not in csv + + def test_values_survive_load(self, tmp_path): + """Independently pin the first replica's concentration grid endpoints.""" + df = load_measurements(write_data_template(tmp_path / 't.txt')) + r0 = df[df['replica'] == 0].sort_values('concentration') + assert r0['concentration'].iloc[0] == pytest.approx(0.0) + assert r0['concentration'].iloc[-1] == pytest.approx(2e-5) + # Signal is strictly increasing in the example (a plausible titration). + assert r0['signal'].is_monotonic_increasing