Skip to content

Commit 4cf23ed

Browse files
committed
feat: add diffpy.app runmacro <.dp-in file> entry point
1 parent 606bcfc commit 4cf23ed

9 files changed

Lines changed: 388 additions & 273 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ exclude = [] # exclude packages matching these glob patterns (empty by default)
5151
namespaces = false # to disable scanning PEP 420 namespaces (true by default)
5252

5353
[project.scripts]
54-
diffpy-apps = "diffpy.apps.app:main"
54+
"diffpy.app" = "diffpy.apps.apps:main"
5555

5656
[tool.setuptools.dynamic]
5757
dependencies = {file = ["requirements/pip.txt"]}

src/diffpy/apps/app_runmacro.py

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
from pathlib import Path
2+
from collections import OrderedDict
3+
import yaml
4+
from textx import metamodel_from_str
5+
6+
from diffpy.apps.pdfadapter import PDFAdapter
7+
import inspect
8+
9+
grammar = r"""
10+
Program:
11+
commands*=Command
12+
variable=VariableBlock
13+
;
14+
15+
Command:
16+
LoadCommand | SetCommand | CreateCommand | SaveCommand
17+
;
18+
19+
LoadCommand:
20+
'load' component=ID name=ID 'from' source=STRING
21+
;
22+
23+
SetCommand:
24+
'set' name=ID attribute=ID 'as' value+=Value[eolterm]
25+
| 'set' name=ID 'as' value+=Value[eolterm]
26+
;
27+
28+
CreateCommand:
29+
'create' 'equation' 'variables' value+=Value[eolterm]
30+
;
31+
32+
SaveCommand:
33+
'save' 'to' source=STRING
34+
;
35+
36+
VariableBlock:
37+
'variables:' '---' content=/[\s\S]*?(?=---)/ '---'
38+
;
39+
40+
Value:
41+
STRICTFLOAT | INT | STRING | RawValue
42+
;
43+
44+
RawValue:
45+
/[^\s]+/
46+
;
47+
"""
48+
49+
50+
class MacroParser:
51+
def __init__(self):
52+
self.pdfadapter = PDFAdapter()
53+
self.meta_model = metamodel_from_str(grammar)
54+
self.meta_model.register_obj_processors(
55+
{
56+
"SetCommand": self.set_command_processor,
57+
"LoadCommand": self.load_command_processor,
58+
"VariableBlock": self.parameter_block_processor,
59+
"CreateCommand": self.create_command_processor,
60+
"SaveCommand": self.save_command_processor,
61+
}
62+
)
63+
# key: method_name.argument_name
64+
# value: argument_value
65+
self.inputs = {}
66+
# key: structure name or profile name set in the macro
67+
# value: 'structure' or 'profile'
68+
self.variables = OrderedDict()
69+
70+
def parse(self, code):
71+
self.meta_model.model_from_str(code)
72+
73+
def input_as_list(self, key, value):
74+
if key in self.inputs:
75+
if not isinstance(self.inputs[key], list):
76+
self.inputs[key] = [self.inputs[key]]
77+
else:
78+
self.inputs[key].append(value)
79+
else:
80+
if isinstance(value, list):
81+
self.inputs[key] = value
82+
else:
83+
self.inputs[key] = [value]
84+
85+
def load_command_processor(self, command):
86+
if command.component == "structure":
87+
# TODO: support multiple sturctures input in the future
88+
key = "initialize_structures.structure_paths"
89+
variable = "structure"
90+
elif command.component == "profile":
91+
key = "initialize_profile.profile_path"
92+
variable = "profile"
93+
else:
94+
raise ValueError(
95+
f"Unknown component type: {command.component} "
96+
"Please use 'structure' or 'profile'."
97+
)
98+
source_path = Path(command.source)
99+
if not source_path.exists():
100+
raise FileNotFoundError(
101+
f"{command.component} {source_path} not found. "
102+
"Please ensure the path is correct and the file exists."
103+
)
104+
self.inputs[key] = str(source_path)
105+
self.variables[command.name] = variable
106+
if variable == "structure":
107+
self.input_as_list("initialize_structures.names", command.name)
108+
109+
def set_command_processor(self, command):
110+
if command.name == "equation":
111+
key = "initialize_contribution.equation"
112+
elif command.name in self.variables:
113+
if self.variables[command.name] == "structure":
114+
if command.attribute == "spacegroup":
115+
key = "initialize_structures.spacegroups"
116+
else:
117+
key = "initialize_structures." + command.attribute
118+
elif self.variables[command.name] == "profile":
119+
key = "initialize_profile." + command.attribute
120+
else:
121+
raise ValueError(
122+
f"Unknown variable type for name: {command.name}. "
123+
"This is an internal error. Please report this issue to "
124+
"the developers."
125+
)
126+
else:
127+
raise ValueError(
128+
f"Unknown name in set command: {command.name}. "
129+
"Please ensure that it is typed correctly as 'equation' or "
130+
"it matches a previously loaded structure or "
131+
"profile. "
132+
)
133+
self.input_as_list(key, command.value)
134+
135+
def parameter_block_processor(self, variable_block):
136+
self.inputs["set_initial_variable_values.variable_name_to_value"] = {}
137+
self.inputs["refine_variables.variable_names"] = []
138+
parameters = yaml.safe_load(variable_block.content)
139+
if not isinstance(parameters, list):
140+
raise ValueError(
141+
"Parameter block should contain a list of parameters. "
142+
"Please use the following format:\n"
143+
"- param1 # use default initial value\n"
144+
"- param2: initial_value\n"
145+
)
146+
for item in parameters:
147+
if isinstance(item, str):
148+
self.inputs["refine_variables.variable_names"].append(
149+
item.replace(".", "_")
150+
)
151+
elif isinstance(item, dict):
152+
pname, pvalue = list(item.items())[0]
153+
self.inputs[
154+
"set_initial_variable_values.variable_name_to_value"
155+
][pname.replace(".", "_")] = pvalue
156+
self.inputs["refine_variables.variable_names"].append(
157+
pname.replace(".", "_")
158+
)
159+
else:
160+
raise ValueError(
161+
"Variables block items are not correctly formatted. "
162+
"Please use the following format:\n"
163+
"- param1 # use default initial value\n"
164+
"- param2: initial_value\n"
165+
)
166+
167+
def create_command_processor(self, command):
168+
self.inputs["add_contribution_variables.variable_names"] = (
169+
command.value
170+
)
171+
172+
def save_command_processor(self, command):
173+
self.inputs["save_results.result_path"] = command.source
174+
175+
def required_args(self, func):
176+
sig = inspect.signature(func)
177+
return [
178+
name
179+
for name, p in sig.parameters.items()
180+
if p.default is inspect.Parameter.empty
181+
and p.kind
182+
in (
183+
inspect.Parameter.POSITIONAL_ONLY,
184+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
185+
inspect.Parameter.KEYWORD_ONLY,
186+
)
187+
]
188+
189+
def call_pdfadapter_method(self, method_name, function_requirement):
190+
func = getattr(self.pdfadapter, method_name)
191+
required_arguments = self.required_args(func)
192+
arguments = {
193+
key.split(".")[1]: value
194+
for key, value in self.inputs.items()
195+
if key.startswith(method_name)
196+
}
197+
if not all(arg in arguments for arg in required_arguments):
198+
missing_args = [
199+
arg for arg in required_arguments if arg not in arguments
200+
]
201+
if function_requirement == "required":
202+
raise ValueError(
203+
"Missing required arguments for function "
204+
f"'{method_name}' {', '.join(missing_args)}. "
205+
"Please provide these arguments in the macro file."
206+
)
207+
elif function_requirement == "optional":
208+
print(
209+
"Missing required arguments for function "
210+
f"'{method_name}' {', '.join(missing_args)}. "
211+
"This function will be skipped. "
212+
"Please provide these arguments in the macro file "
213+
"to activate this function."
214+
)
215+
return
216+
func(**arguments)
217+
218+
def preprocess(self):
219+
methods_to_call = [
220+
("initialize_profile", "required"),
221+
("initialize_structures", "required"),
222+
("initialize_contribution", "required"),
223+
("initialize_recipe", "required"),
224+
("add_contribution_variables", "optional"),
225+
("set_initial_variable_values", "optional"),
226+
]
227+
for method in methods_to_call:
228+
self.call_pdfadapter_method(*method)
229+
230+
def run(self):
231+
methods_to_call = [
232+
("refine_variables", "required"),
233+
("save_results", "optional"),
234+
]
235+
for method in methods_to_call:
236+
self.call_pdfadapter_method(*method)
237+
return self.pdfadapter.get_results()
238+
239+
240+
def runmacro(args):
241+
dpin_path = Path(args.file)
242+
if not dpin_path.exists():
243+
raise FileNotFoundError(
244+
f"{str(dpin_path)} not found. Please check if this file "
245+
"exists and provide the correct path to it."
246+
)
247+
dsl_code = dpin_path.read_text()
248+
parser = MacroParser()
249+
parser.parse(dsl_code)
250+
parser.preprocess()
251+
return parser.run()
252+
253+
254+
if __name__ == "__main__":
255+
parser = MacroParser()
256+
code = f"""
257+
load structure G1 from "{str(Path(__file__).parents[3] / "tests/data/Ni.cif")}"
258+
load profile exp_ni from "{str(Path(__file__).parents[3] / "tests/data/Ni.gr")}"
259+
260+
set G1 spacegroup as auto
261+
set exp_ni q_range as 0.1 25
262+
set exp_ni calculation_range as 1.5 50 0.01
263+
create equation variables s0
264+
set equation as "s0*G1"
265+
save to "results.json"
266+
267+
variables:
268+
---
269+
- G1.a: 3.52
270+
- s0: 0.4
271+
- G1.Uiso_0: 0.005
272+
- G1.delta2: 2
273+
- qdamp: 0.04
274+
- qbroad: 0.02
275+
---
276+
""" # noqa: E501
277+
parser.parse(code)
278+
parser.preprocess()
279+
recipe = parser.pdfadapter.recipe
280+
for pname, param in recipe._parameters.items():
281+
print(f"{pname}: {param.value}")

src/diffpy/apps/apps.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import argparse
2+
3+
from diffpy.apps.version import __version__ # noqa
4+
from diffpy.apps.app_runmacro import runmacro
5+
6+
7+
class DiffpyHelpFormatter(argparse.RawDescriptionHelpFormatter):
8+
"""Format subcommands without showing an extra placeholder entry."""
9+
10+
def _format_action(self, action):
11+
if isinstance(action, argparse._SubParsersAction):
12+
return "".join(
13+
self._format_action(subaction)
14+
for subaction in self._iter_indented_subactions(action)
15+
)
16+
return super()._format_action(action)
17+
18+
19+
def main():
20+
parser = argparse.ArgumentParser(
21+
prog="diffpy.apps",
22+
description=(
23+
"User applications to help with tasks using diffpy packages\n\n"
24+
"For more information, visit: "
25+
"https://github.com/diffpy/diffpy.apps/"
26+
),
27+
formatter_class=DiffpyHelpFormatter,
28+
)
29+
30+
parser.add_argument(
31+
"--version",
32+
action="store_true",
33+
help="Show the program's version number and exit",
34+
)
35+
apps_parsers = parser.add_subparsers(
36+
title="Available applications",
37+
dest="application",
38+
)
39+
runmacro_parser = apps_parsers.add_parser(
40+
"runmacro",
41+
help="Run a macro `<.dp-in>` file",
42+
)
43+
runmacro_parser.add_argument(
44+
"file",
45+
type=str,
46+
help="Path to the `<.dp-in>` macro file to be run",
47+
)
48+
runmacro_parser.set_defaults(func=runmacro)
49+
args = parser.parse_args()
50+
if args.application is None:
51+
parser.print_help()
52+
else:
53+
args.func(args)
54+
55+
56+
if __name__ == "__main__":
57+
main()

src/diffpy/apps/apps_app.py

Lines changed: 0 additions & 33 deletions
This file was deleted.

0 commit comments

Comments
 (0)