diff --git a/HISTORY.rst b/HISTORY.rst index fe16706..f5d17f8 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,7 +1,20 @@ ======= History ======= -2025.9.22: Bugfix: fixed floating-point precision issues in For loops +2026.9.18 -- Standard structure selection for loops over systems + * The "For systems in the database" loop now uses SEAMM's standard structure + selection: the systems to loop over (all, the current one, or those whose name + is / matches / matches a regular expression) and which configurations of each + (last, first, all, the current one, or by name), or a variable holding a list + of configurations or systems. The defaults -- every system, its last + configuration -- are unchanged, and flowcharts saved with the old options are + translated when loaded. + * Bugfix: selecting configurations by "name is" never matched (the name was + compared by identity, not equality), and "name matches" / "name regexp" fell + through to no selection at all. + * Requires seamm 2026.9.18.1 or later. + +2025.11.22: Bugfix: fixed floating-point precision issues in For loops * This fix addresses floating-point precision issues in For loops that caused early termination and generated malformed directory names (e.g., "3.00000000002" or "2.999999999998"). The fix calculates the number of decimal places from loop diff --git a/Makefile b/Makefile index 4b1a1f1..4357e1f 100644 --- a/Makefile +++ b/Makefile @@ -104,3 +104,12 @@ install: uninstall ## install the package to the active Python's site-packages uninstall: clean ## uninstall the package pip uninstall --yes $(MODULE) + +.PHONY: update +update: ## post-release: sync main and dev, reinstall, run checks, push dev + git checkout main + git pull + git checkout dev + git merge --ff-only main + $(MAKE) lint install test + git push diff --git a/devtools/conda-envs/test_env.yaml b/devtools/conda-envs/test_env.yaml index 8775277..d02a912 100644 --- a/devtools/conda-envs/test_env.yaml +++ b/devtools/conda-envs/test_env.yaml @@ -3,14 +3,20 @@ name: test channels: - conda-forge +# The CI environment. The package under test is installed on top of it with +# `pip install . --no-deps`, so everything the tests need must be listed here. +# Pure-Python dependencies (the SEAMM packages) come from PyPI, which has a new +# release within minutes; only compiled packages come from conda-forge. dependencies: # Base depends - python - pip - # Dependencies for this module + # Compiled packages: from conda-forge so the binaries are consistent + - numpy + - openbabel - psutil - - seamm + - rdkit # Testing - black @@ -23,7 +29,8 @@ dependencies: - pydata-sphinx-theme - sphinx-design - # Pip-only installs + # Pure-Python packages: from PyPI - pip: + - seamm>=2026.9.18.1 # Documentation - sphinx-copybutton diff --git a/loop_step/loop.py b/loop_step/loop.py index 29164bd..ba7c01b 100644 --- a/loop_step/loop.py +++ b/loop_step/loop.py @@ -2,7 +2,6 @@ """Non-graphical part of the Loop step in a SEAMM flowchart""" -import fnmatch import logging from pathlib import Path import re @@ -187,7 +186,10 @@ def description_text(self, P=None): elif P["type"] == "For rows in table": subtext = "For rows in table {table}\n" elif P["type"] == "For systems in the database": - subtext = "For system in the database\n" + subtext = seamm.standard_parameters.structure_selection_description( + P + ).replace("will be used", "will be looped over") + subtext += "\n" else: subtext = "Loop type defined by {type}\n" @@ -423,65 +425,9 @@ def run(self): if index_is_int: fmt = f"0{len(str(max(table_indices) + 1))}d" elif P["type"] == "For systems in the database": - # Get a list of all the matching systems and configurations - system_db = self.get_variable("_system_db") - systems = system_db.systems - - # Filter on system names - choice = P["where system name"] - if choice == "is anything": - pass - elif choice == "is": - name = P["system name"] - systems = [s for s in systems if s.name == name] - elif choice == "matches": - pattern = P["system name"] - systems = [s for s in systems if fnmatch.fnmatch(s.name, pattern)] - elif choice == "regexp": - pattern = P["system name"] - systems = [s for s in systems if re.search(pattern, s.name) is not None] - else: - raise RuntimeError( - f"Matching system names by '{choice}' is not supported" - ) - - # Finally, allow only systems that contain the requested configuration - choice = P["default configuration"] - if choice == "last" or choice == "-1": - systems = [s for s in systems if s.n_configurations > 0] - configurations = [s.configurations[-1] for s in systems] - elif choice == "first" or choice == "1": - systems = [s for s in systems if s.n_configurations > 0] - configurations = [s.configurations[0] for s in systems] - elif choice == "name is": - name = P["configuration name"] - systems = [s for s in systems if s.n_configurations > 0] - configurations = [] - for s in systems: - for c in s.configurations: - if c.name is name: - configurations.append(c) - elif choice == "matches": - pattern = P["configuration name"] - systems = [s for s in systems if s.n_configurations > 0] - configurations = [] - for s in systems: - for c in s.configurations: - if fnmatch.fnmatch(c.name, pattern): - configurations.append(c) - elif choice == "regexp": - pattern = P["configuration name"] - configurations = [] - for s in systems: - for c in s.configurations: - if re.search(pattern, c.name) is not None: - configurations.append(c) - elif choice == "all": - name = P["configuration name"] - systems = [s for s in systems if s.n_configurations > 0] - configurations = [] - for s in systems: - configurations.extend(s.configurations) + # The configurations to loop over: the standard SEAMM selection. An + # empty selection is simply a loop with no iterations. + configurations = self.select_configurations(P, errors=False) if self._loop_value is None: self._loop_value = 0 diff --git a/loop_step/loop_parameters.py b/loop_step/loop_parameters.py index eab0393..5363e5d 100644 --- a/loop_step/loop_parameters.py +++ b/loop_step/loop_parameters.py @@ -7,6 +7,34 @@ logger = logging.getLogger(__name__) +# Legacy (pre-2026.9.18) names and values for the system selection, translated on +# loading an old flowchart to the standard SEAMM structure-selection parameters. +_legacy_keys = { + "where system name": "source systems", + "system name": "source system name", + "default configuration": "source configurations", + "configuration name": "source configuration name", +} +_legacy_values = { + "source systems": { + "is anything": "all", + "is": "name is", + "matches": "name matches", + "regexp": "name regexp", + }, + "source configurations": {"-1": "last", "1": "first"}, +} + +_selection_parameters = { + key: dict(value) + for key, value in seamm.standard_parameters.structure_selection_parameters.items() +} +_selection_parameters["source systems"]["default"] = "all" +_selection_parameters["source systems"]["description"] = "For systems:" +_selection_parameters["source configurations"]["default"] = "last" +_selection_parameters["source configurations"]["description"] = "using configuration:" + + class LoopParameters(seamm.Parameters): """The control parameters for loops""" @@ -147,49 +175,9 @@ class LoopParameters(seamm.Parameters): "description": "Values as variables:", "help_text": "Whether to put the values for the row as seperate variables.", }, - "where system name": { - "default": "is anything", - "kind": "string", - "default_units": "", - "enumeration": ("is anything", "is", "matches", "regexp"), - "format_string": "s", - "description": "where name:", - "help_text": "The filter for the system name, defaults to all systems.", - }, - "system name": { - "default": "", - "kind": "string", - "default_units": "", - "enumeration": tuple(), - "format_string": "s", - "description": "", - "help_text": "The filter for the system name", - }, - "default configuration": { - "default": "last", - "kind": "string", - "default_units": "", - "enumeration": ( - "all", - "last", - "first", - "name is", - "name matches", - "name regexp", - ), - "format_string": "s", - "description": "Select configuration:", - "help_text": "The configuration to select as the default.", - }, - "configuration name": { - "default": "", - "kind": "string", - "default_units": "", - "enumeration": tuple(), - "format_string": "s", - "description": "", - "help_text": "The filter for the configuration name", - }, + # The standard SEAMM structure selection, but looping over every system + # and its last configuration by default, as this step always has. + **_selection_parameters, "directory name": { "default": "loop iteration", "kind": "string", @@ -223,3 +211,19 @@ def __init__(self, defaults={}, data=None): parameters given in the class""" super().__init__(defaults={**LoopParameters.parameters, **defaults}, data=data) + + def update(self, data): + """Update from a dictionary, translating the legacy system-selection keys + ('where system name', 'system name', 'default configuration', + 'configuration name') and their values from flowcharts saved before + 2026.9.18 to the standard structure-selection parameters.""" + translated = {} + for key, value in data.items(): + new_key = _legacy_keys.get(key, key) + if new_key in _legacy_values and isinstance(value, dict): + value = dict(value) + value["value"] = _legacy_values[new_key].get( + str(value.get("value", "")).strip(), value.get("value") + ) + translated[new_key] = value + super().update(translated) diff --git a/loop_step/tk_loop.py b/loop_step/tk_loop.py index 8e4dda1..ca135ff 100644 --- a/loop_step/tk_loop.py +++ b/loop_step/tk_loop.py @@ -135,11 +135,15 @@ def create_dialog(self): "type", "where", "query-op", - "where system name", - "default configuration", ): self[widget].bind("<>", self.reset_dialog) self[widget].combobox.config(state="readonly") + # The structure selection: re-lay out when the choices change (they stay + # editable so a $variable can be typed). + for widget in ("source systems", "source configurations"): + self[widget].combobox.bind("<>", self.reset_dialog) + self[widget].combobox.bind("", self.reset_dialog) + self[widget].combobox.bind("", self.reset_dialog) self["errors"].combobox.config(state="readonly") @@ -224,24 +228,9 @@ def reset_dialog(self, widget=None): # frame.rowconfigure(row, weight=1, minsize=100) # row += 1 - self["where system name"].grid( - row=row, column=0, columnspan=2, sticky=tk.EW - ) - choice = self["where system name"].get() - if choice != "is anything": - self["system name"].grid(row=row, column=2, columnspan=2, sticky=tk.EW) - row += 1 - - self["default configuration"].grid( - row=row, column=0, columnspan=2, sticky=tk.EW - ) - op = self["default configuration"].get() - if "name" in op: - self["configuration name"].grid( - row=row, column=2, columnspan=2, sticky=tk.EW - ) - row += 1 - frame.columnconfigure(3, weight=1) + row, widgets = self.layout_structure_selection(row=row, column=0) + sw.align_labels(widgets, sticky=tk.E) + frame.columnconfigure(1, weight=1) self["directory name"].grid(row=row, column=0, columnspan=3, sticky=tk.W) row += 1 else: diff --git a/requirements.txt b/requirements.txt index c706f72..f04a8bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ psutil -seamm +seamm>=2026.9.18.1 diff --git a/tests/test_structure_selection.py b/tests/test_structure_selection.py new file mode 100644 index 0000000..bdd02bd --- /dev/null +++ b/tests/test_structure_selection.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +"""The loop over systems uses SEAMM's standard structure selection, and reads +flowcharts saved with the old option names.""" + +import loop_step +import seamm + + +def _legacy(where="is anything", system_name="", default="last", conf_name=""): + return { + "type": {"value": "For systems in the database", "units": None}, + "where system name": {"value": where, "units": None}, + "system name": {"value": system_name, "units": None}, + "default configuration": {"value": default, "units": None}, + "configuration name": {"value": conf_name, "units": None}, + } + + +def test_defaults_loop_over_every_system_last_configuration(): + P = loop_step.LoopParameters() + assert P["source systems"].value == "all" + assert P["source configurations"].value == "last" + for key in seamm.standard_parameters.structure_selection_parameters: + assert key in P + for key in ("where system name", "default configuration"): + assert key not in P + + +def test_legacy_keys_and_values_are_translated(): + P = loop_step.LoopParameters() + P.from_dict(_legacy()) + assert P["source systems"].value == "all" + assert P["source configurations"].value == "last" + + P.from_dict( + _legacy(where="is", system_name="water", default="name is", conf_name="x") + ) + assert P["source systems"].value == "name is" + assert P["source system name"].value == "water" + assert P["source configurations"].value == "name is" + assert P["source configuration name"].value == "x" + + P.from_dict(_legacy(where="matches", system_name="H2O*", default="-1")) + assert P["source systems"].value == "name matches" + assert P["source configurations"].value == "last" + + P.from_dict(_legacy(where="regexp", system_name="^w", default="1")) + assert P["source systems"].value == "name regexp" + assert P["source configurations"].value == "first" + + P.from_dict(_legacy(default="name matches", conf_name="frame*")) + assert P["source configurations"].value == "name matches" + + +def test_new_keys_pass_through(): + P = loop_step.LoopParameters() + P.from_dict( + { + "source systems": {"value": "current", "units": None}, + "source configurations": {"value": "all", "units": None}, + } + ) + assert P["source systems"].value == "current" + assert P["source configurations"].value == "all" + text = seamm.standard_parameters.structure_selection_description( + P.current_values_to_dict() + ) + assert text == "All configurations of the current system will be used."