flimanalyzer 0.6.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 (110) hide show
  1. flim/__init__.py +2 -0
  2. flim/_version.py +5 -0
  3. flim/analysis/__init__.py +1 -0
  4. flim/analysis/ml/__init__.py +0 -0
  5. flim/analysis/ml/autoencoder.py +188 -0
  6. flim/analyzerapp.py +131 -0
  7. flim/batch.py +235 -0
  8. flim/configs/__init__.py +77 -0
  9. flim/configs/analysis.py +554 -0
  10. flim/configs/data.py +278 -0
  11. flim/configs/plot.py +543 -0
  12. flim/configs/workflow.py +204 -0
  13. flim/core/__init__.py +0 -0
  14. flim/core/calculator.py +0 -0
  15. flim/core/config.py +305 -0
  16. flim/core/execution.py +110 -0
  17. flim/core/fields.py +226 -0
  18. flim/core/filter.py +179 -0
  19. flim/core/graph.py +111 -0
  20. flim/core/headers.py +72 -0
  21. flim/core/importer.py +269 -0
  22. flim/core/parser.py +305 -0
  23. flim/core/paths.py +66 -0
  24. flim/core/prefect_server.py +152 -0
  25. flim/core/preprocessor.py +287 -0
  26. flim/core/registry.py +108 -0
  27. flim/core/runner.py +703 -0
  28. flim/core/schema.py +114 -0
  29. flim/core/settings.py +419 -0
  30. flim/core/tool.py +284 -0
  31. flim/core/tools.py +76 -0
  32. flim/core/types.py +113 -0
  33. flim/core/validation.py +272 -0
  34. flim/core/workflow.py +148 -0
  35. flim/gui/__init__.py +12 -0
  36. flim/gui/app.py +953 -0
  37. flim/gui/configdialog.py +495 -0
  38. flim/gui/datapanel.py +841 -0
  39. flim/gui/delimpanel.py +56 -0
  40. flim/gui/dialogs/__init__.py +473 -0
  41. flim/gui/dialogs/autoencoder.py +89 -0
  42. flim/gui/dialogs/categoryorder.py +130 -0
  43. flim/gui/dialogs/filter.py +86 -0
  44. flim/gui/dialogs/unpivot.py +72 -0
  45. flim/gui/dicttablepanel.py +178 -0
  46. flim/gui/editors.py +1578 -0
  47. flim/gui/events.py +162 -0
  48. flim/gui/icons.py +16 -0
  49. flim/gui/importdlg.py +391 -0
  50. flim/gui/listcontrol.py +472 -0
  51. flim/gui/macos.py +77 -0
  52. flim/gui/plotframe.py +73 -0
  53. flim/gui/progressdlg.py +290 -0
  54. flim/resources/aeaugment.png +0 -0
  55. flim/resources/aefeatureworkflow.png +0 -0
  56. flim/resources/aerun.png +0 -0
  57. flim/resources/aetrain.png +0 -0
  58. flim/resources/aetuneworkflow.png +0 -0
  59. flim/resources/barplot.png +0 -0
  60. flim/resources/boxplot.png +0 -0
  61. flim/resources/categorize.png +0 -0
  62. flim/resources/concatenate.png +0 -0
  63. flim/resources/fileopen.png +0 -0
  64. flim/resources/filter.png +0 -0
  65. flim/resources/flimanalyzer.ico +0 -0
  66. flim/resources/flimanalyzer.png +0 -0
  67. flim/resources/flirrplot.png +0 -0
  68. flim/resources/flirrworkflow.png +0 -0
  69. flim/resources/gmm.png +0 -0
  70. flim/resources/heatmap.png +0 -0
  71. flim/resources/histogram.png +0 -0
  72. flim/resources/import.png +0 -0
  73. flim/resources/kde.png +0 -0
  74. flim/resources/kmeans.png +0 -0
  75. flim/resources/ks.png +0 -0
  76. flim/resources/lineplot.png +0 -0
  77. flim/resources/merge.png +0 -0
  78. flim/resources/order.png +0 -0
  79. flim/resources/pairplot.png +0 -0
  80. flim/resources/pca.png +0 -0
  81. flim/resources/pivot.png +0 -0
  82. flim/resources/randomforest.png +0 -0
  83. flim/resources/relchange.png +0 -0
  84. flim/resources/scatter.png +0 -0
  85. flim/resources/seriesanalysis.png +0 -0
  86. flim/resources/sort.png +0 -0
  87. flim/resources/split.png +0 -0
  88. flim/resources/summary.png +0 -0
  89. flim/resources/swarmplot.png +0 -0
  90. flim/resources/unpivot.png +0 -0
  91. flim/resources/violinplot.png +0 -0
  92. flim/resources/workflow.png +0 -0
  93. flim/results.py +70 -0
  94. flim/tools/__init__.py +7 -0
  95. flim/tools/autoencoder.py +662 -0
  96. flim/tools/common.py +62 -0
  97. flim/tools/data.py +301 -0
  98. flim/tools/ml.py +311 -0
  99. flim/tools/plot.py +1439 -0
  100. flim/tools/statistics.py +348 -0
  101. flim/utils.py +43 -0
  102. flim/workflow/__init__.py +3 -0
  103. flim/workflow/aefeature.py +145 -0
  104. flim/workflow/aetune.py +163 -0
  105. flim/workflow/basicflow.py +99 -0
  106. flimanalyzer-0.6.0.dist-info/METADATA +262 -0
  107. flimanalyzer-0.6.0.dist-info/RECORD +110 -0
  108. flimanalyzer-0.6.0.dist-info/WHEEL +4 -0
  109. flimanalyzer-0.6.0.dist-info/entry_points.txt +5 -0
  110. flimanalyzer-0.6.0.dist-info/licenses/LICENSE +19 -0
@@ -0,0 +1,554 @@
1
+ """Configs of the Analysis tools: statistics, machine learning and autoencoders."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, Literal, Optional, Union
5
+
6
+ from pydantic import AfterValidator, Field, field_validator, model_validator
7
+
8
+ from flim.core.config import (
9
+ BaseConfig,
10
+ CategoryColumn,
11
+ Device,
12
+ FeatureColumn,
13
+ Probability,
14
+ Resources,
15
+ TaskConfig,
16
+ ValueOf,
17
+ )
18
+
19
+ def _not_zero(threads: int) -> int:
20
+ if threads == 0:
21
+ raise ValueError("0 threads: use -1 (all cores) or a positive number")
22
+ return threads
23
+
24
+
25
+ # -1: all cores allocated to the task
26
+ Threads = Annotated[int, Field(ge=-1), AfterValidator(_not_zero)]
27
+
28
+
29
+ class AnalysisConfig(TaskConfig):
30
+ pass
31
+
32
+
33
+ # --- statistics ---
34
+
35
+
36
+ class StatisticsConfig(AnalysisConfig):
37
+ pass
38
+
39
+
40
+ Aggregate = Literal[
41
+ "count",
42
+ "min",
43
+ "max",
44
+ "mean",
45
+ "std",
46
+ "sem",
47
+ "median",
48
+ "percentile(25)",
49
+ "percentile(75)",
50
+ "sum",
51
+ ]
52
+ AGGREGATES: tuple[str, ...] = Aggregate.__args__
53
+
54
+
55
+ class SummaryConfig(StatisticsConfig):
56
+ legacy_keys = {
57
+ "aggs": "aggs",
58
+ "single_table": "singledf",
59
+ }
60
+ # flat headers ("FLIRR\nmean") always: other tools name columns by text
61
+ legacy_dropped = frozenset({"flattenindex"})
62
+
63
+ aggs: list[Aggregate] = Field(
64
+ default_factory=lambda: list(AGGREGATES), title="Statistics"
65
+ )
66
+ single_table: bool = Field(
67
+ default=True,
68
+ title="Single table",
69
+ description="One table for all features; otherwise one table per feature.",
70
+ )
71
+
72
+
73
+ # alpha levels of the KS critical value table (flim.tools.statistics.KS_CRITICAL)
74
+ KSAlpha = Literal[0.1, 0.05, 0.025, 0.01, 0.005, 0.001]
75
+
76
+
77
+ class KSStatsConfig(StatisticsConfig):
78
+ comparison: CategoryColumn = Field(
79
+ default="Treatment",
80
+ title="Compare",
81
+ description=(
82
+ "Its values are compared pairwise, separately within each group of the"
83
+ " other grouping columns."
84
+ ),
85
+ )
86
+ alpha: KSAlpha = Field(default=0.05, title="Significance level")
87
+
88
+
89
+ class RelativeChangeConfig(StatisticsConfig):
90
+ method: Literal["mean", "median"] = Field(
91
+ default="mean", title="Reference statistic"
92
+ )
93
+ reference_group: CategoryColumn = Field(
94
+ default="Treatment",
95
+ title="Reference column",
96
+ description="The category column that holds the reference value.",
97
+ )
98
+ reference_value: Annotated[str, ValueOf("reference_group")] = Field(
99
+ default="Ctrl",
100
+ title="Reference value",
101
+ description="Features are divided by their mean or median in these rows.",
102
+ )
103
+
104
+
105
+ SeriesStatistic = Literal[
106
+ "series_min",
107
+ "series_max",
108
+ "series_range",
109
+ "series_mean",
110
+ "series_median",
111
+ "delta",
112
+ "delta_min",
113
+ "delta_max",
114
+ "delta_sum",
115
+ "delta_cum",
116
+ "delta_norm",
117
+ ]
118
+ SERIES_STATISTICS: tuple[str, ...] = SeriesStatistic.__args__
119
+
120
+
121
+ class SeriesAnalysisConfig(StatisticsConfig):
122
+ legacy_keys = {"statistics": None}
123
+
124
+ statistics: set[SeriesStatistic] = Field(
125
+ default_factory=lambda: set(SERIES_STATISTICS)
126
+ - {"series_mean", "series_median"},
127
+ title="Statistics",
128
+ )
129
+ merge_input: bool = Field(
130
+ default=False,
131
+ title="Include the input",
132
+ description="Also include the series' features in the result.",
133
+ )
134
+
135
+ @classmethod
136
+ def _from_legacy(cls, params):
137
+ # legacy: one boolean per statistic
138
+ if not any(s in params for s in SERIES_STATISTICS):
139
+ return {}
140
+ defaults = cls.model_fields["statistics"].default_factory()
141
+ return {
142
+ "statistics": {s for s in SERIES_STATISTICS if params.pop(s, s in defaults)}
143
+ }
144
+
145
+ def _to_legacy(self):
146
+ return {s: s in self.statistics for s in SERIES_STATISTICS}
147
+
148
+
149
+ # --- machine learning ---
150
+
151
+
152
+ class MLConfig(AnalysisConfig):
153
+ legacy_keys = {"random_seed": None}
154
+ hidden_fields = frozenset({"grouping"})
155
+
156
+ random_seed: Optional[int] = Field(
157
+ default=0,
158
+ title="Random seed",
159
+ description="The same results on every run; empty: different on every run.",
160
+ )
161
+
162
+
163
+ class ParallelResources(Resources):
164
+ """For tools using scikit-learn's ``n_jobs``."""
165
+
166
+ preferred_threads: Threads = Field(
167
+ default=-1,
168
+ title="Threads",
169
+ description="Threads within the task (1 or more); -1: all cores allocated to the task.",
170
+ )
171
+
172
+
173
+ class PCAConfig(MLConfig):
174
+ legacy_keys = {
175
+ **MLConfig.legacy_keys,
176
+ "keep_original": "keeporig",
177
+ "keep_standardized": "keepstd",
178
+ "explained_plot": "explainedhisto",
179
+ }
180
+
181
+ keep_original: bool = Field(
182
+ default=False,
183
+ title="Include the features",
184
+ description="Also include the original features in the result.",
185
+ )
186
+ keep_standardized: bool = Field(
187
+ default=True,
188
+ title="Include standardized features",
189
+ description="Also include the standardized features in the result.",
190
+ )
191
+ explained_plot: bool = Field(
192
+ default=False,
193
+ title="Explained variance plot",
194
+ description="A bar plot of the variance each component explains.",
195
+ )
196
+ n_components: Union[Annotated[int, Field(ge=1)], Probability, None] = Field(
197
+ default=0.999,
198
+ title="Components",
199
+ description=(
200
+ "A number of components, or the fraction of the variance to explain"
201
+ " (0 to 1); empty: all components."
202
+ ),
203
+ )
204
+
205
+
206
+ class KMeansConfig(MLConfig):
207
+ n_clusters: int = Field(default=2, ge=1, title="Clusters")
208
+ cluster_prefix: str = Field(
209
+ default="Cluster",
210
+ title="Cluster column",
211
+ description='Name of the new column; clusters are named "<name> 1", ...',
212
+ )
213
+ init: Literal["k-means++", "random"] = Field(
214
+ default="k-means++", title="Initialization"
215
+ )
216
+ algorithm: Literal["lloyd", "elkan"] = Field(default="lloyd", title="Algorithm")
217
+ n_init: int = Field(
218
+ default=4,
219
+ ge=1,
220
+ title="Runs",
221
+ description="Runs with different starting centers; the best is kept.",
222
+ )
223
+ max_iter: int = Field(default=300, ge=1, title="Max iterations")
224
+ tolerance: float = Field(default=1e-4, gt=0, title="Tolerance")
225
+ resources: ParallelResources = Field(
226
+ default_factory=ParallelResources, title="Resources"
227
+ )
228
+
229
+ @classmethod
230
+ def _from_legacy(cls, params):
231
+ # names removed in scikit-learn 1.3; both meant "lloyd"
232
+ if params.get("algorithm") in ("auto", "full"):
233
+ params["algorithm"] = "lloyd"
234
+ return {}
235
+
236
+
237
+ class GMMConfig(MLConfig):
238
+ legacy_keys = {**MLConfig.legacy_keys, "covariance": "covar_type"}
239
+
240
+ method: Literal["EM", "Variational Bayesian"] = Field(default="EM", title="Method")
241
+ n_components: int = Field(
242
+ default=2,
243
+ ge=1,
244
+ title="Components",
245
+ description="Components of the model; BIC and AIC are reported for 1 to n.",
246
+ )
247
+ covariance: Literal["full", "tied", "diag", "spherical"] = Field(
248
+ default="full", title="Covariance"
249
+ )
250
+
251
+
252
+ class RandomForestConfig(MLConfig):
253
+ legacy_keys = {
254
+ **MLConfig.legacy_keys,
255
+ "target": None,
256
+ "importance_plot": "importancehisto",
257
+ }
258
+
259
+ # the grouping columns are left out of the features
260
+ hidden_fields = frozenset()
261
+
262
+ target: Optional[CategoryColumn] = Field(
263
+ default=None, title="Predict", description="The category column to predict."
264
+ )
265
+ n_estimators: int = Field(default=100, ge=1, title="Trees")
266
+ test_size: Probability = Field(
267
+ default=0.3,
268
+ title="Test fraction",
269
+ description="Fraction of the rows kept for testing the accuracy.",
270
+ )
271
+ importance_plot: bool = Field(default=True, title="Importance plot")
272
+ resources: ParallelResources = Field(
273
+ default_factory=ParallelResources, title="Resources"
274
+ )
275
+
276
+ @classmethod
277
+ def _from_legacy(cls, params):
278
+ return {"target": params.pop("classifier", "") or None}
279
+
280
+ def _to_legacy(self):
281
+ return {"classifier": self.target or ""}
282
+
283
+
284
+ class CategoryRule(BaseConfig):
285
+ """Rows whose ``feature`` lies in (min, max] get the category ``value``."""
286
+
287
+ value: str = Field(title="Value")
288
+ feature: FeatureColumn = Field(title="Feature")
289
+ min: float = Field(title="Min")
290
+ max: float = Field(title="Max")
291
+
292
+ @model_validator(mode="after")
293
+ def _check_range(self):
294
+ if self.min >= self.max:
295
+ raise ValueError(f"'{self.value}': min ({self.min}) >= max ({self.max})")
296
+ return self
297
+
298
+
299
+ def _default_rules():
300
+ return [
301
+ CategoryRule(value="negative", feature="FLIRR", min=-100000.0, max=-0.5),
302
+ CategoryRule(value="neutral", feature="FLIRR", min=-0.5, max=0.5),
303
+ CategoryRule(value="positive", feature="FLIRR", min=0.5, max=100000),
304
+ ]
305
+
306
+
307
+ class CategorizerConfig(AnalysisConfig):
308
+ legacy_keys = {"rules": None}
309
+ hidden_fields = frozenset({"grouping"})
310
+
311
+ name: str = Field(
312
+ default="New Cat",
313
+ title="New column",
314
+ description="Name of the new category column.",
315
+ )
316
+ rules: list[CategoryRule] = Field(
317
+ default_factory=_default_rules,
318
+ title="Rules",
319
+ description=(
320
+ "Rows whose feature lies in a rule's range (min < value <= max) get the"
321
+ " rule's value; the first matching rule wins."
322
+ ),
323
+ )
324
+ default: str = Field(
325
+ default="unassigned",
326
+ title="Default value",
327
+ description="The value of rows that no rule matches.",
328
+ )
329
+ merge_input: bool = Field(
330
+ default=True,
331
+ title="Include the input",
332
+ description="Keep all input columns; otherwise only categories and features.",
333
+ )
334
+
335
+ @classmethod
336
+ def allowed_values(cls, values):
337
+ # the features kept: only without the input's columns (dialogs hide them)
338
+ if values.get("merge_input", True):
339
+ return {"features": (values.get("features"),)}
340
+ return {}
341
+
342
+ @field_validator("rules")
343
+ @classmethod
344
+ def _check_overlaps(cls, rules):
345
+ for i, a in enumerate(rules):
346
+ for b in rules[i + 1 :]:
347
+ if a.feature == b.feature and a.min < b.max and b.min < a.max:
348
+ raise ValueError(f"Rules '{a.value}' and '{b.value}' overlap")
349
+ return rules
350
+
351
+ @classmethod
352
+ def _from_legacy(cls, params):
353
+ if "categories" not in params:
354
+ return {}
355
+ return {
356
+ "rules": [
357
+ CategoryRule(value=c["value"], **c["criteria"])
358
+ for c in params.pop("categories")
359
+ ]
360
+ }
361
+
362
+ def _to_legacy(self):
363
+ return {
364
+ "categories": [
365
+ {
366
+ "value": r.value,
367
+ "criteria": {"feature": r.feature, "min": r.min, "max": r.max},
368
+ }
369
+ for r in self.rules
370
+ ]
371
+ }
372
+
373
+
374
+ # --- autoencoders ---
375
+
376
+ # names of the classes in flim.analysis.ml.autoencoder (not imported: needs torch)
377
+ AutoencoderName = Literal[
378
+ "AE Simulator 1-6",
379
+ "AE Simulator 1-3",
380
+ "AE Dimensionality Reduction 2-10",
381
+ "AE Dimensionality Reduction 2-5",
382
+ ]
383
+
384
+ AE_FEATURES = [
385
+ "FAD a1",
386
+ "FAD a2",
387
+ "FAD t1",
388
+ "FAD t2",
389
+ "FAD photons",
390
+ "NAD(P)H a1",
391
+ "NAD(P)H a2",
392
+ "NAD(P)H t1",
393
+ "NAD(P)H t2",
394
+ "NAD(P)H photons",
395
+ ]
396
+
397
+
398
+ DEFAULT_DEVICE = "auto" # see flim.core.tool.resolve_device
399
+
400
+
401
+ class AutoencoderResources(Resources):
402
+ preferred_device: Device = Field(
403
+ default=DEFAULT_DEVICE,
404
+ title="Device",
405
+ description="auto: a GPU if there is one where the task runs, else the CPU.",
406
+ )
407
+ preferred_threads: Threads = Field(
408
+ default=-1,
409
+ title="Threads",
410
+ description="Threads within the task (1 or more); -1: all cores allocated to the task.",
411
+ )
412
+
413
+
414
+ class AutoencoderConfig(AnalysisConfig):
415
+ resources: AutoencoderResources = Field(default_factory=AutoencoderResources)
416
+
417
+ @classmethod
418
+ def _from_legacy(cls, params):
419
+ if "device" in params:
420
+ return {"resources": {"preferred_device": params.pop("device")}}
421
+ return {}
422
+
423
+ def _to_legacy(self):
424
+ return {"device": self.resources.preferred_device}
425
+
426
+
427
+ PositiveFloat = Annotated[float, Field(gt=0)]
428
+ NonNegativeFloat = Annotated[float, Field(ge=0)]
429
+ PositiveInt = Annotated[int, Field(ge=1)]
430
+
431
+
432
+ class AETrainConfig(AutoencoderConfig):
433
+ legacy_keys = {
434
+ "epochs": "epoches",
435
+ "learning_rates": "learning_rate",
436
+ "weight_decays": "weight_decay",
437
+ "batch_sizes": "batch_size",
438
+ "model_name": "modelfile",
439
+ "architecture": "model",
440
+ "random_seed": None,
441
+ }
442
+
443
+ grouping: list[CategoryColumn] = Field(
444
+ default=["FOV", "Cell"],
445
+ title="Grouping",
446
+ description=(
447
+ "Training and validation sets are split by these groups (e.g. cells)."
448
+ ),
449
+ )
450
+ features: list[FeatureColumn] = Field(
451
+ default_factory=lambda: list(AE_FEATURES), title="Features"
452
+ )
453
+ epochs: PositiveInt = Field(default=20, title="Epochs")
454
+ # each combination of learning rate, weight decay and batch size trains a model
455
+ learning_rates: list[PositiveFloat] = Field(
456
+ default=[1e-4],
457
+ min_length=1,
458
+ title="Learning rates",
459
+ description="Comma-separated; one model per combination with the others.",
460
+ )
461
+ weight_decays: list[NonNegativeFloat] = Field(
462
+ default=[1e-8],
463
+ min_length=1,
464
+ title="Weight decays",
465
+ description="Comma-separated.",
466
+ )
467
+ batch_sizes: list[PositiveInt] = Field(
468
+ default=[128], min_length=1, title="Batch sizes", description="Comma-separated."
469
+ )
470
+ timeseries: CategoryColumn = Field(
471
+ default="Treatment",
472
+ title="Time series",
473
+ description="Not used to split the training and validation sets.",
474
+ )
475
+ model_name: str = Field(
476
+ default="AEModel",
477
+ title="Model name",
478
+ description="Base name of the saved model files.",
479
+ )
480
+ architecture: AutoencoderName = Field(
481
+ default="AE Dimensionality Reduction 2-10", title="Architecture"
482
+ )
483
+ rescale: bool = Field(
484
+ default=False,
485
+ title="Rescale",
486
+ description="Reconstructed values in the units of the input (not 0..1).",
487
+ )
488
+ create_plots: bool = Field(default=True, title="Loss plots")
489
+ train_size: Probability = Field(
490
+ default=0.7,
491
+ title="Training fraction",
492
+ description="Fraction of the groups used for training; the rest validates.",
493
+ )
494
+ checkpoint_interval: PositiveInt = Field(
495
+ default=20,
496
+ title="Checkpoint every",
497
+ description="Save the model every n epochs (and after the last).",
498
+ )
499
+ random_seed: Optional[int] = Field(
500
+ default=None,
501
+ title="Random seed",
502
+ description="The same split and starting weights on every run; empty: random.",
503
+ )
504
+
505
+ @classmethod
506
+ def _from_legacy(cls, params):
507
+ # workflows pass one value of each grid when they map over the combinations
508
+ for key in ("learning_rate", "weight_decay", "batch_size"):
509
+ if key in params and not isinstance(params[key], (list, tuple)):
510
+ params[key] = [params[key]]
511
+ return super()._from_legacy(params)
512
+
513
+
514
+ class _ModelFilesConfig(AutoencoderConfig):
515
+ legacy_keys = {"model_files": None}
516
+
517
+ model_files: list[Path] = Field(
518
+ default=[],
519
+ title="Model files",
520
+ description="Models saved by Autoencoder: Train (one result per model).",
521
+ )
522
+
523
+ @classmethod
524
+ def _from_legacy(cls, params):
525
+ values = super()._from_legacy(params)
526
+ files = params.pop("modelfile", "") # "", one path or a list
527
+ if isinstance(files, (str, Path)):
528
+ files = [files] if files else []
529
+ return {**values, "model_files": files}
530
+
531
+ def _to_legacy(self):
532
+ files = [str(f) for f in self.model_files]
533
+ return {**super()._to_legacy(), "modelfile": files if files else ""}
534
+
535
+
536
+ class AERunConfig(_ModelFilesConfig):
537
+ hidden_fields = frozenset({"grouping"})
538
+
539
+
540
+ class AEAugmentConfig(_ModelFilesConfig):
541
+ grouping: list[CategoryColumn] = Field(
542
+ default=[],
543
+ title="Grouping",
544
+ description="The last column (e.g. Cell) is renumbered in each copy.",
545
+ )
546
+ sets: PositiveInt = Field(
547
+ default=1, title="Copies", description="Simulated copies."
548
+ )
549
+ add_noise: bool = Field(
550
+ default=True,
551
+ title="Add noise",
552
+ description="Add white noise before simulating.",
553
+ )
554
+ snr_db: float = Field(default=0.0, title="Signal-to-noise (dB)")