bindmc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bindmc/main.py +67 -0
- bindmc/webgui/__init__.py +0 -0
- bindmc/webgui/app.py +54 -0
- bindmc/webgui/classes/BindingConstant.py +23 -0
- bindmc/webgui/classes/ChemicalShiftParam.py +40 -0
- bindmc/webgui/classes/Component.py +111 -0
- bindmc/webgui/classes/ExptData.py +485 -0
- bindmc/webgui/classes/ExptDataType.py +92 -0
- bindmc/webgui/classes/FitResult.py +173 -0
- bindmc/webgui/classes/MCMCSim.py +232 -0
- bindmc/webgui/classes/Model.py +86 -0
- bindmc/webgui/classes/RawData.py +36 -0
- bindmc/webgui/classes/Simulation.py +104 -0
- bindmc/webgui/classes/UIBindings.py +19 -0
- bindmc/webgui/classes/__init__.py +28 -0
- bindmc/webgui/components/__init__.py +29 -0
- bindmc/webgui/components/base.py +24 -0
- bindmc/webgui/components/bayes.py +689 -0
- bindmc/webgui/components/bayes_priors.py +351 -0
- bindmc/webgui/components/binding_model.py +330 -0
- bindmc/webgui/components/body.py +276 -0
- bindmc/webgui/components/data_gen.py +419 -0
- bindmc/webgui/components/data_import.py +450 -0
- bindmc/webgui/components/data_model.py +609 -0
- bindmc/webgui/components/fitting.py +886 -0
- bindmc/webgui/components/graph.py +649 -0
- bindmc/webgui/components/header.py +124 -0
- bindmc/webgui/components/simulation.py +385 -0
- bindmc/webgui/export/__init__.py +0 -0
- bindmc/webgui/export/notebook_exporter.py +727 -0
- bindmc/webgui/state/__init__.py +1 -0
- bindmc/webgui/state/statemanager.py +2043 -0
- bindmc/webgui/utils.py +322 -0
- bindmc-0.1.0.dist-info/METADATA +22 -0
- bindmc-0.1.0.dist-info/RECORD +37 -0
- bindmc-0.1.0.dist-info/WHEEL +5 -0
- bindmc-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
from .base import BaseComponent
|
|
2
|
+
from nicegui import ui, run
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import zipfile
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from ..classes import MCMCSim
|
|
9
|
+
from ..utils import safe_filename
|
|
10
|
+
from functools import partial
|
|
11
|
+
import asyncio
|
|
12
|
+
import re
|
|
13
|
+
import emcee
|
|
14
|
+
from matplotlib import pyplot as plt
|
|
15
|
+
import logging
|
|
16
|
+
from .bayes_priors import BayesPriorEditor
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_CORNER_BASE_WIDTH_PCT = 56
|
|
21
|
+
_CORNER_MAX_WIDTH_PCT = 80
|
|
22
|
+
_CHAIN_BASE_HEIGHT_PX = 320
|
|
23
|
+
_CHAIN_PER_ROW_HEIGHT_PX = 120
|
|
24
|
+
_CHAIN_MIN_HEIGHT_PX = 500
|
|
25
|
+
_CHAIN_MAX_HEIGHT_PX = 2400
|
|
26
|
+
_CORNER_MIN_HEIGHT_PX = 420
|
|
27
|
+
_CORNER_MAX_HEIGHT_PX = 1400
|
|
28
|
+
_DISPLAY_DPI = 100
|
|
29
|
+
_EXPORT_DPI = 180
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _corner_width_pct(ndim: int) -> int:
|
|
33
|
+
"""Corner width policy: 3x3 baseline, 4x4 midpoint, 5x5+ capped at 80vw."""
|
|
34
|
+
if ndim <= 3:
|
|
35
|
+
return _CORNER_BASE_WIDTH_PCT
|
|
36
|
+
if ndim == 4:
|
|
37
|
+
return int(round(_CORNER_BASE_WIDTH_PCT + (_CORNER_MAX_WIDTH_PCT - _CORNER_BASE_WIDTH_PCT) * 0.5))
|
|
38
|
+
return _CORNER_MAX_WIDTH_PCT
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _chain_height_px(ndim: int) -> int:
|
|
42
|
+
rows = max(1, int(ndim) + 1)
|
|
43
|
+
height = _CHAIN_BASE_HEIGHT_PX + rows * _CHAIN_PER_ROW_HEIGHT_PX
|
|
44
|
+
return max(_CHAIN_MIN_HEIGHT_PX, min(_CHAIN_MAX_HEIGHT_PX, int(height)))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _corner_height_px(ndim: int) -> int:
|
|
48
|
+
# Keep corner close to square while allowing vertical growth with n.
|
|
49
|
+
height = 280 + int(max(1, ndim) * 170)
|
|
50
|
+
return max(_CORNER_MIN_HEIGHT_PX, min(_CORNER_MAX_HEIGHT_PX, height))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_TRIAL_STEPS = 1000
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run_mcmc_trial(mc, trial_steps: int) -> float:
|
|
57
|
+
"""Run *trial_steps* of MCMC in a worker process and return elapsed wall-clock seconds."""
|
|
58
|
+
import time
|
|
59
|
+
from multiprocessing import Pool
|
|
60
|
+
b = io.StringIO()
|
|
61
|
+
with Pool() as pool:
|
|
62
|
+
t0 = time.monotonic()
|
|
63
|
+
mc.run(samples=trial_steps, pool=pool, tqdm_kwargs={'file': b})
|
|
64
|
+
return time.monotonic() - t0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _format_duration(seconds: float) -> str:
|
|
68
|
+
"""Return a human-readable string for a duration in seconds."""
|
|
69
|
+
s = int(round(seconds))
|
|
70
|
+
if s < 60:
|
|
71
|
+
return f'{s} second{"s" if s != 1 else ""}'
|
|
72
|
+
m, s = divmod(s, 60)
|
|
73
|
+
if m < 60:
|
|
74
|
+
return f'{m} min {s} s' if s else f'{m} minute{"s" if m != 1 else ""}'
|
|
75
|
+
h, m = divmod(m, 60)
|
|
76
|
+
return f'{h} h {m} min' if m else f'{h} hour{"s" if h != 1 else ""}'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BayesPanel(BaseComponent):
|
|
80
|
+
|
|
81
|
+
def setup_bindings(self) -> None:
|
|
82
|
+
self.prior_editor.setup_bindings()
|
|
83
|
+
self.sm.add_listener("fit_changed", self._refresh_for_active_fit)
|
|
84
|
+
self.sm.add_listener("active_context_changed", self._rebuild_dark_species_card)
|
|
85
|
+
self.sm.add_listener("data_model_processed", self._rebuild_dark_species_card)
|
|
86
|
+
|
|
87
|
+
def setup_nicegui(self) -> None:
|
|
88
|
+
self.prior_editor = BayesPriorEditor(self)
|
|
89
|
+
self._dark_species_visible = False # must be set before bind_visibility_from
|
|
90
|
+
self.container = ui.column().classes('w-full')
|
|
91
|
+
|
|
92
|
+
with self.container:
|
|
93
|
+
# Dark / silent species card (shown only for UV-vis / fluorescence datasets)
|
|
94
|
+
with ui.card().classes('w-full mb-4').bind_visibility_from(self, '_dark_species_visible'):
|
|
95
|
+
ui.label('Dark / Silent Species').classes('text-base font-semibold mb-1')
|
|
96
|
+
ui.label(
|
|
97
|
+
'Tick species whose ε / fluorescence amplitude is zero for each observable column.'
|
|
98
|
+
).classes('text-xs text-gray-500 mb-2')
|
|
99
|
+
self.dark_species_rows = ui.column().classes('w-full gap-1')
|
|
100
|
+
|
|
101
|
+
# MCMC Configuration
|
|
102
|
+
with ui.card().classes('w-full mb-4'):
|
|
103
|
+
ui.label('MCMC Configuration').classes('text-lg font-bold mb-2')
|
|
104
|
+
#with ui.grid(columns='auto 80px').classes('w-full'):
|
|
105
|
+
with ui.grid(columns=2):
|
|
106
|
+
with ui.column().classes():
|
|
107
|
+
with ui.row():
|
|
108
|
+
ui.label("Number of Walkers:").classes('self-center mr-2')
|
|
109
|
+
self.nwalkers_input = ui.number(
|
|
110
|
+
value=20,
|
|
111
|
+
min=10,
|
|
112
|
+
max=1000
|
|
113
|
+
).classes('self-center mr-2')
|
|
114
|
+
with ui.row():
|
|
115
|
+
|
|
116
|
+
ui.label("Number of Steps:").classes('self-center mr-2')
|
|
117
|
+
self.nsteps_input = ui.number(
|
|
118
|
+
value=10000,
|
|
119
|
+
min=500,
|
|
120
|
+
max=1000000
|
|
121
|
+
).classes('self-center mr-2')
|
|
122
|
+
with ui.row():
|
|
123
|
+
ui.label("Steps per Chunk:").classes('self-center mr-2')
|
|
124
|
+
self.chunk_size_input = ui.number(
|
|
125
|
+
value=500,
|
|
126
|
+
min=10,
|
|
127
|
+
max=1000000
|
|
128
|
+
).classes('self-center mr-2')
|
|
129
|
+
with ui.column().classes('ml-5'):
|
|
130
|
+
self.edit_priors_button = ui.button("Edit priors (bounds)", on_click=self.prior_editor.open)
|
|
131
|
+
self.export_notebook_button = ui.button(
|
|
132
|
+
"Export to Notebook",
|
|
133
|
+
on_click=self._open_export_dialog,
|
|
134
|
+
).classes('mt-2')
|
|
135
|
+
|
|
136
|
+
# Control buttons
|
|
137
|
+
with ui.row().classes('mt-4'):
|
|
138
|
+
self.run_button = ui.button('Run Bayesian Analysis', on_click=self.run_analysis)
|
|
139
|
+
self.stop_button = ui.button('Stop Analysis', on_click=self.stop_analysis)
|
|
140
|
+
self.stop_button.set_enabled(False)
|
|
141
|
+
|
|
142
|
+
with ui.tabs().classes('w-full') as tabs:
|
|
143
|
+
run = ui.tab('Run', icon='go')
|
|
144
|
+
results = ui.tab('Results', icon='box')
|
|
145
|
+
with ui.tab_panels(tabs, value=run).classes('w-full h-full'):
|
|
146
|
+
with ui.tab_panel(run):
|
|
147
|
+
# Progress Display
|
|
148
|
+
# --- NiceGUI elements ---
|
|
149
|
+
self.progress_bar = ui.linear_progress(value=0,show_value=False)
|
|
150
|
+
self.progress_label = ui.label('Progress: 0%').classes('mt-2')
|
|
151
|
+
self.status_log = ui.textarea(label='Status Log').classes('w-full h-32 mt-2')
|
|
152
|
+
# Timers are created only while a run is active and canceled afterwards.
|
|
153
|
+
ui.markdown('#### Chains')
|
|
154
|
+
with ui.row().classes('w-full justify-center'):
|
|
155
|
+
self.chain_chart = ui.matplotlib().classes('w-full')
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
with ui.tab_panel(results):
|
|
160
|
+
# Results area
|
|
161
|
+
self.result_area = ui.markdown('### MCMC results ').classes('mt-4')
|
|
162
|
+
|
|
163
|
+
ui.markdown('#### Chains figure')
|
|
164
|
+
with ui.row().classes('w-full justify-center'):
|
|
165
|
+
self.result_chains = ui.matplotlib().classes('w-full')
|
|
166
|
+
ui.markdown('#### Corner plot')
|
|
167
|
+
with ui.row().classes('w-full justify-center'):
|
|
168
|
+
self.result_corner = ui.matplotlib().classes('w-full')
|
|
169
|
+
with ui.row().classes('w-full justify-center gap-2 mt-2 mb-2'):
|
|
170
|
+
ui.button('Download Chains Figure', on_click=self.download_chain_figure)
|
|
171
|
+
ui.button('Download Corner Figure', on_click=self.download_corner_figure)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Control variables
|
|
177
|
+
self.is_running = False
|
|
178
|
+
self.should_stop = False
|
|
179
|
+
self.completed_steps = 0
|
|
180
|
+
self.progress_timer = None
|
|
181
|
+
self.status_timer = None
|
|
182
|
+
self.graph_timer = None
|
|
183
|
+
# Maps fit UUID → MCMCSim run for that fit (session-only; sampler state is not persisted)
|
|
184
|
+
self._fit_to_mcmc: dict = {}
|
|
185
|
+
self._apply_chain_container_style(self.chain_chart, ndim=2)
|
|
186
|
+
self._rebuild_dark_species_card()
|
|
187
|
+
self._apply_chain_container_style(self.result_chains, ndim=2)
|
|
188
|
+
self._apply_corner_container_style(ndim=3)
|
|
189
|
+
|
|
190
|
+
def _set_figure_size(self, fig, width_in: float, height_in: float) -> None:
|
|
191
|
+
fig.set_dpi(_DISPLAY_DPI)
|
|
192
|
+
fig.set_size_inches(width_in, height_in, forward=True)
|
|
193
|
+
|
|
194
|
+
def _apply_chain_container_style(self, chart, ndim: int) -> None:
|
|
195
|
+
height_px = _chain_height_px(ndim)
|
|
196
|
+
chart.style(f'width: min(96vw, 1320px); height: {height_px}px; margin: 0 auto;')
|
|
197
|
+
|
|
198
|
+
def _apply_corner_container_style(self, ndim: int) -> None:
|
|
199
|
+
width_pct = _corner_width_pct(ndim)
|
|
200
|
+
height_px = _corner_height_px(ndim)
|
|
201
|
+
self.result_corner.style(f'width: {width_pct}vw; max-width: 80vw; min-width: 420px; height: {height_px}px; margin: 0 auto;')
|
|
202
|
+
|
|
203
|
+
def _chain_figsize(self, ndim: int) -> tuple[float, float]:
|
|
204
|
+
rows = max(1, int(ndim) + 1)
|
|
205
|
+
return (12.5, max(6.0, rows * 1.7))
|
|
206
|
+
|
|
207
|
+
def _corner_figsize(self, ndim: int) -> tuple[float, float]:
|
|
208
|
+
side = max(7.0, min(22.0, float(ndim) * 2.6))
|
|
209
|
+
return (side, side)
|
|
210
|
+
|
|
211
|
+
def _get_burnin(self, notify: bool = True) -> int:
|
|
212
|
+
if not hasattr(self, 'mcmc') or self.mcmc.mc is None or self.mcmc.mc.sampler is None:
|
|
213
|
+
return 0
|
|
214
|
+
tau = None
|
|
215
|
+
try:
|
|
216
|
+
tau = self.mcmc.mc.sampler.get_autocorr_time()
|
|
217
|
+
except emcee.autocorr.AutocorrError as e:
|
|
218
|
+
tau = e.tau
|
|
219
|
+
if notify:
|
|
220
|
+
s = (
|
|
221
|
+
f"Autocorrelation time is likely too short. Max tau is {int(np.max(tau))}; "
|
|
222
|
+
f"nsteps is {self.completed_steps}. Re-run for at least {int(50*np.max(tau))} steps."
|
|
223
|
+
)
|
|
224
|
+
ui.notify(s, type="warning")
|
|
225
|
+
self.result_area.content += f"""
|
|
226
|
+
|
|
227
|
+
WARNING: {s}"""
|
|
228
|
+
return int(5 * np.max(tau)) if tau is not None else 0
|
|
229
|
+
|
|
230
|
+
def _start_run_timers(self) -> None:
|
|
231
|
+
"""Create live-update timers for an active run."""
|
|
232
|
+
self._stop_run_timers()
|
|
233
|
+
with self.container:
|
|
234
|
+
self.progress_timer = ui.timer(0.1, callback=self._update_progress_bar, once=False, active=True)
|
|
235
|
+
self.status_timer = ui.timer(0.1, callback=self._update_status_log, once=False, active=True)
|
|
236
|
+
self.graph_timer = ui.timer(1.0, callback=self._update_graphs, once=False, active=True)
|
|
237
|
+
|
|
238
|
+
def _stop_run_timers(self) -> None:
|
|
239
|
+
"""Cancel and clear any active run timers."""
|
|
240
|
+
for timer_attr in ("progress_timer", "status_timer", "graph_timer"):
|
|
241
|
+
timer = getattr(self, timer_attr, None)
|
|
242
|
+
if timer is None:
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
timer.cancel(with_current_invocation=True)
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
setattr(self, timer_attr, None)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def run_analysis(self):
|
|
252
|
+
if self.is_running:
|
|
253
|
+
ui.notify("Analysis already running!", type='warning')
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
active_fit = self.sm.active_fit_or_none
|
|
257
|
+
if active_fit is None:
|
|
258
|
+
ui.notify('No active fit result.', type='warning')
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if active_fit.bd_model is None:
|
|
262
|
+
print('No bindtools model selected for fitting, generating one.')
|
|
263
|
+
ui.notify("Running an initial fit using least_sq")
|
|
264
|
+
m1 = self.sm.generate_binding_model_for_fit(active_fit)
|
|
265
|
+
m1 = await run.cpu_bound(partial(m1.runModel,ret=True,skip_col=np.shape(self.sm.active_expt_data.col_to_comp)[0],method=active_fit.fit_method))
|
|
266
|
+
active_fit.bd_model = m1
|
|
267
|
+
|
|
268
|
+
nsteps_target = int(self.nsteps_input.value)
|
|
269
|
+
nwalkers = int(self.nwalkers_input.value)
|
|
270
|
+
obslist = self.sm.active_expt_data.get_obs_list(self.sm._expt_dtypes)
|
|
271
|
+
|
|
272
|
+
# Create MCMC simulation (not yet registered in state)
|
|
273
|
+
self.mcmc = MCMCSim(
|
|
274
|
+
model=self.sm.active_model,
|
|
275
|
+
expt_data=self.sm.active_expt_data,
|
|
276
|
+
bd_model=active_fit.bd_model,
|
|
277
|
+
nwalkers=nwalkers,
|
|
278
|
+
nsteps_target=nsteps_target,
|
|
279
|
+
priors=list(self.prior_editor.priors) if self.prior_editor.priors else []
|
|
280
|
+
)
|
|
281
|
+
self.mcmc.setup(obslist)
|
|
282
|
+
|
|
283
|
+
# --- Timing trial (skipped for short runs) ---
|
|
284
|
+
if nsteps_target > _TRIAL_STEPS:
|
|
285
|
+
self.run_button.set_enabled(False)
|
|
286
|
+
ui.notify(
|
|
287
|
+
f'Running a {_TRIAL_STEPS:,}-step timing trial ({nwalkers} walkers) — please wait…',
|
|
288
|
+
type='info',
|
|
289
|
+
timeout=60000,
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
trial_elapsed = await run.cpu_bound(
|
|
293
|
+
partial(_run_mcmc_trial, self.mcmc.mc, _TRIAL_STEPS)
|
|
294
|
+
)
|
|
295
|
+
it_s = _TRIAL_STEPS / trial_elapsed
|
|
296
|
+
full_seconds = nsteps_target * trial_elapsed / _TRIAL_STEPS
|
|
297
|
+
full_time_str = _format_duration(full_seconds)
|
|
298
|
+
|
|
299
|
+
with ui.dialog() as timing_dialog, ui.card().classes('w-[min(560px,92vw)]'):
|
|
300
|
+
ui.label('Runtime estimate').classes('text-lg font-bold')
|
|
301
|
+
ui.label(
|
|
302
|
+
f'{_TRIAL_STEPS:,} steps took {trial_elapsed:.1f} s ({it_s:.2f} it/s).'
|
|
303
|
+
).classes('text-sm text-gray-600 mt-1')
|
|
304
|
+
ui.label(
|
|
305
|
+
f'The full run ({nsteps_target:,} steps, {nwalkers} walkers) '
|
|
306
|
+
f'will take approximately {full_time_str}.'
|
|
307
|
+
).classes('mt-2')
|
|
308
|
+
ui.label('Do you want to continue?').classes('mt-1 font-medium')
|
|
309
|
+
with ui.row().classes('w-full justify-end gap-2 mt-3'):
|
|
310
|
+
ui.button('Cancel', on_click=lambda: timing_dialog.submit(False))
|
|
311
|
+
ui.button('Continue', on_click=lambda: timing_dialog.submit(True))
|
|
312
|
+
|
|
313
|
+
should_continue = await timing_dialog
|
|
314
|
+
timing_dialog.delete()
|
|
315
|
+
if not should_continue:
|
|
316
|
+
self.run_button.set_enabled(True)
|
|
317
|
+
return
|
|
318
|
+
except Exception as e:
|
|
319
|
+
ui.notify(f'Timing trial failed ({e}); proceeding without estimate.', type='warning')
|
|
320
|
+
|
|
321
|
+
# Register in state now that the user has confirmed (or the trial was skipped)
|
|
322
|
+
self.sm.add_mcmc(self.mcmc, emit_events=False)
|
|
323
|
+
self._fit_to_mcmc[active_fit.id] = self.mcmc
|
|
324
|
+
self.sm.save_to_storage()
|
|
325
|
+
self._clear_results()
|
|
326
|
+
# Setup UI for running state
|
|
327
|
+
self.is_running = True
|
|
328
|
+
self.should_stop = False
|
|
329
|
+
self.run_button.set_enabled(False)
|
|
330
|
+
self.stop_button.set_enabled(True)
|
|
331
|
+
self.progress_bar.value = 0
|
|
332
|
+
self.progress_label.text = 'Starting MCMC analysis...'
|
|
333
|
+
self._log_status('Starting MCMC analysis...')
|
|
334
|
+
self._start_run_timers()
|
|
335
|
+
|
|
336
|
+
total_steps = self.mcmc.nsteps_target
|
|
337
|
+
nwalkers = self.mcmc.nwalkers
|
|
338
|
+
self.completed_steps = 0
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
# Run MCMC in chunks
|
|
342
|
+
self.mcmc.cancel_event.clear()
|
|
343
|
+
# this function is part of the bindgui mcmc object, not the parent
|
|
344
|
+
# it runs the mcmc in cpu-bound chunks
|
|
345
|
+
await self.mcmc.run(int(self.chunk_size_input.value))
|
|
346
|
+
await asyncio.sleep(0.1)
|
|
347
|
+
|
|
348
|
+
if self.should_stop:
|
|
349
|
+
self.progress_label.text = f'Analysis stopped at {self.completed_steps}/{total_steps} steps with {nwalkers} walkers'
|
|
350
|
+
self._log_status('Analysis stopped by user')
|
|
351
|
+
ui.notify("MCMC analysis stopped by user", type='warning')
|
|
352
|
+
else:
|
|
353
|
+
self.progress_label.text = f'MCMC Complete! ({total_steps} steps with {nwalkers} walkers)'
|
|
354
|
+
self._log_status('MCMC analysis completed successfully')
|
|
355
|
+
ui.notify("MCMC analysis completed successfully!", type='positive')
|
|
356
|
+
|
|
357
|
+
# Update results
|
|
358
|
+
self._update_results(self.mcmc)
|
|
359
|
+
|
|
360
|
+
except Exception as e:
|
|
361
|
+
self.progress_label.text = 'Analysis failed'
|
|
362
|
+
self._log_status(f'Error: {str(e)}')
|
|
363
|
+
ui.notify(f"MCMC analysis failed: {str(e)}", type='negative')
|
|
364
|
+
|
|
365
|
+
finally:
|
|
366
|
+
self._stop_run_timers()
|
|
367
|
+
# Reset UI state
|
|
368
|
+
self.is_running = False
|
|
369
|
+
self.should_stop = False
|
|
370
|
+
self.run_button.set_enabled(True)
|
|
371
|
+
self.stop_button.set_enabled(False)
|
|
372
|
+
|
|
373
|
+
def stop_analysis(self):
|
|
374
|
+
"""Stop the running analysis."""
|
|
375
|
+
if self.is_running:
|
|
376
|
+
self.should_stop = True
|
|
377
|
+
self.mcmc.cancel_event.set()
|
|
378
|
+
ui.notify("Stopping analysis after current chunk...", type='info')
|
|
379
|
+
else:
|
|
380
|
+
ui.notify("No analysis is currently running", type='warning')
|
|
381
|
+
|
|
382
|
+
def _log_status(self, message):
|
|
383
|
+
"""Add a timestamped message to the status log."""
|
|
384
|
+
import datetime
|
|
385
|
+
timestamp = datetime.datetime.now().strftime('%H:%M:%S')
|
|
386
|
+
current_log = self.status_log.value or ''
|
|
387
|
+
new_message = f'[{timestamp}] {message}\n'
|
|
388
|
+
self.status_log.value = current_log + new_message
|
|
389
|
+
logger.info(message)
|
|
390
|
+
|
|
391
|
+
# Auto-scroll to bottom (approximate)
|
|
392
|
+
lines = (current_log + new_message).count('\n')
|
|
393
|
+
if lines > 8: # Keep only recent messages visible
|
|
394
|
+
lines_to_keep = '\n'.join((current_log + new_message).split('\n')[-8:])
|
|
395
|
+
self.status_log.value = lines_to_keep
|
|
396
|
+
|
|
397
|
+
def _update_results(self, mcmc):
|
|
398
|
+
"""Update the results area with MCMC summary."""
|
|
399
|
+
# Calculate acceptance fraction if available
|
|
400
|
+
acceptance_frac = "N/A"
|
|
401
|
+
if mcmc.mc and mcmc.mc.sampler and hasattr(mcmc.mc.sampler, 'acceptance_fraction'):
|
|
402
|
+
acceptance_frac = f"{mcmc.mc.sampler.acceptance_fraction.mean():.3f}"
|
|
403
|
+
|
|
404
|
+
self.result_area.content = f"""
|
|
405
|
+
## MCMC Analysis Complete
|
|
406
|
+
|
|
407
|
+
**Configuration:**
|
|
408
|
+
- Walkers: {mcmc.nwalkers}
|
|
409
|
+
- Steps target: {mcmc.nsteps_target}
|
|
410
|
+
- Chunk Size: {self.chunk_size_input.value}
|
|
411
|
+
|
|
412
|
+
**Results:**
|
|
413
|
+
- Chains shape: {mcmc.mc.sampler.chain.shape if mcmc.mc and mcmc.mc.sampler else 'N/A'}
|
|
414
|
+
- Acceptance fraction: {acceptance_frac}
|
|
415
|
+
|
|
416
|
+
Analysis completed at {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')}
|
|
417
|
+
"""
|
|
418
|
+
self._make_result_graphs()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _update_graphs(self):
|
|
422
|
+
if hasattr(self,'mcmc'):
|
|
423
|
+
while not self.mcmc.q3_samples.empty():
|
|
424
|
+
|
|
425
|
+
samples = self.mcmc.q3_samples.get()
|
|
426
|
+
chains = samples['chains'] # shape: (nwalkers, nsteps, ndim)
|
|
427
|
+
acceptance_frac = samples['acceptance_fraction'] # shape: (nwalkers,)
|
|
428
|
+
|
|
429
|
+
nsteps, nwalkers, ndim = chains.shape
|
|
430
|
+
self._apply_chain_container_style(self.chain_chart, ndim)
|
|
431
|
+
|
|
432
|
+
f = self.chain_chart.figure
|
|
433
|
+
f.clear()
|
|
434
|
+
w, h = self._chain_figsize(ndim)
|
|
435
|
+
self._set_figure_size(f, w, h)
|
|
436
|
+
axs = []
|
|
437
|
+
for ii in range(ndim):
|
|
438
|
+
axs.append(f.add_subplot(ndim+1, 1, ii+1))
|
|
439
|
+
axs.append(f.add_subplot(ndim+1, 1, ndim+1))
|
|
440
|
+
|
|
441
|
+
# Plot chains for first 5 walkers per dimension
|
|
442
|
+
walkers_to_plot = range(min(5, nwalkers))
|
|
443
|
+
for d in range(ndim):
|
|
444
|
+
for w in walkers_to_plot:
|
|
445
|
+
axs[d].plot(chains[:, w, d], label=f'Dim {d} Walker {w}')
|
|
446
|
+
axs[d].set_title(f'Parameter {d}')
|
|
447
|
+
axs[d].set_xlabel('Steps')
|
|
448
|
+
|
|
449
|
+
# Plot acceptance fraction
|
|
450
|
+
axs[-1].bar(range(nwalkers), acceptance_frac)
|
|
451
|
+
axs[-1].set_title('Acceptance Fraction per Walker')
|
|
452
|
+
axs[-1].set_xlabel('Walker')
|
|
453
|
+
axs[-1].set_ylabel('Acceptance Fraction')
|
|
454
|
+
|
|
455
|
+
f.tight_layout()
|
|
456
|
+
self.chain_chart.update()
|
|
457
|
+
|
|
458
|
+
def _update_progress_bar(self):
|
|
459
|
+
if hasattr(self,'mcmc'):
|
|
460
|
+
self.progress_bar.set_value(self.mcmc.q_percent_done.get() if not self.mcmc.q_percent_done.empty() else self.progress_bar.value)
|
|
461
|
+
|
|
462
|
+
def _update_status_log(self):
|
|
463
|
+
if hasattr(self,'mcmc'):
|
|
464
|
+
while not self.mcmc.q2_tqdm_out.empty():
|
|
465
|
+
logstr = self.mcmc.q2_tqdm_out.get()
|
|
466
|
+
pattern = r'\b(\d+)\b/.*?\[(\d{2}:\d{2}).*?,\s*([\d.]+)it/s\]'
|
|
467
|
+
|
|
468
|
+
match = re.search(pattern, logstr)
|
|
469
|
+
if match:
|
|
470
|
+
iters, time, it_s = match.groups()
|
|
471
|
+
self._log_status(f"{iters} iterations completed in {time}, {it_s} it/s")
|
|
472
|
+
self.completed_steps += int(iters)
|
|
473
|
+
it_s_val = float(it_s)
|
|
474
|
+
current_chunk = float(self.chunk_size_input.value)
|
|
475
|
+
if it_s_val > current_chunk / 2:
|
|
476
|
+
new_chunk = int(round(3 * it_s_val))
|
|
477
|
+
self.chunk_size_input.value = new_chunk
|
|
478
|
+
self.mcmc.chunk_size_val.value = new_chunk
|
|
479
|
+
self._log_status(
|
|
480
|
+
f">> **Chunk size adjusted:** sampling rate ({it_s_val:.1f} it/s) "
|
|
481
|
+
f" exceeded chunk size / 2 ({current_chunk / 2:.1f}). "
|
|
482
|
+
f" Chunk size set to **{new_chunk}** for the next chunk."
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def _clear_results(self):
|
|
486
|
+
self.result_chains.figure.clear()
|
|
487
|
+
self.result_corner.figure.clear()
|
|
488
|
+
self.result_area.content = ""
|
|
489
|
+
|
|
490
|
+
self.result_chains.update()
|
|
491
|
+
self.result_corner.update()
|
|
492
|
+
|
|
493
|
+
def _clear_run_state(self) -> None:
|
|
494
|
+
"""Reset the Run tab's live-progress UI without touching the Results tab."""
|
|
495
|
+
self.chain_chart.figure.clear()
|
|
496
|
+
self.chain_chart.update()
|
|
497
|
+
self.status_log.set_value("")
|
|
498
|
+
self.progress_bar.set_value(0)
|
|
499
|
+
self.progress_label.set_text("")
|
|
500
|
+
self.completed_steps = 0
|
|
501
|
+
|
|
502
|
+
def _rebuild_dark_species_card(self) -> None:
|
|
503
|
+
"""Rebuild dark-species toggle rows for the active dataset."""
|
|
504
|
+
expt_data = self.sm.active_expt_data_or_none
|
|
505
|
+
if expt_data is None or not expt_data.has_linear_obs(self.sm._expt_dtypes):
|
|
506
|
+
self._dark_species_visible = False
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
species = list(self.sm.species)
|
|
510
|
+
lin_cols = expt_data.linear_obs_cols(self.sm._expt_dtypes)
|
|
511
|
+
self._dark_species_visible = bool(lin_cols)
|
|
512
|
+
|
|
513
|
+
self.dark_species_rows.clear()
|
|
514
|
+
with self.dark_species_rows:
|
|
515
|
+
for col, meas in lin_cols:
|
|
516
|
+
dark_set = set(expt_data.dark_species.get(col, []))
|
|
517
|
+
prefix = "UV-vis" if meas == "uvvis" else "Fluorescence"
|
|
518
|
+
with ui.row().classes("items-center gap-4 flex-wrap"):
|
|
519
|
+
ui.label(f"{prefix}: {col}").classes("font-medium w-40")
|
|
520
|
+
for sp in species:
|
|
521
|
+
def _toggle(e, _col=col, _sp=sp) -> None:
|
|
522
|
+
ed = self.sm.active_expt_data
|
|
523
|
+
d = list(ed.dark_species.get(_col, []))
|
|
524
|
+
if e.value:
|
|
525
|
+
if _sp not in d:
|
|
526
|
+
d.append(_sp)
|
|
527
|
+
else:
|
|
528
|
+
d = [s for s in d if s != _sp]
|
|
529
|
+
ed.dark_species[_col] = d
|
|
530
|
+
|
|
531
|
+
ui.checkbox(sp, value=(sp in dark_set), on_change=_toggle).classes("text-sm")
|
|
532
|
+
|
|
533
|
+
def _refresh_for_active_fit(self, e=None) -> None:
|
|
534
|
+
"""Called on fit_changed: clear MCMC state and restore results for the newly active fit."""
|
|
535
|
+
if self.is_running:
|
|
536
|
+
return
|
|
537
|
+
self._clear_run_state()
|
|
538
|
+
self._clear_results()
|
|
539
|
+
active_fit = self.sm.active_fit_or_none
|
|
540
|
+
if active_fit is None:
|
|
541
|
+
return
|
|
542
|
+
found_mcmc = self._fit_to_mcmc.get(active_fit.id)
|
|
543
|
+
if found_mcmc is not None and getattr(found_mcmc, 'mc', None) is not None and getattr(found_mcmc.mc, 'sampler', None) is not None:
|
|
544
|
+
self.mcmc = found_mcmc
|
|
545
|
+
self._update_results(self.mcmc)
|
|
546
|
+
|
|
547
|
+
def _make_result_graphs(self):
|
|
548
|
+
if self.mcmc.mc.sampler is None:
|
|
549
|
+
ui.notify("No chain available; re-run MCMC", type="negative")
|
|
550
|
+
return
|
|
551
|
+
ndim = int(self.mcmc.mc.sampler.ndim)
|
|
552
|
+
self._apply_chain_container_style(self.result_chains, ndim)
|
|
553
|
+
self._apply_corner_container_style(ndim)
|
|
554
|
+
|
|
555
|
+
f = self.result_chains.figure
|
|
556
|
+
f.clear()
|
|
557
|
+
w, h = self._chain_figsize(ndim)
|
|
558
|
+
self._set_figure_size(f, w, h)
|
|
559
|
+
self.mcmc.mc.plot_chain(fig=f)
|
|
560
|
+
f.tight_layout()
|
|
561
|
+
self.result_chains.update()
|
|
562
|
+
|
|
563
|
+
f= self.result_corner.figure
|
|
564
|
+
f.clear()
|
|
565
|
+
cw, ch = self._corner_figsize(ndim)
|
|
566
|
+
self._set_figure_size(f, cw, ch)
|
|
567
|
+
burnin = self._get_burnin(notify=True)
|
|
568
|
+
self.mcmc.mc.make_corner_fig(burnin=burnin,fig=f)
|
|
569
|
+
f.tight_layout()
|
|
570
|
+
self.result_corner.update()
|
|
571
|
+
|
|
572
|
+
def _download_figure(self, fig, filename: str) -> None:
|
|
573
|
+
buf = io.BytesIO()
|
|
574
|
+
fig.savefig(buf, format='png', dpi=_EXPORT_DPI, bbox_inches='tight')
|
|
575
|
+
buf.seek(0)
|
|
576
|
+
ui.download.content(buf.getvalue(), filename=filename)
|
|
577
|
+
|
|
578
|
+
def download_chain_figure(self) -> None:
|
|
579
|
+
if not hasattr(self, 'mcmc') or self.mcmc.mc is None or self.mcmc.mc.sampler is None:
|
|
580
|
+
ui.notify('No chain figure available for download.', type='warning')
|
|
581
|
+
return
|
|
582
|
+
ndim = int(self.mcmc.mc.sampler.ndim)
|
|
583
|
+
fig = plt.figure()
|
|
584
|
+
w, h = self._chain_figsize(ndim)
|
|
585
|
+
fig.set_dpi(_EXPORT_DPI)
|
|
586
|
+
fig.set_size_inches(w * 1.2, h * 1.2, forward=True)
|
|
587
|
+
self.mcmc.mc.plot_chain(fig=fig)
|
|
588
|
+
fig.tight_layout()
|
|
589
|
+
active_fit = self.sm.active_fit_or_none
|
|
590
|
+
stem = active_fit.name if active_fit is not None else 'mcmc'
|
|
591
|
+
filename = f"{safe_filename(stem, fallback='mcmc')}_chains.png"
|
|
592
|
+
self._download_figure(fig, filename)
|
|
593
|
+
plt.close(fig)
|
|
594
|
+
|
|
595
|
+
def download_corner_figure(self) -> None:
|
|
596
|
+
if not hasattr(self, 'mcmc') or self.mcmc.mc is None or self.mcmc.mc.sampler is None:
|
|
597
|
+
ui.notify('No corner figure available for download.', type='warning')
|
|
598
|
+
return
|
|
599
|
+
ndim = int(self.mcmc.mc.sampler.ndim)
|
|
600
|
+
fig = plt.figure()
|
|
601
|
+
w, h = self._corner_figsize(ndim)
|
|
602
|
+
fig.set_dpi(_EXPORT_DPI)
|
|
603
|
+
fig.set_size_inches(w * 1.2, h * 1.2, forward=True)
|
|
604
|
+
burnin = self._get_burnin(notify=False)
|
|
605
|
+
self.mcmc.mc.make_corner_fig(burnin=burnin, fig=fig)
|
|
606
|
+
fig.tight_layout()
|
|
607
|
+
active_fit = self.sm.active_fit_or_none
|
|
608
|
+
stem = active_fit.name if active_fit is not None else 'mcmc'
|
|
609
|
+
filename = f"{safe_filename(stem, fallback='mcmc')}_corner.png"
|
|
610
|
+
self._download_figure(fig, filename)
|
|
611
|
+
plt.close(fig)
|
|
612
|
+
|
|
613
|
+
# ------------------------------------------------------------------
|
|
614
|
+
# Notebook export
|
|
615
|
+
# ------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
def _open_export_dialog(self) -> None:
|
|
618
|
+
active_fit = self.sm.active_fit_or_none
|
|
619
|
+
if active_fit is None:
|
|
620
|
+
ui.notify('No active fit to export.', type='warning')
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
has_sampler = (
|
|
624
|
+
hasattr(self, 'mcmc')
|
|
625
|
+
and getattr(self.mcmc, 'mc', None) is not None
|
|
626
|
+
and getattr(self.mcmc.mc, 'sampler', None) is not None
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
include_chains: dict = {'value': False}
|
|
630
|
+
|
|
631
|
+
with ui.dialog() as dialog, ui.card().classes('min-w-[24rem]'):
|
|
632
|
+
ui.label('Export MCMC Notebook').classes('text-lg font-bold mb-2')
|
|
633
|
+
|
|
634
|
+
if has_sampler:
|
|
635
|
+
chain_mb = (
|
|
636
|
+
self.mcmc.mc.sampler.backend.chain.nbytes
|
|
637
|
+
+ self.mcmc.mc.sampler.backend.log_prob.nbytes
|
|
638
|
+
) / (1024 * 1024)
|
|
639
|
+
options = {
|
|
640
|
+
'code': 'Run MCMC (code only)',
|
|
641
|
+
'chains': f'Export and load saved chains (.hdf, ~{chain_mb:.1f} MB uncompressed)',
|
|
642
|
+
}
|
|
643
|
+
radio = ui.radio(options=options, value='code').classes('mt-2')
|
|
644
|
+
radio.on_value_change(
|
|
645
|
+
lambda e: include_chains.update({'value': e.value == 'chains'})
|
|
646
|
+
)
|
|
647
|
+
else:
|
|
648
|
+
ui.label('Run MCMC (code only)').classes('font-medium mt-2')
|
|
649
|
+
ui.label(
|
|
650
|
+
'MCMC has not yet run in this session; chains cannot be exported.'
|
|
651
|
+
).classes('text-sm text-orange-600 mt-1')
|
|
652
|
+
|
|
653
|
+
with ui.row().classes('mt-4 gap-2 justify-end w-full'):
|
|
654
|
+
ui.button('Cancel', on_click=dialog.close)
|
|
655
|
+
ui.button('Export', on_click=lambda: (
|
|
656
|
+
dialog.close(),
|
|
657
|
+
self._do_export_notebook(include_chains['value']),
|
|
658
|
+
)).props('color=primary')
|
|
659
|
+
|
|
660
|
+
dialog.open()
|
|
661
|
+
|
|
662
|
+
def _do_export_notebook(self, include_chains: bool) -> None:
|
|
663
|
+
active_fit = self.sm.active_fit_or_none
|
|
664
|
+
if active_fit is None:
|
|
665
|
+
ui.notify('No active fit to export.', type='negative')
|
|
666
|
+
return
|
|
667
|
+
|
|
668
|
+
mcmc_arg = self.mcmc if hasattr(self, 'mcmc') else None
|
|
669
|
+
try:
|
|
670
|
+
notebook, csv_df, h5_bytes = self.sm.dump_mcmc_notebook(mcmc_arg, include_chains)
|
|
671
|
+
except Exception as exc:
|
|
672
|
+
ui.notify(f'Notebook export failed: {exc}', type='negative')
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
stem = safe_filename(active_fit.name, fallback='mcmc')
|
|
676
|
+
nb_bytes = json.dumps(notebook, indent=1).encode()
|
|
677
|
+
csv_bytes = csv_df.to_csv(index=False, float_format='{:.5e}'.format).encode()
|
|
678
|
+
|
|
679
|
+
buf = io.BytesIO()
|
|
680
|
+
with zipfile.ZipFile(buf, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
|
681
|
+
zf.writestr(f'{stem}.ipynb', nb_bytes)
|
|
682
|
+
zf.writestr(f'{stem}_data.csv', csv_bytes)
|
|
683
|
+
if h5_bytes is not None:
|
|
684
|
+
zf.writestr(f'{stem}_chains.hdf', h5_bytes)
|
|
685
|
+
buf.seek(0)
|
|
686
|
+
|
|
687
|
+
zip_filename = f'{stem}_mcmc_notebook.zip'
|
|
688
|
+
ui.download.content(buf.read(), filename=zip_filename)
|
|
689
|
+
ui.notify(f'Notebook exported as {zip_filename}.', type='positive')
|