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.
Files changed (37) hide show
  1. bindmc/main.py +67 -0
  2. bindmc/webgui/__init__.py +0 -0
  3. bindmc/webgui/app.py +54 -0
  4. bindmc/webgui/classes/BindingConstant.py +23 -0
  5. bindmc/webgui/classes/ChemicalShiftParam.py +40 -0
  6. bindmc/webgui/classes/Component.py +111 -0
  7. bindmc/webgui/classes/ExptData.py +485 -0
  8. bindmc/webgui/classes/ExptDataType.py +92 -0
  9. bindmc/webgui/classes/FitResult.py +173 -0
  10. bindmc/webgui/classes/MCMCSim.py +232 -0
  11. bindmc/webgui/classes/Model.py +86 -0
  12. bindmc/webgui/classes/RawData.py +36 -0
  13. bindmc/webgui/classes/Simulation.py +104 -0
  14. bindmc/webgui/classes/UIBindings.py +19 -0
  15. bindmc/webgui/classes/__init__.py +28 -0
  16. bindmc/webgui/components/__init__.py +29 -0
  17. bindmc/webgui/components/base.py +24 -0
  18. bindmc/webgui/components/bayes.py +689 -0
  19. bindmc/webgui/components/bayes_priors.py +351 -0
  20. bindmc/webgui/components/binding_model.py +330 -0
  21. bindmc/webgui/components/body.py +276 -0
  22. bindmc/webgui/components/data_gen.py +419 -0
  23. bindmc/webgui/components/data_import.py +450 -0
  24. bindmc/webgui/components/data_model.py +609 -0
  25. bindmc/webgui/components/fitting.py +886 -0
  26. bindmc/webgui/components/graph.py +649 -0
  27. bindmc/webgui/components/header.py +124 -0
  28. bindmc/webgui/components/simulation.py +385 -0
  29. bindmc/webgui/export/__init__.py +0 -0
  30. bindmc/webgui/export/notebook_exporter.py +727 -0
  31. bindmc/webgui/state/__init__.py +1 -0
  32. bindmc/webgui/state/statemanager.py +2043 -0
  33. bindmc/webgui/utils.py +322 -0
  34. bindmc-0.1.0.dist-info/METADATA +22 -0
  35. bindmc-0.1.0.dist-info/RECORD +37 -0
  36. bindmc-0.1.0.dist-info/WHEEL +5 -0
  37. bindmc-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,727 @@
1
+ """
2
+ Export BindTools project state to Jupyter notebooks.
3
+
4
+ Public API
5
+ ----------
6
+ export_simulation_notebook(sim, model) -> dict
7
+ Returns an nbformat-4 notebook dict ready to be written as .ipynb JSON.
8
+
9
+ export_fit_notebook(fit, model, expt_data, raw_data) -> tuple[dict, pd.DataFrame]
10
+ Returns (notebook_dict, csv_dataframe).
11
+ Write the DataFrame to a CSV file alongside the notebook so the generated
12
+ code can load it with ``pd.read_csv("data.csv")``.
13
+
14
+ Both functions produce self-contained notebooks that depend only on standard
15
+ scientific Python libraries (numpy, pandas, matplotlib, lmfit) and
16
+ ``bindtools``; they never import ``nicegui`` or anything from ``webgui``.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from datetime import datetime
21
+ from typing import TYPE_CHECKING
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ if TYPE_CHECKING: # avoid runtime webgui imports
27
+ from ..classes.ExptData import ExptData
28
+ from ..classes.FitResult import FitResult
29
+ from ..classes.MCMCSim import MCMCSim
30
+ from ..classes.Model import Model
31
+ from ..classes.RawData import RawData
32
+ from ..classes.Simulation import Simulation
33
+
34
+ from ..utils import safe_filename
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Low-level notebook construction helpers
39
+ # ---------------------------------------------------------------------------
40
+
41
+ def _md_cell(source: str) -> dict:
42
+ return {"cell_type": "markdown", "metadata": {}, "source": source}
43
+
44
+
45
+ def _code_cell(source: str) -> dict:
46
+ return {
47
+ "cell_type": "code",
48
+ "execution_count": None,
49
+ "metadata": {},
50
+ "outputs": [],
51
+ "source": source,
52
+ }
53
+
54
+
55
+ def _notebook(cells: list[dict]) -> dict:
56
+ return {
57
+ "nbformat": 4,
58
+ "nbformat_minor": 5,
59
+ "metadata": {
60
+ "kernelspec": {
61
+ "display_name": "Python 3",
62
+ "language": "python",
63
+ "name": "python3",
64
+ },
65
+ "language_info": {"name": "python", "version": "3.12.0"},
66
+ },
67
+ "cells": cells,
68
+ }
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Simulation notebook
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def export_simulation_notebook(sim: "Simulation", model: "Model") -> dict:
76
+ """Export a Simulation as an nbformat-4 notebook dict.
77
+
78
+ The generated notebook:
79
+ 1. Embeds component concentrations from *sim.comp_concs* as inline data.
80
+ 2. Uses binding constants from *model.binding_constants*.
81
+ 3. Calls ``bd.bindingModel(...).calcSpeciation()`` to reproduce the result.
82
+ """
83
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
84
+
85
+ # ------------------------------------------------------------------
86
+ # Cell 0 — Title / metadata (markdown)
87
+ # ------------------------------------------------------------------
88
+ md_title = "\n".join([
89
+ f"# Simulation: {sim.name or 'Unnamed'}",
90
+ "",
91
+ f"**Model:** {model.name} ",
92
+ f"**Equation:** `{model.eq_str}` ",
93
+ f"**Generated:** {timestamp} ",
94
+ "",
95
+ "| Parameter | logK |",
96
+ "|-----------|------|",
97
+ *[
98
+ f"| log{bc.species} | {bc.logK if bc.logK is not None else 0.0} |"
99
+ for bc in model.binding_constants
100
+ ],
101
+ ])
102
+
103
+ # ------------------------------------------------------------------
104
+ # Cell 1 — Imports
105
+ # ------------------------------------------------------------------
106
+ imports = (
107
+ "import numpy as np\n"
108
+ "import pandas as pd\n"
109
+ "import matplotlib.pyplot as plt\n"
110
+ "import bindtools.binding as bd"
111
+ )
112
+
113
+ # ------------------------------------------------------------------
114
+ # Cell 2 — Model definition
115
+ # ------------------------------------------------------------------
116
+ eq_mat_list = model.eq_mat.tolist()
117
+ model_def = "\n".join([
118
+ "# Model definition",
119
+ f"eq_mat = np.array({eq_mat_list!r}, dtype=float)",
120
+ f"component_names = {model.component_names!r}",
121
+ f"species_names = {model.species!r}",
122
+ ])
123
+
124
+ # ------------------------------------------------------------------
125
+ # Cell 3 — Component concentrations (inlined from sim.comp_concs)
126
+ # ------------------------------------------------------------------
127
+ comp_dict = {col: sim.comp_concs[col].tolist() for col in sim.comp_concs.columns}
128
+ comp_concs_code = "\n".join([
129
+ "# Component concentrations",
130
+ f"comp_concs = pd.DataFrame({comp_dict!r})",
131
+ ])
132
+
133
+ # ------------------------------------------------------------------
134
+ # Cell 4 — Binding parameters
135
+ # ------------------------------------------------------------------
136
+ param_lines = [
137
+ "# Binding parameters (log10 association constants)",
138
+ "params = {",
139
+ ]
140
+ for bc in model.binding_constants:
141
+ val = bc.logK if bc.logK is not None else 0.0
142
+ comment = " # component pseudo-constant" if bc.isComp else ""
143
+ param_lines.append(f" 'log{bc.species}': {val!r},{comment}")
144
+ param_lines.append("}")
145
+ params_code = "\n".join(param_lines)
146
+
147
+ # ------------------------------------------------------------------
148
+ # Cell 5 — Run the binding model and compute speciation
149
+ # ------------------------------------------------------------------
150
+ run_code = (
151
+ "# Set up and run the binding model\n"
152
+ "bm = bd.bindingModel(\n"
153
+ " eq_mat,\n"
154
+ " component_names,\n"
155
+ " species_names,\n"
156
+ " compConcs=comp_concs.values,\n"
157
+ ")\n"
158
+ "bm.prepModel()\n"
159
+ "\n"
160
+ "# Fix all parameters to simulation values\n"
161
+ "for pname, pval in params.items():\n"
162
+ " bm.params[pname].set(value=pval, vary=False)\n"
163
+ "\n"
164
+ "# Calculate speciation\n"
165
+ "spec = bm.calcSpeciation()\n"
166
+ "results = pd.DataFrame(spec, columns=species_names, index=comp_concs.index)\n"
167
+ "print(results)"
168
+ )
169
+
170
+ # ------------------------------------------------------------------
171
+ # Cell 6 — Plot
172
+ # ------------------------------------------------------------------
173
+ # Note: curly braces inside the string are notebook f-string syntax,
174
+ # evaluated when the *notebook* runs, not at generation time.
175
+ plot_code = (
176
+ "# Plot speciation\n"
177
+ "fig, ax = plt.subplots(figsize=(8, 5))\n"
178
+ "if len(component_names) >= 2:\n"
179
+ " x_vals = comp_concs.iloc[:, 1].values / comp_concs.iloc[:, 0].values\n"
180
+ " x_label = f'[{component_names[1]}]$_{{tot}}$ / [{component_names[0]}]$_{{tot}}$'\n"
181
+ "else:\n"
182
+ " x_vals = np.arange(len(comp_concs))\n"
183
+ " x_label = 'Step'\n"
184
+ "for col in results.columns:\n"
185
+ " ax.plot(x_vals, results[col], label=f'[{col}]$_{{free}}$')\n"
186
+ "ax.set_xlabel(x_label)\n"
187
+ "ax.set_ylabel('Concentration (M)')\n"
188
+ "ax.legend()\n"
189
+ "plt.tight_layout()\n"
190
+ "plt.show()"
191
+ )
192
+
193
+ cells = [
194
+ _md_cell(md_title),
195
+ _code_cell(imports),
196
+ _code_cell(model_def),
197
+ _code_cell(comp_concs_code),
198
+ _code_cell(params_code),
199
+ _code_cell(run_code),
200
+ _code_cell(plot_code),
201
+ ]
202
+ return _notebook(cells)
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Fit notebook
207
+ # ---------------------------------------------------------------------------
208
+
209
+ def _build_lin_obs_cell4_lines(
210
+ lin_obs_col_names: list,
211
+ lin_obs_param_map: list,
212
+ ) -> list[str]:
213
+ """Return source lines that reconstruct spec_to_linear before m.prepModel().
214
+
215
+ lin_obs_param_map is a list-of-lists: [obs_idx][spec_idx] → dict or None.
216
+ Active cells: dict with keys 'name', 'min', 'max'.
217
+ Dark cells: None.
218
+ """
219
+ n_lin_obs = len(lin_obs_col_names)
220
+ lines = [
221
+ "",
222
+ "# UV-vis / fluorescence: reconstruct specToLinear before prepModel()",
223
+ f"_lin_obs_col_names = {lin_obs_col_names!r}",
224
+ f"_lin_obs_param_map = {lin_obs_param_map!r}",
225
+ f"_n_lin_obs = {n_lin_obs}",
226
+ "spec_to_linear = np.empty((len(species_names), _n_lin_obs), dtype=object)",
227
+ "for _oi, _prow in enumerate(_lin_obs_param_map):",
228
+ " for _si, _pcell in enumerate(_prow):",
229
+ " if _pcell is None:",
230
+ " spec_to_linear[_si, _oi] = 0.0",
231
+ " else:",
232
+ " spec_to_linear[_si, _oi] = lmfit.Parameter(",
233
+ " _pcell['name'], value=1.0, min=_pcell['min'], max=_pcell['max'], vary=True",
234
+ " )",
235
+ "m.specToLinear = spec_to_linear",
236
+ ]
237
+ return lines
238
+
239
+
240
+ def _build_analytical_lin_obs_lines(lin_obs_param_map: list) -> list[str]:
241
+ """Return setup lines for the analytical path's linear_obs_param_map.
242
+
243
+ Extracts the param name (or None) from each cell so
244
+ fitfun_analytical_fast_exchange can call calc_analytical_linear_observables.
245
+ """
246
+ # Serialise as [[name_or_none, ...], ...]
247
+ name_map = [
248
+ [cell["name"] if cell is not None else None for cell in row]
249
+ for row in lin_obs_param_map
250
+ ]
251
+ return [
252
+ f"m.analytical_linear_obs_param_map = {name_map!r}",
253
+ ]
254
+
255
+
256
+ def export_fit_notebook(
257
+ fit: "FitResult",
258
+ model: "Model",
259
+ expt_data: "ExptData",
260
+ raw_data: "RawData",
261
+ obs_type_names: "list[str] | None",
262
+ lin_obs_col_names: "list[str] | None" = None,
263
+ lin_obs_param_map: "list[list] | None" = None,
264
+ ) -> tuple[dict, pd.DataFrame]:
265
+ """Export a FitResult as an nbformat-4 notebook dict plus a CSV DataFrame.
266
+
267
+ The notebook is designed to *re-run* the fit from scratch. Original
268
+ fitted values are embedded as comments so the user can verify the result.
269
+ The companion CSV (second return value) should be saved as ``data.csv``
270
+ next to the notebook.
271
+ """
272
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
273
+
274
+ n_comp = int(np.shape(expt_data.col_to_comp)[0])
275
+ col_to_comp_list = np.array(expt_data.col_to_comp, dtype=float).tolist()
276
+
277
+ integ_to_spec = expt_data.integ_to_spec
278
+ if isinstance(integ_to_spec, np.ndarray) and integ_to_spec.ndim == 2 and integ_to_spec.size > 0:
279
+ integ_to_spec_list: list | None = integ_to_spec.tolist()
280
+ else:
281
+ integ_to_spec_list = None
282
+
283
+ delta_to_spec = expt_data.delta_to_spec
284
+ has_delta = (
285
+ isinstance(delta_to_spec, np.ndarray)
286
+ and delta_to_spec.ndim == 2
287
+ and delta_to_spec.size > 0
288
+ )
289
+
290
+ obs_list = obs_type_names
291
+ # ------------------------------------------------------------------
292
+ # Cell 0 — Summary markdown
293
+ # ------------------------------------------------------------------
294
+ param_rows = []
295
+ for pname, pinfo in (fit.params or {}).items():
296
+ if isinstance(pinfo, dict):
297
+ val = pinfo.get("value", "?")
298
+ stderr = pinfo.get("stderr", None)
299
+ vary = pinfo.get("vary", False)
300
+ if vary and stderr is not None:
301
+ param_rows.append(f"| {pname} | {val:.6g} ± {stderr:.3g} | fitted |")
302
+ else:
303
+ param_rows.append(f"| {pname} | {val:.6g} | fixed |")
304
+
305
+ aic_str = f"{fit.aic:.4g}" if fit.aic is not None else "n/a"
306
+ bic_str = f"{fit.bic:.4g}" if fit.bic is not None else "n/a"
307
+ chisqr_str = f"{fit.chisqr:.4g}" if fit.chisqr is not None else "n/a"
308
+ method_str = fit.fit_method or "least_squares"
309
+
310
+ md_title = "\n".join([
311
+ f"# Fit: {fit.name or 'Unnamed'}",
312
+ "",
313
+ f"**Model:** {model.name} ",
314
+ f"**Equation:** `{model.eq_str}` ",
315
+ f"**Generated:** {timestamp} ",
316
+ "",
317
+ "## Original fit results",
318
+ "",
319
+ f"| Metric | Value |",
320
+ f"|--------|-------|",
321
+ f"| AIC | {aic_str} |",
322
+ f"| BIC | {bic_str} |",
323
+ f"| χ² | {chisqr_str} |",
324
+ f"| Method | {method_str} |",
325
+ f"| Success | {fit.success} |",
326
+ "",
327
+ "| Parameter | Value | Status |",
328
+ "|-----------|-------|--------|",
329
+ *param_rows,
330
+ *(
331
+ [f"", f"**Termination:** {fit.termination_message}"]
332
+ if fit.termination_message
333
+ else []
334
+ ),
335
+ ])
336
+
337
+ # ------------------------------------------------------------------
338
+ # Cell 1 — Imports
339
+ # ------------------------------------------------------------------
340
+ imports = (
341
+ "import numpy as np\n"
342
+ "import pandas as pd\n"
343
+ "import matplotlib.pyplot as plt\n"
344
+ "import lmfit\n"
345
+ "import bindtools.binding as bd\n"
346
+ "from IPython.display import HTML, display"
347
+ )
348
+
349
+ # ------------------------------------------------------------------
350
+ # Cell 2 — Model definition
351
+ # ------------------------------------------------------------------
352
+ model_def = "\n".join([
353
+ "# Model definition",
354
+ f"eq_mat = np.array({model.eq_mat.tolist()!r}, dtype=float)",
355
+ f"component_names = {model.component_names!r}",
356
+ f"species_names = {model.species!r}",
357
+ f"obs_list = {obs_list!r}",
358
+ ])
359
+
360
+ # ------------------------------------------------------------------
361
+ # Cell 3 — Load data
362
+ # ------------------------------------------------------------------
363
+ stem = safe_filename(fit.name or "fit")
364
+ # Capture the column order used by the GUI (selected + reordered via column_mapping).
365
+ # Embedding this lets the notebook reconstruct the same data slice from the raw CSV.
366
+ sorted_cols = expt_data.sorted_data.columns.tolist()
367
+ load_data = "\n".join([
368
+ "# Load experimental data — keep the companion CSV alongside this notebook.",
369
+ "# The column list below reproduces the selection and ordering used in BindTools.",
370
+ f"data = pd.read_csv('{stem}_data.csv')",
371
+ f"data_cols = {sorted_cols!r}",
372
+ "data = data[data_cols]",
373
+ "raw = data.to_numpy(dtype=float)",
374
+ ])
375
+
376
+ # ------------------------------------------------------------------
377
+ # Cell 4 — Construct binding model (mirrors reference notebook pattern)
378
+ # ------------------------------------------------------------------
379
+ col_to_comp_repr = repr(col_to_comp_list)
380
+ if integ_to_spec_list is not None:
381
+ spec_to_integ_line = f"spec_to_integ = np.array({integ_to_spec_list!r}, dtype=float)"
382
+ spec_to_integ_arg = "spec_to_integ"
383
+ else:
384
+ spec_to_integ_line = "spec_to_integ = None"
385
+ spec_to_integ_arg = "None"
386
+
387
+ if has_delta:
388
+ spec_to_dd_line = f"spec_to_dd = np.array({delta_to_spec.T.tolist()!r}, dtype=object)"
389
+ spec_to_dd_arg = "spec_to_dd"
390
+ else:
391
+ spec_to_dd_line = "spec_to_dd = None"
392
+ spec_to_dd_arg = "None"
393
+
394
+ is_analytical = bool(getattr(fit, "analytical_fast_exchange", False))
395
+ analytical_topology = getattr(fit, "analytical_topology", None)
396
+ analytical_obs_columns = list(getattr(fit, "analytical_obs_columns", []))
397
+ analytical_obs_components = list(getattr(fit, "analytical_obs_components", []))
398
+ analytical_complex_indices = list(getattr(fit, "analytical_complex_indices", []))
399
+
400
+ has_lin_obs = bool(lin_obs_col_names and lin_obs_param_map)
401
+
402
+ if is_analytical:
403
+ analytical_setup_lines: list[str] = [
404
+ "",
405
+ "# Analytical fast-exchange backend — must be configured before prepModel()",
406
+ "m.analytical_fast_exchange = True",
407
+ f"m.analytical_topology = {analytical_topology!r}",
408
+ f"m.analytical_obs_columns = {analytical_obs_columns!r}",
409
+ f"m.analytical_obs_components = {analytical_obs_components!r}",
410
+ f"m.analytical_complex_indices = {analytical_complex_indices!r}",
411
+ ]
412
+ if has_lin_obs:
413
+ analytical_setup_lines += _build_analytical_lin_obs_lines(lin_obs_param_map) # type: ignore[arg-type]
414
+ else:
415
+ analytical_setup_lines = []
416
+
417
+ lin_obs_lines = (
418
+ _build_lin_obs_cell4_lines(lin_obs_col_names, lin_obs_param_map) # type: ignore[arg-type]
419
+ if has_lin_obs
420
+ else []
421
+ )
422
+
423
+ construct_model = "\n".join([
424
+ "# Matrices mapping data columns → components and species → observables",
425
+ f"col_to_comp = np.array({col_to_comp_repr}, dtype=float)",
426
+ spec_to_integ_line,
427
+ spec_to_dd_line,
428
+ "",
429
+ "# Construct binding model",
430
+ "m = bd.bindingModel(",
431
+ " eq_mat,",
432
+ " component_names,",
433
+ " species_names,",
434
+ f" {spec_to_integ_arg},",
435
+ f" {spec_to_dd_arg},",
436
+ " col_to_comp,",
437
+ " obs_list,",
438
+ " raw,",
439
+ ")",
440
+ "# Compute component concentrations from the data matrix",
441
+ "m.compConcs = np.dot(m.rawData[:, :m.nComp], m.colToComp[:, :m.nComp].T)",
442
+ *lin_obs_lines,
443
+ *analytical_setup_lines,
444
+ "m.prepModel()",
445
+ ])
446
+
447
+ # ------------------------------------------------------------------
448
+ # Cell 5 — Set parameters (initial_value for re-running; fitted in comments)
449
+ # ------------------------------------------------------------------
450
+ param_set_lines = [
451
+ "# Set parameter bounds and initial guesses.",
452
+ "# Original fitted values are shown in comments — use them to verify your re-run.",
453
+ ]
454
+ for pname, pinfo in (fit.params or {}).items():
455
+ if not isinstance(pinfo, dict):
456
+ continue
457
+ vary = pinfo.get("vary", False)
458
+ min_val = pinfo.get("min", 0)
459
+ max_val = pinfo.get("max", 14)
460
+ init_val = pinfo.get("initial_value", pinfo.get("value", 0.0))
461
+ fitted_val = pinfo.get("value", init_val)
462
+ stderr = pinfo.get("stderr", None)
463
+
464
+ if vary and stderr is not None:
465
+ comment = f" # fitted: {fitted_val:.6g} ± {stderr:.3g}"
466
+ elif vary:
467
+ comment = f" # fitted: {fitted_val:.6g}"
468
+ else:
469
+ comment = " # fixed"
470
+
471
+ param_set_lines.append(
472
+ f"m.params[{pname!r}].set(value={float(init_val)!r}, "
473
+ f"vary={vary!r}, min={float(min_val)!r}, max={float(max_val)!r})"
474
+ f"{comment}"
475
+ )
476
+
477
+ set_params_code = "\n".join(param_set_lines)
478
+
479
+ # ------------------------------------------------------------------
480
+ # Cell 6 — Run the fit
481
+ # ------------------------------------------------------------------
482
+ run_fit = "\n".join([
483
+ "# Run the fit",
484
+ f"skip_col = {n_comp} # number of component-concentration columns",
485
+ f"m.runModel(skip_col=skip_col, method={method_str!r})",
486
+ "display(HTML(m.miniResult._repr_html_()))",
487
+ ])
488
+
489
+ # ------------------------------------------------------------------
490
+ # Cell 7 — Plot residuals
491
+ # ------------------------------------------------------------------
492
+ plot_resid = (
493
+ "# Residual plot — adjust plotMask to select which observables to show\n"
494
+ "n_obs = len(obs_list)\n"
495
+ "bd.makeFitResidPlot(\n"
496
+ " m,\n"
497
+ " xindex=1,\n"
498
+ " plotMask=tuple(range(n_obs)),\n"
499
+ " skip_end=None,\n"
500
+ " xvals=m.rawData[:, 1] / m.rawData[:, 0],\n"
501
+ " xlabel=r'[' + component_names[1] + r']$_{\\mathrm{tot}}$ / [' + component_names[0] + r']$_{\\mathrm{tot}}$',\n"
502
+ " ylabel='Observable',\n"
503
+ " labels=obs_list,\n"
504
+ ")"
505
+ )
506
+
507
+ # ------------------------------------------------------------------
508
+ # Cell 8 — MCMC section (markdown header)
509
+ # ------------------------------------------------------------------
510
+ md_mcmc = "\n".join([
511
+ "## MCMC sampling",
512
+ "",
513
+ "Use the cell below to explore the posterior distribution of the fit parameters with `emcee`.",
514
+ "Uncomment and adjust as needed.",
515
+ ])
516
+
517
+ # ------------------------------------------------------------------
518
+ # Cell 9 — MCMC code (commented out)
519
+ # ------------------------------------------------------------------
520
+ mcmc_code = "\n".join([
521
+ "# # bd.MCMC requires the fit to have been run first (m.runModel above).",
522
+ "# # obs_types maps each observable column to its noise model.",
523
+ "# # Built-in names: 'NMRInteg', 'concMeas', 'deltaH', 'deltaF'.",
524
+ "# # Use a custom string for anything else.",
525
+ "# obs_types = [bd.ObsType(name) for name in obs_list]",
526
+ "#",
527
+ "# # Number of walkers and steps — increase for production runs.",
528
+ "# n_walkers = 50",
529
+ "# n_steps = 2000",
530
+ "#",
531
+ "# mc = bd.MCMC(m, obs_types, walkers=n_walkers, samples=n_steps)",
532
+ "# mc.run()",
533
+ "#",
534
+ "# # Inspect autocorrelation time to check convergence",
535
+ "# mc.get_tau()",
536
+ "#",
537
+ "# # Walker trace plot",
538
+ "# mc.plot_chain()",
539
+ "# plt.show()",
540
+ "#",
541
+ "# # Corner plot (burn-in is estimated automatically from autocorr time)",
542
+ "# mc.plot_corner()",
543
+ "# plt.show()",
544
+ ])
545
+
546
+ cells = [
547
+ _md_cell(md_title),
548
+ _code_cell(imports),
549
+ _code_cell(model_def),
550
+ _code_cell(load_data),
551
+ _code_cell(construct_model),
552
+ _code_cell(set_params_code),
553
+ _code_cell(run_fit),
554
+ _code_cell(plot_resid),
555
+ _md_cell(md_mcmc),
556
+ _code_cell(mcmc_code),
557
+ ]
558
+
559
+ csv_df = raw_data.data.copy()
560
+ return _notebook(cells), csv_df
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # MCMC notebook
565
+ # ---------------------------------------------------------------------------
566
+
567
+ def export_mcmc_notebook(
568
+ mcmc: "MCMCSim | None",
569
+ fit: "FitResult",
570
+ model: "Model",
571
+ expt_data: "ExptData",
572
+ raw_data: "RawData",
573
+ obs_type_names: list[str],
574
+ include_chains: bool,
575
+ lin_obs_col_names: "list[str] | None" = None,
576
+ lin_obs_param_map: "list[list] | None" = None,
577
+ ) -> tuple[dict, pd.DataFrame, bytes | None]:
578
+ """Export a FitResult + MCMC run as an nbformat-4 notebook dict, CSV DataFrame,
579
+ and optionally HDF5 bytes (chains file).
580
+
581
+ Parameters
582
+ ----------
583
+ mcmc : MCMCSim or None
584
+ The MCMC run. When *None*, a code-only notebook is produced with
585
+ default walker / step counts.
586
+ fit, model, expt_data, raw_data : linked domain objects
587
+ obs_type_names : list[str]
588
+ Noise-model name for each observable, e.g. ``['deltaF', 'deltaF']``.
589
+ include_chains : bool
590
+ When True the notebook loads chains from a companion HDF5 file and the
591
+ function also returns the raw HDF5 bytes. Requires
592
+ ``mcmc.mc.sampler`` to be non-None.
593
+
594
+ Returns
595
+ -------
596
+ notebook : dict nbformat-4 compatible dict.
597
+ csv_df : pd.DataFrame Raw data for the companion CSV.
598
+ h5_bytes : bytes or None
599
+ """
600
+ # Build the first 8 cells (title … residual plot) from the fit notebook.
601
+ # The fit notebook has exactly 10 cells; we discard the two commented-out
602
+ # MCMC template cells at the end and replace them with live code.
603
+ base_nb, csv_df = export_fit_notebook(
604
+ fit, model, expt_data, raw_data,
605
+ obs_type_names=obs_type_names,
606
+ lin_obs_col_names=lin_obs_col_names,
607
+ lin_obs_param_map=lin_obs_param_map,
608
+ )
609
+ cells = base_nb["cells"][:8]
610
+
611
+ # MCMC parameters — use MCMCSim values when available, fall back to defaults
612
+ n_walkers = int(mcmc.nwalkers) if mcmc is not None else 50
613
+ n_steps = int(mcmc.nsteps_target) if mcmc is not None else 2000
614
+ thin = int(mcmc.thin) if mcmc is not None else 1
615
+ burn = int(mcmc.burn) if mcmc is not None else 200
616
+
617
+ stem = safe_filename(fit.name or "fit")
618
+
619
+ # ------------------------------------------------------------------
620
+ # Cell 8 — MCMC section header (markdown)
621
+ # ------------------------------------------------------------------
622
+ if include_chains:
623
+ md_mcmc = "\n".join([
624
+ "## MCMC sampling — loading saved chains",
625
+ "",
626
+ f"Chains are loaded from `{stem}_chains.hdf` (included in the zip).",
627
+ "Adjust `burnin` as needed.",
628
+ ])
629
+ else:
630
+ md_mcmc = "\n".join([
631
+ "## MCMC sampling",
632
+ "",
633
+ "Run the cell below to explore the posterior distribution via MCMC.",
634
+ f"Walkers: {n_walkers}, steps: {n_steps}, thin: {thin}.",
635
+ ])
636
+
637
+ # ------------------------------------------------------------------
638
+ # Cell 9 — MCMC code
639
+ # ------------------------------------------------------------------
640
+ if include_chains:
641
+ mcmc_code = "\n".join([
642
+ "import h5py",
643
+ "import corner as corner",
644
+ "",
645
+ f"with h5py.File('{stem}_chains.hdf', 'r') as f:",
646
+ " chain = f['mcmc/chain'][:] # (nsteps, nwalkers, ndim)",
647
+ " log_prob = f['mcmc/log_prob'][:] # (nsteps, nwalkers)",
648
+ "",
649
+ "param_labels = [p for p in m.params if m.params[p].vary]",
650
+ "ndim = chain.shape[2]",
651
+ "",
652
+ "# Walker trace plot",
653
+ "n_rows = ndim + 1",
654
+ "fig, axes = plt.subplots(n_rows, 1, figsize=(12, 3 + n_rows * 1.7), sharex=True)",
655
+ "for i, label in enumerate(param_labels):",
656
+ " axes[i].plot(chain[:, :, i], alpha=0.3, lw=0.5, color='k')",
657
+ " axes[i].set_ylabel(label)",
658
+ "axes[-1].plot(log_prob, alpha=0.3, lw=0.5, color='k')",
659
+ "axes[-1].set_ylabel('log prob')",
660
+ "axes[-1].set_xlabel('step')",
661
+ "fig.tight_layout()",
662
+ "plt.show()",
663
+ "",
664
+ f"# Corner plot — burnin from original run ({burn}); adjust as needed",
665
+ f"burnin = {burn}",
666
+ "flat_chain = chain[burnin:].reshape(-1, ndim)",
667
+ "if len(param_labels) < ndim:",
668
+ " param_labels += [f'sigma_{i}' for i in range(len(param_labels), ndim)]",
669
+ "corner.corner(flat_chain, labels=param_labels)",
670
+ "plt.show()",
671
+ ])
672
+ else:
673
+ mcmc_code = "\n".join([
674
+ f"obs_type_names = {obs_type_names!r}",
675
+ "obs_types = [bd.ObsType(name) for name in obs_type_names]",
676
+ "",
677
+ f"n_walkers = {n_walkers}",
678
+ f"n_steps = {n_steps}",
679
+ f"thin = {thin}",
680
+ "",
681
+ "mc = bd.MCMC(m, obs_types, walkers=n_walkers, samples=n_steps)",
682
+ "mc.run(thin=thin)",
683
+ "",
684
+ "# Inspect autocorrelation time to check convergence",
685
+ "mc.get_tau()",
686
+ "",
687
+ "# Walker trace plot",
688
+ "mc.plot_chain()",
689
+ "plt.show()",
690
+ "",
691
+ "# Corner plot (burn-in estimated automatically from autocorr time)",
692
+ "mc.plot_corner()",
693
+ "plt.show()",
694
+ "",
695
+ f"# mc.save('{stem}_chains.hdf') # uncomment to save chains",
696
+ ])
697
+
698
+ cells.append(_md_cell(md_mcmc))
699
+ cells.append(_code_cell(mcmc_code))
700
+
701
+ # ------------------------------------------------------------------
702
+ # HDF5 bytes (include_chains=True only)
703
+ # ------------------------------------------------------------------
704
+ h5_bytes: bytes | None = None
705
+ if (
706
+ include_chains
707
+ and mcmc is not None
708
+ and getattr(mcmc, "mc", None) is not None
709
+ and getattr(mcmc.mc, "sampler", None) is not None
710
+ ):
711
+ import h5py # lazy import — h5py only needed at export time
712
+
713
+ backend = mcmc.mc.sampler.backend
714
+ with h5py.File("mcmc_export.h5", "w", driver="core", backing_store=False) as hf:
715
+ g = hf.create_group("mcmc")
716
+ g.create_dataset("chain", data=backend.chain)
717
+ g.create_dataset("accepted", data=backend.accepted)
718
+ g.create_dataset("log_prob", data=backend.log_prob)
719
+ has_blobs = backend.blobs is not None
720
+ g.attrs["has_blobs"] = has_blobs
721
+ if has_blobs:
722
+ g.create_dataset("blobs", data=backend.blobs)
723
+ g.attrs["iteration"] = backend.iteration
724
+ hf.flush()
725
+ h5_bytes = bytes(hf.id.get_file_image())
726
+
727
+ return _notebook(cells), csv_df, h5_bytes