pyKES 0.2.0__tar.gz → 0.2.2__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 (39) hide show
  1. {pykes-0.2.0 → pykes-0.2.2}/PKG-INFO +1 -1
  2. {pykes-0.2.0 → pykes-0.2.2}/pyproject.toml +1 -1
  3. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/database/data_processing.py +238 -56
  4. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/database/database_experiments.py +4 -4
  5. pykes-0.2.2/src/pyKES/streamlit_app/chunked_processing.py +280 -0
  6. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/streamlit_app/components/data_upload_component.py +152 -63
  7. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/streamlit_app/config_interface.py +5 -4
  8. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/version_information.py +53 -67
  9. {pykes-0.2.0 → pykes-0.2.2}/README.md +0 -0
  10. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/__init__.py +0 -0
  11. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/database/__init__.py +0 -0
  12. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/fitting_ODE.py +0 -0
  13. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/pathways/__init__.py +0 -0
  14. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/pathways/pathways.py +0 -0
  15. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/pathways/transform_pathways_data.py +0 -0
  16. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/plotting/__init__.py +0 -0
  17. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/plotting/lighten_colors.py +0 -0
  18. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/plotting/plotting_pathways_transformed.py +0 -0
  19. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/plotting/plotting_tools.py +0 -0
  20. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/reaction_ODE.py +0 -0
  21. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/reaction_model.py +0 -0
  22. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/streamlit_app/components/__init__.py +0 -0
  23. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/streamlit_app/components/analysis_results_component.py +0 -0
  24. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/streamlit_app/components/home_component.py +0 -0
  25. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/streamlit_app/components/results_table_component.py +0 -0
  26. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/streamlit_app/components/time_series_component.py +0 -0
  27. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/__init__.py +0 -0
  28. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/calculate_absorption.py +0 -0
  29. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/find_nearest.py +0 -0
  30. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/get_experiments.py +0 -0
  31. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/harmonize_time_series.py +0 -0
  32. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/make_json_serializable.py +0 -0
  33. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/max_rate.py +0 -0
  34. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/offset_correction.py +0 -0
  35. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/resolve_attributes.py +0 -0
  36. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/time_series_resampling.py +0 -0
  37. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/unit_handler/__init__.py +0 -0
  38. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/unit_handler/config.py +0 -0
  39. {pykes-0.2.0 → pykes-0.2.2}/src/pyKES/utilities/unit_handler/quantity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyKES
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python package for kinetic modelling of chemical reaction networks.
5
5
  Keywords: kinetics,photocatalysis,water-splitting,reaction-networks,ode
6
6
  Author: Jacob Schneidewind
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "pyKES"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Python package for kinetic modelling of chemical reaction networks."
9
9
  keywords = ["kinetics", "photocatalysis", "water-splitting", "reaction-networks", "ode"]
10
10
  authors = [
@@ -131,6 +131,155 @@ def read_in_single_experiment(file_name: str,
131
131
  }
132
132
 
133
133
 
134
+ def ensure_processed_column(database: ExperimentalDataset) -> None:
135
+ """
136
+ Make sure ``overview_df`` carries the ``Processed`` flag as text.
137
+
138
+ Idempotent, and called by both entry points that touch the flag, so
139
+ neither depends on the other having run first.
140
+
141
+ Parameters
142
+ ----------
143
+ database : ExperimentalDataset
144
+ Dataset whose ``overview_df`` is given the column if it lacks one.
145
+
146
+ Returns
147
+ -------
148
+ None : None
149
+ """
150
+
151
+ # The flag is text, not a bool: it round-trips through Excel and HDF5, and
152
+ # comes back as the strings this module compares against. Seeding it with a
153
+ # bool gives the column bool dtype, which pandas then refuses to write
154
+ # 'True' into once the first experiment succeeds.
155
+ if "Processed" not in database.overview_df.columns:
156
+ database.overview_df["Processed"] = "False"
157
+
158
+
159
+ def select_unprocessed_experiments(database: ExperimentalDataset,
160
+ overview_df_experiment_column: Optional[str] = 'Experiment') -> list:
161
+ """
162
+ List the experiments of the overview sheet that still need processing.
163
+
164
+ Parameters
165
+ ----------
166
+ database : ExperimentalDataset
167
+ Dataset whose ``overview_df`` lists the experiments. A missing
168
+ ``Processed`` column is added to it in place.
169
+ overview_df_experiment_column : str, optional
170
+ Column of ``overview_df`` holding the experiment names.
171
+
172
+ Returns
173
+ -------
174
+ list of str
175
+ Experiments that are either not flagged as processed or not yet held
176
+ by ``database.experiments``.
177
+ """
178
+
179
+ ensure_processed_column(database)
180
+
181
+ unprocessed = (
182
+ database.overview_df["Processed"].ne('True')
183
+ | ~database.overview_df[overview_df_experiment_column].isin(database.experiments))
184
+
185
+ return database.overview_df.loc[unprocessed,
186
+ overview_df_experiment_column].astype(str).tolist()
187
+
188
+
189
+ def ingest_experiment(experiment_name: str,
190
+ database: ExperimentalDataset,
191
+ metadata_retrival_function: callable,
192
+ raw_data_reading_function: callable,
193
+ processing_function: callable,
194
+ overview_df_experiment_column: Optional[str] = 'Experiment',
195
+ directory: Optional[Path] = None,
196
+ external_version: Optional[dict] = None) -> dict:
197
+ """
198
+ Process one experiment of the overview sheet and add it to the dataset.
199
+
200
+ One step of `read_in_experiments_single_threaded`, exposed separately so a
201
+ caller can drive the ingestion one experiment at a time — which is what the
202
+ Streamlit page does to keep its progress bar alive in the browser.
203
+
204
+ Parameters
205
+ ----------
206
+ experiment_name : str
207
+ Experiment to process, as named in ``overview_df``.
208
+ database : ExperimentalDataset
209
+ Dataset the experiment is added to; mutated in place on success.
210
+ metadata_retrival_function : callable
211
+ Returns the metadata dict for an experiment name.
212
+ raw_data_reading_function : callable
213
+ Reads the raw data for an experiment from ``directory``.
214
+ processing_function : callable
215
+ Turns raw data and metadata into the processed-data dict.
216
+ overview_df_experiment_column : str, optional
217
+ Column of ``overview_df`` holding the experiment names.
218
+ directory : Path, optional
219
+ Directory the raw-data files are read from.
220
+ external_version : dict, optional
221
+ Provenance of the external app, stamped onto the experiment. Already
222
+ resolved — unlike the loop functions, this is not defaulted to the
223
+ dataset's own entry.
224
+
225
+ Returns
226
+ -------
227
+ result : dict
228
+ Result of `read_in_single_experiment`.
229
+ """
230
+
231
+ ensure_processed_column(database)
232
+
233
+ result = read_in_single_experiment(
234
+ file_name = experiment_name,
235
+ database = database,
236
+ metadata_retrival_function = metadata_retrival_function,
237
+ raw_data_reading_function = raw_data_reading_function,
238
+ processing_function = processing_function,
239
+ directory = directory,
240
+ legacy_mode = False,
241
+ external_version = external_version
242
+ )
243
+
244
+ if not result['success']:
245
+ print(f"Failed to process {result['file']}: {result['error']}")
246
+ return result
247
+
248
+ database.add_experiment(result['data'])
249
+
250
+ database.overview_df.loc[
251
+ database.overview_df[overview_df_experiment_column].eq(result['data'].experiment_name),
252
+ "Processed",
253
+ ] = 'True'
254
+
255
+ return result
256
+
257
+
258
+ def finalize_processing_run(database: ExperimentalDataset,
259
+ results: list,
260
+ external_version: Optional[dict] = None) -> None:
261
+ """
262
+ Stamp the dataset version after a processing or reprocessing run.
263
+
264
+ Parameters
265
+ ----------
266
+ database : ExperimentalDataset
267
+ Dataset whose ``version`` dict is updated in place.
268
+ results : list of dict
269
+ Results of the run; the dataset is only stamped when at least one
270
+ experiment succeeded.
271
+ external_version : dict, optional
272
+ Provenance of the external app.
273
+
274
+ Returns
275
+ -------
276
+ None : None
277
+ """
278
+
279
+ if any(result['success'] for result in results):
280
+ database.stamp_version(processed=True, external_version=external_version)
281
+
282
+
134
283
  def read_in_experiments_single_threaded(database: ExperimentalDataset,
135
284
  metadata_retrival_function: callable,
136
285
  raw_data_reading_function: callable,
@@ -158,8 +307,8 @@ def read_in_experiments_single_threaded(database: ExperimentalDataset,
158
307
  directory : Path, optional
159
308
  Directory the raw-data files are read from.
160
309
  external_version : dict, optional
161
- Provenance of the external app (e.g. its git commit), stamped onto
162
- every processed experiment. Defaults to the dataset's own
310
+ Provenance of the external app (e.g. its own project version), stamped
311
+ onto every processed experiment. Defaults to the dataset's own
163
312
  ``version['external_version']``.
164
313
  progress_callback : callable, optional
165
314
  Called as ``(completed, total, experiment_name)`` once before the loop
@@ -173,24 +322,8 @@ def read_in_experiments_single_threaded(database: ExperimentalDataset,
173
322
  `read_in_single_experiment`.
174
323
  """
175
324
 
176
- # The flag is text, not a bool: it round-trips through Excel and HDF5, and
177
- # comes back as the strings this module compares against below. Seeding it
178
- # with a bool gives the column bool dtype, which pandas then refuses to
179
- # write 'True' into once the first experiment succeeds.
180
- if "Processed" not in database.overview_df.columns:
181
- database.overview_df["Processed"] = "False"
182
-
183
- # Returning only the experiments which have not been processed
184
- # Do not contain "Processed" column or "Processed" is not True
185
- # also returns experiments which are not in the database.experiments dict,
325
+ experiments = select_unprocessed_experiments(database, overview_df_experiment_column)
186
326
 
187
- mask = (
188
- database.overview_df["Processed"].ne('True')
189
- | ~database.overview_df[overview_df_experiment_column].isin(database.experiments))
190
-
191
- experiments = database.overview_df.loc[mask,
192
- overview_df_experiment_column].astype(str).tolist()
193
-
194
327
  total_experiments = len(experiments)
195
328
  external_version = resolve_external_version(database, external_version)
196
329
 
@@ -201,37 +334,21 @@ def read_in_experiments_single_threaded(database: ExperimentalDataset,
201
334
 
202
335
  for completed, experiment_name in enumerate(experiments, start=1):
203
336
 
204
- result = read_in_single_experiment(
205
- file_name = experiment_name,
337
+ results.append(ingest_experiment(
338
+ experiment_name = experiment_name,
206
339
  database = database,
207
340
  metadata_retrival_function = metadata_retrival_function,
208
341
  raw_data_reading_function = raw_data_reading_function,
209
342
  processing_function = processing_function,
343
+ overview_df_experiment_column = overview_df_experiment_column,
210
344
  directory = directory,
211
- legacy_mode = False,
212
345
  external_version = external_version
213
- )
214
-
215
- results.append(result)
216
-
217
- if result['success']:
218
- # Add experiment to database
219
- database.add_experiment(result['data'])
220
-
221
- # Setting "Processed" to True in dataframe
222
- database.overview_df.loc[
223
- database.overview_df[overview_df_experiment_column].eq(result['data'].experiment_name),
224
- "Processed",
225
- ] = 'True'
226
-
227
- else:
228
- print(f"Failed to process {result['file']}: {result['error']}")
346
+ ))
229
347
 
230
348
  if progress_callback is not None:
231
349
  progress_callback(completed, total_experiments, experiment_name)
232
350
 
233
- if any(result['success'] for result in results):
234
- database.stamp_version(processed=True, external_version=external_version)
351
+ finalize_processing_run(database, results, external_version)
235
352
 
236
353
  return results
237
354
 
@@ -270,8 +387,7 @@ def read_in_experiments_multiprocessing(database: ExperimentalDataset,
270
387
  else:
271
388
  print(f"Failed to process {result['file']}: {result['error']}")
272
389
 
273
- if any(result['success'] for result in results):
274
- database.stamp_version(processed=True, external_version=external_version)
390
+ finalize_processing_run(database, results, external_version)
275
391
 
276
392
  return results
277
393
 
@@ -351,6 +467,80 @@ def reprocess_single_experiment(experiment: Experiment,
351
467
  }
352
468
 
353
469
 
470
+ def reprocess_experiment_by_name(experiment_name: str,
471
+ database: ExperimentalDataset,
472
+ processing_function: callable,
473
+ metadata_retrival_function: Optional[callable] = None,
474
+ external_version: Optional[dict] = None) -> dict:
475
+ """
476
+ Reprocess one experiment of a dataset, addressed by name.
477
+
478
+ Name-addressed counterpart of `reprocess_single_experiment`, so that a
479
+ caller stepping through the experiments one at a time can reach both
480
+ pipelines through the same ``(experiment_name, **context)`` call shape as
481
+ `ingest_experiment`.
482
+
483
+ Parameters
484
+ ----------
485
+ experiment_name : str
486
+ Experiment to reprocess; must be present in ``database.experiments``.
487
+ database : ExperimentalDataset
488
+ Dataset holding the experiment and the overview sheet.
489
+ processing_function : callable
490
+ ``(raw_data_dict, metadata_dict) -> processed_data_dict``.
491
+ metadata_retrival_function : callable, optional
492
+ When given, metadata is refreshed from ``database.overview_df``.
493
+ external_version : dict, optional
494
+ Provenance of the external app, already resolved.
495
+
496
+ Returns
497
+ -------
498
+ result : dict
499
+ Result of `reprocess_single_experiment`.
500
+ """
501
+
502
+ return reprocess_single_experiment(
503
+ experiment = database.experiments[experiment_name],
504
+ processing_function = processing_function,
505
+ metadata_retrival_function = metadata_retrival_function,
506
+ overview_df = database.overview_df,
507
+ external_version = external_version
508
+ )
509
+
510
+
511
+ def select_experiments_to_reprocess(database: ExperimentalDataset,
512
+ experiment_names: Optional[list] = None) -> list:
513
+ """
514
+ Resolve which experiments of a dataset a reprocessing run covers.
515
+
516
+ Parameters
517
+ ----------
518
+ database : ExperimentalDataset
519
+ Dataset holding the experiments.
520
+ experiment_names : list of str, optional
521
+ Requested experiments. Defaults to every experiment in the dataset.
522
+
523
+ Returns
524
+ -------
525
+ list of str
526
+ Experiments to reprocess.
527
+
528
+ Raises
529
+ ------
530
+ ValueError
531
+ If any requested experiment is not held by the dataset.
532
+ """
533
+
534
+ if experiment_names is None:
535
+ return sorted(database.experiments.keys())
536
+
537
+ unknown_experiments = [name for name in experiment_names if name not in database.experiments]
538
+ if unknown_experiments:
539
+ raise ValueError(f"Experiments not present in the dataset: {unknown_experiments}")
540
+
541
+ return list(experiment_names)
542
+
543
+
354
544
  def reprocess_experiments(database: ExperimentalDataset,
355
545
  processing_function: callable,
356
546
  metadata_retrival_function: Optional[callable] = None,
@@ -390,12 +580,7 @@ def reprocess_experiments(database: ExperimentalDataset,
390
580
  One result dict per experiment, as returned by
391
581
  `reprocess_single_experiment`.
392
582
  """
393
- if experiment_names is None:
394
- experiment_names = sorted(database.experiments.keys())
395
-
396
- unknown_experiments = [name for name in experiment_names if name not in database.experiments]
397
- if unknown_experiments:
398
- raise ValueError(f"Experiments not present in the dataset: {unknown_experiments}")
583
+ experiment_names = select_experiments_to_reprocess(database, experiment_names)
399
584
 
400
585
  total_experiments = len(experiment_names)
401
586
  external_version = resolve_external_version(database, external_version)
@@ -407,21 +592,18 @@ def reprocess_experiments(database: ExperimentalDataset,
407
592
 
408
593
  for completed, experiment_name in enumerate(experiment_names, start=1):
409
594
 
410
- result = reprocess_single_experiment(
411
- experiment = database.experiments[experiment_name],
595
+ results.append(reprocess_experiment_by_name(
596
+ experiment_name = experiment_name,
597
+ database = database,
412
598
  processing_function = processing_function,
413
599
  metadata_retrival_function = metadata_retrival_function,
414
- overview_df = database.overview_df,
415
600
  external_version = external_version
416
- )
417
-
418
- results.append(result)
601
+ ))
419
602
 
420
603
  if progress_callback is not None:
421
604
  progress_callback(completed, total_experiments, experiment_name)
422
605
 
423
- if any(result['success'] for result in results):
424
- database.stamp_version(processed=True, external_version=external_version)
606
+ finalize_processing_run(database, results, external_version)
425
607
 
426
608
  return results
427
609
 
@@ -292,8 +292,8 @@ class ExperimentalDataset:
292
292
  Whether a processing function was just run, which additionally
293
293
  sets ``'last_processed'``.
294
294
  external_version : dict, optional
295
- Provenance of the external app (e.g. its git commit), merged into
296
- the existing ``'external_version'`` entry.
295
+ Provenance of the external app (e.g. its own project version),
296
+ merged into the existing ``'external_version'`` entry.
297
297
 
298
298
  Returns
299
299
  -------
@@ -318,7 +318,7 @@ class ExperimentalDataset:
318
318
  alongside the data::
319
319
 
320
320
  dataset.set_external_version({'app': 'photocat',
321
- 'commit': get_git_commit(__file__)})
321
+ 'version': get_project_version(__file__)})
322
322
 
323
323
  Parameters
324
324
  ----------
@@ -648,7 +648,7 @@ def usage_example():
648
648
  # Add dataset-level attributes
649
649
  dataset.plotting_instruction = {'xlabel': 'Time (s)', 'ylabel': 'Current (mA)'}
650
650
  dataset.group_mapping = {'GroupA': ['Exp1'], 'GroupB': ['Exp2']}
651
- dataset.set_external_version({'app': 'usage_example', 'commit': 'abc123'})
651
+ dataset.set_external_version({'app': 'usage_example', 'version': '0.3.0'})
652
652
 
653
653
  exp1 = Experiment(
654
654
  experiment_name="Exp1",
@@ -0,0 +1,280 @@
1
+ """
2
+ Drive a long processing run one experiment per Streamlit rerun.
3
+
4
+ The pyKES app is also deployed as a static browser page via stlite, which runs
5
+ Streamlit inside Pyodide on the browser's *single* event loop — there is no
6
+ separate script-runner thread. A loop that processes every experiment inside
7
+ one script run therefore never lets Streamlit deliver anything: the progress
8
+ messages it writes are queued and only flushed once the run yields to the
9
+ event loop, which happens after the loop has already finished. The progress
10
+ bar was invisible in the browser for exactly this reason.
11
+
12
+ A job started here is instead advanced by one experiment per rerun. Each rerun
13
+ ends, the event loop gets control, and the bar painted by that rerun reaches
14
+ the screen.
15
+
16
+ The reruns are app-scoped, so the page that hosts a job has to skip its
17
+ expensive sections while `any_active_job` is true — the data-upload page
18
+ rewrites the whole HDF5 file in its download section, which must not happen
19
+ once per experiment. Fragment-scoped reruns would avoid that, but they are not
20
+ usable here: ``st.rerun(scope="fragment")`` is rejected outside a fragment
21
+ rerun, and the only way into one is a widget interaction or a ``run_every``
22
+ timer that would then poll for the entire duration of the job.
23
+
24
+ The same code path is used when running under ``streamlit run``; nothing here
25
+ is browser-specific.
26
+
27
+ See [docs/browser_deployment.md](../../../docs/browser_deployment.md).
28
+ """
29
+
30
+ import shutil
31
+ from typing import Callable, Optional
32
+
33
+ import streamlit as st
34
+
35
+ from pyKES.database.data_processing import finalize_processing_run
36
+
37
+
38
+ # Session-state key holding the results of the most recently finished job,
39
+ # derived from the job's own key. The results outlive the job itself so the page
40
+ # can report them after the final rerun has torn the job down.
41
+ JOB_RESULTS_KEY_TEMPLATE = "{job_key}_results"
42
+
43
+
44
+ def job_results_key(job_key: str) -> str:
45
+ """
46
+ Session-state key under which a finished job parks its results.
47
+
48
+ Parameters
49
+ ----------
50
+ job_key : str
51
+ Key identifying the job.
52
+
53
+ Returns
54
+ -------
55
+ str
56
+ Session-state key of the results list.
57
+ """
58
+
59
+ return JOB_RESULTS_KEY_TEMPLATE.format(job_key=job_key)
60
+
61
+
62
+ def start_chunked_job(job_key: str,
63
+ experiment_names: list,
64
+ context: dict,
65
+ staging_directory: Optional[str] = None) -> None:
66
+ """
67
+ Register a processing run to be advanced one experiment per rerun.
68
+
69
+ The caller is expected to trigger a full ``st.rerun`` afterwards, so the
70
+ page re-renders with the job active.
71
+
72
+ Parameters
73
+ ----------
74
+ job_key : str
75
+ Session-state key identifying this job. One per page section.
76
+ experiment_names : list of str
77
+ Experiments to process, in order.
78
+ context : dict
79
+ Keyword arguments handed to the step function alongside the experiment
80
+ name — the processing callables, the staging directory and the
81
+ already-resolved external version. Must include the
82
+ `ExperimentalDataset` under ``'database'``: both step functions take it,
83
+ and the job is stamped against it when it finishes.
84
+ staging_directory : str, optional
85
+ Directory holding uploaded files for the duration of the job. Removed
86
+ when the job finishes.
87
+
88
+ Returns
89
+ -------
90
+ None : None
91
+ """
92
+
93
+ st.session_state.pop(job_results_key(job_key), None)
94
+
95
+ st.session_state[job_key] = {
96
+ 'experiment_names': experiment_names,
97
+ 'completed': 0,
98
+ 'results': [],
99
+ 'painted': False,
100
+ 'staging_directory': staging_directory,
101
+ 'context': context,
102
+ }
103
+
104
+
105
+ def active_job(job_key: str) -> Optional[dict]:
106
+ """
107
+ Return the job registered under a key, if there is one.
108
+
109
+ Parameters
110
+ ----------
111
+ job_key : str
112
+ Session-state key identifying the job.
113
+
114
+ Returns
115
+ -------
116
+ dict or None
117
+ The job state, or None when no job is running.
118
+ """
119
+
120
+ return st.session_state.get(job_key)
121
+
122
+
123
+ def collect_job_results(job_key: str) -> Optional[list]:
124
+ """
125
+ Take the results parked by the most recently finished job.
126
+
127
+ Parameters
128
+ ----------
129
+ job_key : str
130
+ Session-state key identifying the job.
131
+
132
+ Returns
133
+ -------
134
+ list of dict or None
135
+ The results, or None when no job has finished since the last call.
136
+ """
137
+
138
+ return st.session_state.pop(job_results_key(job_key), None)
139
+
140
+
141
+ def paint_job_progress(job: dict) -> None:
142
+ """
143
+ Draw the progress bar for the experiment that is about to be processed.
144
+
145
+ Painting is a rerun of its own, doing no work, so that the bar reaches the
146
+ screen *before* the next experiment occupies the event loop. Painting and
147
+ processing in the same rerun would leave the bar one experiment behind and
148
+ never show the first one — which is the whole bug this module exists for.
149
+
150
+ Parameters
151
+ ----------
152
+ job : dict
153
+ Job state; ``painted`` is set in place.
154
+
155
+ Returns
156
+ -------
157
+ None : None
158
+ """
159
+
160
+ completed = job['completed']
161
+ total = len(job['experiment_names'])
162
+
163
+ st.progress(
164
+ completed / total,
165
+ text = f"Processing experiment {completed + 1}/{total}: {job['experiment_names'][completed]}",
166
+ )
167
+
168
+ job['painted'] = True
169
+
170
+
171
+ def run_job_step(job: dict, step_function: Callable[..., dict]) -> None:
172
+ """
173
+ Process the next experiment of a job.
174
+
175
+ Parameters
176
+ ----------
177
+ job : dict
178
+ Job state; ``results``, ``completed`` and ``painted`` are updated in
179
+ place.
180
+ step_function : callable
181
+ ``(experiment_name, **context) -> result_dict``, e.g.
182
+ `pyKES.database.data_processing.ingest_experiment`.
183
+
184
+ Returns
185
+ -------
186
+ None : None
187
+ """
188
+
189
+ experiment_name = job['experiment_names'][job['completed']]
190
+
191
+ job['results'].append(step_function(experiment_name, **job['context']))
192
+ job['completed'] += 1
193
+ job['painted'] = False
194
+
195
+
196
+ def finish_chunked_job(job_key: str, job: dict) -> None:
197
+ """
198
+ Stamp the dataset, clean up the staging directory and retire the job.
199
+
200
+ Parameters
201
+ ----------
202
+ job_key : str
203
+ Session-state key identifying the job.
204
+ job : dict
205
+ Job state to retire.
206
+
207
+ Returns
208
+ -------
209
+ None : None
210
+ """
211
+
212
+ # Stamped once for the whole run, as the non-chunked loop functions do,
213
+ # rather than after every experiment.
214
+ finalize_processing_run(job['context']['database'],
215
+ job['results'],
216
+ job['context'].get('external_version'))
217
+
218
+ if job['staging_directory'] is not None:
219
+ shutil.rmtree(job['staging_directory'])
220
+
221
+ st.session_state[job_results_key(job_key)] = job['results']
222
+ del st.session_state[job_key]
223
+
224
+
225
+ def render_chunked_job(job_key: str, step_function: Callable[..., dict]) -> None:
226
+ """
227
+ Advance a job by one phase and rerun for the next.
228
+
229
+ Alternates painting and processing, so every experiment is announced
230
+ before it runs and the announcement reaches the screen while it runs.
231
+
232
+ Parameters
233
+ ----------
234
+ job_key : str
235
+ Session-state key identifying the job.
236
+ step_function : callable
237
+ ``(experiment_name, **context) -> result_dict``.
238
+
239
+ Returns
240
+ -------
241
+ None : None
242
+ """
243
+
244
+ job = active_job(job_key)
245
+
246
+ if job is None:
247
+ return
248
+
249
+ # st.rerun raises, so each branch below ends the script run.
250
+ if job['completed'] == len(job['experiment_names']):
251
+ finish_chunked_job(job_key, job)
252
+ st.rerun()
253
+
254
+ if job['painted']:
255
+ run_job_step(job, step_function)
256
+ else:
257
+ paint_job_progress(job)
258
+
259
+ st.rerun()
260
+
261
+
262
+ def any_active_job(job_keys: list) -> bool:
263
+ """
264
+ Report whether any of the given jobs is currently running.
265
+
266
+ Pages use this to skip sections that are too expensive to re-render on
267
+ every step of a job.
268
+
269
+ Parameters
270
+ ----------
271
+ job_keys : list of str
272
+ Session-state keys of the jobs the page can start.
273
+
274
+ Returns
275
+ -------
276
+ bool
277
+ True while at least one of them is active.
278
+ """
279
+
280
+ return any(active_job(job_key) is not None for job_key in job_keys)