Repository: openforcefield/openff-qcsubmit

Issue:
`PortalClient` no longer accepts `memory_cache_key` keyword, leading to failing tests
**Problem**
The newest version of QCSubmit removed the `memory_cache_key` keyword from the `PortalClient` (seen at this [commit](https://github.com/MolSSI/QCFractal/commit/bb032490ded34a410f3b93b34409038a9ff30d10)), which is causing issues in CI tests due to the initialization of the `CachedPortalClient` [here](https://github.com/openforcefield/openff-qcsubmit/blob/main/openff/qcsubmit/utils/utils.py#L77)

**Proposed solution**
We could simply also remove this keyword for consistency, but I'm not sure if anyone is using it, so I thought it would be best to discuss it.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/openforcefield/openff-qcsubmit/pull/317?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openforcefield) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 95.29%. Comparing base [(`c298486`)](https://app.codecov.io/gh/openforcefield/openff-qcsubmit/commit/c298486c068c413c945d3dee57df40f1585b9e5b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openforcefield) to head [(`f3b9865`)](https://app.codecov.io/gh/openforcefield/openff-qcsubmit/commit/f3b9865c400ae481f00205aa03bae66db8dfdb1d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openforcefield).

<details><summary>Additional details and impacted files</summary>


</details>

Patch:
diff --git a/devtools/conda-envs/basic.yaml b/devtools/conda-envs/basic.yaml
index 0bf1483..7ffa66f 100644
--- a/devtools/conda-envs/basic.yaml
+++ b/devtools/conda-envs/basic.yaml
@@ -25,7 +25,7 @@ dependencies:
 
   - qcengine >=0.25
   - qcelemental >=0.25.1
-  - qcfractal >=0.56,<0.58
+  - qcfractal >=0.56
   - qcarchivetesting
   - qcportal
 
@@ -39,14 +39,13 @@ dependencies:
   # this should be changed to openff-toolkit-base
 
   - openff-toolkit
-  - openff-units >=0.2.1
+  - openff-units ~=0.2.1
   - pydantic >1.10.17,<3
   - pyyaml
   - torsiondrive
   - basis_set_exchange
   - typing-extensions
   - h5py>=3.6.0
-  - psycopg2        # for qcfractal to pip-install without issue
 
   # Optional
   - openmmforcefields
diff --git a/devtools/conda-envs/docs.yaml b/devtools/conda-envs/docs.yaml
index 253dbbb..1678317 100644
--- a/devtools/conda-envs/docs.yaml
+++ b/devtools/conda-envs/docs.yaml
@@ -27,10 +27,11 @@ dependencies:
     # Standard dependencies
 
   - openff-toolkit-base
+  - openff-units =0.2
   - rdkit
   - pydantic
   - pyyaml
-  - qcportal >=0.52,<0.58
+  - qcportal >=0.52
   - torsiondrive
   - basis_set_exchange
   - typing-extensions
diff --git a/devtools/conda-envs/psi4.yaml b/devtools/conda-envs/psi4.yaml
index 95f0ae1..4bf8aa5 100644
--- a/devtools/conda-envs/psi4.yaml
+++ b/devtools/conda-envs/psi4.yaml
@@ -24,7 +24,7 @@ dependencies:
 
   - qcengine >=0.25
   - qcelemental >=0.25.1
-  - qcfractal >=0.56,<0.58
+  - qcfractal >=0.56
   - qcarchivetesting
   - qcportal
 
@@ -36,7 +36,7 @@ dependencies:
   ### Core dependencies.
 
   - openff-toolkit-base
-  - openff-units >=0.2.1
+  - openff-units ~=0.2.1
   - rdkit
   - pydantic >1.10.17,<3
   - pyyaml
@@ -50,6 +50,4 @@ dependencies:
   - openff-fragmenter-base >=0.2.0
   - openmm
 
-  - pint=0.21
-  - openff-units=0.2.1
   - postgresql
diff --git a/docs/releasehistory.md b/docs/releasehistory.md
index ceb4677..e378076 100644
--- a/docs/releasehistory.md
+++ b/docs/releasehistory.md
@@ -16,6 +16,7 @@ Releases are given with dates in DD-MM-YYYY format.
 ### Bugfixes
 * [PR #300:] Fixes [an issue](https://github.com/openforcefield/openff-qcsubmit/issues/299) where methods to retrieve `BasicResultCollection` and `OptimizationResultCollection` objects from QCArchive would crash if an entry was missing a CMILES.
 * [PR #303:] Fixes [an issue](https://github.com/openforcefield/openff-qcsubmit/issues/297) where `OptimizationResultCollection.create_basic_dataset` was not reusing molecule IDs from QCArchive.
+* [PR #318:] Do not use `memory_cache_key`, which was dropped in QCPortal 0.58 and generally not used prior.
 
 ### Miscellaneous
 * [PR #308:] Switches version handling to use `versioningit`.
@@ -168,6 +169,7 @@ For more information on this release, see https://github.com/openforcefield/open
 [PR #300:]: https://github.com/openforcefield/openff-qcsubmit/pull/300
 [PR #303:]: https://github.com/openforcefield/openff-qcsubmit/pull/303
 [PR #308:]: https://github.com/openforcefield/openff-qcsubmit/pull/308
+[PR #318:]: https://github.com/openforcefield/openff-qcsubmit/pull/318
 
 [@jthorton]: https://github.com/jthorton
 [@dotsdl]: https://github.com/dotsdl
diff --git a/openff/qcsubmit/exceptions.py b/openff/qcsubmit/exceptions.py
index c7315a5..dcff919 100644
--- a/openff/qcsubmit/exceptions.py
+++ b/openff/qcsubmit/exceptions.py
@@ -217,3 +217,21 @@ class RecordTypeError(QCSubmitException):
 
     error_type = "record_type_error"
     header = "Record Type Error"
+
+
+class InvalidConvergeSettingsError(AssertionError):
+    """
+    Invalid converge options were passed to GeometricProcedure()
+    """
+
+    error_type = "invalid_converge_settings_error"
+    header = "Invalid Converge Settings Error"
+
+
+class ConflictingConvergeSettingsError(ValueError):
+    """
+    Conflicting converge and convergence_set options were passed to GeometricProcedure()
+    """
+
+    error_type = "conflicting_converge_settings_error"
+    header = "Conflicting Converge Settings Error"
diff --git a/openff/qcsubmit/procedures.py b/openff/qcsubmit/procedures.py
index 4b4bb86..5f03d81 100644
--- a/openff/qcsubmit/procedures.py
+++ b/openff/qcsubmit/procedures.py
@@ -2,13 +2,18 @@
 The procedure settings controllers
 """
 
-from typing import Any, Dict
+from typing import Any, Dict, List
 
 from qcportal.optimization import OptimizationSpecification
 from typing_extensions import Literal
 
-from openff.qcsubmit._pydantic import BaseModel, Field, validator
-from openff.qcsubmit.validators import literal_lower, literal_upper
+from openff.qcsubmit._pydantic import BaseModel, Field, root_validator, validator
+from openff.qcsubmit.exceptions import ConflictingConvergeSettingsError
+from openff.qcsubmit.validators import (
+    check_custom_converge,
+    literal_lower,
+    literal_upper,
+)
 
 
 class GeometricProcedure(BaseModel):
@@ -44,6 +49,44 @@ class GeometricProcedure(BaseModel):
             +-------------------+---------------------+--------+---------+--------+--------+-------+
             | `GAU_VERYTIGHT`   | Gaussian very tight | 1e-6   | 1e-6    | 2e-6   | 4e-6   | 6e-6  |
             +-------------------+---------------------+--------+---------+--------+--------+-------+
+
+            The recommended use case is to provide the name of one of these sets with the `convergence_set` keyword.
+
+            Alternatively, you can provide a custom convergence criteria set by providing a list of strings to the `converge` keyword.
+            These should be provided in the following format, with the `convergence_set` keyword set to 'CUSTOM':
+
+            ```
+            convergence_set = 'CUSTOM',
+            converge = ['energy', '1e-6', 'grms', '3e-4', 'gmax', '4.5e-4', 'drms', '1.2e-3', 'dmax', '1.8e-3']
+            ```
+
+            Not all the flags are required, please see the GeomeTRIC documentation for more information on custom convergence criteria sets.
+            Note that the units are are Hartree for energies and Bohr for distances.
+
+            The `maxiter` flag can also be passed to the `converge` keyword to tell the program to exit gracefully upon reaching the maximum number of iterations.
+            This can be used to run a few optimization steps to relax excessively high forces.
+            It can be passed with a list of custom criteria:
+
+            ```
+            convergence_set = 'CUSTOM',
+            converge = ['energy', '1e-6', 'grms', '3e-4', 'gmax', '4.5e-4', 'drms', '1.2e-3', 'dmax', '1.8e-3', 'maxiter']
+            ```
+
+            It can also be used in conjunction with one of the `convergence_set` options:
+
+            ```
+            convergence_set = 'GAU',
+            converge = ['maxiter']
+            ```
+
+            The `maxiter` flag to the `converge` keyword should not be followed by anything; to set the maximum number of iterations, please use the separate `maxiter` keyword:
+
+            ```
+            convergence_set = 'GAU',
+            converge = ['maxiter']
+            maxiter = 5
+            ```
+
     """
 
     program: Literal["geometric"] = Field(
@@ -83,6 +126,7 @@ class GeometricProcedure(BaseModel):
         "INTERFRAG_TIGHT",
         "GAU_TIGHT",
         "GAU_VERYTIGHT",
+        "CUSTOM",
     ] = Field(
         "GAU",
         description="The set of convergence criteria to be used for the optimisation.",
@@ -91,6 +135,10 @@ class GeometricProcedure(BaseModel):
         {},
         description="The list of constraints orginsed by set and freeze that should be used in the optimization",
     )
+    converge: List = Field(
+        [],
+        description="(Optional): The custom-specified convergence criteria to be used for the optimization. If none provided, will fall back to the option provided in convergence_set.",
+    )
 
     class Config:
         validate_assignment = True
@@ -99,8 +147,40 @@ class GeometricProcedure(BaseModel):
     _convergence_set_check = validator("convergence_set", pre=True, allow_reuse=True)(
         literal_upper
     )
+    _converge_check = validator("converge", pre=True, allow_reuse=True)(
+        check_custom_converge
+    )
     _coordsys_check = validator("coordsys", pre=True, allow_reuse=True)(literal_lower)
 
+    @root_validator()
+    def check_convergence_all(cls, values):
+        convergence_set = values.get("convergence_set")
+        convergence_keywords = values.get("converge")
+
+        # Make sure that if a custom convergence set is provided via the converge keyword, the convergence_set keyword is set to 'CUSTOM'
+        if len(convergence_keywords) > 0 and convergence_set != "CUSTOM":
+            # It is okay to provide only maxiter to converge, and use a regular convergence_set
+            if (
+                len(convergence_keywords) == 1
+                and convergence_keywords[0].lower() == "maxiter"
+            ):
+                pass
+            else:
+                raise ConflictingConvergeSettingsError(
+                    f"Received convergence_set = {convergence_set} and converge = {convergence_keywords}. If a custom "
+                    "convergence criteria set is provided via the converge keyword, the convergence_set keyword must "
+                    "be set to 'CUSTOM'."
+                )
+
+        # Make sure that if convergence_set = CUSTOM, the converge keyword is not empty
+        elif convergence_set == "CUSTOM" and len(convergence_keywords) < 2:
+            raise ConflictingConvergeSettingsError(
+                f"Received convergence_set = {convergence_set} and converge = {convergence_keywords}. If "
+                "convergence_set = 'CUSTOM', the convergence criteria must be specified by "
+                "converge = ['energy','1e-6',...]."
+            )
+        return values
+
     def get_optimzation_keywords(self) -> Dict[str, Any]:
         """
         Create the optimization specification to be used in qcarchive.
@@ -111,6 +191,10 @@ class GeometricProcedure(BaseModel):
         exclude = {"program"}
         if self.constraints is not None:
             exclude.add("constraints")
+        if self.convergence_set == "CUSTOM":
+            exclude.add("convergence_set")
+        if len(self.converge) == 0:
+            exclude.add("converge")
 
         return self.dict(exclude=exclude)
 
diff --git a/openff/qcsubmit/utils/utils.py b/openff/qcsubmit/utils/utils.py
index 3a0444f..6e0354e 100644
--- a/openff/qcsubmit/utils/utils.py
+++ b/openff/qcsubmit/utils/utils.py
@@ -42,7 +42,6 @@ class _CachedPortalClient(PortalClient):
         show_motd: bool = True,
         *,
         cache_max_size: int = 0,
-        memory_cache_key: Optional[str] = None,
     ):
         """Parameters
         ----------
@@ -74,7 +73,6 @@ class _CachedPortalClient(PortalClient):
             show_motd=show_motd,
             cache_dir=cache_dir,
             cache_max_size=cache_max_size,
-            memory_cache_key=memory_cache_key,
         )
         self.record_cache = RecordCache(
             os.path.join(self.cache.cache_dir, "cache.sqlite"), read_only=False
diff --git a/openff/qcsubmit/validators.py b/openff/qcsubmit/validators.py
index c49392e..45f3427 100644
--- a/openff/qcsubmit/validators.py
+++ b/openff/qcsubmit/validators.py
@@ -18,6 +18,7 @@ from openff.qcsubmit.exceptions import (
     BondConnectionError,
     ConstraintError,
     DihedralConnectionError,
+    InvalidConvergeSettingsError,
     LinearTorsionError,
     MolecularComplexError,
 )
@@ -39,6 +40,60 @@ def literal_upper(literal: str) -> str:
     return literal.upper()
 
 
+def check_custom_converge(convergence_keyword_list: list) -> list:
+    """
+    Check that the custom convergence criteria passed are valid.
+    """
+
+    allowed_keys = ["energy", "grms", "gmax", "drms", "dmax", "maxiter"]
+
+    # Check if keywords are in allowed keys accepted by GeomeTRIC
+    for i, keyword in enumerate(convergence_keyword_list):
+
+        # Check if the entry is in the allowed keys
+        if keyword.lower() in allowed_keys:
+
+            # maxiter must not be followed by a number
+            if keyword.lower() == "maxiter":
+                if i != len(convergence_keyword_list) - 1:
+                    if convergence_keyword_list[i + 1].lower() not in allowed_keys:
+                        raise InvalidConvergeSettingsError(
+                            "No value should follow the maxiter flag specified here in converge. To specify the "
+                            "maximum number of iterations, please use the separate maxiter keyword. Provided value "
+                            f"was {convergence_keyword_list[i + 1]}"
+                        )
+
+            # If not maxiter, next number should be a string, but able to be made into a float
+            else:
+                try:
+                    assert isinstance(convergence_keyword_list[i + 1], str)
+                    float(convergence_keyword_list[i + 1])
+                except (AssertionError, TypeError):
+                    raise InvalidConvergeSettingsError(
+                        "The value following the keyword must be a string that can be converted to a float. Value "
+                        f"for {keyword} is {convergence_keyword_list[i + 1]}, with type "
+                        f"{type(convergence_keyword_list[i + 1])}"
+                    )
+
+        # If the entry is not in the allowed keys, make sure the previous entry is a valid flag, and the current entry
+        # is a string that can be converted to a float
+        elif convergence_keyword_list[i - 1].lower() in allowed_keys:
+
+            try:
+                assert isinstance(keyword, str)
+                float(keyword)
+            except (AssertionError, TypeError):
+                raise InvalidConvergeSettingsError(
+                    f"The value following the keyword must be a string that can be converted to a float. Value for {convergence_keyword_list[i - 1]} is {keyword}, with type {type(keyword)}"
+                )
+
+        else:
+            raise InvalidConvergeSettingsError(
+                f"Invalid flag provided in converge. Allowed flags are {allowed_keys}. Flags must be provided as a list with the following format: ['energy', '1e-6', 'grms', '3e-4', 'gmax', '4.5e-4', 'drms', '1.2e-3', 'dmax', '1.8e-3', 'maxiter']. Provided option was {convergence_keyword_list}"
+            )
+    return convergence_keyword_list
+
+
 def check_improper_connection(
     improper: Tuple[int, int, int, int], molecule: off.Molecule
 ) -> Tuple[int, int, int, int]:


Test patch:
diff --git a/openff/qcsubmit/_tests/test_procedures.py b/openff/qcsubmit/_tests/test_procedures.py
new file mode 100644
index 0000000..6390cd0
--- /dev/null
+++ b/openff/qcsubmit/_tests/test_procedures.py
@@ -0,0 +1,197 @@
+import pytest
+
+from openff.qcsubmit._pydantic import ValidationError
+from openff.qcsubmit.procedures import GeometricProcedure
+
+
+@pytest.mark.parametrize(
+    "convergence_set",
+    [
+        pytest.param(
+            "GAU",
+            id="Default convergence_set value",
+        ),
+        pytest.param(
+            "GAU_VERYTIGHT",
+            id="Very tight convergence_set value",
+        ),
+        pytest.param(
+            "TURBOMOLE",
+            id="Turbomole convergence_set value",
+        ),
+    ],
+)
+def test_convergence_set(convergence_set):
+    """
+    Test that the convergence_set is set correctly.
+    """
+    procedure = GeometricProcedure(convergence_set=convergence_set)
+    assert procedure.convergence_set == convergence_set
+    assert procedure.get_optimzation_keywords()["convergence_set"] == convergence_set
+    assert "converge" not in procedure.get_optimzation_keywords()
+
+
+@pytest.mark.parametrize(
+    "conv_keywords",
+    [
+        pytest.param(
+            (
+                "CUSTOM",
+                [
+                    "energy",
+                    "1e-8",
+                ],
+            ),
+            id="Just energy",
+        ),
+        pytest.param(
+            (
+                "CUSTOM",
+                [
+                    "grms",
+                    "3e-4",
+                    "energy",
+                    "1e-6",
+                    "drms",
+                    "1.2e-3",
+                    "gmax",
+                    "4.5e-4",
+                    "dmax",
+                    "1.8e-3",
+                ],
+            ),
+            id="All specified, out of order",
+        ),
+        pytest.param(
+            (
+                "CUSTOM",
+                ["grms", "3e-4", "energy", "1e-6"],
+            ),
+            id="Two specified",
+        ),
+    ],
+)
+def test_custom_converge(conv_keywords):
+    """
+    Test that the custom convergence criteria is set correctly.
+    """
+    convergence_set, converge = conv_keywords
+    procedure = GeometricProcedure(convergence_set=convergence_set, converge=converge)
+    assert procedure.converge == converge
+    assert procedure.get_optimzation_keywords()["converge"] == converge
+    assert "convergence_set" not in procedure.get_optimzation_keywords()
+
+
+@pytest.mark.parametrize(
+    "conv_keywords",
+    [
+        pytest.param(
+            ("CUSTOM", ["energy", "1e-8", "maxiter"], 3),
+            id="Custom + maxiter",
+        ),
+        pytest.param(
+            ("GAU_VERYTIGHT", ["maxiter"], 3),
+            id="Regular + maxiter",
+        ),
+    ],
+)
+def test_converge_maxiter(conv_keywords):
+    """
+    Test that --converge maxiter works with both custom convergence options and convergence_set.
+    """
+    convergence_set, converge, maxiter = conv_keywords
+    procedure = GeometricProcedure(
+        convergence_set=convergence_set, converge=converge, maxiter=maxiter
+    )
+    assert procedure.converge == converge
+    assert procedure.get_optimzation_keywords()["converge"] == converge
+    assert procedure.convergence_set == convergence_set
+    if convergence_set != "CUSTOM":
+        assert (
+            procedure.get_optimzation_keywords()["convergence_set"] == convergence_set
+        )
+    else:
+        assert "convergence_set" not in procedure.get_optimzation_keywords()
+    assert procedure.maxiter == maxiter
+    assert procedure.get_optimzation_keywords()["maxiter"] == maxiter
+
+
+@pytest.mark.parametrize(
+    "converge",
+    [
+        pytest.param(
+            "GAU",
+        ),
+        pytest.param(
+            ["energy", 1e-8],
+        ),
+        pytest.param(["hello", "1e-8"]),
+        pytest.param(["energy", "grms", "maxiter"]),
+        pytest.param(["energy", "1e-8", "maxiter", "5"]),
+        pytest.param(["energy", "hello"]),
+        pytest.param(["GAU", "maxiter"]),
+    ],
+)
+def test_converge_validation_errors(converge):
+    """
+    Make sure that invalid values of converge raise a ValidationError
+    """
+    with pytest.raises(ValidationError):
+        GeometricProcedure(converge=converge)
+
+
+@pytest.mark.parametrize(
+    "convergence_set",
+    [
+        pytest.param(
+            "HELLO",
+        ),
+        pytest.param("GAU maxiter"),
+    ],
+)
+def test_convergence_set_validation_errors(convergence_set):
+    """
+    Make sure that invalid values of convergence_set raise a ValidationError
+    """
+    with pytest.raises(ValidationError):
+        GeometricProcedure(convergence_set=convergence_set)
+
+
+def test_convergence_set_attribute_error():
+    """
+    Make sure that an AttributeError is raised if the user tries to set a converge list as convergence_set
+    """
+    with pytest.raises(AttributeError):
+        GeometricProcedure(convergence_set=["energy", "1e-8"])
+
+
+@pytest.mark.parametrize(
+    "conv_keywords",
+    [
+        pytest.param(
+            (
+                "CUSTOM",
+                ["maxiter"],
+            ),
+            id="Custom but just maxiter",
+        ),
+        pytest.param(
+            ("CUSTOM", []),
+            id="Custom with empty list",
+        ),
+        pytest.param(
+            (
+                "GAU",
+                ["energy", "1e-8"],
+            ),
+            id="Regular convergence_set but custom converge also provided",
+        ),
+    ],
+)
+def test_both_validation_errors(conv_keywords):
+    """
+    Make sure that if incompatible values of converge and convergence_set are set, a ValidationError is raised
+    """
+    convergence_set, converge = conv_keywords
+    with pytest.raises(ValidationError):
+        GeometricProcedure(convergence_set=convergence_set, converge=converge)
diff --git a/openff/qcsubmit/_tests/test_submissions.py b/openff/qcsubmit/_tests/test_submissions.py
index 4e2bae1..a0ed99f 100644
--- a/openff/qcsubmit/_tests/test_submissions.py
+++ b/openff/qcsubmit/_tests/test_submissions.py
@@ -5,6 +5,7 @@ Here we use the qcfractal snowflake fixture to set up the database.
 """
 
 import pytest
+import regex as re
 from openff.toolkit.topology import Molecule
 from qcelemental.models.procedures import OptimizationProtocols
 from qcengine.testing import has_program
@@ -33,6 +34,7 @@ from openff.qcsubmit.factories import (
     OptimizationDatasetFactory,
     TorsiondriveDatasetFactory,
 )
+from openff.qcsubmit.procedures import GeometricProcedure
 from openff.qcsubmit.results import (
     BasicResultCollection,
     OptimizationResultCollection,
@@ -835,6 +837,219 @@ def test_optimization_submissions(fulltest_client, specification):
                 assert "scf quadrupole" in result.properties.keys()
 
 
+@pytest.mark.parametrize(
+    "opt_keywords",
+    [
+        pytest.param(
+            (
+                "CUSTOM",
+                [
+                    "energy",
+                    "1e-8",
+                    "maxiter",
+                ],
+                3,
+                "custom convergence with maxiter",
+            ),
+            id="Custom convergence with maxiter",
+        ),
+        pytest.param(
+            (
+                "GAU_VERYTIGHT",
+                [
+                    "maxiter",
+                ],
+                3,
+                "Default conv with maxiter",
+            ),
+            id="Default conv with maxiter",
+        ),
+        pytest.param(
+            (
+                "CUSTOM",
+                [
+                    "energy",
+                    "1e-4",
+                    "grms",
+                    "3e-2",
+                    "gmax",
+                    "4.5e-1",
+                    "drms",
+                    "1.2e-3",
+                    "dmax",
+                    "1.8e-1",
+                ],
+                300,
+                "Custom convergence, no maxiter",
+            ),
+            id="Custom convergence, no maxiter",
+        ),
+    ],
+)
+def test_optimization_submissions_convergence(fulltest_client, opt_keywords):
+    """Test submitting an Optimization dataset with custom convergence options."""
+
+    client = fulltest_client
+
+    convergence_set, converge, maxit, ds_suffix = opt_keywords
+
+    ethane = Molecule.from_file(get_data("ethane.sdf"), "sdf")
+
+    dataset = OptimizationDataset(
+        dataset_name="Test optimizations with converge " + ds_suffix,
+        description="Test optimization dataset with constraints" + ds_suffix,
+        dataset_tagline="Testing optimization datasets" + ds_suffix,
+    )
+
+    dataset.clear_qcspecs()
+    dataset.add_qc_spec(
+        method="openff-1.0.0",
+        basis="smirnoff",
+        program="openmm",
+        spec_name="test_spec",
+        spec_description="test_spec",
+        overwrite=True,
+    )
+
+    # add the molecule
+    index = ethane.to_smiles()
+    dataset.add_molecule(index=index, molecule=ethane)
+
+    # Add the GeometricProcedure so we can submit the dataset
+    dataset.optimization_procedure = GeometricProcedure(
+        program="geometric",
+        maxiter=maxit,
+        convergence_set=convergence_set,
+        converge=converge,
+    )
+
+    # only save final gradients, results, if --converge maxiter not requested
+    if "maxiter" not in converge:
+        dataset.protocols = OptimizationProtocols(trajectory="initial_and_final")
+
+    # now submit
+    dataset.submit(client=client)
+
+    await_results(
+        client,
+        check_fn=PortalClient.get_optimizations,
+        timeout=240,
+    )
+
+    # make sure of the results are complete
+    ds = client.get_dataset(
+        legacy_qcsubmit_ds_type_to_next_qcf_ds_type[dataset.type], dataset.dataset_name
+    )
+
+    query = ds.iterate_records(specification_names="test_spec")
+
+    # Dictionary to help parse GeomeTRIC output, relating the `--converge` keywords to output prints
+    keyword_to_stdout = {
+        "energy": r"\|Delta-E\|",
+        "grms": r"RMS-Grad ",
+        "gmax": r"Max-Grad ",
+        "drms": r"RMS-Disp ",
+        "dmax": r"Max-Disp ",
+    }
+    convergence_set_to_value = {
+        "GAU_VERYTIGHT": [
+            "energy",
+            "1.0e-6",
+            "grms",
+            "1.0e-6",
+            "gmax",
+            "2.0e-6",
+            "drms",
+            "4.0e-6",
+            "dmax",
+            "6.0e-6",
+        ],
+        "CUSTOM": converge,
+    }
+
+    for name, spec, record in query:
+        assert record.status == RecordStatusEnum.complete
+        assert record.error is None
+
+        # Check that the converge keywords were passed to the record's input
+        assert [
+            key.lower() for key in record.specification.keywords["converge"]
+        ] == dataset.optimization_procedure.converge
+
+        if convergence_set != "CUSTOM":
+            # Confirm that convergence_set was passed to record input
+            assert (
+                record.specification.keywords["convergence_set"]
+                == dataset.optimization_procedure.convergence_set
+            )
+
+        else:
+            # Confirm that convergence_set is absent from record input
+            assert "convergence_set" not in record.specification.keywords
+
+        # Parse the geomeTRIC output to check the convergence criteria were passed to GeomeTRIC
+        geometric_output = record.stdout
+
+        # Confirm that GeomeTRIC is using the requested convergence criteria
+        for i, key in enumerate(convergence_set_to_value[convergence_set]):
+            try:
+                float(key)  # Only want to check the string flags
+            except ValueError:
+                if key != "maxiter":
+                    output_key = keyword_to_stdout[key]
+                    matches = re.findall(
+                        r"{} \< \d\.\d\de\-\d\d".format(output_key), geometric_output
+                    )
+                    assert len(matches) == 1
+                    assert float(matches[0].split()[-1]) == float(
+                        convergence_set_to_value[convergence_set][i + 1]
+                    )
+
+        # Checking --converge maxiter
+        using_converge_maxiter = (
+            len(
+                re.findall(
+                    r"Converge-on-maxiter set: Will exit with success if maximum number of iterations \({}\) is reached".format(
+                        maxit
+                    ),
+                    geometric_output,
+                )
+            )
+            == 1
+        )
+        converged_due_to_maxiter = (
+            len(
+                re.findall(
+                    r"Exiting normally because --converge maxiter was set",
+                    geometric_output,
+                )
+            )
+            == 1
+        )
+
+        if "maxiter" in converge:
+            # Length of trajectory is the number of steps. Should be equal to maxiter + 1
+            # if --converge maxiter was requested
+            assert len(record.trajectory) == dataset.optimization_procedure.maxiter + 1
+
+            # Confirm that maxiter is set and the correct max number of iterations was passed
+            assert using_converge_maxiter
+
+            # Confirm that it actually did exit due to maxiter
+            assert converged_due_to_maxiter
+
+        else:
+            # If not using maxiter, should only have two results
+            # since we only chose to keep `initial_and_final` trajectory
+            assert len(record.trajectory) == 2
+
+            # Confirm that maxiter is NOT set
+            assert not using_converge_maxiter
+
+            # Confirm that it did NOT exit due to maxiter
+            assert not converged_due_to_maxiter
+
+
 @pytest.mark.xfail(
     reason="Known issue with recent versions of pcm https://github.com/PCMSolver/pcmsolver/issues/206"
 )