diff --git a/docs/input.md b/docs/input.md index 6fc03bd1..a0bf2c29 100644 --- a/docs/input.md +++ b/docs/input.md @@ -106,6 +106,7 @@ This section defines how the configuration space is explored. "type" : "lmp-template", "lmp" : "template.lammps", "plm" : "template.plumed", "trj_freq" : 10, "revisions" : {"V_NSTEPS" : [40], "V_TEMP" : [150, 200]}, + "strict_revisions": true, "conf_idx": [0], "n_sample" : 3 } ], @@ -120,6 +121,19 @@ This section defines how the configuration space is explored. ] } ``` + +For an `"lmp-template"` task group, `revisions` keys are replaced as complete +tokens in both LAMMPS and PLUMED templates. By default, `strict_revisions` is +`false`: standalone `V_*` tokens not listed in `revisions` are preserved and +reported as warnings because they may be native LAMMPS or PLUMED identifiers. +Set `strict_revisions` to `true` to stop task generation when such tokens are +found. + +For a `"customized-lmp-template"` task group, `strict_revisions` defaults to +`false` for the same compatibility reason: its shell commands may intentionally +generate templates containing `V_*` tokens. Set it to `true` to apply strict +validation to the generated templates. + The {dargs:argument}`"type" : "lmp"` means that configurations are explored by LAMMPS DPMD runs. The {dargs:argument}`"config"` key defines the lmp configs. The {dargs:argument}`"configurations"` provides the initial configurations (coordinates of atoms and the simulation cell) of the DPMD simulations. It is a list. The elements of the list are `dict`s that defines how the configurations are generated diff --git a/dpgen2/exploration/task/customized_lmp_template_task_group.py b/dpgen2/exploration/task/customized_lmp_template_task_group.py index d7022516..f25a50c5 100644 --- a/dpgen2/exploration/task/customized_lmp_template_task_group.py +++ b/dpgen2/exploration/task/customized_lmp_template_task_group.py @@ -62,6 +62,7 @@ def set_lmp( output_lmp_conf_name: str = lmp_conf_name, output_lmp_tmpl_name: str = lmp_input_name, output_plm_tmpl_name: Optional[str] = None, + strict_revisions: bool = False, ) -> None: r"""Set lammps task. @@ -99,11 +100,14 @@ def set_lmp( Generated lmp input file name. output_plm_tmpl_name : str Generated plm input file name. + strict_revisions : bool + Whether undefined V_* revision tokens fail task generation. """ self.numb_models = numb_models self.lmp_template = Path(input_lmp_tmpl_name).read_text().split("\n") self.revisions = revisions + self.strict_revisions = strict_revisions self.traj_freq = traj_freq self.has_plm = input_plm_tmpl_name is not None self.do_custom = ( @@ -218,6 +222,7 @@ def _make_customized_task_group( self.output_plm_tmpl_name if self.has_plm else None, revisions=self.revisions, traj_freq=self.traj_freq, + strict_revisions=self.strict_revisions, ) conf_fc = Path(self.output_lmp_conf_name).read_text() lmp_tgroup.set_conf( diff --git a/dpgen2/exploration/task/lmp_template_task_group.py b/dpgen2/exploration/task/lmp_template_task_group.py index d1f8e9fc..0d33a7b7 100644 --- a/dpgen2/exploration/task/lmp_template_task_group.py +++ b/dpgen2/exploration/task/lmp_template_task_group.py @@ -1,11 +1,18 @@ import itertools import random +import re +import warnings from pathlib import ( Path, ) from typing import ( List, Optional, + Set, +) + +from dflow.python import ( + FatalError, ) from dpgen2.constants import ( @@ -49,9 +56,11 @@ def set_lmp( extra_pair_style_args: str = "", pimd_bead: Optional[str] = None, input_extra_files: Optional[List[str]] = None, + strict_revisions: bool = False, ) -> None: self.lmp_template = Path(lmp_template_fname).read_text().split("\n") self.revisions = revisions + self.strict_revisions = strict_revisions self.traj_freq = traj_freq self.extra_pair_style_args = extra_pair_style_args self.pimd_bead = pimd_bead @@ -101,6 +110,21 @@ def make_task( if self.plm_set: templates.append(self.plm_template) conts = self.make_cont(templates, self.revisions) + # Validate: check for unreplaced V_* variables in substituted templates + template_raw = "\n".join(lmp_template) + if self.plm_set: + template_raw += "\n" + "\n".join(self.plm_template) + # Flatten all template variants (LAMMPS + PLUMED) for validation + all_conts = [c for c_list in conts for c in c_list] + try: + check_revisions_completeness( + all_conts, + list(self.revisions.keys()), + template_raw=template_raw, + strict=self.strict_revisions, + ) + except ValueError as exc: + raise FatalError(str(exc)) from exc nconts = len(conts[0]) for cc, ii in itertools.product(confs, range(nconts)): # type: ignore if not self.plm_set: @@ -214,7 +238,167 @@ def revise_lmp_input_plm(lmp_lines, in_plm, out_plm="output.plumed"): def revise_by_keys(lmp_lines, keys, values): + """Replace complete revision tokens without matching identifier prefixes.""" for kk, vv in zip(keys, values): # type: ignore + replacement = str(vv) + pattern = re.compile(rf"(? str: + """Remove unquoted LAMMPS comments while preserving quoted hash characters. + + This prevents V_* patterns in comments (e.g., "# set V_PRESS later") + from being flagged as unreplaced variables without hiding executable + placeholders inside single- or double-quoted strings. + """ + stripped: List[str] = [] + for line in content.split("\n"): + quote: Optional[str] = None + escaped = False + kept: List[str] = [] + for char in line: + if escaped: + kept.append(char) + escaped = False + elif char == "\\" and quote is not None: + kept.append(char) + escaped = True + elif char in ("'", '"'): + if quote == char: + quote = None + elif quote is None: + quote = char + kept.append(char) + elif char == "#" and quote is None: + break + else: + kept.append(char) + stripped.append("".join(kept)) + return "\n".join(stripped) + + +def find_unreplaced_variables(content: str) -> Set[str]: + """Scan text for standalone V_* tokens that may be unreplaced revisions. + + Strips LAMMPS comments before scanning to avoid false positives from + commented-out variable references. + + Parameters + ---------- + content : str + The LAMMPS input content after revision substitution. + + Returns + ------- + Set[str] + Set of variable names (e.g. {"V_PRESS", "V_UNDEFINED"}) still present. + """ + stripped = _strip_lammps_comments(content) + return set(_REVISION_VARIABLE_PATTERN.findall(stripped)) + + +def report_undefined_revision_variables( + variables: Set[str], + revision_keys: List[str], + strict: bool, +) -> None: + """Raise for undefined tokens in strict mode, otherwise emit a warning.""" + if not variables: + return + message = ( + f"LAMMPS template contains undefined revision variable(s): " + f"{sorted(variables)}. Defined revisions: {sorted(revision_keys)}. " + f"Please add missing variables to 'revisions' in your exploration config, " + f"or remove them from the template." + ) + if strict: + raise ValueError(message) + warnings.warn( + message + " Continuing because strict revision validation is disabled.", + stacklevel=3, + ) + + +def check_revisions_completeness( + templates_content: List[str], + revision_keys: List[str], + template_raw: str = "", + strict: bool = True, +) -> None: + """Validate that all V_* placeholders in the template have been substituted. + + This function performs three checks: + 1. **Raw-template definition check**: Compare complete placeholder tokens with + the revision keys before applying substitutions. + 2. **Post-substitution residual check**: After applying revisions, scan the output + for any remaining V_* variables that were not replaced. This catches typos in + template variables or missing keys in revisions. + 3. **Unused key warning**: If a revision key is defined but never appears in the + raw template, emit a warning (possible typo in the key name). + + Parameters + ---------- + templates_content : List[str] + List of template strings after revision substitution (one per revision combo). + revision_keys : List[str] + The keys defined in the revisions dict. + template_raw : str + The raw template content before substitution (for unused key detection). + strict : bool + If true, undefined V_* tokens are errors. If false, warn and continue + so templates may use V_* as native LAMMPS or PLUMED identifiers. + + Raises + ------ + ValueError + If undefined V_* tokens are found and strict is true. + + Warns + ----- + UserWarning + If undefined V_* tokens are found and strict is false, or if a + revision key is unused. + """ + revision_key_set = set(revision_keys) + + # Check 1: Compare raw tokens before substitution so a shorter defined key + # cannot erase the prefix of a longer undefined placeholder. + raw_variables = find_unreplaced_variables(template_raw) if template_raw else set() + undefined_raw = raw_variables - revision_key_set + report_undefined_revision_variables( + undefined_raw, + revision_keys, + strict=strict, + ) + + # Check 2: Residual unreplaced variables + all_unreplaced: Set[str] = set() + for content in templates_content: + all_unreplaced.update(find_unreplaced_variables(content)) + + report_undefined_revision_variables( + all_unreplaced - undefined_raw, + revision_keys, + strict=strict, + ) + + # Check 3: Unused revision keys (warning only) + if template_raw and revision_keys: + for key in revision_keys: + if key not in raw_variables: + warnings.warn( + f"Revision key '{key}' is defined but does not appear in the " + f"LAMMPS/PLUMED template. Possible typo?", + stacklevel=3, + ) diff --git a/dpgen2/exploration/task/make_task_group_from_config.py b/dpgen2/exploration/task/make_task_group_from_config.py index 2859ac8f..d3b4ce52 100644 --- a/dpgen2/exploration/task/make_task_group_from_config.py +++ b/dpgen2/exploration/task/make_task_group_from_config.py @@ -133,6 +133,11 @@ def lmp_template_task_group_args(): doc_lmp_template_fname = "The file name of lammps input template" doc_plm_template_fname = "The file name of plumed input template" doc_revisions = "The revisions. Should be a dict providing the key - list of desired values pair. Key is the word to be replaced in the templates, and it may appear in both the lammps and plumed input templates. All values in the value list will be enmerated." + doc_strict_revisions = ( + "Whether undefined V_* revision tokens fail task generation. " + "Defaults to false so templates using V_* as native LAMMPS or PLUMED " + "identifiers are warned about and preserved." + ) doc_traj_freq = "The frequency of dumping configurations and thermodynamic states" doc_extra_pair_style_args = "The extra arguments for pair_style" doc_pimd_bead = "Bead index for PIMD, None for non-PIMD" @@ -165,6 +170,13 @@ def lmp_template_task_group_args(): alias=["plm_template", "plm"], ), Argument("revisions", dict, optional=True, default={}, doc=doc_revisions), + Argument( + "strict_revisions", + bool, + optional=True, + default=False, + doc=doc_strict_revisions, + ), Argument( "traj_freq", int, @@ -201,6 +213,11 @@ def customized_lmp_template_task_group_args(): doc_input_lmp_tmpl_name = "The file name of lammps input template" doc_input_plm_tmpl_name = "The file name of plumed input template" doc_revisions = "The revisions. Should be a dict providing the key - list of desired values pair. Key is the word to be replaced in the templates, and it may appear in both the lammps and plumed input templates. All values in the value list will be enmerated." + doc_strict_revisions = ( + "Whether undefined V_* revision tokens fail task generation. " + "Defaults to false for customized templates so generated V_* tokens " + "are warned about and preserved." + ) doc_traj_freq = "The frequency of dumping configurations and thermodynamic states" doc_custom_shell_commands = ( "Customized shell commands to be run for each configuration. " @@ -237,6 +254,13 @@ def customized_lmp_template_task_group_args(): "custom_shell_commands", list, optional=False, doc=doc_custom_shell_commands ), Argument("revisions", dict, optional=True, default={}, doc=doc_revisions), + Argument( + "strict_revisions", + bool, + optional=True, + default=False, + doc=doc_strict_revisions, + ), Argument( "traj_freq", int, diff --git a/tests/exploration/test_customized_lmp_templ_task_group.py b/tests/exploration/test_customized_lmp_templ_task_group.py index 57462759..262a68a1 100644 --- a/tests/exploration/test_customized_lmp_templ_task_group.py +++ b/tests/exploration/test_customized_lmp_templ_task_group.py @@ -12,6 +12,9 @@ ) import numpy as np +from dflow.python import ( + FatalError, +) try: from exploration.context import ( @@ -259,3 +262,47 @@ def test_lmp(self): * len(self.lmp_rev_mat["V_TEMP"]) * 2, ) + + def test_empty_revisions_default_to_non_strict(self): + task_group = CustomizedLmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + custom_shell_commands=self.shell_cmd, + revisions=self.rev_empty, + traj_freq=self.traj_freq, + input_lmp_conf_name="foo.lmp", + input_lmp_tmpl_name=self.lmp_template_fname, + input_plm_tmpl_name=None, + input_extra_files=[self.py_script], + output_dir_pattern="task_*", + output_lmp_conf_name="bar.lmp", + output_lmp_tmpl_name="lmp.template", + ) + + with self.assertWarnsRegex(UserWarning, "V_NSTEPS"): + task_group.make_task() + + self.assertFalse(task_group.strict_revisions) + self.assertEqual(len(task_group), len(self.confs) * 2) + + def test_empty_revisions_can_opt_into_strict_mode(self): + task_group = CustomizedLmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + custom_shell_commands=self.shell_cmd, + revisions=self.rev_empty, + traj_freq=self.traj_freq, + input_lmp_conf_name="foo.lmp", + input_lmp_tmpl_name=self.lmp_template_fname, + input_plm_tmpl_name=None, + input_extra_files=[self.py_script], + output_dir_pattern="task_*", + output_lmp_conf_name="bar.lmp", + output_lmp_tmpl_name="lmp.template", + strict_revisions=True, + ) + + with self.assertRaisesRegex(FatalError, "V_NSTEPS"): + task_group.make_task() diff --git a/tests/exploration/test_lmp_templ_task_group.py b/tests/exploration/test_lmp_templ_task_group.py index 6211137d..ec6c9f22 100644 --- a/tests/exploration/test_lmp_templ_task_group.py +++ b/tests/exploration/test_lmp_templ_task_group.py @@ -24,6 +24,10 @@ patch, ) +from dflow.python import ( + FatalError, +) + from dpgen2.constants import ( lmp_conf_name, lmp_input_name, @@ -33,6 +37,11 @@ ExplorationStage, LmpTemplateTaskGroup, ) +from dpgen2.exploration.task.lmp_template_task_group import ( + check_revisions_completeness, + find_unreplaced_variables, + revise_by_keys, +) in_lmp_template = textwrap.dedent( """variable NSTEPS equal V_NSTEPS @@ -339,6 +348,19 @@ def test_lmp(self): ) idx += 1 + def test_set_lmp_preserves_positional_traj_freq(self): + task_group = LmpTemplateTaskGroup() + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + None, + self.lmp_rev_mat, + self.traj_freq, + ) + + self.assertEqual(task_group.traj_freq, self.traj_freq) + self.assertFalse(task_group.strict_revisions) + def test_lmp_plm(self): task_group = LmpTemplateTaskGroup() task_group.set_conf(self.confs) @@ -388,6 +410,7 @@ def test_lmp_plm(self): idx += 1 def test_lmp_empty(self): + """Non-strict empty revisions should warn but succeed.""" task_group = LmpTemplateTaskGroup() task_group.set_conf(self.confs) task_group.set_lmp( @@ -395,25 +418,18 @@ def test_lmp_empty(self): self.lmp_template_fname, revisions=self.rev_empty, traj_freq=self.traj_freq, + strict_revisions=False, ) - task_group.make_task() + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as w: + _warnings.simplefilter("always") + task_group.make_task() + var_warnings = [x for x in w if "V_NSTEPS" in str(x.message)] + self.assertGreater(len(var_warnings), 0) + # Should still produce tasks ngroup = len(task_group) - self.assertEqual( - ngroup, - len(self.confs), - ) - idx = 0 - for cc in range(len(self.confs)): - ee = expected_lmp_template.split("\n") - self.assertEqual( - task_group[idx].files()[lmp_conf_name], - self.confs[cc], - ) - self.assertEqual( - task_group[idx].files()[lmp_input_name].split("\n"), - ee, - ) - idx += 1 + self.assertEqual(ngroup, len(self.confs)) def test_lmp_pimd(self): task_group = LmpTemplateTaskGroup() @@ -436,3 +452,317 @@ def test_lmp_pimd(self): task_group[0].files()[lmp_input_name].split("\n"), ee, ) + + +class TestRevisionVariablePrecheck(unittest.TestCase): + """Test PR6: validation of revision variables in LAMMPS templates.""" + + def setUp(self): + self.lmp_template_fname = Path("lmp_precheck.template") + self.numb_models = 4 + self.confs = ["foo"] + self.traj_freq = 10 + + def tearDown(self): + if self.lmp_template_fname.exists(): + os.remove(self.lmp_template_fname) + + def _write_template(self, content): + self.lmp_template_fname.write_text(content) + + def test_undefined_variable_raises(self): + """Template has V_PRESS but revisions only define V_NSTEPS and V_TEMP.""" + template = textwrap.dedent( + """\ + variable NSTEPS equal V_NSTEPS + variable TEMP equal V_TEMP + variable PRESS equal V_PRESS + + pair_style deepmd + pair_coeff * * + dump dpgen_dump + run ${NSTEPS} + """ + ) + self._write_template(template) + task_group = LmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + revisions={"V_NSTEPS": [1000], "V_TEMP": [300]}, + traj_freq=self.traj_freq, + strict_revisions=True, + ) + with self.assertRaises(FatalError) as ctx: + task_group.make_task() + self.assertIn("V_PRESS", str(ctx.exception)) + self.assertIn("undefined revision variable", str(ctx.exception).lower()) + + def test_no_revisions_strict_mode_raises(self): + """Strict mode rejects V_* variables when no revisions are provided.""" + template = textwrap.dedent( + """\ + variable NSTEPS equal V_NSTEPS + variable TEMP equal V_TEMP + + pair_style deepmd + pair_coeff * * + dump dpgen_dump + run ${NSTEPS} + """ + ) + self._write_template(template) + task_group = LmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + revisions={}, + traj_freq=self.traj_freq, + strict_revisions=True, + ) + + with self.assertRaisesRegex(FatalError, "V_NSTEPS"): + task_group.make_task() + + def test_all_variables_defined_no_error(self): + """All V_* variables are covered by revisions — should succeed.""" + template = textwrap.dedent( + """\ + variable NSTEPS equal V_NSTEPS + variable TEMP equal V_TEMP + + pair_style deepmd + pair_coeff * * + dump dpgen_dump + run ${NSTEPS} + """ + ) + self._write_template(template) + task_group = LmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + revisions={"V_NSTEPS": [1000], "V_TEMP": [300, 600]}, + traj_freq=self.traj_freq, + ) + # Should not raise + task_group.make_task() + self.assertEqual(len(task_group), 2) # 1 conf * 2 V_TEMP values + + def test_unused_revision_key_warns(self): + """Revision defines V_TYPO that doesn't appear in template — should warn.""" + template = textwrap.dedent( + """\ + variable NSTEPS equal V_NSTEPS + + pair_style deepmd + pair_coeff * * + dump dpgen_dump + run ${NSTEPS} + """ + ) + self._write_template(template) + task_group = LmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + revisions={"V_NSTEPS": [1000], "V_TYPO": [42]}, + traj_freq=self.traj_freq, + ) + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as w: + _warnings.simplefilter("always") + task_group.make_task() + # Should have at least one warning about V_TYPO + typo_warnings = [x for x in w if "V_TYPO" in str(x.message)] + self.assertGreater(len(typo_warnings), 0) + + def test_lammps_variables_without_revision_prefix_are_not_flagged(self): + """LAMMPS references without the reserved V_* prefix remain untouched.""" + template = textwrap.dedent( + """\ + variable NSTEPS equal V_NSTEPS + + pair_style deepmd + pair_coeff * * + dump dpgen_dump + velocity all create ${TEMP} 12345 + run ${NSTEPS} + """ + ) + self._write_template(template) + task_group = LmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + revisions={"V_NSTEPS": [1000]}, + traj_freq=self.traj_freq, + ) + # ${TEMP} is LAMMPS syntax, not a dpgen revision variable — should not raise + task_group.make_task() + self.assertEqual(len(task_group), 1) + + def test_v_prefixed_lammps_identifier_is_reserved_in_strict_mode(self): + """Native V_* identifiers warn by default and fail in strict mode.""" + template = textwrap.dedent( + f""" + variable NSTEPS equal V_NSTEPS + variable V_MAX equal 3.0 + + pair_style deepmd + pair_coeff * * + dump dpgen_dump + velocity all create {chr(36)}{{V_MAX}} 12345 + run {chr(36)}{{NSTEPS}} + """ + ).lstrip() + self._write_template(template) + task_group = LmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + revisions={"V_NSTEPS": [1000]}, + traj_freq=self.traj_freq, + strict_revisions=True, + ) + with self.assertRaisesRegex(FatalError, "V_MAX"): + task_group.make_task() + + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + revisions={"V_NSTEPS": [1000]}, + traj_freq=self.traj_freq, + ) + with self.assertWarnsRegex(UserWarning, "V_MAX"): + task_group.make_task() + self.assertFalse(task_group.strict_revisions) + self.assertEqual(len(task_group), 1) + + def test_plumed_template_undefined_variable_raises(self): + """V_* in PLUMED template but not in revisions should also be caught.""" + lmp_template = textwrap.dedent( + """\ + variable NSTEPS equal V_NSTEPS + variable TEMP equal V_TEMP + + pair_style deepmd + pair_coeff * * + dump dpgen_dump + fix dpgen_plm + run ${NSTEPS} + """ + ) + plm_template = textwrap.dedent( + """\ + DISTANCE ATOMS=3,5 LABEL=d1 + RESTRAINT ARG=d1 AT=V_DIST0 KAPPA=150.0 LABEL=restraint + """ + ) + self._write_template(lmp_template) + plm_fname = Path("plm_precheck.template") + plm_fname.write_text(plm_template) + try: + task_group = LmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + plm_template_fname=str(plm_fname), + # V_DIST0 is used in PLUMED template but NOT defined here + revisions={"V_NSTEPS": [1000], "V_TEMP": [300]}, + traj_freq=self.traj_freq, + strict_revisions=True, + ) + with self.assertRaises(FatalError) as ctx: + task_group.make_task() + self.assertIn("V_DIST0", str(ctx.exception)) + finally: + plm_fname.unlink(missing_ok=True) + + def test_commented_variables_not_flagged(self): + """V_* in LAMMPS comments should NOT trigger errors.""" + template = textwrap.dedent( + """\ + variable NSTEPS equal V_NSTEPS + # TODO: add V_PRESS support later + # variable PRESS equal V_PRESS + + pair_style deepmd + pair_coeff * * + dump dpgen_dump + run ${NSTEPS} + """ + ) + self._write_template(template) + task_group = LmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + revisions={"V_NSTEPS": [1000]}, + traj_freq=self.traj_freq, + ) + # V_PRESS is only in comments — should NOT raise + task_group.make_task() + self.assertEqual(len(task_group), 1) + + def test_undefined_longer_placeholder_raises_before_substitution(self): + """A defined prefix must not erase a longer undefined placeholder.""" + template = textwrap.dedent( + """\ + variable TEMP equal V_TEMPERATURE + + pair_style deepmd + pair_coeff * * + dump dpgen_dump + run 1 + """ + ) + self._write_template(template) + task_group = LmpTemplateTaskGroup() + task_group.set_conf(self.confs) + task_group.set_lmp( + self.numb_models, + self.lmp_template_fname, + revisions={"V_TEMP": [300]}, + traj_freq=self.traj_freq, + strict_revisions=True, + ) + with self.assertRaisesRegex(FatalError, "V_TEMPERATURE"): + task_group.make_task() + + def test_overlapping_defined_placeholders_are_replaced_as_tokens(self): + lines = ["print V_TEMP V_TEMPERATURE"] + revised = revise_by_keys(lines, ["V_TEMP", "V_TEMPERATURE"], [300, 450]) + self.assertEqual(revised, ["print 300 450"]) + + def test_quoted_hashes_preserve_revision_placeholders(self): + content = textwrap.dedent( + r"""\ + print "# target V_MISSING" + print 'hash # target V_OTHER' + print "escaped \"# target V_ESCAPED" + # V_COMMENT_ONLY + """ + ) + self.assertEqual( + find_unreplaced_variables(content), + {"V_MISSING", "V_OTHER", "V_ESCAPED"}, + ) + + def test_unused_revision_key_uses_complete_comment_aware_tokens(self): + template = "print V_TEMPERATURE # V_TEMP" + with self.assertWarnsRegex(UserWarning, "Revision key 'V_TEMP'"): + check_revisions_completeness( + templates_content=["print 450"], + revision_keys=["V_TEMP", "V_TEMPERATURE"], + template_raw=template, + ) diff --git a/tests/exploration/test_make_task_group_from_config.py b/tests/exploration/test_make_task_group_from_config.py index f9fe93be..254605ae 100644 --- a/tests/exploration/test_make_task_group_from_config.py +++ b/tests/exploration/test_make_task_group_from_config.py @@ -63,6 +63,17 @@ def test_template(self): self.numb_models, self.mass_map, self.config_template ) self.assertTrue(isinstance(tgroup, LmpTemplateTaskGroup)) + self.assertFalse(tgroup.strict_revisions) + + def test_template_strict_revisions(self): + strict_config = { + **self.config_template, + "strict_revisions": True, + } + tgroup = make_lmp_task_group_from_config( + self.numb_models, self.mass_map, strict_config + ) + self.assertTrue(tgroup.strict_revisions) class TestMakeCalyTaskGroupFromConfig(unittest.TestCase):