Skip to content
Open
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
85 changes: 85 additions & 0 deletions core/io/template.py
Original file line number Diff line number Diff line change
@@ -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
``var<TAB>signal`` 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 'var<TAB>signal' header followed by\n"
'# concentration<TAB>signal 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
161 changes: 161 additions & 0 deletions gui/dialogs/simulator_dialog.py
Original file line number Diff line number Diff line change
@@ -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()
53 changes: 53 additions & 0 deletions gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading