11"""
22rcmc.py - Rate Constant Matrix Contraction (RCMC) Queue
33=======================================================
4- Queue implementation applying the RCMC algorithm (Type A) to dynamically
4+ Queue implementation applying the RCMC algorithm to dynamically
55expanding chemical reaction networks. Determines the exploration priority
66of frontier EQ nodes based on transient population dynamics.
77
1414
1515import numpy as np
1616from scipy .linalg import lu_factor , lu_solve
17- from multioptpy .Wrapper .mapper import ExplorationQueue , ExplorationTask
17+ try :
18+ from multioptpy .Wrapper .mapper import ExplorationQueue , ExplorationTask
19+ except ImportError :
20+ # ── Minimal stub base classes used when running as __main__ ──────────
21+ # These are NOT part of RCMCQueue and are never used in normal library
22+ # operation (multioptpy must be installed for that).
23+ class ExplorationQueue : # type: ignore[no-redef]
24+ """Stub base class – library mode requires the real multioptpy."""
25+ def __init__ (self , rng_seed : int = 42 ) -> None :
26+ self ._tasks : list = []
27+
28+ def push (self , task ) -> None :
29+ self ._tasks .append (task )
30+
31+ class ExplorationTask : # type: ignore[no-redef]
32+ """Stub task object – mirrors the real ExplorationTask signature."""
33+ def __init__ (
34+ self ,
35+ node_id : int ,
36+ xyz_file : str = "" ,
37+ afir_params : dict | None = None ,
38+ priority : float = 0.0 ,
39+ metadata : dict | None = None ,
40+ ** kwargs ,
41+ ) -> None :
42+ self .node_id = node_id
43+ self .xyz_file = xyz_file
44+ self .afir_params = afir_params if afir_params is not None else {}
45+ self .priority = priority
46+ self .metadata = metadata if metadata is not None else {}
1847
1948logger = logging .getLogger (__name__ )
2049
@@ -410,4 +439,209 @@ def pop(self) -> ExplorationTask | None:
410439 if len (self ._tasks ) > 10 :
411440 pop_lines .append (f" ... ({ len (self ._tasks ) - 10 } more)" )
412441 logger .debug ("RCMC remaining queue (top 10):\n %s" , "\n " .join (pop_lines ))
413- return selected
442+ return selected
443+
444+ # ══════════════════════════════════════════════════════════════════════════════
445+ # Standalone CLI entry point
446+ # Usage:
447+ # python rcmc.py reaction_network.json --temperature 500 --time 1e-6
448+ # python rcmc.py reaction_network.json -T 300 -t 1.0 --start-node 0 --output-dir ./out
449+ # ══════════════════════════════════════════════════════════════════════════════
450+
451+ if __name__ == "__main__" :
452+ import argparse
453+ import json
454+ import sys
455+ from dataclasses import dataclass
456+
457+ # ── Argument parsing ──────────────────────────────────────────────────────
458+ parser = argparse .ArgumentParser (
459+ prog = "rcmc.py" ,
460+ description = (
461+ "Standalone RCMC analysis.\n "
462+ "Loads a reaction-network JSON, builds the rate-constant matrix,\n "
463+ "applies Type-A contraction up to the specified time scale, and\n "
464+ "reports the transient population distribution for each EQ node."
465+ ),
466+ formatter_class = argparse .RawDescriptionHelpFormatter ,
467+ )
468+ parser .add_argument (
469+ "json_file" ,
470+ help = "Path to reaction_network.json produced by MultiOptPy." ,
471+ )
472+ parser .add_argument (
473+ "-T" , "--temperature" ,
474+ type = float ,
475+ default = 300.0 ,
476+ metavar = "K" ,
477+ help = "Temperature in Kelvin (default: 300.0)." ,
478+ )
479+ parser .add_argument (
480+ "-t" , "--time" ,
481+ type = float ,
482+ default = 1.0 ,
483+ metavar = "S" ,
484+ help = "Reaction time scale in seconds (default: 1.0)." ,
485+ )
486+ parser .add_argument (
487+ "--start-node" ,
488+ type = int ,
489+ default = 0 ,
490+ metavar = "ID" ,
491+ help = "node_id of the initial EQ node (default: 0)." ,
492+ )
493+ parser .add_argument (
494+ "--output-dir" ,
495+ type = str ,
496+ default = None ,
497+ metavar = "DIR" ,
498+ help = (
499+ "Directory to write rcmc_K_contracted.csv "
500+ "(default: none – results printed to stdout only)."
501+ ),
502+ )
503+ parser .add_argument (
504+ "--log-level" ,
505+ type = str ,
506+ default = "WARNING" ,
507+ choices = ["DEBUG" , "INFO" , "WARNING" , "ERROR" ],
508+ help = "Logging verbosity (default: WARNING)." ,
509+ )
510+ args = parser .parse_args ()
511+
512+ # ── Logging setup ─────────────────────────────────────────────────────────
513+ logging .basicConfig (
514+ level = getattr (logging , args .log_level ),
515+ format = "%(levelname)s %(name)s: %(message)s" ,
516+ stream = sys .stderr ,
517+ )
518+
519+ # ── Lightweight graph stubs ───────────────────────────────────────────────
520+ @dataclass
521+ class _Node :
522+ node_id : int
523+ energy : float # Hartree
524+ xyz_file : str = ""
525+ has_real_energy : bool = True
526+
527+ @dataclass
528+ class _Edge :
529+ node_id_1 : int
530+ node_id_2 : int
531+ ts_energy : float # Hartree (None-safe: kept as float here)
532+
533+ class _Graph :
534+ def __init__ (self , nodes : list , edges : list ) -> None :
535+ self ._nodes = nodes
536+ self ._edges = edges
537+
538+ def all_nodes (self ) -> list :
539+ return self ._nodes
540+
541+ def all_edges (self ) -> list :
542+ return self ._edges
543+
544+ # ── Load JSON ─────────────────────────────────────────────────────────────
545+ try :
546+ with open (args .json_file , encoding = "utf-8" ) as fh :
547+ data = json .load (fh )
548+ except (OSError , json .JSONDecodeError ) as exc :
549+ sys .exit (f"ERROR: Cannot read '{ args .json_file } ': { exc } " )
550+
551+ raw_nodes = data .get ("nodes" , [])
552+ raw_edges = data .get ("edges" , [])
553+
554+ if not raw_nodes :
555+ sys .exit ("ERROR: No nodes found in the JSON file." )
556+
557+ nodes = [
558+ _Node (
559+ node_id = nd ["node_id" ],
560+ energy = nd ["energy_hartree" ],
561+ xyz_file = nd .get ("xyz_file" , "" ),
562+ )
563+ for nd in raw_nodes
564+ ]
565+
566+ edges = []
567+ for ed in raw_edges :
568+ ts_e = ed .get ("ts_energy_hartree" )
569+ if ts_e is None :
570+ continue # skip edges without a TS energy
571+ if ed ["node_id_1" ] == ed ["node_id_2" ]:
572+ continue # skip self-loops
573+ edges .append (
574+ _Edge (
575+ node_id_1 = ed ["node_id_1" ],
576+ node_id_2 = ed ["node_id_2" ],
577+ ts_energy = ts_e ,
578+ )
579+ )
580+
581+ graph = _Graph (nodes , edges )
582+ node_ids = {nd .node_id for nd in nodes }
583+
584+ # ── Build RCMCQueue and populate with stub tasks ──────────────────────────
585+ queue = RCMCQueue (
586+ temperature_K = args .temperature ,
587+ reaction_time_s = args .time ,
588+ start_node_id = args .start_node ,
589+ output_dir = args .output_dir ,
590+ )
591+ queue .set_graph (graph )
592+
593+ # Add every EQ node as a candidate task so pop() can rank them all.
594+ for nd in nodes :
595+ queue ._tasks .append (
596+ ExplorationTask (
597+ node_id = nd .node_id ,
598+ xyz_file = nd .xyz_file if hasattr (nd , "xyz_file" ) else "" ,
599+ afir_params = {},
600+ priority = 0.0 ,
601+ metadata = {"delta_E_hartree" : nd .energy },
602+ )
603+ )
604+
605+ # ── Run RCMC – one full pop() pass ────────────────────────────────────────
606+ print (
607+ f"\n { '═' * 60 } \n "
608+ f" RCMC standalone analysis\n "
609+ f" JSON : { args .json_file } \n "
610+ f" Nodes : { len (nodes )} | Edges: { len (edges )} \n "
611+ f" T : { args .temperature } K\n "
612+ f" Time scale: { args .time :.3e} s\n "
613+ f" Start node: EQ{ args .start_node } \n "
614+ f"{ '═' * 60 } "
615+ )
616+
617+ top_task = queue .pop ()
618+
619+ # ── Print ranked population distribution ─────────────────────────────────
620+ # After pop() the remaining tasks carry updated priorities (populations).
621+ # Re-collect all tasks including the one that was popped.
622+ all_tasks = list (queue ._tasks )
623+ if top_task is not None :
624+ all_tasks .insert (0 , top_task ) # re-insert at front (highest priority)
625+
626+ all_tasks .sort (key = lambda t : t .priority , reverse = True )
627+
628+ col_w = max (len (f"EQ{ t .node_id } " ) for t in all_tasks ) + 2
629+ print (f"\n { 'Node' :<{col_w }} { 'Population' :>18} { 'Rank' :>6} " )
630+ print (f"{ '─' * col_w } { '─' * 18 } { '─' * 6 } " )
631+ for rank , task in enumerate (all_tasks , start = 1 ):
632+ marker = " ◀ highest priority" if rank == 1 else ""
633+ print (
634+ f"EQ{ task .node_id :<{col_w - 2 }} { task .priority :18.8e} { rank :6d} { marker } "
635+ )
636+
637+ if top_task is not None :
638+ print (
639+ f"\n RCMC recommends exploring: EQ{ top_task .node_id } "
640+ f"(population = { top_task .priority :.6e} )"
641+ )
642+
643+ if args .output_dir :
644+ out_file = os .path .join (args .output_dir , "rcmc_K_contracted.csv" )
645+ print (f"\n Contracted K-matrix + populations saved to: { out_file } " )
646+
647+ print ()
0 commit comments