Skip to content

Commit c1d3d8a

Browse files
committed
refactor: improve status reporting structure and format detection
- Wrapped folder status results in a dict with "summary" (showing percent of each status) and "details" (per-folder info). - Added computation of status percentages (PENDING, DONE, NOT_CONVERGED) for the summary. - Updated `write_status_report` to determine output format (JSON/YAML) automatically from the file extension. - Ensured `forces_sum` is always included and uses `np.nan` for missing values, with conversion to JSON/YAML-safe output. - Improved robustness of serialization for nested structures and NaN values. - Updated CLI to reflect the new API and output handling.
1 parent 16f0717 commit c1d3d8a

1 file changed

Lines changed: 60 additions & 43 deletions

File tree

src/vasp_snake/report_status.py

Lines changed: 60 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import json
22
import os
3+
from collections import Counter
34
from enum import Enum
45

6+
import click
57
import numpy as np
6-
7-
try:
8-
import yaml
9-
except ImportError:
10-
yaml = None
8+
import yaml
119

1210
from vasp_snake.force import parse_forces_and_check_zero
1311

@@ -21,20 +19,20 @@ class JobStatus(Enum):
2119

2220

2321
def classify_folders(root=".", atol=1e-6):
24-
status = {}
22+
details = {}
2523
for folder in sorted(os.listdir(root)):
2624
folder_path = os.path.join(root, folder)
2725
if not os.path.isdir(folder_path) or folder.startswith("."):
2826
continue
2927
outcar = os.path.join(folder_path, "OUTCAR")
3028
if not os.path.exists(outcar):
31-
forces_sum = [np.nan] * 3
29+
forces_sum = [np.nan, np.nan, np.nan]
3230
job_status = JobStatus.PENDING
3331
reason = "OUTCAR missing"
3432
else:
3533
forces_sum, is_converged = parse_forces_and_check_zero(outcar, atol=atol)
3634
if forces_sum is None:
37-
forces_sum = [np.nan] * 3
35+
forces_sum = [np.nan, np.nan, np.nan]
3836
job_status = JobStatus.NOT_CONVERGED
3937
reason = "No force block found"
4038
elif is_converged:
@@ -49,52 +47,71 @@ def classify_folders(root=".", atol=1e-6):
4947
float(f)
5048
for f in (forces_sum if forces_sum is not None else [np.nan] * 3)
5149
]
52-
status[folder] = {
50+
details[folder] = {
5351
"status": job_status.value,
5452
"forces_sum": forces_sum,
5553
"reason": reason,
5654
}
57-
return status
55+
# Compute summary percentages
56+
status_list = [v["status"] for v in details.values()]
57+
total = len(status_list)
58+
counter = Counter(status_list)
59+
summary = {
60+
status: counter.get(status, 0) / total if total else 0.0
61+
for status in [s.value for s in JobStatus]
62+
}
63+
return {"summary": summary, "details": details}
64+
5865

66+
def write_status_report(status_dict, filename):
67+
# Numpy floats/nans don't serialize well with json/yaml, so convert
68+
def convert(obj):
69+
if isinstance(obj, float) and np.isnan(obj):
70+
return None # Or "NaN" if you want the string
71+
if isinstance(obj, (np.generic, np.ndarray)):
72+
return obj.tolist()
73+
return obj
74+
75+
# Recursively convert NaN in all nested structures
76+
def recursive_convert(o):
77+
if isinstance(o, dict):
78+
return {k: recursive_convert(v) for k, v in o.items()}
79+
elif isinstance(o, list):
80+
return [recursive_convert(x) for x in o]
81+
else:
82+
return convert(o)
5983

60-
def write_status_report(status_dict, filename, out_format="json"):
61-
if out_format == "json":
84+
output = recursive_convert(status_dict)
85+
ext = os.path.splitext(filename)[-1].lower()
86+
if ext in [".json"]:
6287
with open(filename, "w") as f:
63-
json.dump(status_dict, f, indent=2)
64-
elif out_format == "yaml":
65-
if yaml is None:
66-
raise ImportError("pyyaml is required for YAML output.")
88+
json.dump(output, f, indent=2)
89+
elif ext in [".yaml", ".yml"]:
6790
with open(filename, "w") as f:
68-
yaml.dump(status_dict, f, sort_keys=False)
91+
yaml.dump(output, f, sort_keys=False)
6992
else:
70-
raise ValueError("Unsupported format: {}".format(out_format))
93+
raise ValueError(f"Unknown file extension: {ext}")
7194

7295

73-
# Optional: CLI interface
74-
if __name__ == "__main__":
75-
import click
96+
@click.command()
97+
@click.option(
98+
"--output",
99+
default="vasp_status.json",
100+
help="Output file name (default: vasp_status.json)",
101+
)
102+
@click.option("--folders", default=".", help="Root directory containing folders")
103+
@click.option(
104+
"--atol",
105+
default=1e-6,
106+
type=float,
107+
show_default=True,
108+
help="Convergence tolerance for force sum norm",
109+
)
110+
def main(output, folders, atol):
111+
status = classify_folders(folders, atol=atol)
112+
write_status_report(status, output)
76113

77-
@click.command()
78-
@click.option(
79-
"--format",
80-
"out_format",
81-
type=click.Choice(["json", "yaml"], case_sensitive=False),
82-
default="json",
83-
help="Output format (json or yaml)",
84-
)
85-
@click.option("--output", default=None, help="Output file name (optional)")
86-
@click.option("--folders", default=".", help="Root directory containing folders")
87-
@click.option(
88-
"--atol",
89-
default=1e-6,
90-
type=float,
91-
show_default=True,
92-
help="Convergence tolerance for force sum norm",
93-
)
94-
def main(out_format, output, folders, atol):
95-
status = classify_folders(folders, atol=atol)
96-
if output is None:
97-
output = f"vasp_status.{out_format}"
98-
write_status_report(status, output, out_format)
99114

115+
# Optional: CLI interface
116+
if __name__ == "__main__":
100117
main()

0 commit comments

Comments
 (0)