rapmat 0.2.2__tar.gz → 0.2.3__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 (81) hide show
  1. {rapmat-0.2.2 → rapmat-0.2.3}/PKG-INFO +1 -1
  2. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/calculators/vasp.py +2 -1
  3. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/csp.py +1 -14
  4. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/storage/base.py +2 -1
  5. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/storage/surrealdb_store.py +46 -32
  6. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/base_results.py +71 -4
  7. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/hull.py +16 -117
  8. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/results.py +4 -2
  9. {rapmat-0.2.2 → rapmat-0.2.3}/tests/conftest.py +28 -19
  10. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_db_config_and_locking.py +0 -23
  11. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_dedup_analysis.py +7 -12
  12. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_hull.py +34 -26
  13. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_processing_loop.py +4 -9
  14. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_render.py +3 -3
  15. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_storage.py +95 -29
  16. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_tui.py +39 -1
  17. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_tui_layout.py +3 -3
  18. {rapmat-0.2.2 → rapmat-0.2.3}/.github/workflows/python-publish.yml +0 -0
  19. {rapmat-0.2.2 → rapmat-0.2.3}/.gitignore +0 -0
  20. {rapmat-0.2.2 → rapmat-0.2.3}/LICENSE +0 -0
  21. {rapmat-0.2.2 → rapmat-0.2.3}/README.md +0 -0
  22. {rapmat-0.2.2 → rapmat-0.2.3}/pyproject.toml +0 -0
  23. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/__init__.py +0 -0
  24. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/__main__.py +0 -0
  25. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/calculators/__init__.py +0 -0
  26. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/calculators/factory.py +0 -0
  27. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/calculators/mattersim.py +0 -0
  28. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/calculators/nequip.py +0 -0
  29. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/calculators/upet.py +0 -0
  30. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/config.py +0 -0
  31. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/__init__.py +0 -0
  32. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/dedup.py +0 -0
  33. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/dedup_analysis.py +0 -0
  34. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/evaluation.py +0 -0
  35. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/generation_worker.py +0 -0
  36. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/hull.py +0 -0
  37. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/phonon.py +0 -0
  38. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/phonon_stability.py +0 -0
  39. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/relaxation.py +0 -0
  40. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/core/sanity.py +0 -0
  41. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/db_config.py +0 -0
  42. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/main.py +0 -0
  43. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/storage/__init__.py +0 -0
  44. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/storage/descriptors.py +0 -0
  45. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/__init__.py +0 -0
  46. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/app.py +0 -0
  47. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/router.py +0 -0
  48. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/__init__.py +0 -0
  49. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/csp_resume.py +0 -0
  50. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/csp_search.py +0 -0
  51. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/db_settings.py +0 -0
  52. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/dedup.py +0 -0
  53. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/eval.py +0 -0
  54. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/home.py +0 -0
  55. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/phonon.py +0 -0
  56. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/status.py +0 -0
  57. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/study_create.py +0 -0
  58. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/study_detail.py +0 -0
  59. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/screens/study_list.py +0 -0
  60. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/state.py +0 -0
  61. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/tasks.py +0 -0
  62. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/widgets/__init__.py +0 -0
  63. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/widgets/calc_fields.py +0 -0
  64. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/widgets/config_grid.py +0 -0
  65. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/widgets/dialog.py +0 -0
  66. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/widgets/dropdown.py +0 -0
  67. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/widgets/form.py +0 -0
  68. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/widgets/progress.py +0 -0
  69. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/widgets/status_bar.py +0 -0
  70. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/tui/widgets/table.py +0 -0
  71. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/utils/__init__.py +0 -0
  72. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/utils/common.py +0 -0
  73. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/utils/console.py +0 -0
  74. {rapmat-0.2.2 → rapmat-0.2.3}/rapmat/utils/structure.py +0 -0
  75. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_checkbox.py +0 -0
  76. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_evaluation.py +0 -0
  77. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_relaxation.py +0 -0
  78. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_sanity.py +0 -0
  79. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_ui_toggle.py +0 -0
  80. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_utils.py +0 -0
  81. {rapmat-0.2.2 → rapmat-0.2.3}/tests/test_vasp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rapmat
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Rapmat - rapid materials discovery using MLIPs and random search
5
5
  Project-URL: Homepage, https://github.com/milevevvvv/rapmat
6
6
  Author-email: Michael Levenets <milevev256@gmail.com>
@@ -5,7 +5,8 @@ from ase.calculators.vasp import Vasp
5
5
 
6
6
  def build_calculator_vasp(config: dict, directory: Path | None = None) -> Vasp:
7
7
  kwargs = dict(config)
8
- if directory is not None:
8
+
9
+ if directory is not None and "directory" not in kwargs:
9
10
  kwargs["directory"] = str(directory)
10
11
 
11
12
  if "txt" not in kwargs:
@@ -31,8 +31,7 @@ def run_processing_loop(
31
31
  from rapmat.core.sanity import check_sanity
32
32
  from rapmat.utils.console import get_logger
33
33
  logger = get_logger("rapmat.csp")
34
- from rapmat.utils.structure import (calculate_thickness, format_spg,
35
- standardize_atoms)
34
+ from rapmat.utils.structure import format_spg, standardize_atoms
36
35
 
37
36
  class _ProgressCalcCallback:
38
37
 
@@ -161,17 +160,11 @@ def run_processing_loop(
161
160
 
162
161
  _report("Metadata preparation...")
163
162
  energy = relaxed_structure.info["energy"]
164
- volume = relaxed_structure.get_volume()
165
- enthalpy = energy + pressure_evA3 * volume
166
163
 
167
164
  meta = {
168
165
  "energy_per_atom": energy / len(relaxed_structure),
169
- "energy_total": energy,
170
- "enthalpy_per_atom": enthalpy / len(relaxed_structure),
171
- "volume": volume,
172
166
  "fmax": relaxed_structure.info["fmax"],
173
167
  "converged": relaxed_structure.info["converged"],
174
- "thickness": 0.0,
175
168
  }
176
169
 
177
170
  _report("Checking sanity...")
@@ -191,12 +184,6 @@ def run_processing_loop(
191
184
  )
192
185
  break
193
186
 
194
- if search_dim == 2:
195
- _report("Calculating thickness...")
196
- current_thickness = calculate_thickness(relaxed_structure)
197
- relaxed_structure.info["thickness"] = current_thickness
198
- meta["thickness"] = current_thickness
199
-
200
187
  _report("Saving to database...")
201
188
  store.update_structure(
202
189
  struct_id,
@@ -1,7 +1,7 @@
1
1
  from pymatgen.util import string
2
2
  from abc import ABC, abstractmethod
3
3
  from pathlib import Path
4
- from typing import List, Optional, Tuple
4
+ from typing import Callable, List, Optional, Tuple
5
5
 
6
6
  import numpy as np
7
7
  from ase import Atoms
@@ -108,6 +108,7 @@ class StructureStore(ABC):
108
108
  status: Optional[str] = None,
109
109
  statuses: Optional[tuple[str, ...]] = None,
110
110
  symprec: float = 1e-3,
111
+ progress_callback: Optional[Callable[..., None]] = None,
111
112
  ) -> List[dict]: ...
112
113
 
113
114
  @abstractmethod
@@ -2,17 +2,18 @@ import json
2
2
  import threading
3
3
  from datetime import datetime
4
4
  from pathlib import Path
5
- from typing import List, Optional, Tuple
5
+ from typing import Callable, List, Optional, Tuple
6
6
 
7
7
  import numpy as np
8
8
  from ase import Atoms
9
9
  from ase.io.jsonio import decode as ase_decode
10
10
  from ase.io.jsonio import encode as ase_encode
11
+ from ase.units import GPa
11
12
  from filelock import FileLock, Timeout
12
13
  from surrealdb import RecordID, Surreal
13
14
 
14
15
  from rapmat.storage.base import StructureStore, StructureDescriptor
15
- from rapmat.utils.structure import format_spg
16
+ from rapmat.utils.structure import calculate_thickness, format_spg
16
17
 
17
18
  # ------------------------------------------------------------------ #
18
19
  # Schema
@@ -46,19 +47,19 @@ DEFINE FIELD IF NOT EXISTS status ON structure TYPE string
46
47
  ASSERT $value IN ["generating", "generated", "relaxed", "discarded", "error"];
47
48
  DEFINE FIELD IF NOT EXISTS gen_spg ON structure TYPE option<int>;
48
49
  DEFINE FIELD IF NOT EXISTS gen_fu ON structure TYPE option<int>;
49
- DEFINE FIELD IF NOT EXISTS formula ON structure TYPE string;
50
50
  DEFINE FIELD IF NOT EXISTS energy_per_atom ON structure TYPE float;
51
- DEFINE FIELD IF NOT EXISTS energy_total ON structure TYPE float;
52
51
  DEFINE FIELD IF NOT EXISTS fmax ON structure TYPE float;
53
52
  DEFINE FIELD IF NOT EXISTS converged ON structure TYPE bool;
54
- DEFINE FIELD IF NOT EXISTS thickness ON structure TYPE option<float>;
55
- DEFINE FIELD IF NOT EXISTS enthalpy_per_atom ON structure TYPE option<float>;
56
- DEFINE FIELD IF NOT EXISTS volume ON structure TYPE option<float>;
57
53
  DEFINE FIELD IF NOT EXISTS min_phonon_freq ON structure TYPE option<float>;
58
54
  DEFINE FIELD IF NOT EXISTS duplicate ON structure TYPE option<bool>;
59
55
  DEFINE FIELD IF NOT EXISTS initial_atoms_json ON structure TYPE string;
60
56
  DEFINE FIELD IF NOT EXISTS final_atoms_json ON structure TYPE string;
61
57
  DEFINE FIELD IF NOT EXISTS timestamp ON structure TYPE string;
58
+ REMOVE FIELD IF EXISTS formula ON TABLE structure;
59
+ REMOVE FIELD IF EXISTS energy_total ON TABLE structure;
60
+ REMOVE FIELD IF EXISTS volume ON TABLE structure;
61
+ REMOVE FIELD IF EXISTS enthalpy_per_atom ON TABLE structure;
62
+ REMOVE FIELD IF EXISTS thickness ON TABLE structure;
62
63
  DEFINE INDEX IF NOT EXISTS idx_struct_run ON structure FIELDS run;
63
64
  DEFINE INDEX IF NOT EXISTS idx_struct_status ON structure FIELDS status;
64
65
  DEFINE INDEX IF NOT EXISTS idx_struct_run_status ON structure FIELDS run, status;
@@ -500,14 +501,9 @@ class SurrealDBStore(StructureStore):
500
501
  "status": "generating",
501
502
  "gen_spg": spg,
502
503
  "gen_fu": fu,
503
- "formula": "",
504
504
  "energy_per_atom": 0.0,
505
- "energy_total": 0.0,
506
505
  "fmax": 0.0,
507
506
  "converged": False,
508
- "thickness": None,
509
- "enthalpy_per_atom": None,
510
- "volume": None,
511
507
  "min_phonon_freq": None,
512
508
  "initial_atoms_json": "",
513
509
  "final_atoms_json": "",
@@ -543,7 +539,6 @@ class SurrealDBStore(StructureStore):
543
539
  ) -> None:
544
540
  updates: dict = {
545
541
  "status": "generated",
546
- "formula": atoms.get_chemical_formula(),
547
542
  "initial_atoms_json": ase_encode(atoms),
548
543
  "timestamp": datetime.now().isoformat(),
549
544
  }
@@ -583,24 +578,13 @@ class SurrealDBStore(StructureStore):
583
578
 
584
579
  if atoms is not None:
585
580
  updates["final_atoms_json"] = ase_encode(atoms)
586
- updates["formula"] = atoms.get_chemical_formula()
587
581
  if vector is not None:
588
582
  col = self._vec_col()
589
583
  updates[col] = vector.astype(np.float32).tolist()
590
584
 
591
585
  updates["energy_per_atom"] = float(meta.get("energy_per_atom", 0.0))
592
- updates["energy_total"] = float(meta.get("energy_total", 0.0))
593
586
  updates["fmax"] = float(meta.get("fmax", 0.0))
594
587
  updates["converged"] = bool(meta.get("converged", False))
595
- updates["thickness"] = (
596
- float(meta.get("thickness", 0.0))
597
- if meta.get("thickness") is not None
598
- else None
599
- )
600
- if "enthalpy_per_atom" in meta:
601
- updates["enthalpy_per_atom"] = float(meta["enthalpy_per_atom"])
602
- if "volume" in meta:
603
- updates["volume"] = float(meta["volume"])
604
588
 
605
589
  self._db.query(
606
590
  f"UPDATE {_record_id('structure', struct_id)} MERGE $data",
@@ -697,6 +681,7 @@ class SurrealDBStore(StructureStore):
697
681
  status: Optional[str] = None,
698
682
  statuses: Optional[tuple[str, ...]] = None,
699
683
  symprec: float = 1e-3,
684
+ progress_callback: Optional[Callable[..., None]] = None,
700
685
  ) -> List[dict]:
701
686
  effective = statuses or ((status,) if status else None)
702
687
 
@@ -714,28 +699,57 @@ class SurrealDBStore(StructureStore):
714
699
  self._db.query(f"SELECT * FROM structure WHERE {where}", params)
715
700
  )
716
701
 
702
+ try:
703
+ meta = self.get_run_metadata(run_name)
704
+ cfg = (meta or {}).get("config", {})
705
+ except Exception:
706
+ cfg = {}
707
+ pressure_gpa = float(cfg.get("pressure_gpa", 0.0))
708
+ domain = cfg.get("domain", "bulk")
709
+ pressure_evA3 = pressure_gpa * GPa
710
+
717
711
  results: List[dict] = []
718
- for row in rows:
712
+ total = len(rows)
713
+ for i, row in enumerate(rows):
714
+ if progress_callback is not None:
715
+ progress_callback(i + 1, total, f"Processing structure {i + 1}/{total}…")
719
716
  initial_raw = row.get("initial_atoms_json", "")
720
717
  final_raw = row.get("final_atoms_json", "")
721
718
  initial_atoms = ase_decode(initial_raw) if initial_raw else None
722
719
  final_atoms = ase_decode(final_raw) if final_raw else None
723
720
 
721
+ atoms = final_atoms if final_atoms is not None else initial_atoms
722
+ energy_per_atom = row["energy_per_atom"]
723
+
724
+ n = len(atoms) if atoms is not None else 0
725
+ formula = atoms.get_chemical_formula() if atoms is not None else ""
726
+ volume = atoms.get_volume() if atoms is not None else None
727
+ energy_total = energy_per_atom * n if n else 0.0
728
+ if pressure_gpa > 0 and volume is not None and n:
729
+ enthalpy_per_atom = energy_per_atom + pressure_evA3 * volume / n
730
+ else:
731
+ enthalpy_per_atom = None
732
+ thickness = (
733
+ calculate_thickness(atoms)
734
+ if domain != "bulk" and atoms is not None
735
+ else None
736
+ )
737
+
724
738
  d: dict = {
725
739
  "id": _extract_id(row["id"]),
726
- "formula": row["formula"],
727
- "energy_per_atom": row["energy_per_atom"],
728
- "energy_total": row.get("energy_total", 0.0),
729
- "enthalpy_per_atom": row.get("enthalpy_per_atom"),
730
- "volume": row.get("volume"),
740
+ "formula": formula,
741
+ "energy_per_atom": energy_per_atom,
742
+ "energy_total": energy_total,
743
+ "enthalpy_per_atom": enthalpy_per_atom,
744
+ "volume": volume,
731
745
  "fmax": row["fmax"],
732
746
  "converged": row["converged"],
733
- "thickness": row.get("thickness"),
747
+ "thickness": thickness,
734
748
  "min_phonon_freq": row.get("min_phonon_freq"),
735
749
  "duplicate": row.get("duplicate"),
736
750
  "initial_atoms": initial_atoms,
737
751
  "final_atoms": final_atoms,
738
- "atoms": (final_atoms if final_atoms is not None else initial_atoms),
752
+ "atoms": atoms,
739
753
  "status": row["status"],
740
754
  "timestamp": row.get("timestamp", ""),
741
755
  "gen_spg": row.get("gen_spg"),
@@ -12,6 +12,7 @@ from rapmat.tui.router import ScreenRouter
12
12
  from rapmat.tui.state import AppState
13
13
  from rapmat.tui.tasks import BackgroundTask
14
14
  from rapmat.tui.widgets.dropdown import DropdownSelect
15
+ from rapmat.tui.widgets.progress import ProgressPanel
15
16
  from rapmat.tui.widgets.table import SortableTable
16
17
 
17
18
 
@@ -230,10 +231,74 @@ class BaseResultsScreen:
230
231
  self._phonon_task: "BackgroundTask | None" = None
231
232
  self._phonon_modal_body: urwid.Widget | None = None
232
233
 
234
+ self._outer_placeholder: urwid.WidgetPlaceholder | None = None
235
+ self._loading_task: "BackgroundTask | None" = None
236
+
233
237
  def build(self) -> urwid.Widget:
234
- self._fetch_data()
235
- self._main_frame = self._build_frame()
236
- return self._main_frame
238
+ self._outer_placeholder = urwid.WidgetPlaceholder(urwid.SolidFill(" "))
239
+ self.update_footer_keys()
240
+ self._start_async_fetch()
241
+ return self._outer_placeholder
242
+
243
+ def _loading_title(self) -> str:
244
+ return f" Loading {self.title} "
245
+
246
+ def _start_async_fetch(self) -> None:
247
+ panel = ProgressPanel(title=self._loading_title())
248
+ panel.set_progress(0, 1, "Loading structures from database…")
249
+
250
+ loading_widget = urwid.Filler(urwid.BoxAdapter(panel, 10), valign="middle")
251
+ if self._outer_placeholder is not None:
252
+ self._outer_placeholder.original_widget = loading_widget
253
+
254
+ loop = self._state.loop
255
+
256
+ if loop is None:
257
+ self._fetch_data()
258
+ self._main_frame = self._build_frame()
259
+ if self._outer_placeholder is not None:
260
+ self._outer_placeholder.original_widget = self._main_frame
261
+ return
262
+
263
+ def _worker(progress) -> None:
264
+ def _cb(
265
+ current: int, total: int, message: str = "", is_log: bool = False
266
+ ) -> None:
267
+ if progress.cancelled:
268
+ raise KeyboardInterrupt("Cancelled by user")
269
+ progress.update(current=current, total=total, message=message)
270
+ if is_log and message:
271
+ progress.log(message)
272
+
273
+ self._fetch_data(progress_callback=_cb)
274
+
275
+ def _on_progress(current: int, total: int, message: str) -> None:
276
+ panel.set_progress(current, total, message)
277
+
278
+ def _on_log(line: str) -> None:
279
+ panel.add_log(line)
280
+
281
+ def _on_complete() -> None:
282
+ self._main_frame = self._build_frame()
283
+ if self._outer_placeholder is not None:
284
+ self._outer_placeholder.original_widget = self._main_frame
285
+
286
+ current = self._router.current
287
+ if current is not self and current is not None:
288
+ current.on_resume()
289
+
290
+ def _on_error(error: str) -> None:
291
+ panel.set_finished(False, f"Error: {error}")
292
+
293
+ self._loading_task = BackgroundTask(
294
+ fn=_worker,
295
+ loop=loop,
296
+ on_progress=_on_progress,
297
+ on_log=_on_log,
298
+ on_complete=_on_complete,
299
+ on_error=_on_error,
300
+ )
301
+ self._loading_task.start()
237
302
 
238
303
  def on_resume(self) -> None:
239
304
  self.update_footer_keys()
@@ -244,7 +309,7 @@ class BaseResultsScreen:
244
309
  def update_footer_keys(self, message: str = "") -> None:
245
310
  pass
246
311
 
247
- def _fetch_data(self) -> None:
312
+ def _fetch_data(self, progress_callback=None) -> None:
248
313
  pass
249
314
 
250
315
  def _columns_def(self) -> list[tuple[str, int]]:
@@ -798,6 +863,8 @@ class BaseResultsScreen:
798
863
  return None
799
864
  if self._phonon_task is not None:
800
865
  self._phonon_task.cancel()
866
+ if self._loading_task is not None:
867
+ self._loading_task.cancel()
801
868
  self._router.pop()
802
869
  return None
803
870
  return key
@@ -1,13 +1,9 @@
1
1
  from pathlib import Path
2
2
 
3
- import urwid
4
-
5
3
  from rapmat.tui.router import ScreenRouter
6
4
  from rapmat.tui.screens.base_results import BaseResultsScreen, _dyn_stability
7
5
  from rapmat.tui.state import AppState
8
- from rapmat.tui.tasks import BackgroundTask
9
6
  from rapmat.tui.widgets.dialog import ModalDialog
10
- from rapmat.tui.widgets.progress import ProgressPanel
11
7
 
12
8
 
13
9
  class PhaseAnalysisScreen(BaseResultsScreen):
@@ -21,8 +17,6 @@ class PhaseAnalysisScreen(BaseResultsScreen):
21
17
  self._use_enthalpy: bool = False
22
18
  self._show_all: bool = True
23
19
  self._hull_cutoff: float = 0.0
24
- self._loading_task: BackgroundTask | None = None
25
- self._outer_placeholder: urwid.WidgetPlaceholder | None = None
26
20
 
27
21
  def update_footer_keys(self, message: str = "") -> None:
28
22
  if not self._state.status_bar:
@@ -47,118 +41,9 @@ class PhaseAnalysisScreen(BaseResultsScreen):
47
41
  self._state.status_bar.clear_message()
48
42
 
49
43
  # ------------------------------------------------------------------ #
50
- # Async build: show loading panel, compute in background, then table
44
+ # Data fetch
51
45
  # ------------------------------------------------------------------ #
52
46
 
53
- def build(self) -> urwid.Widget:
54
- self._outer_placeholder = urwid.WidgetPlaceholder(urwid.SolidFill(" "))
55
- self.update_footer_keys()
56
- self._start_async_fetch()
57
- return self._outer_placeholder
58
-
59
- def _start_async_fetch(self) -> None:
60
- panel = ProgressPanel(title=" Phase Analysis ")
61
- panel.set_progress(0, 1, "Computing phase analysis...")
62
- panel.add_log("Loading structures from database...")
63
-
64
- loading_widget = urwid.Filler(urwid.BoxAdapter(panel, 10), valign="middle")
65
- if self._outer_placeholder is not None:
66
- self._outer_placeholder.original_widget = loading_widget
67
-
68
- loop = self._state.loop
69
- if loop is None:
70
- self._fetch_data()
71
- self._main_frame = self._build_frame()
72
- if self._outer_placeholder is not None:
73
- self._outer_placeholder.original_widget = self._main_frame
74
- return
75
-
76
- _store = self._state.store
77
- _active_study = self._state.active_study
78
- _show_all = self._show_all
79
- _hull_cutoff = self._hull_cutoff
80
-
81
- _result_box: dict = {}
82
-
83
- def _worker(progress) -> None:
84
- progress.log("Resolving study...")
85
- study_id = _active_study
86
- if not study_id:
87
- progress.fail("No active study selected.")
88
- return
89
- if isinstance(study_id, str) and ":" in study_id:
90
- study_id = study_id.split(":")[-1]
91
-
92
- study = _store.get_study(str(study_id))
93
- if not study:
94
- progress.fail(f"Study '{study_id}' not found.")
95
- return
96
-
97
- symprec = study.get("config", {}).get("symprec", 1e-3)
98
- system = study.get("system", "")
99
- from rapmat.utils.common import parse_system
100
-
101
- elements = parse_system(system)
102
- system_size = len(elements)
103
-
104
- progress.log(
105
- f"System: {system} ({system_size} element{'s' if system_size != 1 else ''})"
106
- )
107
-
108
- if system_size < 2:
109
- from rapmat.core.hull import build_energy_ranking
110
-
111
- progress.log("Building energy ranking...")
112
- sd, use_enthalpy = build_energy_ranking(
113
- _store, str(study_id), show_all=_show_all, hull_cutoff=_hull_cutoff
114
- )
115
- else:
116
- from rapmat.core.hull import build_phase_diagram
117
-
118
- progress.log("Building phase diagram...")
119
- _, sd, use_enthalpy = build_phase_diagram(
120
- _store,
121
- str(study_id),
122
- symprec=symprec,
123
- show_all=_show_all,
124
- hull_cutoff=_hull_cutoff,
125
- )
126
-
127
- progress.update(1, 1, f"Done - {len(sd)} structures")
128
- progress.log(f"Computed {len(sd)} structures.")
129
-
130
- _result_box["study_id"] = str(study_id)
131
- _result_box["study"] = study
132
- _result_box["system"] = system
133
- _result_box["system_size"] = system_size
134
- _result_box["sd"] = sd
135
- _result_box["use_enthalpy"] = use_enthalpy
136
-
137
- def _on_progress(current: int, total: int, message: str) -> None:
138
- panel.set_progress(current, total, message)
139
-
140
- def _on_log(line: str) -> None:
141
- panel.add_log(line)
142
-
143
- def _on_complete() -> None:
144
- self._apply_fetch_result(_result_box)
145
- self._main_frame = self._build_frame()
146
- if self._outer_placeholder is not None:
147
- self._outer_placeholder.original_widget = self._main_frame
148
-
149
- def _on_error(error: str) -> None:
150
- panel.set_finished(False, f"Error: {error}")
151
-
152
- self._loading_task = BackgroundTask(
153
- fn=_worker,
154
- loop=loop,
155
- on_progress=_on_progress,
156
- on_log=_on_log,
157
- on_complete=_on_complete,
158
- on_error=_on_error,
159
- )
160
- self._loading_task.start()
161
-
162
47
  def _apply_fetch_result(self, box: dict) -> None:
163
48
  self._study_id = box.get("study_id", "")
164
49
  self._study_system = box.get("system", "")
@@ -202,7 +87,12 @@ class PhaseAnalysisScreen(BaseResultsScreen):
202
87
  r.get("duplicate") is not None for r in self._results
203
88
  )
204
89
 
205
- def _fetch_data(self) -> None:
90
+ def _fetch_data(self, progress_callback=None) -> None:
91
+ def _log(msg: str) -> None:
92
+ if progress_callback is not None:
93
+ progress_callback(0, 1, msg, True)
94
+
95
+ _log("Resolving study…")
206
96
  study_id = self._state.active_study
207
97
  if not study_id:
208
98
  return
@@ -221,10 +111,15 @@ class PhaseAnalysisScreen(BaseResultsScreen):
221
111
 
222
112
  elements = parse_system(system)
223
113
  system_size = len(elements)
114
+ _log(
115
+ f"System: {system} "
116
+ f"({system_size} element{'s' if system_size != 1 else ''})"
117
+ )
224
118
 
225
119
  if system_size < 2:
226
120
  from rapmat.core.hull import build_energy_ranking
227
121
 
122
+ _log("Building energy ranking…")
228
123
  sd, use_enthalpy = build_energy_ranking(
229
124
  store,
230
125
  str(study_id),
@@ -234,6 +129,7 @@ class PhaseAnalysisScreen(BaseResultsScreen):
234
129
  else:
235
130
  from rapmat.core.hull import build_phase_diagram
236
131
 
132
+ _log("Building phase diagram…")
237
133
  _, sd, use_enthalpy = build_phase_diagram(
238
134
  store,
239
135
  str(study_id),
@@ -242,6 +138,9 @@ class PhaseAnalysisScreen(BaseResultsScreen):
242
138
  hull_cutoff=self._hull_cutoff,
243
139
  )
244
140
 
141
+ if progress_callback is not None:
142
+ progress_callback(1, 1, f"Done - {len(sd)} structures")
143
+
245
144
  box.update(
246
145
  {
247
146
  "study_id": str(study_id),
@@ -30,7 +30,7 @@ class ResultsScreen(BaseResultsScreen):
30
30
  else:
31
31
  self._state.status_bar.clear_message()
32
32
 
33
- def _fetch_data(self) -> None:
33
+ def _fetch_data(self, progress_callback=None) -> None:
34
34
  run_name = self._state.active_run or ""
35
35
  self._run_name = run_name
36
36
 
@@ -41,7 +41,9 @@ class ResultsScreen(BaseResultsScreen):
41
41
  self._pressure_gpa = float(config.get("pressure_gpa", 0.0))
42
42
  self._phonon_cutoff = config.get("phonon_cutoff")
43
43
 
44
- records = store.get_structures(run_name, status="relaxed")
44
+ records = store.get_structures(
45
+ run_name, status="relaxed", progress_callback=progress_callback
46
+ )
45
47
 
46
48
  if self._pressure_gpa > 0:
47
49
  records.sort(
@@ -52,6 +52,21 @@ def _ensure_descriptor(store: StructureStore) -> None:
52
52
  store.register_descriptor(_TEST_DESCRIPTOR)
53
53
 
54
54
 
55
+ def add_generated_candidate(
56
+ store: StructureStore,
57
+ run_name: str,
58
+ struct_id: str,
59
+ atoms: Atoms,
60
+ vector: np.ndarray | None = None,
61
+ spg: int = 1,
62
+ fu: int = 1,
63
+ ) -> str:
64
+ """Insert a single 'generated' candidate via the placeholder pipeline."""
65
+ store.add_generation_placeholders(run_name, [(struct_id, spg, fu)])
66
+ store.update_generated_structure(struct_id, atoms, vector=vector)
67
+ return struct_id
68
+
69
+
55
70
  def add_relaxed_structure(
56
71
  store: StructureStore,
57
72
  run_name: str,
@@ -59,35 +74,29 @@ def add_relaxed_structure(
59
74
  energy_per_atom: float,
60
75
  struct_id: str,
61
76
  vector: np.ndarray | None = None,
62
- enthalpy_per_atom: float | None = None,
63
77
  ) -> None:
64
- """Add a structure and immediately mark it relaxed with the given energy."""
65
- n_atoms = len(atoms)
66
- energy_total = energy_per_atom * n_atoms
78
+ """Add a structure and immediately mark it relaxed with the given energy.
79
+
80
+ Derived quantities (formula, volume, energy_total, enthalpy, thickness)
81
+ are computed at read time by get_structures, so only the canonical
82
+ metadata is written here.
83
+ """
67
84
  if vector is None:
68
85
  vector = np.zeros(VECTOR_DIM, dtype=np.float32)
69
86
  _ensure_descriptor(store)
70
87
 
71
- # Insert as "generated" candidate via add_structures
72
- store.add_structures(run_name, [
73
- {"id": struct_id, "atoms": atoms, "vector": vector, "status": "generated"},
74
- ])
75
-
76
- # Immediately update to relaxed
77
- metadata = {
78
- "energy_per_atom": energy_per_atom,
79
- "energy_total": energy_total,
80
- "fmax": 0.01,
81
- "converged": True,
82
- }
83
- if enthalpy_per_atom is not None:
84
- metadata["enthalpy_per_atom"] = enthalpy_per_atom
88
+ add_generated_candidate(store, run_name, struct_id, atoms, vector=vector)
89
+
85
90
  store.update_structure(
86
91
  struct_id,
87
92
  "relaxed",
88
93
  atoms=atoms,
89
94
  vector=vector,
90
- metadata=metadata,
95
+ metadata={
96
+ "energy_per_atom": energy_per_atom,
97
+ "fmax": 0.01,
98
+ "converged": True,
99
+ },
91
100
  )
92
101
 
93
102
 
@@ -16,35 +16,12 @@ from rapmat.storage import SurrealDBStore
16
16
  class TestDbConfig:
17
17
  def test_load_returns_none_when_no_file(self, tmp_path, monkeypatch):
18
18
  monkeypatch.setattr("rapmat.db_config._DB_CONFIG_FILE", tmp_path / "nope.toml")
19
- monkeypatch.delenv("RAPMAT_DB_URL", raising=False)
20
- monkeypatch.delenv("RAPMAT_DB_NS", raising=False)
21
- monkeypatch.delenv("RAPMAT_DB_NAME", raising=False)
22
- monkeypatch.delenv("RAPMAT_DB_USER", raising=False)
23
- monkeypatch.delenv("RAPMAT_DB_PASSWORD", raising=False)
24
19
  assert load_db_config() is None
25
20
 
26
- def test_env_vars_override(self, tmp_path, monkeypatch):
27
- cfg_file = tmp_path / "db.toml"
28
- cfg_file.write_text(
29
- '[server]\nurl = "ws://old/rpc"\nnamespace = "ns"\n'
30
- 'database = "db"\nusername = "u"\npassword = "p"\n'
31
- )
32
- monkeypatch.setattr("rapmat.db_config._DB_CONFIG_FILE", cfg_file)
33
- monkeypatch.setenv("RAPMAT_DB_URL", "ws://new/rpc")
34
- full = load_db_config()
35
- assert full is not None
36
- assert full["server"]["url"] == "ws://new/rpc"
37
- assert full["server"]["namespace"] == "ns"
38
-
39
21
  def test_save_and_load_roundtrip(self, tmp_path, monkeypatch):
40
22
  cfg_file = tmp_path / "db.toml"
41
23
  monkeypatch.setattr("rapmat.db_config._DB_CONFIG_FILE", cfg_file)
42
24
  monkeypatch.setattr("rapmat.db_config.APP_CONFIG_DIR", tmp_path)
43
- monkeypatch.delenv("RAPMAT_DB_URL", raising=False)
44
- monkeypatch.delenv("RAPMAT_DB_NS", raising=False)
45
- monkeypatch.delenv("RAPMAT_DB_NAME", raising=False)
46
- monkeypatch.delenv("RAPMAT_DB_USER", raising=False)
47
- monkeypatch.delenv("RAPMAT_DB_PASSWORD", raising=False)
48
25
 
49
26
  save_db_config(
50
27
  general={"mode": "remote"},