|
31 | 31 | from arc.level.protocol import CompositeProtocol |
32 | 32 | from arc.level.reporting import ( |
33 | 33 | SpeciesSection, |
| 34 | + build_species_report_dict, |
34 | 35 | format_log_event, |
35 | 36 | write_composite_notebook, |
| 37 | + write_species_report_yaml, |
36 | 38 | ) |
37 | 39 |
|
38 | 40 |
|
@@ -403,6 +405,199 @@ def test_notebook_executes_and_recomputes_expected_final_value(self): |
403 | 405 | self.assertIn(f"{expected_kjmol:,.3f}", all_text) |
404 | 406 |
|
405 | 407 |
|
| 408 | +# --------------------------------------------------------------------------- # |
| 409 | +# build_species_report_dict + write_species_report_yaml # |
| 410 | +# --------------------------------------------------------------------------- # |
| 411 | + |
| 412 | + |
| 413 | +import yaml # noqa: E402 (import after the other module-level imports for grouping) |
| 414 | + |
| 415 | + |
| 416 | +class TestSpeciesReportDict(unittest.TestCase): |
| 417 | + """``build_species_report_dict`` produces the consumable per-species summary. |
| 418 | +
|
| 419 | + The notebook (``sp_composite.ipynb``) is for *independent verification* via |
| 420 | + Run-All; this YAML report is for *consumption* — readable in plain text, |
| 421 | + parseable by tooling, one file per species with every term's contribution |
| 422 | + spelled out next to the QM-output paths backing it. |
| 423 | + """ |
| 424 | + |
| 425 | + def setUp(self): |
| 426 | + self.tmp = tempfile.mkdtemp() |
| 427 | + self.base_path = os.path.join(self.tmp, "base.out") |
| 428 | + self.hi_path = os.path.join(self.tmp, "delta_T__high.out") |
| 429 | + self.lo_path = os.path.join(self.tmp, "delta_T__low.out") |
| 430 | + _write_gaussian_fixture(self.base_path, -76.345678) |
| 431 | + _write_gaussian_fixture(self.hi_path, -76.346500) # lower (more negative) → contribution = high - low < 0 |
| 432 | + _write_gaussian_fixture(self.lo_path, -76.345600) |
| 433 | + self.section = _make_two_term_section( |
| 434 | + paths={"base": self.base_path, |
| 435 | + "delta_T__high": self.hi_path, |
| 436 | + "delta_T__low": self.lo_path}, |
| 437 | + ) |
| 438 | + |
| 439 | + def tearDown(self): |
| 440 | + # Tests create nested directories (e.g. Species/H2O/...) — use rmtree |
| 441 | + # to clean them up wholesale rather than enumerating fixture files. |
| 442 | + import shutil |
| 443 | + shutil.rmtree(self.tmp, ignore_errors=True) |
| 444 | + |
| 445 | + def test_top_level_fields(self): |
| 446 | + d = build_species_report_dict( |
| 447 | + section=self.section, |
| 448 | + e_elect_kj_per_mol=-200000.0, |
| 449 | + timestamp="2026-04-30T13:10:32Z", |
| 450 | + arc_version="1.1.0", |
| 451 | + arc_commit="74fc4fa5", |
| 452 | + ) |
| 453 | + self.assertEqual(d["species"], "H2O") |
| 454 | + self.assertEqual(d["kind"], "species") |
| 455 | + self.assertEqual(d["generated_at"], "2026-04-30T13:10:32Z") |
| 456 | + self.assertEqual(d["arc_version"], "1.1.0") |
| 457 | + self.assertEqual(d["arc_commit"], "74fc4fa5") |
| 458 | + |
| 459 | + def test_protocol_block(self): |
| 460 | + d = build_species_report_dict( |
| 461 | + section=self.section, |
| 462 | + e_elect_kj_per_mol=-200000.0, |
| 463 | + timestamp="2026-04-30T13:10:32Z", |
| 464 | + arc_version="1.1.0", |
| 465 | + arc_commit="abc", |
| 466 | + ) |
| 467 | + self.assertIsNone(d["protocol"]["preset"]) # explicit recipe in fixture |
| 468 | + self.assertIn("DOI", d["protocol"]["reference"]) |
| 469 | + # Formula spells out the sum the protocol evaluates. |
| 470 | + self.assertIn("E_base", d["protocol"]["formula"]) |
| 471 | + self.assertIn("delta_T", d["protocol"]["formula"]) |
| 472 | + |
| 473 | + def test_units_block(self): |
| 474 | + d = build_species_report_dict( |
| 475 | + section=self.section, |
| 476 | + e_elect_kj_per_mol=-200000.0, |
| 477 | + timestamp="t", |
| 478 | + arc_version="v", |
| 479 | + arc_commit="c", |
| 480 | + ) |
| 481 | + self.assertEqual(d["units"]["energy"], "kJ/mol") |
| 482 | + self.assertEqual(d["units"]["energy_alt"], "Hartree") |
| 483 | + |
| 484 | + def test_base_block(self): |
| 485 | + d = build_species_report_dict( |
| 486 | + section=self.section, |
| 487 | + e_elect_kj_per_mol=-200000.0, |
| 488 | + timestamp="t", |
| 489 | + arc_version="v", |
| 490 | + arc_commit="c", |
| 491 | + ) |
| 492 | + base = d["base"] |
| 493 | + self.assertEqual(base["sub_label"], "base") |
| 494 | + self.assertEqual(base["path"], self.base_path) |
| 495 | + # Energy parsed via arc.parser; cross-check Hartree↔kJ/mol consistency. |
| 496 | + self.assertAlmostEqual( |
| 497 | + base["energy_kj_per_mol"] / E_h_kJmol, |
| 498 | + base["energy_hartree"], |
| 499 | + places=6, |
| 500 | + ) |
| 501 | + |
| 502 | + def test_terms_block_has_one_entry_per_correction(self): |
| 503 | + d = build_species_report_dict( |
| 504 | + section=self.section, |
| 505 | + e_elect_kj_per_mol=-200000.0, |
| 506 | + timestamp="t", |
| 507 | + arc_version="v", |
| 508 | + arc_commit="c", |
| 509 | + ) |
| 510 | + self.assertEqual(len(d["terms"]), 1) # fixture has exactly one correction |
| 511 | + term = d["terms"][0] |
| 512 | + self.assertEqual(term["label"], "delta_T") |
| 513 | + self.assertEqual(term["type"], "DeltaTerm") |
| 514 | + self.assertEqual(len(term["sub_jobs"]), 2) |
| 515 | + self.assertEqual({sj["sub_label"] for sj in term["sub_jobs"]}, |
| 516 | + {"delta_T__high", "delta_T__low"}) |
| 517 | + # Contribution = E[high] - E[low] = -76.346500 - (-76.345600) = -0.000900 Ha |
| 518 | + # = -0.000900 × E_h_kJmol ≈ -2.363 kJ/mol |
| 519 | + self.assertAlmostEqual(term["contribution_hartree"], -0.000900, places=6) |
| 520 | + self.assertAlmostEqual(term["contribution_kj_per_mol"], |
| 521 | + -0.000900 * E_h_kJmol, places=3) |
| 522 | + |
| 523 | + def test_final_block_uses_caller_supplied_e_elect(self): |
| 524 | + d = build_species_report_dict( |
| 525 | + section=self.section, |
| 526 | + e_elect_kj_per_mol=-200000.123, |
| 527 | + timestamp="t", |
| 528 | + arc_version="v", |
| 529 | + arc_commit="c", |
| 530 | + ) |
| 531 | + self.assertEqual(d["final"]["e_elect_kj_per_mol"], -200000.123) |
| 532 | + self.assertAlmostEqual(d["final"]["e_elect_hartree"], |
| 533 | + -200000.123 / E_h_kJmol, places=6) |
| 534 | + self.assertEqual(d["final"]["e_elect_source"], "sp_composite") |
| 535 | + |
| 536 | + def test_flags_propagated(self): |
| 537 | + section = _make_two_term_section( |
| 538 | + paths={"base": self.base_path, |
| 539 | + "delta_T__high": self.hi_path, |
| 540 | + "delta_T__low": self.lo_path}, |
| 541 | + flags=["MRCC degenerate-system fallback for delta_Q__high"], |
| 542 | + ) |
| 543 | + d = build_species_report_dict( |
| 544 | + section=section, |
| 545 | + e_elect_kj_per_mol=-200000.0, |
| 546 | + timestamp="t", |
| 547 | + arc_version="v", |
| 548 | + arc_commit="c", |
| 549 | + ) |
| 550 | + self.assertEqual(len(d["flags"]), 1) |
| 551 | + self.assertIn("MRCC", d["flags"][0]) |
| 552 | + |
| 553 | + def test_yaml_round_trips(self): |
| 554 | + out = os.path.join(self.tmp, "sp_composite_report.yml") |
| 555 | + write_species_report_yaml( |
| 556 | + path=out, |
| 557 | + section=self.section, |
| 558 | + e_elect_kj_per_mol=-200000.0, |
| 559 | + timestamp="2026-04-30T13:10:32Z", |
| 560 | + arc_version="1.1.0", |
| 561 | + arc_commit="74fc4fa5", |
| 562 | + ) |
| 563 | + self.assertTrue(os.path.exists(out)) |
| 564 | + with open(out) as fh: |
| 565 | + loaded = yaml.safe_load(fh) |
| 566 | + self.assertEqual(loaded["species"], "H2O") |
| 567 | + self.assertEqual(loaded["kind"], "species") |
| 568 | + self.assertIn("base", loaded) |
| 569 | + self.assertEqual(len(loaded["terms"]), 1) |
| 570 | + self.assertEqual(loaded["terms"][0]["label"], "delta_T") |
| 571 | + |
| 572 | + def test_yaml_writer_creates_parent_directory(self): |
| 573 | + nested = os.path.join(self.tmp, "Species", "H2O", "sp_composite_report.yml") |
| 574 | + write_species_report_yaml( |
| 575 | + path=nested, |
| 576 | + section=self.section, |
| 577 | + e_elect_kj_per_mol=-200000.0, |
| 578 | + timestamp="t", |
| 579 | + arc_version="v", |
| 580 | + arc_commit="c", |
| 581 | + ) |
| 582 | + self.assertTrue(os.path.exists(nested)) |
| 583 | + |
| 584 | + def test_writer_is_deterministic(self): |
| 585 | + """Two writes with the same inputs produce byte-identical files.""" |
| 586 | + out_a = os.path.join(self.tmp, "a.yml") |
| 587 | + out_b = os.path.join(self.tmp, "b.yml") |
| 588 | + for out in (out_a, out_b): |
| 589 | + write_species_report_yaml( |
| 590 | + path=out, |
| 591 | + section=self.section, |
| 592 | + e_elect_kj_per_mol=-200000.0, |
| 593 | + timestamp="2026-04-30T13:10:32Z", |
| 594 | + arc_version="1.1.0", |
| 595 | + arc_commit="74fc4fa5", |
| 596 | + ) |
| 597 | + with open(out_a, "rb") as fa, open(out_b, "rb") as fb: |
| 598 | + self.assertEqual(fa.read(), fb.read()) |
| 599 | + |
| 600 | + |
406 | 601 | # --------------------------------------------------------------------------- # |
407 | 602 | # Import placement (module-level, per project guidelines) # |
408 | 603 | # --------------------------------------------------------------------------- # |
|
0 commit comments