Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion HISTORY.rst
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 10 additions & 3 deletions devtools/conda-envs/test_env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
68 changes: 7 additions & 61 deletions loop_step/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
90 changes: 47 additions & 43 deletions loop_step/loop_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
29 changes: 9 additions & 20 deletions loop_step/tk_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,15 @@ def create_dialog(self):
"type",
"where",
"query-op",
"where system name",
"default configuration",
):
self[widget].bind("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", self.reset_dialog)
self[widget].combobox.bind("<Return>", self.reset_dialog)
self[widget].combobox.bind("<FocusOut>", self.reset_dialog)

self["errors"].combobox.config(state="readonly")

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
psutil
seamm
seamm>=2026.9.18.1
69 changes: 69 additions & 0 deletions tests/test_structure_selection.py
Original file line number Diff line number Diff line change
@@ -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."
Loading