bindmc 0.1.4__tar.gz → 0.1.6__tar.gz

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.
Files changed (42) hide show
  1. {bindmc-0.1.4 → bindmc-0.1.6}/PKG-INFO +3 -3
  2. {bindmc-0.1.4 → bindmc-0.1.6}/pyproject.toml +3 -3
  3. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/__main__.py +2 -0
  4. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/main.py +11 -2
  5. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/MCMCSim.py +10 -3
  6. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/bayes.py +164 -23
  7. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/body.py +2 -0
  8. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/fitting.py +23 -26
  9. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/export/notebook_exporter.py +6 -1
  10. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/state/statemanager.py +6 -12
  11. {bindmc-0.1.4 → bindmc-0.1.6}/README.md +0 -0
  12. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/__init__.py +0 -0
  13. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/Class model.md +0 -0
  14. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/TODO.md +0 -0
  15. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/TODO_old.md +0 -0
  16. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/__init__.py +0 -0
  17. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/app.py +0 -0
  18. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/BindingConstant.py +0 -0
  19. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/ChemicalShiftParam.py +0 -0
  20. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/Component.py +0 -0
  21. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/ExptData.py +0 -0
  22. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/ExptDataType.py +0 -0
  23. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/FitResult.py +0 -0
  24. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/Model.py +0 -0
  25. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/RawData.py +0 -0
  26. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/Simulation.py +0 -0
  27. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/UIBindings.py +0 -0
  28. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/classes/__init__.py +0 -0
  29. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/__init__.py +0 -0
  30. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/base.py +0 -0
  31. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/bayes_priors.py +0 -0
  32. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/binding_model.py +0 -0
  33. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/data_gen.py +0 -0
  34. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/data_import.py +0 -0
  35. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/data_model.py +0 -0
  36. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/graph.py +0 -0
  37. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/header.py +0 -0
  38. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/components/simulation.py +0 -0
  39. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/default_models.json +0 -0
  40. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/export/__init__.py +0 -0
  41. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/state/__init__.py +0 -0
  42. {bindmc-0.1.4 → bindmc-0.1.6}/src/bindmc/webgui/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bindmc
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Keywords: chemistry,analytical chemistry,binding constants,supramolecular
5
5
  Author: Martin Peeks
6
6
  Author-email: Martin Peeks <martinp23@googlemail.com>, m.peeks@unsw.edu.au
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3.14
12
12
  Classifier: Topic :: Scientific/Engineering :: Chemistry
13
13
  Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
14
14
  Requires-Dist: arviz==0.21.0
15
- Requires-Dist: bindtools>=0.1.3
15
+ Requires-Dist: bindtools>=0.2.1
16
16
  Requires-Dist: corner==2.2.3
17
17
  Requires-Dist: emcee==3.1.6
18
18
  Requires-Dist: h5py>=3.14.0
@@ -22,7 +22,7 @@ Requires-Dist: matplotlib==3.10.7
22
22
  Requires-Dist: nicegui[plotly]==3.3.1
23
23
  Requires-Dist: numba>=0.65.1
24
24
  Requires-Dist: numpy>=2.3.5
25
- Requires-Dist: openpyxl==3.1.2
25
+ Requires-Dist: openpyxl>=3.1.5
26
26
  Requires-Dist: pandas>=2.3.3
27
27
  Requires-Dist: platformdirs>=3.0.0
28
28
  Requires-Dist: pywebview>=5
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "bindmc"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  readme = "README.md"
9
9
  keywords = ["chemistry", "analytical chemistry", "binding constants", "supramolecular"]
10
10
  classifiers = [
@@ -28,7 +28,7 @@ classifiers = [
28
28
  requires-python = ">=3.12"
29
29
  dependencies = [
30
30
  "arviz==0.21.0",
31
- "bindtools>=0.1.3",
31
+ "bindtools>=0.2.1",
32
32
  "corner==2.2.3",
33
33
  "emcee==3.1.6",
34
34
  "h5py>=3.14.0",
@@ -38,7 +38,7 @@ dependencies = [
38
38
  "nicegui[plotly]==3.3.1",
39
39
  "numba>=0.65.1",
40
40
  "numpy>=2.3.5",
41
- "openpyxl==3.1.2",
41
+ "openpyxl>=3.1.5",
42
42
  "pandas>=2.3.3",
43
43
  "platformdirs>=3.0.0",
44
44
  "pywebview>=5",
@@ -2,3 +2,5 @@
2
2
 
3
3
  if __name__ in {"__main__", "__mp_main__"}:
4
4
  import bindmc.main
5
+ bindmc.main.main()
6
+
@@ -18,6 +18,11 @@ from nicegui import native, ui, app
18
18
  from bindmc.webgui.app import BindMCServer
19
19
  import logging
20
20
  import nicegui
21
+ import nicegui.binding
22
+
23
+ # Suppress NiceGUI's data binding warning (which confuses users with chemical binding)
24
+ nicegui.binding.MAX_PROPAGATION_TIME = float("inf")
25
+
21
26
  from packaging.version import InvalidVersion, Version
22
27
  from pathlib import Path
23
28
  from platformdirs import user_data_dir
@@ -107,7 +112,11 @@ storage_path.mkdir(parents=True, exist_ok=True)
107
112
  # Redirect native window persistence data away from default paths
108
113
  app.native.start_args["storage_path"] = str(storage_path)
109
114
 
110
- if __name__ == "__main__":
111
- ui.run(title="BindMC", reload=reload, native=native_mode, port=native.find_open_port(), storage_secret="bindmc_secret")
115
+ def main() -> None:
116
+ ui.run(title="BindMC", reload=reload, native=native_mode, port=native.find_open_port(), storage_secret="bindmc_secret", reconnect_timeout=300)
117
+
118
+ if __name__ in {"__main__", "__mp_main__"}:
119
+ main()
120
+
112
121
 
113
122
 
@@ -25,6 +25,7 @@ class MCMCSim:
25
25
 
26
26
  burn: int = 100
27
27
  thin: int = 1
28
+ max_retained_points: int = 1000
28
29
  seed: Optional[int] = None
29
30
  chains: np.ndarray = field(default_factory=lambda: np.array([])) # Array to hold the MCMC chains
30
31
  priors: list[dict[str, Any]] = field(default_factory=list)
@@ -198,11 +199,17 @@ class MCMCSim:
198
199
  logger.info("MCMC run cancelled.")
199
200
  return self.mc
200
201
  chunk_size = self.chunk_size_val.value
201
- samples = min(chunk_size, self.nsteps_target - self.nsteps_done)
202
+ remaining_raw = self.nsteps_target - self.nsteps_done
203
+ raw_chunk = min(chunk_size, remaining_raw)
204
+
205
+ # Convert raw chunk size to stored samples chunk size based on thin factor
206
+ samples_stored = max(1, raw_chunk // self.thin)
207
+ actual_raw = samples_stored * self.thin
208
+
202
209
  b = io.StringIO()
203
- self.mc.run(samples=samples, pool=pool, tqdm_kwargs={"file": b})
210
+ self.mc.run(samples=samples_stored, thin=self.thin, pool=pool, tqdm_kwargs={"file": b})
204
211
 
205
- self.nsteps_done += samples
212
+ self.nsteps_done += actual_raw
206
213
  self.q_percent_done.put(self.nsteps_done / self.nsteps_target)
207
214
  self.q2_tqdm_out.put(b.getvalue().splitlines()[-1])
208
215
  if self.mc.sampler is not None:
@@ -122,6 +122,10 @@ class BayesPanel(BaseComponent):
122
122
  "Export to Notebook",
123
123
  on_click=self._open_export_dialog,
124
124
  ).classes("mt-2")
125
+ self.advanced_settings_button = ui.button(
126
+ "Advanced Settings",
127
+ on_click=self._open_advanced_settings_dialog,
128
+ ).classes("mt-2")
125
129
 
126
130
  # Control buttons
127
131
  with ui.row().classes("mt-4"):
@@ -162,6 +166,7 @@ class BayesPanel(BaseComponent):
162
166
  self.is_running = False
163
167
  self.should_stop = False
164
168
  self.completed_steps = 0
169
+ self.mcmc_max_points = 1000
165
170
  self.progress_timer = None
166
171
  self.status_timer = None
167
172
  self.graph_timer = None
@@ -205,8 +210,8 @@ class BayesPanel(BaseComponent):
205
210
  tau = e.tau
206
211
  if notify:
207
212
  s = (
208
- f"Autocorrelation time is likely too short. Max tau is {int(np.max(tau))}; "
209
- f"nsteps is {self.completed_steps}. Re-run for at least {int(50 * np.max(tau))} steps."
213
+ f"Autocorrelation time is likely too short. Max tau is {int(np.max(tau))* self.mcmc.thin}; "
214
+ f"nsteps is {self.completed_steps} (before thinning). Re-run for at least {int(50 * np.max(tau) * self.mcmc.thin)} steps."
210
215
  )
211
216
  ui.notify(s, type="warning")
212
217
  self.result_area.content += f"""
@@ -244,8 +249,18 @@ class BayesPanel(BaseComponent):
244
249
  ui.notify("No active fit result.", type="warning")
245
250
  return
246
251
 
252
+ ndim = self.get_ndim()
253
+ min_walkers = 2 * ndim
254
+ nwalkers = int(self.nwalkers_input.value)
255
+ if nwalkers < min_walkers:
256
+ ui.notify(
257
+ f"You need at least {min_walkers} parameters.",
258
+ type="warning",
259
+ )
260
+ return
261
+
247
262
  if active_fit.bd_model is None:
248
- print("No bindtools model selected for fitting, generating one.")
263
+ logger.info("No bindtools model selected for fitting, generating one.")
249
264
  ui.notify("Running an initial fit using least_sq")
250
265
  m1 = self.sm.generate_binding_model_for_fit(active_fit)
251
266
  m1 = await run.cpu_bound(
@@ -262,6 +277,10 @@ class BayesPanel(BaseComponent):
262
277
  nwalkers = int(self.nwalkers_input.value)
263
278
  obslist = self.sm.active_expt_data.get_obs_list(self.sm._expt_dtypes)
264
279
 
280
+ # Calculate thinning factor dynamically based on max points limit
281
+ thin = max(1, nsteps_target // self.mcmc_max_points)
282
+
283
+ logger.info("Setting up for MCMC run")
265
284
  # Create MCMC simulation (not yet registered in state)
266
285
  self.mcmc = MCMCSim(
267
286
  model=self.sm.active_model,
@@ -269,6 +288,8 @@ class BayesPanel(BaseComponent):
269
288
  bd_model=active_fit.bd_model,
270
289
  nwalkers=nwalkers,
271
290
  nsteps_target=nsteps_target,
291
+ thin=thin,
292
+ max_retained_points=self.mcmc_max_points,
272
293
  priors=list(self.prior_editor.priors) if self.prior_editor.priors else [],
273
294
  )
274
295
  self.mcmc.setup(obslist)
@@ -281,12 +302,13 @@ class BayesPanel(BaseComponent):
281
302
  type="info",
282
303
  timeout=60000,
283
304
  )
305
+ logger.info("Running a trial run")
284
306
  try:
285
307
  trial_elapsed = await run.cpu_bound(partial(_run_mcmc_trial, self.mcmc.mc, _TRIAL_STEPS))
286
308
  it_s = _TRIAL_STEPS / trial_elapsed
287
309
  full_seconds = nsteps_target * trial_elapsed / _TRIAL_STEPS
288
310
  full_time_str = _format_duration(full_seconds)
289
-
311
+ logger.info("Trial run finished; took {trial_elapsed:.1f} s ({it_s:.2f} it/s).")
290
312
  with ui.dialog() as timing_dialog, ui.card().classes("w-[min(560px,92vw)]"):
291
313
  ui.label("Runtime estimate").classes("text-lg font-bold")
292
314
  ui.label(f"{_TRIAL_STEPS:,} steps took {trial_elapsed:.1f} s ({it_s:.2f} it/s).").classes(
@@ -296,6 +318,8 @@ class BayesPanel(BaseComponent):
296
318
  f"The full run ({nsteps_target:,} steps, {nwalkers} walkers) "
297
319
  f"will take approximately {full_time_str}."
298
320
  ).classes("mt-2")
321
+ logger.info(f"The full run ({nsteps_target:,} steps, {nwalkers} walkers) "
322
+ f"will take approximately {full_time_str}.")
299
323
  ui.label("Do you want to continue?").classes("mt-1 font-medium")
300
324
  with ui.row().classes("w-full justify-end gap-2 mt-3"):
301
325
  ui.button("Cancel", on_click=lambda: timing_dialog.submit(False))
@@ -320,6 +344,7 @@ class BayesPanel(BaseComponent):
320
344
  self.run_button.set_enabled(False)
321
345
  self.stop_button.set_enabled(True)
322
346
  self.progress_bar.value = 0
347
+ logger.info("Starting MCMC analysis")
323
348
  self.progress_label.text = "Starting MCMC analysis..."
324
349
  self._log_status("Starting MCMC analysis...")
325
350
  self._start_run_timers()
@@ -346,9 +371,11 @@ class BayesPanel(BaseComponent):
346
371
  self.progress_label.text = f"MCMC Complete! ({total_steps} steps with {nwalkers} walkers)"
347
372
  self._log_status("MCMC analysis completed successfully")
348
373
  ui.notify("MCMC analysis completed successfully!", type="positive")
349
-
374
+ self._log_status("Generating result figures... this may take a while...")
350
375
  # Update results
351
376
  self._update_results(self.mcmc)
377
+ self._log_status("Figure generation complete!")
378
+
352
379
 
353
380
  except Exception as e:
354
381
  self.progress_label.text = "Analysis failed"
@@ -409,7 +436,7 @@ class BayesPanel(BaseComponent):
409
436
 
410
437
  Analysis completed at {pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S")}
411
438
  """
412
- self._make_result_graphs()
439
+ ui.timer(0, self._make_result_graphs, once=True)
413
440
 
414
441
  def _update_graphs(self):
415
442
  if hasattr(self, "mcmc"):
@@ -436,7 +463,10 @@ class BayesPanel(BaseComponent):
436
463
  for w in walkers_to_plot:
437
464
  axs[d].plot(chains[:, w, d], label=f"Dim {d} Walker {w}")
438
465
  axs[d].set_title(f"Parameter {d}")
439
- axs[d].set_xlabel("Steps")
466
+ if self.mcmc.thin != 1:
467
+ axs[d].set_xlabel(f"Steps (thinning 1-in-{self.mcmc.thin} points)")
468
+ else:
469
+ axs[d].set_xlabel(f"Steps")
440
470
 
441
471
  # Plot acceptance fraction
442
472
  axs[-1].bar(range(nwalkers), acceptance_frac)
@@ -542,8 +572,63 @@ class BayesPanel(BaseComponent):
542
572
  ):
543
573
  self.mcmc = found_mcmc
544
574
  self._update_results(self.mcmc)
575
+ self.nwalkers_input.set_value(found_mcmc.nwalkers)
576
+ self.nsteps_input.set_value(found_mcmc.nsteps_target)
577
+ self.mcmc_max_points = getattr(found_mcmc, "max_retained_points", 1000)
578
+ ndim = self.get_ndim()
579
+ self.nwalkers_input.min = max(2, 2 * ndim)
580
+ else:
581
+ self.mcmc_max_points = 1000
582
+ self.update_default_walkers()
583
+
584
+ def get_ndim(self) -> int:
585
+ active_fit = self.sm.active_fit_or_none
586
+ if active_fit is None:
587
+ return 0
588
+
589
+ varying_params = 0
590
+ if getattr(active_fit, "params", None):
591
+ varying_params = sum(1 for p in active_fit.params.values() if isinstance(p, dict) and p.get("vary") is True)
592
+ elif active_fit.bd_model is not None:
593
+ if active_fit.bd_model.miniResult is not None:
594
+ varying_params = sum(1 for p in active_fit.bd_model.miniResult.params.values() if p.vary)
595
+ elif active_fit.bd_model.params is not None:
596
+ varying_params = sum(1 for p in active_fit.bd_model.params.values() if p.vary)
597
+
598
+ unique_dtypes = set()
599
+ expt_data = self.sm.active_expt_data_or_none
600
+ if expt_data is not None:
601
+ for col, details in expt_data.col_details.items():
602
+ if details.get("depindep") == "dep":
603
+ dtype_key = details.get("dtype")
604
+ if dtype_key is not None:
605
+ edt = self.sm._expt_dtypes.get(dtype_key)
606
+ if edt is not None:
607
+ unique_dtypes.add(edt.meas)
608
+
609
+ return varying_params + len(unique_dtypes)
610
+
611
+ def update_default_walkers(self) -> None:
612
+ active_fit = self.sm.active_fit_or_none
613
+ if active_fit is None:
614
+ return
545
615
 
546
- def _make_result_graphs(self):
616
+ ndim = self.get_ndim()
617
+ default_walkers = 2 * ndim
618
+ self.nwalkers_input.min = max(2, default_walkers)
619
+
620
+ found_mcmc = self._fit_to_mcmc.get(active_fit.id)
621
+ if (
622
+ found_mcmc is not None
623
+ and getattr(found_mcmc, "mc", None) is not None
624
+ and getattr(found_mcmc.mc, "sampler", None) is not None
625
+ ):
626
+ self.nwalkers_input.set_value(found_mcmc.nwalkers)
627
+ else:
628
+ self.nwalkers_input.set_value(default_walkers)
629
+
630
+
631
+ async def _make_result_graphs(self):
547
632
  if self.mcmc.mc.sampler is None:
548
633
  ui.notify("No chain available; re-run MCMC", type="negative")
549
634
  return
@@ -551,21 +636,26 @@ class BayesPanel(BaseComponent):
551
636
  self._apply_chain_container_style(self.result_chains, ndim)
552
637
  self._apply_corner_container_style(ndim)
553
638
 
554
- f = self.result_chains.figure
555
- f.clear()
639
+ def _plot_chain_sync(fig, mc, w, h):
640
+ fig.clear()
641
+ self._set_figure_size(fig, w, h)
642
+ mc.plot_chain(fig=fig)
643
+ fig.tight_layout()
644
+
556
645
  w, h = self._chain_figsize(ndim)
557
- self._set_figure_size(f, w, h)
558
- self.mcmc.mc.plot_chain(fig=f)
559
- f.tight_layout()
646
+ await run.io_bound(_plot_chain_sync, self.result_chains.figure, self.mcmc.mc, w, h)
560
647
  self.result_chains.update()
561
648
 
562
- f = self.result_corner.figure
563
- f.clear()
564
- cw, ch = self._corner_figsize(ndim)
565
- self._set_figure_size(f, cw, ch)
566
649
  burnin = self._get_burnin(notify=True)
567
- self.mcmc.mc.make_corner_fig(burnin=burnin, fig=f)
568
- f.tight_layout()
650
+
651
+ def _plot_corner_sync(fig, mc, burnin, cw, ch):
652
+ fig.clear()
653
+ self._set_figure_size(fig, cw, ch)
654
+ mc.make_corner_fig(burnin=burnin, fig=fig)
655
+ fig.tight_layout()
656
+
657
+ cw, ch = self._corner_figsize(ndim)
658
+ await run.io_bound(_plot_corner_sync, self.result_corner.figure, self.mcmc.mc, burnin, cw, ch)
569
659
  self.result_corner.update()
570
660
 
571
661
  async def _download_figure(self, fig, filename: str) -> None:
@@ -578,13 +668,19 @@ class BayesPanel(BaseComponent):
578
668
  if not hasattr(self, "mcmc") or self.mcmc.mc is None or self.mcmc.mc.sampler is None:
579
669
  ui.notify("No chain figure available for download.", type="warning")
580
670
  return
671
+ ui.notify("Generating chain figure for download...", type="info")
581
672
  ndim = int(self.mcmc.mc.sampler.ndim)
582
673
  fig = plt.figure()
583
674
  w, h = self._chain_figsize(ndim)
584
675
  fig.set_dpi(_EXPORT_DPI)
585
676
  fig.set_size_inches(w * 1.2, h * 1.2, forward=True)
586
- self.mcmc.mc.plot_chain(fig=fig)
587
- fig.tight_layout()
677
+
678
+ def _plot(f, mc):
679
+ mc.plot_chain(fig=f)
680
+ f.tight_layout()
681
+
682
+ await run.io_bound(_plot, fig, self.mcmc.mc)
683
+
588
684
  active_fit = self.sm.active_fit_or_none
589
685
  stem = active_fit.name if active_fit is not None else "mcmc"
590
686
  filename = f"{safe_filename(stem, fallback='mcmc')}_chains.png"
@@ -595,20 +691,65 @@ class BayesPanel(BaseComponent):
595
691
  if not hasattr(self, "mcmc") or self.mcmc.mc is None or self.mcmc.mc.sampler is None:
596
692
  ui.notify("No corner figure available for download.", type="warning")
597
693
  return
694
+ ui.notify("Generating corner figure for download...", type="info")
598
695
  ndim = int(self.mcmc.mc.sampler.ndim)
599
696
  fig = plt.figure()
600
697
  w, h = self._corner_figsize(ndim)
601
698
  fig.set_dpi(_EXPORT_DPI)
602
699
  fig.set_size_inches(w * 1.2, h * 1.2, forward=True)
603
700
  burnin = self._get_burnin(notify=False)
604
- self.mcmc.mc.make_corner_fig(burnin=burnin, fig=fig)
605
- fig.tight_layout()
701
+
702
+ def _plot(f, mc, burnin):
703
+ mc.make_corner_fig(burnin=burnin, fig=f)
704
+ f.tight_layout()
705
+
706
+ await run.io_bound(_plot, fig, self.mcmc.mc, burnin)
707
+
606
708
  active_fit = self.sm.active_fit_or_none
607
709
  stem = active_fit.name if active_fit is not None else "mcmc"
608
710
  filename = f"{safe_filename(stem, fallback='mcmc')}_corner.png"
609
711
  await self._download_figure(fig, filename)
610
712
  plt.close(fig)
611
713
 
714
+ def _open_advanced_settings_dialog(self) -> None:
715
+ with ui.dialog() as dialog, ui.card().classes("min-w-[24rem] p-6 rounded-lg shadow-lg"):
716
+ ui.label("Advanced MCMC Settings").classes("text-lg font-bold mb-2")
717
+ ui.label("Control how MCMC chains are thinned for plotting and export.").classes("text-xs text-gray-500 mb-4")
718
+
719
+ ui.label("Max points to retain (per walker):").classes("text-sm font-semibold mb-1")
720
+ max_points_input = ui.number(value=self.mcmc_max_points, min=10, max=1000000).classes("w-full mb-2")
721
+
722
+ thin_label = ui.label("").classes("text-xs text-gray-500 font-medium bg-gray-50 p-2 rounded w-full")
723
+
724
+ def update_thin_factor():
725
+ try:
726
+ nsteps = int(self.nsteps_input.value)
727
+ max_pts = int(max_points_input.value or 1000)
728
+ thin = max(1, nsteps // max_pts)
729
+ retained = nsteps // thin
730
+ thin_label.text = f"Calculated thinning factor: {thin} (retains {retained} points)"
731
+ except Exception:
732
+ thin_label.text = "Invalid steps or points value"
733
+
734
+ max_points_input.on_value_change(update_thin_factor)
735
+ update_thin_factor() # Initial update
736
+
737
+ async def save_settings():
738
+ self.mcmc_max_points = int(max_points_input.value or 1000)
739
+ # If MCMCSim exists, update its values
740
+ if hasattr(self, "mcmc") and self.mcmc is not None:
741
+ self.mcmc.max_retained_points = self.mcmc_max_points
742
+ self.mcmc.thin = max(1, self.mcmc.nsteps_target // self.mcmc_max_points)
743
+ self.sm.save_to_storage()
744
+ dialog.close()
745
+ ui.notify("Advanced settings saved.", type="positive")
746
+
747
+ with ui.row().classes("mt-6 gap-2 justify-end w-full"):
748
+ ui.button("Cancel", on_click=dialog.close).props("flat")
749
+ ui.button("Save", on_click=save_settings).props("color=primary")
750
+
751
+ dialog.open()
752
+
612
753
  # ------------------------------------------------------------------
613
754
  # Notebook export
614
755
  # ------------------------------------------------------------------
@@ -194,6 +194,8 @@ class Body(BaseComponent):
194
194
  if e.args == "Data model setup":
195
195
  # Ensure the data model is updated when switching to the Data model setup tab
196
196
  self.components["data_model"]._populate_blocks()
197
+ if e.args == "MCMC":
198
+ self.components["mcmc"].update_default_walkers()
197
199
  if e.args == "Fit Results":
198
200
  pass
199
201
  # Ensure the fit results graph is updated when switching to the Fit Results tab
@@ -80,6 +80,12 @@ def _prepare_fit_plot_frames(fit: FitResult) -> tuple[pd.DataFrame, pd.DataFrame
80
80
 
81
81
 
82
82
  def _infer_analytical_fast_exchange_config(model, expt_data, expt_dtypes: dict) -> dict[str, object] | None:
83
+ import warnings
84
+ warnings.warn(
85
+ "_infer_analytical_fast_exchange_config is deprecated. Topology detection has been relocated to bindtools.bindingModel.prepModel().",
86
+ DeprecationWarning,
87
+ stacklevel=2,
88
+ )
83
89
  if model is None or expt_data is None:
84
90
  return None
85
91
 
@@ -229,8 +235,8 @@ class FittingPanel(BaseComponent):
229
235
 
230
236
  with self.container:
231
237
  ui.label("Fitting panel").classes("text-lg font-bold mb-4")
232
- with ui.row():
233
- with ui.card():
238
+ with ui.row().classes("w-full gap-4 items-start flex-col lg:flex-row"):
239
+ with ui.card().classes("w-full lg:w-80 shrink-0"):
234
240
  ui.label("Fitting options to go here.")
235
241
  self.fit_alg_select = ui.select(
236
242
  ["least_squares", "l-bfgs", "ampgo"],
@@ -437,16 +443,8 @@ class FittingPanel(BaseComponent):
437
443
  type="info",
438
444
  )
439
445
 
440
- analytical_cfg = _infer_analytical_fast_exchange_config(
441
- self.sm.active_model,
442
- self.sm.active_expt_data,
443
- self.sm._expt_dtypes,
444
- )
445
-
446
-
447
-
448
- self.m1 = self.sm.generate_binding_model_for_fit(analytical_cfg=analytical_cfg)
449
- if analytical_cfg is not None:
446
+ self.m1 = self.sm.generate_binding_model_for_fit()
447
+ if self.m1.analytical_fast_exchange:
450
448
  ui.notify(
451
449
  f"Using analytical fast-exchange backend ({self.m1.analytical_topology}).",
452
450
  type="info",
@@ -494,18 +492,10 @@ class FittingPanel(BaseComponent):
494
492
  init_expt_data=self.sm.active_expt_data,
495
493
  init_model=self.sm.active_model,
496
494
  bd_model=self.m1,
497
- analytical_fast_exchange=analytical_cfg is not None,
495
+ analytical_fast_exchange=self.m1.analytical_fast_exchange,
498
496
  analytical_topology=self.m1.analytical_topology,
499
- analytical_obs_columns=(
500
- [str(x) for x in cast(list[str], analytical_cfg["obs_columns"])]
501
- if analytical_cfg is not None
502
- else []
503
- ),
504
- analytical_obs_components=(
505
- [int(x) for x in cast(list[int], analytical_cfg["obs_components"])]
506
- if analytical_cfg is not None
507
- else []
508
- ),
497
+ analytical_obs_columns=[str(x) for x in self.m1.analytical_obs_columns],
498
+ analytical_obs_components=[int(x) for x in self.m1.analytical_obs_components],
509
499
  analytical_complex_indices=self.m1.analytical_complex_indices,
510
500
  )
511
501
  self.sm.add_fit(new_fit)
@@ -535,7 +525,7 @@ class FittingPanel(BaseComponent):
535
525
 
536
526
  def _update_fit_graphs(self, e=None) -> None:
537
527
  """Update the fit results display."""
538
- print("Updating fit results...")
528
+ logger.info("Updating fit results...")
539
529
  self.fit_graph.clear_graph(update=False)
540
530
  if len(self.sm.fits) > 0:
541
531
  self.speciation_graph.clear_graph(update=False)
@@ -718,7 +708,7 @@ class FittingPanel(BaseComponent):
718
708
  class FitResultsCard(BaseComponent):
719
709
  def setup_nicegui(self) -> None:
720
710
 
721
- with ui.card():
711
+ with ui.card().classes("w-full lg:flex-1 min-w-0 overflow-hidden"):
722
712
  # ui.label("Fitting Results to go here.")
723
713
  with ui.row().classes("w-full"):
724
714
  ui.label("Results:")
@@ -785,7 +775,14 @@ class FitResultsCard(BaseComponent):
785
775
 
786
776
  fitParams = list(dict.fromkeys(fitParams)) # Remove duplicates
787
777
 
788
- paramCols = [{"name": param, "label": param, "field": param} for param in fitParams]
778
+ paramCols = [
779
+ {
780
+ "name": param,
781
+ "label": f"logK({param[3:]})" if param.startswith("log") else param,
782
+ "field": param,
783
+ }
784
+ for param in fitParams
785
+ ]
789
786
 
790
787
  stat_col_names = {"chisqr", "aic", "bic", "message", "covariance"}
791
788
  stat_cols = [c for c in self.default_columns if c["name"] in stat_col_names]
@@ -659,6 +659,7 @@ def export_mcmc_notebook(
659
659
  f"with h5py.File('{stem}_chains.hdf', 'r') as f:",
660
660
  " chain = f['mcmc/chain'][:] # (nsteps, nwalkers, ndim)",
661
661
  " log_prob = f['mcmc/log_prob'][:] # (nsteps, nwalkers)",
662
+ " thin = f['mcmc'].attrs.get('thin', 1)",
662
663
  "",
663
664
  "param_labels = [p for p in m.params if m.params[p].vary]",
664
665
  "ndim = chain.shape[2]",
@@ -671,7 +672,10 @@ def export_mcmc_notebook(
671
672
  " axes[i].set_ylabel(label)",
672
673
  "axes[-1].plot(log_prob, alpha=0.3, lw=0.5, color='k')",
673
674
  "axes[-1].set_ylabel('log prob')",
674
- "axes[-1].set_xlabel('step')",
675
+ "if thin != 1:",
676
+ " axes[-1].set_xlabel(f'step (thinning 1-in-{thin} points)')",
677
+ "else:",
678
+ " axes[-1].set_xlabel('step')",
675
679
  "fig.tight_layout()",
676
680
  "plt.show()",
677
681
  "",
@@ -738,6 +742,7 @@ def export_mcmc_notebook(
738
742
  if has_blobs:
739
743
  g.create_dataset("blobs", data=backend.blobs)
740
744
  g.attrs["iteration"] = backend.iteration
745
+ g.attrs["thin"] = thin
741
746
  hf.flush()
742
747
  h5_bytes = bytes(hf.id.get_file_image())
743
748
 
@@ -1111,6 +1111,7 @@ class StateManager:
1111
1111
  "model_id": str(m.model_id) if m.model_id else None,
1112
1112
  "expt_data_id": str(m.expt_data_id) if m.expt_data_id else None,
1113
1113
  "nsteps_done": m.nsteps_done,
1114
+ "max_retained_points": m.max_retained_points,
1114
1115
  }
1115
1116
  for m in self.mcmcs.values()
1116
1117
  ]
@@ -1691,7 +1692,10 @@ bd.makeFitResidPlot(fit,plotMask=(0,1),ylabel='Chemical shift (ppm)')"""
1691
1692
  # return model_str
1692
1693
 
1693
1694
  def generate_binding_model_for_fit(
1694
- self, fit: Optional[FitResult] = None, analytical_cfg: Optional[dict[str, object]] = None
1695
+ self,
1696
+ fit: Optional[FitResult] = None,
1697
+ analytical_cfg: Optional[dict[str, object]] = None,
1698
+ force_numerical: bool = False,
1695
1699
  ) -> bd.bindingModel:
1696
1700
  """Generate a bindtools.bindingModel from the current active model."""
1697
1701
  old_fit = None
@@ -1777,17 +1781,7 @@ bd.makeFitResidPlot(fit,plotMask=(0,1),ylabel='Chemical shift (ppm)')"""
1777
1781
  model.analytical_linear_obs_columns = lin_cols
1778
1782
  model.analytical_linear_obs_param_map = linear_obs_param_map
1779
1783
 
1780
- # Always infer topology to allow analytical concentrations in slow exchange
1781
- from bindmc.webgui.utils import _infer_simple_fast_exchange_topology
1782
- topology_res = _infer_simple_fast_exchange_topology(
1783
- self.active_model.eq_mat, len(self.active_model.component_names)
1784
- )
1785
- if topology_res is not None:
1786
- topo_name, complex_indices = topology_res
1787
- model.analytical_topology = topo_name
1788
- model.analytical_complex_indices = complex_indices
1789
-
1790
- model.prepModel()
1784
+ model.prepModel(force_numerical=force_numerical)
1791
1785
 
1792
1786
  for k in self.active_model.binding_constants:
1793
1787
  model.params[f"log{k.species}"].set(
File without changes
File without changes
File without changes
File without changes