bindmc 0.1.5__tar.gz → 0.1.7__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.5 → bindmc-0.1.7}/PKG-INFO +13 -3
  2. {bindmc-0.1.5 → bindmc-0.1.7}/README.md +10 -0
  3. {bindmc-0.1.5 → bindmc-0.1.7}/pyproject.toml +3 -3
  4. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/__main__.py +2 -0
  5. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/main.py +10 -1
  6. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/ExptData.py +1 -1
  7. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/FitResult.py +0 -4
  8. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/MCMCSim.py +10 -3
  9. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/bayes.py +157 -21
  10. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/body.py +2 -0
  11. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/data_model.py +102 -94
  12. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/fitting.py +31 -109
  13. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/export/notebook_exporter.py +32 -19
  14. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/state/statemanager.py +10 -17
  15. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/__init__.py +0 -0
  16. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/Class model.md +0 -0
  17. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/TODO.md +0 -0
  18. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/TODO_old.md +0 -0
  19. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/__init__.py +0 -0
  20. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/app.py +0 -0
  21. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/BindingConstant.py +0 -0
  22. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/ChemicalShiftParam.py +0 -0
  23. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/Component.py +0 -0
  24. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/ExptDataType.py +0 -0
  25. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/Model.py +0 -0
  26. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/RawData.py +0 -0
  27. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/Simulation.py +0 -0
  28. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/UIBindings.py +0 -0
  29. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/classes/__init__.py +0 -0
  30. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/__init__.py +0 -0
  31. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/base.py +0 -0
  32. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/bayes_priors.py +0 -0
  33. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/binding_model.py +0 -0
  34. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/data_gen.py +0 -0
  35. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/data_import.py +0 -0
  36. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/graph.py +0 -0
  37. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/header.py +0 -0
  38. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/components/simulation.py +0 -0
  39. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/default_models.json +0 -0
  40. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/export/__init__.py +0 -0
  41. {bindmc-0.1.5 → bindmc-0.1.7}/src/bindmc/webgui/state/__init__.py +0 -0
  42. {bindmc-0.1.5 → bindmc-0.1.7}/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.5
3
+ Version: 0.1.7
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.2
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
@@ -53,6 +53,16 @@ Download the latest executable for your platform from the [Releases](https://git
53
53
  pip install bindmc
54
54
  ```
55
55
 
56
+ ## Upgrades
57
+
58
+ ### Pre-built Binary
59
+ Re-download from the link above.
60
+
61
+ ### Pip
62
+ ```bash
63
+ pip install --upgrade bindmc
64
+ ```
65
+
56
66
  ## Usage
57
67
 
58
68
  If using the pre-built binary, run the downloaded executable.
@@ -15,6 +15,16 @@ Download the latest executable for your platform from the [Releases](https://git
15
15
  pip install bindmc
16
16
  ```
17
17
 
18
+ ## Upgrades
19
+
20
+ ### Pre-built Binary
21
+ Re-download from the link above.
22
+
23
+ ### Pip
24
+ ```bash
25
+ pip install --upgrade bindmc
26
+ ```
27
+
18
28
  ## Usage
19
29
 
20
30
  If using the pre-built binary, run the downloaded executable.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "bindmc"
7
- version = "0.1.5"
7
+ version = "0.1.7"
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.2",
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
 
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
+
110
118
  if __name__ in {"__main__", "__mp_main__"}:
111
- ui.run(title="BindMC", reload=reload, native=native_mode, port=native.find_open_port(), storage_secret="bindmc_secret")
119
+ main()
120
+
112
121
 
113
122
 
@@ -443,7 +443,7 @@ class ExptData:
443
443
  species_key = species_names[j]
444
444
  parameter_matrix[i, j] = _get_parameter_for_species(species_key, delta_col)
445
445
  else:
446
- parameter_matrix[i, j] = 0.0
446
+ parameter_matrix[i, j] = None
447
447
 
448
448
  self.delta_to_spec = parameter_matrix
449
449
  return parameter_matrix
@@ -30,8 +30,6 @@ class FitResult:
30
30
  bd_model: Optional[bd.bindingModel] = None
31
31
  analytical_fast_exchange: bool = False
32
32
  analytical_topology: Optional[str] = None
33
- analytical_obs_columns: list[str] = field(default_factory=list)
34
- analytical_obs_components: list[int] = field(default_factory=list)
35
33
  analytical_complex_indices: list[int] = field(default_factory=list)
36
34
 
37
35
  init_model: InitVar[Optional[Model]] = None # The model used for the fit, if any
@@ -150,8 +148,6 @@ class FitResult:
150
148
  "success": self.success,
151
149
  "analytical_fast_exchange": self.analytical_fast_exchange,
152
150
  "analytical_topology": self.analytical_topology,
153
- "analytical_obs_columns": list(self.analytical_obs_columns),
154
- "analytical_obs_components": list(self.analytical_obs_components),
155
151
  "analytical_complex_indices": list(self.analytical_complex_indices),
156
152
  "fit_speciation": (
157
153
  self.fit_speciation.to_dict(orient="list") if isinstance(self.fit_speciation, pd.DataFrame) else {}
@@ -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,6 +249,16 @@ 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
263
  logger.info("No bindtools model selected for fitting, generating one.")
249
264
  ui.notify("Running an initial fit using least_sq")
@@ -262,6 +277,9 @@ 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
+
265
283
  logger.info("Setting up for MCMC run")
266
284
  # Create MCMC simulation (not yet registered in state)
267
285
  self.mcmc = MCMCSim(
@@ -270,6 +288,8 @@ class BayesPanel(BaseComponent):
270
288
  bd_model=active_fit.bd_model,
271
289
  nwalkers=nwalkers,
272
290
  nsteps_target=nsteps_target,
291
+ thin=thin,
292
+ max_retained_points=self.mcmc_max_points,
273
293
  priors=list(self.prior_editor.priors) if self.prior_editor.priors else [],
274
294
  )
275
295
  self.mcmc.setup(obslist)
@@ -351,9 +371,11 @@ class BayesPanel(BaseComponent):
351
371
  self.progress_label.text = f"MCMC Complete! ({total_steps} steps with {nwalkers} walkers)"
352
372
  self._log_status("MCMC analysis completed successfully")
353
373
  ui.notify("MCMC analysis completed successfully!", type="positive")
354
-
374
+ self._log_status("Generating result figures... this may take a while...")
355
375
  # Update results
356
376
  self._update_results(self.mcmc)
377
+ self._log_status("Figure generation complete!")
378
+
357
379
 
358
380
  except Exception as e:
359
381
  self.progress_label.text = "Analysis failed"
@@ -414,7 +436,7 @@ class BayesPanel(BaseComponent):
414
436
 
415
437
  Analysis completed at {pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S")}
416
438
  """
417
- self._make_result_graphs()
439
+ ui.timer(0, self._make_result_graphs, once=True)
418
440
 
419
441
  def _update_graphs(self):
420
442
  if hasattr(self, "mcmc"):
@@ -441,7 +463,10 @@ class BayesPanel(BaseComponent):
441
463
  for w in walkers_to_plot:
442
464
  axs[d].plot(chains[:, w, d], label=f"Dim {d} Walker {w}")
443
465
  axs[d].set_title(f"Parameter {d}")
444
- 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")
445
470
 
446
471
  # Plot acceptance fraction
447
472
  axs[-1].bar(range(nwalkers), acceptance_frac)
@@ -547,8 +572,63 @@ class BayesPanel(BaseComponent):
547
572
  ):
548
573
  self.mcmc = found_mcmc
549
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
615
+
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)
550
629
 
551
- def _make_result_graphs(self):
630
+
631
+ async def _make_result_graphs(self):
552
632
  if self.mcmc.mc.sampler is None:
553
633
  ui.notify("No chain available; re-run MCMC", type="negative")
554
634
  return
@@ -556,21 +636,26 @@ class BayesPanel(BaseComponent):
556
636
  self._apply_chain_container_style(self.result_chains, ndim)
557
637
  self._apply_corner_container_style(ndim)
558
638
 
559
- f = self.result_chains.figure
560
- 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
+
561
645
  w, h = self._chain_figsize(ndim)
562
- self._set_figure_size(f, w, h)
563
- self.mcmc.mc.plot_chain(fig=f)
564
- f.tight_layout()
646
+ await run.io_bound(_plot_chain_sync, self.result_chains.figure, self.mcmc.mc, w, h)
565
647
  self.result_chains.update()
566
648
 
567
- f = self.result_corner.figure
568
- f.clear()
569
- cw, ch = self._corner_figsize(ndim)
570
- self._set_figure_size(f, cw, ch)
571
649
  burnin = self._get_burnin(notify=True)
572
- self.mcmc.mc.make_corner_fig(burnin=burnin, fig=f)
573
- 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)
574
659
  self.result_corner.update()
575
660
 
576
661
  async def _download_figure(self, fig, filename: str) -> None:
@@ -583,13 +668,19 @@ class BayesPanel(BaseComponent):
583
668
  if not hasattr(self, "mcmc") or self.mcmc.mc is None or self.mcmc.mc.sampler is None:
584
669
  ui.notify("No chain figure available for download.", type="warning")
585
670
  return
671
+ ui.notify("Generating chain figure for download...", type="info")
586
672
  ndim = int(self.mcmc.mc.sampler.ndim)
587
673
  fig = plt.figure()
588
674
  w, h = self._chain_figsize(ndim)
589
675
  fig.set_dpi(_EXPORT_DPI)
590
676
  fig.set_size_inches(w * 1.2, h * 1.2, forward=True)
591
- self.mcmc.mc.plot_chain(fig=fig)
592
- 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
+
593
684
  active_fit = self.sm.active_fit_or_none
594
685
  stem = active_fit.name if active_fit is not None else "mcmc"
595
686
  filename = f"{safe_filename(stem, fallback='mcmc')}_chains.png"
@@ -600,20 +691,65 @@ class BayesPanel(BaseComponent):
600
691
  if not hasattr(self, "mcmc") or self.mcmc.mc is None or self.mcmc.mc.sampler is None:
601
692
  ui.notify("No corner figure available for download.", type="warning")
602
693
  return
694
+ ui.notify("Generating corner figure for download...", type="info")
603
695
  ndim = int(self.mcmc.mc.sampler.ndim)
604
696
  fig = plt.figure()
605
697
  w, h = self._corner_figsize(ndim)
606
698
  fig.set_dpi(_EXPORT_DPI)
607
699
  fig.set_size_inches(w * 1.2, h * 1.2, forward=True)
608
700
  burnin = self._get_burnin(notify=False)
609
- self.mcmc.mc.make_corner_fig(burnin=burnin, fig=fig)
610
- 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
+
611
708
  active_fit = self.sm.active_fit_or_none
612
709
  stem = active_fit.name if active_fit is not None else "mcmc"
613
710
  filename = f"{safe_filename(stem, fallback='mcmc')}_corner.png"
614
711
  await self._download_figure(fig, filename)
615
712
  plt.close(fig)
616
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
+
617
753
  # ------------------------------------------------------------------
618
754
  # Notebook export
619
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
@@ -140,14 +140,15 @@ class DataModelPanel(BaseComponent):
140
140
 
141
141
  with self.dataModel_specInteg_block:
142
142
  self.spec_integ_inps: dict[str, ui.input] = {}
143
+ self.spec_integ_cbs: dict[str, ui.checkbox] = {}
143
144
  for i, spec in enumerate(self.sm.species):
144
145
  with ui.row().classes("items-center"):
145
146
  ui.label(f"Species conc. [{spec}]_free:")
146
147
  self.spec_integ_inps[spec] = ui.input().classes("flex-1").props("clearable")
147
148
 
148
149
  self.spec_integ_inps[spec].on("blur", lambda c=self.spec_integ_inps[spec]: self.set_focus(c))
149
- b = ui.checkbox("Enabled", value=True).props(f"testid=spec-enabled-{spec}")
150
- self.spec_integ_inps[spec].bind_enabled_from(b, "value")
150
+ self.spec_integ_cbs[spec] = ui.checkbox("Enabled", value=True).props(f"testid=spec-enabled-{spec}")
151
+ self.spec_integ_inps[spec].bind_enabled_from(self.spec_integ_cbs[spec], "value")
151
152
  if (
152
153
  hasattr(active_expt, "integ_to_spec")
153
154
  and active_expt.integ_to_spec is not None
@@ -157,9 +158,9 @@ class DataModelPanel(BaseComponent):
157
158
  active_expt.integ_to_spec[i], active_expt.columns
158
159
  )
159
160
  if self.spec_integ_inps[spec].value == "":
160
- b.value = False
161
+ self.spec_integ_cbs[spec].value = False
161
162
  else:
162
- b.value = False
163
+ self.spec_integ_cbs[spec].value = False
163
164
 
164
165
  def _gen_spec_fast_exchange_block(self):
165
166
  """Generate the species fast exchange block.
@@ -227,95 +228,94 @@ class DataModelPanel(BaseComponent):
227
228
  self.fast_ex_chem_shift_params = []
228
229
  # map per spec-delta index -> { species_name: {card, shift_num, fixed_cb, min_num, max_num} }
229
230
  self.fast_ex_chem_shift_map = []
231
+ self.specDeltaCbs = []
230
232
 
231
233
  # keep the fast-exchange column names for later processing and bindings
232
234
  self.fast_ex_list_names = list(fast_ex_list)
233
- if len(extra_deps) > 0:
234
- with self.dataModel_specFastExchange_block:
235
- # species_list = [f'{x}_free' for x in self.sm.species]
236
-
237
- # default_shift_species = _default_analytical_shift_species(
238
- # self.sm.active_model.eq_mat,
239
- # list(self.sm.active_model.component_names),
240
- # species_list,
241
- # len(self.fast_ex_list_names),
242
- # )
243
- # if default_shift_species is not None:
244
- # ui.label(
245
- # f"Analytical model detected: defaulting fast-exchange expression to [{default_shift_species}] "
246
- # "for this observable. You can edit it if needed."
247
- # ).classes("text-xs text-gray-600")
248
-
249
- for i, shift in enumerate(self.fast_ex_list_names):
250
- # card per chemical shift column
251
- card = ui.card().classes("mb-2")
252
- self.specDeltaCards.append(card)
253
- with card:
254
- ui.label(f"Fast exchange shift {i + 1} ({shift})").classes("text-sm font-semibold")
255
- with ui.row().classes("items-center"):
256
- inp = ui.input().classes("flex-1").props("clearable")
257
- self.specDeltaInps.append(inp)
258
- # species chips row will be added below the input to allow quick insertion
259
- with ui.row().classes("gap-1 mt-2"):
260
- for text in [f"{x}_free" for x in self.sm.species]:
261
- ui.chip(
262
- text,
263
- color="teal",
264
- on_click=lambda h=text: self._insert_species_into_fast_inp(h),
265
- )
266
- # placeholder checkbox to enable the input
267
- en_cb = ui.checkbox("Enabled", value=True)
268
- inp.bind_enabled_from(en_cb, "value")
269
- # placeholder param block element (initially empty/hidden)
270
- pb = ui.element()
271
- self.fast_ex_chem_shift_blocks.append(pb)
272
- self.fast_ex_chem_shift_map.append({})
273
- # store param dict for later
274
- # self.fast_ex_chem_shift_params.append({'shift': None, 'fixed': False, 'min': None, 'max': None})
275
-
276
- # bind blur handler to create/update parameter block
277
- # use default args to capture current inp and index
278
- # allow chips/clicks to insert into this input by remembering last focus
279
- inp.on("focus", lambda e, widget=inp: self.set_focus(widget))
280
- inp.on("click", lambda e, widget=inp: self.set_focus(widget))
281
- # handle blur to create/update parameter sub-blocks
282
- inp.on("blur", lambda e, idx=i, widget=inp: self._handle_spec_delta_blur(idx, widget))
283
- # immediate change handler: reparse on every change
284
- inp.on_value_change(lambda e, idx=i, widget=inp: self._handle_spec_delta_blur(idx, widget))
285
-
286
- # if there is saved delta_to_spec data, populate the input
287
- if (
288
- hasattr(active_expt, "delta_to_spec")
289
- and active_expt.delta_to_spec is not None
290
- and len(active_expt.delta_to_spec) > 0
291
- ):
292
- delta_for_eqn = active_expt.delta_to_spec[i].copy()
293
-
294
- for ij, el in enumerate(delta_for_eqn):
295
- if np.isclose(el, 0):
296
- delta_for_eqn[ij] = 0
297
- else:
298
- if (f"{self.sm.species[ij]}_free", shift) in active_expt.limiting_shifts:
299
- s = active_expt.limiting_shifts[f"{self.sm.species[ij]}_free", shift]
300
- if s.value:
301
- delta_for_eqn[ij] = delta_for_eqn[ij] / s.value
302
- else:
303
- delta_for_eqn[ij] = 1
304
-
305
- # self.sm.active_expt_data.limiting_shifts[f'{self.sm.species[i]}_free',shift].value
306
-
307
- # delta_for_eqn[delta_for_eqn != 0] = 1
308
-
309
- value = self.vec_to_conc_expression(
310
- delta_for_eqn, [f"{x}_free" for x in self.sm.species]
235
+ with self.dataModel_specFastExchange_block:
236
+ # species_list = [f'{x}_free' for x in self.sm.species]
237
+
238
+ default_shift_species = None
239
+ if simple_model is not None and len(simple_model[1]) > 0:
240
+ default_shift_species = f"{self.sm.species[simple_model[1][0]]}_free"
241
+
242
+ if default_shift_species is not None:
243
+ ui.label(
244
+ f"Analytical model detected: defaulting fast-exchange expression to [{default_shift_species}] "
245
+ "for this observable. You can edit it if needed."
246
+ ).classes("text-xs text-gray-600")
247
+
248
+ for i, shift in enumerate(self.fast_ex_list_names):
249
+ # card per chemical shift column
250
+ card = ui.card().classes("mb-2")
251
+ self.specDeltaCards.append(card)
252
+ with card:
253
+ ui.label(f"Fast exchange shift {i + 1} ({shift})").classes("text-sm font-semibold")
254
+ with ui.row().classes("items-center"):
255
+ inp = ui.input().classes("flex-1").props("clearable")
256
+ self.specDeltaInps.append(inp)
257
+ # species chips row will be added below the input to allow quick insertion
258
+ with ui.row().classes("gap-1 mt-2"):
259
+ for text in [f"{x}_free" for x in self.sm.species]:
260
+ ui.chip(
261
+ text,
262
+ color="teal",
263
+ on_click=lambda h=text: self._insert_species_into_fast_inp(h),
311
264
  )
312
- inp.value = value
313
- if value == "":
314
- en_cb.value = False
315
- # elif default_shift_species is not None:
316
- # inp.value = f"[{default_shift_species}]"
317
- # # Build corresponding ChemicalShiftParam widgets immediately.
318
- # self._handle_spec_delta_blur(i, inp)
265
+ # placeholder checkbox to enable the input
266
+ en_cb = ui.checkbox("Enabled", value=True)
267
+ self.specDeltaCbs.append(en_cb)
268
+ inp.bind_enabled_from(en_cb, "value")
269
+ # placeholder param block element (initially empty/hidden)
270
+ pb = ui.element()
271
+ self.fast_ex_chem_shift_blocks.append(pb)
272
+ self.fast_ex_chem_shift_map.append({})
273
+ # store param dict for later
274
+ # self.fast_ex_chem_shift_params.append({'shift': None, 'fixed': False, 'min': None, 'max': None})
275
+
276
+ # bind blur handler to create/update parameter block
277
+ # use default args to capture current inp and index
278
+ # allow chips/clicks to insert into this input by remembering last focus
279
+ inp.on("focus", lambda e, widget=inp: self.set_focus(widget))
280
+ inp.on("click", lambda e, widget=inp: self.set_focus(widget))
281
+ # handle blur to create/update parameter sub-blocks
282
+ inp.on("blur", lambda e, idx=i, widget=inp: self._handle_spec_delta_blur(idx, widget))
283
+ # immediate change handler: reparse on every change
284
+ inp.on_value_change(lambda e, idx=i, widget=inp: self._handle_spec_delta_blur(idx, widget))
285
+
286
+ # if there is saved delta_to_spec data, populate the input
287
+ if (
288
+ hasattr(active_expt, "delta_to_spec")
289
+ and active_expt.delta_to_spec is not None
290
+ and len(active_expt.delta_to_spec) > 0
291
+ ):
292
+ delta_for_eqn = active_expt.delta_to_spec[i].copy()
293
+
294
+ for ij, el in enumerate(delta_for_eqn):
295
+ if np.isclose(el, 0):
296
+ delta_for_eqn[ij] = 0
297
+ else:
298
+ if (f"{self.sm.species[ij]}_free", shift) in active_expt.limiting_shifts:
299
+ s = active_expt.limiting_shifts[f"{self.sm.species[ij]}_free", shift]
300
+ if s.value:
301
+ delta_for_eqn[ij] = delta_for_eqn[ij] / s.value
302
+ else:
303
+ delta_for_eqn[ij] = 1
304
+
305
+ # self.sm.active_expt_data.limiting_shifts[f'{self.sm.species[i]}_free',shift].value
306
+
307
+ # delta_for_eqn[delta_for_eqn != 0] = 1
308
+
309
+ value = self.vec_to_conc_expression(
310
+ delta_for_eqn, [f"{x}_free" for x in self.sm.species]
311
+ )
312
+ inp.value = value
313
+ if value == "":
314
+ en_cb.value = False
315
+ elif default_shift_species is not None:
316
+ inp.value = f"[{default_shift_species}]"
317
+ # Build corresponding ChemicalShiftParam widgets immediately.
318
+ self._handle_spec_delta_blur(i, inp)
319
319
 
320
320
  # finished generating fast-exchange block
321
321
 
@@ -371,6 +371,14 @@ class DataModelPanel(BaseComponent):
371
371
  if not isinstance(cs_obj, ChemicalShiftParam):
372
372
  # create with safe defaults
373
373
  cs_obj = ChemicalShiftParam(species=spec_name, col=col_name, fixed=False)
374
+ try:
375
+ if hasattr(active_expt, "data") and active_expt.data is not None and col_name in active_expt.data.columns:
376
+ first_val = float(active_expt.data[col_name].dropna().iloc[0])
377
+ cs_obj.value = round(first_val, 4)
378
+ cs_obj._min = round(first_val - 1.0, 4)
379
+ cs_obj._max = round(first_val + 1.0, 4)
380
+ except Exception:
381
+ pass
374
382
  active_expt.limiting_shifts[k] = cs_obj
375
383
 
376
384
  # reuse if exists
@@ -463,10 +471,10 @@ class DataModelPanel(BaseComponent):
463
471
  target.integ_to_spec = None
464
472
  else:
465
473
  integ_to_spec = [
466
- self.conc_expression_to_vec(input.value, target.columns)
467
- if input.enabled
474
+ self.conc_expression_to_vec(self.spec_integ_inps[spec].value, target.columns)
475
+ if self.spec_integ_cbs[spec].value
468
476
  else np.zeros(len(target.columns))
469
- for input in self.spec_integ_inps.values()
477
+ for spec in self.sm.species
470
478
  ]
471
479
  integ_to_spec = np.array(integ_to_spec)
472
480
  if integ_to_spec.size == 0 or np.all(np.isclose(integ_to_spec, 0)):
@@ -483,9 +491,9 @@ class DataModelPanel(BaseComponent):
483
491
  if hasattr(self, "specDeltaInps") and isinstance(self.specDeltaInps, list) and len(self.specDeltaInps) > 0:
484
492
  species_label_list = [f"{s}_free" for s in self.sm.species]
485
493
  for idx, input_widget in enumerate(self.specDeltaInps):
494
+ cb = self.specDeltaCbs[idx]
486
495
  if (
487
- hasattr(input_widget, "enabled")
488
- and input_widget.enabled
496
+ cb.value
489
497
  and isinstance(input_widget.value, str)
490
498
  and input_widget.value.strip()
491
499
  ):
@@ -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
 
@@ -126,8 +132,6 @@ def _infer_analytical_fast_exchange_config(model, expt_data, expt_dtypes: dict)
126
132
  return {
127
133
  "topology": topo_name,
128
134
  "complex_indices": complex_indices,
129
- "obs_columns": [], # no NMR shift columns
130
- "obs_components": [],
131
135
  }
132
136
 
133
137
  # Pure NMR shift path (existing behaviour).
@@ -140,85 +144,9 @@ def _infer_analytical_fast_exchange_config(model, expt_data, expt_dtypes: dict)
140
144
  if finite.size > 0 and np.any(~np.isclose(finite, 0.0)):
141
145
  return None
142
146
 
143
- component_free_labels = [f"{name}_free" for name in model.component_names]
144
- if len(component_free_labels) != 2:
145
- return None
146
-
147
- # Build optional hints from existing user mappings when available.
148
- shift_species_by_col: dict[str, set[str]] = {}
149
- if isinstance(expt_data.limiting_shifts, dict):
150
- for (species, col_name), _ in expt_data.limiting_shifts.items():
151
- if col_name is None:
152
- continue
153
- shift_species_by_col.setdefault(str(col_name), set()).add(str(species))
154
-
155
- delta_species_hints: dict[str, set[str]] = {}
156
- delta_to_spec = expt_data.delta_to_spec
157
- if isinstance(delta_to_spec, np.ndarray) and delta_to_spec.ndim == 2 and delta_to_spec.size > 0:
158
- # In this UI flow, delta_to_spec rows are created from fast-exchange observable columns.
159
- # For analytical mode all dependent observables are shift observables, so the row order
160
- # corresponds to obs_list.
161
- n_rows = min(delta_to_spec.shape[0], len(obs_list))
162
- n_species = min(delta_to_spec.shape[1], len(component_free_labels))
163
- for ridx in range(n_rows):
164
- col_name = obs_list[ridx]
165
- for sidx in range(n_species):
166
- try:
167
- is_nonzero = not np.isclose(float(delta_to_spec[ridx, sidx]), 0.0)
168
- except Exception:
169
- is_nonzero = bool(delta_to_spec[ridx, sidx])
170
- if is_nonzero:
171
- delta_species_hints.setdefault(col_name, set()).add(component_free_labels[sidx])
172
-
173
- def _component_from_text_hints(col_name: str, dtype_key: str | None) -> int | None:
174
- # Tokenize to avoid over-matching short component names in arbitrary strings.
175
- tokens = []
176
- for src in (col_name, dtype_key or ""):
177
- parts = [t for t in re.split(r"[^A-Za-z0-9]+", str(src).lower()) if t]
178
- tokens.extend(parts)
179
- matches = [idx for idx, comp in enumerate(model.component_names) if str(comp).lower() in tokens]
180
- return matches[0] if len(matches) == 1 else None
181
-
182
- obs_components: list[int] = []
183
- unresolved: list[tuple[int, str]] = []
184
- for obs_idx, col in enumerate(obs_list):
185
- col_meta = expt_data.col_details.get(col, {})
186
- dtype_key = col_meta.get("dtype")
187
-
188
- included_species = set()
189
- included_species |= shift_species_by_col.get(col, set())
190
- included_species |= delta_species_hints.get(col, set())
191
-
192
- has_comp0 = component_free_labels[0] in included_species
193
- has_comp1 = component_free_labels[1] in included_species
194
- if has_comp0 != has_comp1:
195
- obs_components.append(0 if has_comp0 else 1)
196
- continue
197
-
198
- inferred = _component_from_text_hints(col, dtype_key)
199
- if inferred is not None:
200
- obs_components.append(inferred)
201
- continue
202
-
203
- unresolved.append((obs_idx, col))
204
- obs_components.append(-1)
205
-
206
- # Final fallback keeps analytical mode enabled even without manual shift metadata.
207
- if unresolved:
208
- for obs_idx, col in unresolved:
209
- fallback = obs_idx % len(component_free_labels)
210
- obs_components[obs_idx] = fallback
211
- logger.info(
212
- "Analytical fast-exchange: inferred observable '%s' as component %d by default fallback.",
213
- col,
214
- fallback,
215
- )
216
-
217
147
  return {
218
148
  "topology": topo_name,
219
149
  "complex_indices": complex_indices,
220
- "obs_columns": list(obs_list),
221
- "obs_components": obs_components,
222
150
  }
223
151
 
224
152
 
@@ -229,11 +157,11 @@ class FittingPanel(BaseComponent):
229
157
 
230
158
  with self.container:
231
159
  ui.label("Fitting panel").classes("text-lg font-bold mb-4")
232
- with ui.row():
233
- with ui.card():
234
- ui.label("Fitting options to go here.")
160
+ with ui.row().classes("w-full gap-4 items-start flex-col lg:flex-row"):
161
+ with ui.card().classes("w-full lg:w-80 shrink-0"):
162
+ # ui.label("Fitting options to go here.")
235
163
  self.fit_alg_select = ui.select(
236
- ["least_squares", "l-bfgs", "ampgo"],
164
+ ["least_squares", "l-bfgs", "l-bfgs-b", "nelder-mead", "ampgo"],
237
165
  label="Algorithm",
238
166
  on_change=lambda e: print(f"Selected: {e.value}"),
239
167
  value="least_squares",
@@ -437,16 +365,8 @@ class FittingPanel(BaseComponent):
437
365
  type="info",
438
366
  )
439
367
 
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:
368
+ self.m1 = self.sm.generate_binding_model_for_fit()
369
+ if self.m1.analytical_fast_exchange:
450
370
  ui.notify(
451
371
  f"Using analytical fast-exchange backend ({self.m1.analytical_topology}).",
452
372
  type="info",
@@ -494,18 +414,8 @@ class FittingPanel(BaseComponent):
494
414
  init_expt_data=self.sm.active_expt_data,
495
415
  init_model=self.sm.active_model,
496
416
  bd_model=self.m1,
497
- analytical_fast_exchange=analytical_cfg is not None,
417
+ analytical_fast_exchange=self.m1.analytical_fast_exchange,
498
418
  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
- ),
509
419
  analytical_complex_indices=self.m1.analytical_complex_indices,
510
420
  )
511
421
  self.sm.add_fit(new_fit)
@@ -520,6 +430,11 @@ class FittingPanel(BaseComponent):
520
430
  "initial_value": v.init_value,
521
431
  }
522
432
 
433
+ print("DEBUG: specToInteg =", self.sm.active_expt_data.integ_to_spec)
434
+ print("DEBUG: specToDd =", self.sm.active_expt_data.delta_to_spec)
435
+ print("DEBUG: obsList =", self.m1.obsList)
436
+ print("DEBUG: calc_obs shape =", np.shape(calc_obs))
437
+
523
438
  self.sm.active_fit.calc_obs = pd.DataFrame(calc_obs, columns=self.m1.obsList)
524
439
 
525
440
  self.sm.active_fit.fit_speciation = pd.DataFrame(speciation, columns=[x + "_free" for x in self.sm.species])
@@ -718,7 +633,7 @@ class FittingPanel(BaseComponent):
718
633
  class FitResultsCard(BaseComponent):
719
634
  def setup_nicegui(self) -> None:
720
635
 
721
- with ui.card():
636
+ with ui.card().classes("w-full lg:flex-1 min-w-0 overflow-hidden"):
722
637
  # ui.label("Fitting Results to go here.")
723
638
  with ui.row().classes("w-full"):
724
639
  ui.label("Results:")
@@ -785,7 +700,14 @@ class FitResultsCard(BaseComponent):
785
700
 
786
701
  fitParams = list(dict.fromkeys(fitParams)) # Remove duplicates
787
702
 
788
- paramCols = [{"name": param, "label": param, "field": param} for param in fitParams]
703
+ paramCols = [
704
+ {
705
+ "name": param,
706
+ "label": f"logK({param[3:]})" if param.startswith("log") else param,
707
+ "field": param,
708
+ }
709
+ for param in fitParams
710
+ ]
789
711
 
790
712
  stat_col_names = {"chisqr", "aic", "bic", "message", "covariance"}
791
713
  stat_cols = [c for c in self.default_columns if c["name"] in stat_col_names]
@@ -803,7 +725,7 @@ class FitResultsCard(BaseComponent):
803
725
  for fit in self.sm.fits.values():
804
726
  row = {
805
727
  "name": fit.name,
806
- "chisqr": self.rounded_value(fit.chisqr),
728
+ "chisqr": self.rounded_value(fit.chisqr,scientific=True),
807
729
  "aic": self.rounded_value(fit.aic, dp=1),
808
730
  "bic": self.rounded_value(fit.bic, dp=1),
809
731
  "message": fit.termination_message,
@@ -820,10 +742,10 @@ class FitResultsCard(BaseComponent):
820
742
  self.add_body_slot()
821
743
  self.table.update()
822
744
 
823
- def rounded_value(self, value: float, dp: int = 3) -> str:
745
+ def rounded_value(self, value: float, dp: int = 3, scientific=False) -> str:
824
746
  """Round the value for display."""
825
- if abs(value) >= 10000 or abs(value) < 0.001:
826
- return f"{value:.4e}"
747
+ if abs(value) >= 10000 or abs(value) < 0.001 or scientific:
748
+ return f"{value:.3e}"
827
749
  else:
828
750
  return f"{value:.{dp}f}" if isinstance(value, float) else str(value)
829
751
 
@@ -239,17 +239,7 @@ def _build_lin_obs_cell4_lines(
239
239
  return lines
240
240
 
241
241
 
242
- def _build_analytical_lin_obs_lines(lin_obs_param_map: list) -> list[str]:
243
- """Return setup lines for the analytical path's linear_obs_param_map.
244
242
 
245
- Extracts the param name (or None) from each cell so
246
- fitfun_analytical_fast_exchange can call calc_analytical_linear_observables.
247
- """
248
- # Serialise as [[name_or_none, ...], ...]
249
- name_map = [[cell["name"] if cell is not None else None for cell in row] for row in lin_obs_param_map]
250
- return [
251
- f"m.analytical_linear_obs_param_map = {name_map!r}",
252
- ]
253
243
 
254
244
 
255
245
  def export_fit_notebook(
@@ -382,7 +372,31 @@ def export_fit_notebook(
382
372
  spec_to_integ_arg = "None"
383
373
 
384
374
  if has_delta:
385
- spec_to_dd_line = f"spec_to_dd = np.array({delta_to_spec.T.tolist()!r}, dtype=object)"
375
+ # Build spec_to_dd reconstruction lines dynamically to properly instantiate lmfit.Parameters
376
+ spec_to_dd_lines = [
377
+ "spec_to_dd = np.empty((len(species_names), len(obs_list)), dtype=object)"
378
+ ]
379
+ delta_T = delta_to_spec.T
380
+ for i in range(delta_T.shape[0]):
381
+ for j in range(delta_T.shape[1]):
382
+ cell = delta_T[i, j]
383
+ if cell is None:
384
+ spec_to_dd_lines.append(f"spec_to_dd[{i}, {j}] = None")
385
+ elif isinstance(cell, (int, float, np.floating)):
386
+ if np.isnan(cell):
387
+ spec_to_dd_lines.append(f"spec_to_dd[{i}, {j}] = None")
388
+ else:
389
+ spec_to_dd_lines.append(f"spec_to_dd[{i}, {j}] = {float(cell)!r}")
390
+ else: # lmfit.Parameter
391
+ pname = getattr(cell, "name", "")
392
+ pval = getattr(cell, "value", 0.0)
393
+ pmin = getattr(cell, "min", -np.inf)
394
+ pmax = getattr(cell, "max", np.inf)
395
+ pvary = getattr(cell, "vary", True)
396
+ spec_to_dd_lines.append(
397
+ f"spec_to_dd[{i}, {j}] = lmfit.Parameter({pname!r}, value={pval!r}, min={pmin!r}, max={pmax!r}, vary={pvary!r})"
398
+ )
399
+ spec_to_dd_line = "\n".join(spec_to_dd_lines)
386
400
  spec_to_dd_arg = "spec_to_dd"
387
401
  else:
388
402
  spec_to_dd_line = "spec_to_dd = None"
@@ -390,8 +404,6 @@ def export_fit_notebook(
390
404
 
391
405
  is_analytical = bool(getattr(fit, "analytical_fast_exchange", False))
392
406
  analytical_topology = getattr(fit, "analytical_topology", None)
393
- analytical_obs_columns = list(getattr(fit, "analytical_obs_columns", []))
394
- analytical_obs_components = list(getattr(fit, "analytical_obs_components", []))
395
407
  analytical_complex_indices = list(getattr(fit, "analytical_complex_indices", []))
396
408
 
397
409
  has_lin_obs = bool(lin_obs_col_names and lin_obs_param_map)
@@ -399,15 +411,11 @@ def export_fit_notebook(
399
411
  if is_analytical:
400
412
  analytical_setup_lines: list[str] = [
401
413
  "",
402
- "# Analytical fast-exchange backend — must be configured before prepModel()",
414
+ "# Analytical fast-exchange speciation",
403
415
  "m.analytical_fast_exchange = True",
404
416
  f"m.analytical_topology = {analytical_topology!r}",
405
- f"m.analytical_obs_columns = {analytical_obs_columns!r}",
406
- f"m.analytical_obs_components = {analytical_obs_components!r}",
407
417
  f"m.analytical_complex_indices = {analytical_complex_indices!r}",
408
418
  ]
409
- if has_lin_obs:
410
- analytical_setup_lines += _build_analytical_lin_obs_lines(lin_obs_param_map) # type: ignore[arg-type]
411
419
  else:
412
420
  analytical_setup_lines = []
413
421
 
@@ -659,6 +667,7 @@ def export_mcmc_notebook(
659
667
  f"with h5py.File('{stem}_chains.hdf', 'r') as f:",
660
668
  " chain = f['mcmc/chain'][:] # (nsteps, nwalkers, ndim)",
661
669
  " log_prob = f['mcmc/log_prob'][:] # (nsteps, nwalkers)",
670
+ " thin = f['mcmc'].attrs.get('thin', 1)",
662
671
  "",
663
672
  "param_labels = [p for p in m.params if m.params[p].vary]",
664
673
  "ndim = chain.shape[2]",
@@ -671,7 +680,10 @@ def export_mcmc_notebook(
671
680
  " axes[i].set_ylabel(label)",
672
681
  "axes[-1].plot(log_prob, alpha=0.3, lw=0.5, color='k')",
673
682
  "axes[-1].set_ylabel('log prob')",
674
- "axes[-1].set_xlabel('step')",
683
+ "if thin != 1:",
684
+ " axes[-1].set_xlabel(f'step (thinning 1-in-{thin} points)')",
685
+ "else:",
686
+ " axes[-1].set_xlabel('step')",
675
687
  "fig.tight_layout()",
676
688
  "plt.show()",
677
689
  "",
@@ -738,6 +750,7 @@ def export_mcmc_notebook(
738
750
  if has_blobs:
739
751
  g.create_dataset("blobs", data=backend.blobs)
740
752
  g.attrs["iteration"] = backend.iteration
753
+ g.attrs["thin"] = thin
741
754
  hf.flush()
742
755
  h5_bytes = bytes(hf.id.get_file_image())
743
756
 
@@ -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
  ]
@@ -1166,6 +1167,9 @@ class StateManager:
1166
1167
  fittemp = data.get("fits", []) # List of fits
1167
1168
  if fittemp:
1168
1169
  for fit in fittemp:
1170
+ # backward compatibility to load pre-0.1.7 fits
1171
+ fit.pop("analytical_obs_columns",None)
1172
+ fit.pop("analytical_obs_components",None)
1169
1173
  fit_result = FitResult(**fit)
1170
1174
  fit_result.fit_speciation = pd.DataFrame(fit.get("fit_speciation", {}))
1171
1175
  fit_result.calc_obs = pd.DataFrame(fit.get("calc_obs", {}))
@@ -1232,7 +1236,7 @@ class StateManager:
1232
1236
  parameter.vary = bool(cell.get("vary", True))
1233
1237
  reconstructed_row.append(parameter)
1234
1238
  elif cell is None:
1235
- reconstructed_row.append(0.0)
1239
+ reconstructed_row.append(None)
1236
1240
  elif isinstance(cell, (str, int, float)):
1237
1241
  try:
1238
1242
  reconstructed_row.append(float(cell))
@@ -1691,7 +1695,10 @@ bd.makeFitResidPlot(fit,plotMask=(0,1),ylabel='Chemical shift (ppm)')"""
1691
1695
  # return model_str
1692
1696
 
1693
1697
  def generate_binding_model_for_fit(
1694
- self, fit: Optional[FitResult] = None, analytical_cfg: Optional[dict[str, object]] = None
1698
+ self,
1699
+ fit: Optional[FitResult] = None,
1700
+ analytical_cfg: Optional[dict[str, object]] = None,
1701
+ force_numerical: bool = False,
1695
1702
  ) -> bd.bindingModel:
1696
1703
  """Generate a bindtools.bindingModel from the current active model."""
1697
1704
  old_fit = None
@@ -1732,16 +1739,12 @@ bd.makeFitResidPlot(fit,plotMask=(0,1),ylabel='Chemical shift (ppm)')"""
1732
1739
  cfg = {
1733
1740
  "topology": fit.analytical_topology,
1734
1741
  "complex_indices": list(getattr(fit, "analytical_complex_indices", [])),
1735
- "obs_columns": list(getattr(fit, "analytical_obs_columns", [])),
1736
- "obs_components": list(getattr(fit, "analytical_obs_components", [])),
1737
1742
  }
1738
1743
 
1739
1744
  if cfg is not None:
1740
1745
  model.analytical_fast_exchange = True
1741
1746
  model.analytical_topology = str(cfg["topology"])
1742
1747
  model.analytical_complex_indices = [int(x) for x in cfg["complex_indices"]] # type: ignore[index]
1743
- model.analytical_obs_columns = [str(x) for x in cfg["obs_columns"]] # type: ignore[index]
1744
- model.analytical_obs_components = [int(x) for x in cfg["obs_components"]] # type: ignore[index]
1745
1748
  logger.info(
1746
1749
  "Using analytical fast-exchange backend (%s model) for fitting.",
1747
1750
  model.analytical_topology,
@@ -1777,17 +1780,7 @@ bd.makeFitResidPlot(fit,plotMask=(0,1),ylabel='Chemical shift (ppm)')"""
1777
1780
  model.analytical_linear_obs_columns = lin_cols
1778
1781
  model.analytical_linear_obs_param_map = linear_obs_param_map
1779
1782
 
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()
1783
+ model.prepModel(force_numerical=force_numerical)
1791
1784
 
1792
1785
  for k in self.active_model.binding_constants:
1793
1786
  model.params[f"log{k.species}"].set(
File without changes
File without changes
File without changes