diff --git a/watertap/tools/oli_api/client.py b/watertap/tools/oli_api/client.py index cc69aa9699..2afcebb090 100644 --- a/watertap/tools/oli_api/client.py +++ b/watertap/tools/oli_api/client.py @@ -188,9 +188,9 @@ def generate_dbs_file( dbs_file_inputs["modelName"] = "OLI_analysis" # TODO: unknown bug where only "liquid1" phase is found in Flash analysis - valid_phases = ["liquid1", "vapor", "solid", "liquid2"] + self.valid_phases = ["liquid1", "vapor", "solid", "liquid2"] if phases is not None: - invalid_phases = [p for p in phases if p not in valid_phases] + invalid_phases = [p for p in phases if p not in self.valid_phases] if invalid_phases: raise RuntimeError( "Failed DBS file generation. " @@ -198,7 +198,7 @@ def generate_dbs_file( ) dbs_file_inputs["phases"] = phases else: - dbs_file_inputs["phases"] = ["liquid1", "solid"] + dbs_file_inputs["phases"] = ["liquid1", "solid", "vapor"] valid_databanks = ["XSC"] if databanks is not None: @@ -333,7 +333,29 @@ def _get_flash_mode(self, dbs_file_id, flash_method, burst_job_tag=None): headers = self.credential_manager.headers base_url = self.credential_manager.engine_url valid_get_flashes = ["corrosion-contact-surface", "chemistry-info"] - valid_post_flashes = ["isothermal", "corrosion-rates", "wateranalysis"] + valid_post_flashes = [ + "isothermal", + "corrosion-rates", + "wateranalysis", + "bubblepoint", + ] + + if flash_method in [ + "bubblepoint", + # TODO: uncomment the methods below only after trying and testing + # "dewpoint", + # "vapor-amount", + # "vapor-fraction", + # "isochoric", + ]: + dbs_summary = self.get_dbs_file_summary(dbs_file_id) + phase_list = dbs_summary["chemistry_info"]["result"]["phases"] + + if "vapor" not in phase_list: + raise RuntimeError( + "A vapor function ('{flash_method}') was called without included 'vapor' as a phase in the model" + ) + if flash_method in valid_get_flashes: mode = "GET" url = f"{base_url}/file/{dbs_file_id}/{flash_method}" diff --git a/watertap/tools/oli_api/conftest.py b/watertap/tools/oli_api/conftest.py index f6a4b2bb52..223c3e6ab5 100644 --- a/watertap/tools/oli_api/conftest.py +++ b/watertap/tools/oli_api/conftest.py @@ -56,6 +56,7 @@ CredentialManager, cryptography_available, ) +import re @pytest.fixture(scope="session") @@ -102,6 +103,37 @@ def oliapi_instance( cred_file_path.unlink() +@pytest.fixture(scope="function") +def oliapi_instance_with_invalid_phase( + tmp_path: Path, + auth_credentials: dict, + local_dbs_file: Path, + source_water: dict, +) -> OLIApi: + + if not cryptography_available: + pytest.skip(reason="cryptography module not available.") + cred_file_path = tmp_path / "pytest-credentials.txt" + + credentials = { + **auth_credentials, + "config_file": cred_file_path, + } + credential_manager = CredentialManager(**credentials, test=True) + with OLIApi(credential_manager, interactive_mode=False) as oliapi: + oliapi.upload_dbs_file(str(local_dbs_file)) + with pytest.raises( + RuntimeError, + match=re.escape( + "Failed DBS file generation. Unexpected phase(s): ['invalid_phase']" + ), + ): + oliapi.generate_dbs_file(source_water, phases=["invalid_phase"]) + yield oliapi + with contextlib.suppress(FileNotFoundError): + cred_file_path.unlink() + + @pytest.fixture def flash_instance(scope="session"): flash = Flash() diff --git a/watertap/tools/oli_api/flash.py b/watertap/tools/oli_api/flash.py index bd30dbb3fc..97e46cf79d 100644 --- a/watertap/tools/oli_api/flash.py +++ b/watertap/tools/oli_api/flash.py @@ -44,7 +44,7 @@ # derivative works, incorporate into other computer software, distribute, and sublicense such enhancements # or derivative works thereof, in binary and source code form. ############################################################################### -__author__ = "Oluwamayowa Amusat, Alexander Dudchenko, Paul Vecchiarelli" +__author__ = "Oluwamayowa Amusat, Alexander Dudchenko, Paul Vecchiarelli, Adam Atia" import logging @@ -66,6 +66,8 @@ output_unit_set, ) +from numpy import reshape, sqrt + _logger = logging.getLogger(__name__) handler = logging.StreamHandler() formatter = logging.Formatter( @@ -469,6 +471,7 @@ def configure_flash_analysis( "vapor-fraction", "isochoric", ]: + if calculated_variable is not None: if calculated_variable not in ["temperature", "pressure"]: raise RuntimeError( @@ -1062,6 +1065,7 @@ def _find_props(data, path=None): raise RuntimeError(f"Unexpected type for data: {type(data)}") def _get_nested_data(data, keys): + for key in keys: data = data[key] return data @@ -1080,6 +1084,7 @@ def _extract_values(data, keys): if "unit" in values: unit = values["unit"] if values["unit"] else "dimensionless" extracted_values.update({"units": unit}) + elif all(k in values for k in ["found", "phase"]): extracted_values = values else: @@ -1089,7 +1094,7 @@ def _extract_values(data, keys): "units": unit, "values": values["value"], } - else: + elif "values" in values: extracted_values = { k: { "units": unit, @@ -1097,6 +1102,23 @@ def _extract_values(data, keys): } for k, v in values["values"].items() } + elif "data" in values: + # intended for vaporDiffusivityMatrix + mat_dim = int(sqrt(len(values["data"]))) + diffmat = reshape(values["data"], newshape=(mat_dim, mat_dim)) + + extracted_values = { + f'({values["speciesNames"][i]},{values["speciesNames"][j]})': { + "units": values["unit"], + "values": diffmat[i][j], + } + for i in range(len(diffmat)) + for j in range(i, len(diffmat)) + } + else: + raise NotImplementedError( + f"results structure not accounted for. results:\n{values}" + ) else: raise RuntimeError(f"Unexpected type for data: {type(values)}") return extracted_values @@ -1126,7 +1148,10 @@ def _create_input_dict(props, result): if isinstance(prop[-1], int): prop_tag = _get_nested_data(result, prop)["name"] else: - _logger.warning(f"Unexpected result in result") + _logger.warning( + f"Unexpected result:\n{result}\n\ninput_dict:\n{input_dict}" + ) + label = f"{prop_tag}_{phase_tag}" if phase_tag else prop_tag input_dict[k][label] = _extract_values(result, prop) return input_dict diff --git a/watertap/tools/oli_api/tests/test_client.py b/watertap/tools/oli_api/tests/test_client.py index 6a197d21c2..2d3d1e07ab 100644 --- a/watertap/tools/oli_api/tests/test_client.py +++ b/watertap/tools/oli_api/tests/test_client.py @@ -67,3 +67,15 @@ def test_get_dbs_file_summary(oliapi_instance: OLIApi, local_dbs_file: Path): oliapi_instance.get_user_dbs_file_ids() dbs_file_id = oliapi_instance.upload_dbs_file(local_dbs_file) oliapi_instance.get_dbs_file_summary(dbs_file_id) + + +@pytest.mark.unit +def test_valid_phases(oliapi_instance: OLIApi): + valid_phases = ["liquid1", "vapor", "solid", "liquid2"] + for v in oliapi_instance.valid_phases: + assert v in valid_phases + + +@pytest.mark.unit +def test_invalid_phases(oliapi_instance_with_invalid_phase: OLIApi): + oliapi_instance_with_invalid_phase diff --git a/watertap/tools/oli_api/tests/test_flash.py b/watertap/tools/oli_api/tests/test_flash.py index c8128d36e5..706896d176 100644 --- a/watertap/tools/oli_api/tests/test_flash.py +++ b/watertap/tools/oli_api/tests/test_flash.py @@ -142,3 +142,33 @@ def test_isothermal_flash_survey( dbs_file_id, isothermal_input, ) + + +@pytest.mark.unit +def test_bubble_point( + flash_instance: Flash, source_water: dict, oliapi_instance: OLIApi, tmp_path: Path +): + dbs_file_id = oliapi_instance.session_dbs_files[-1] + + stream_input = flash_instance.configure_water_analysis(source_water) + inflows = flash_instance.get_apparent_species_from_true( + stream_input, + oliapi_instance, + dbs_file_id, + ) + bubblepoint_input = flash_instance.configure_flash_analysis( + inflows=inflows, + flash_method="bubblepoint", + calculated_variable="pressure", + ) + + saturation_pressure = flash_instance.run_flash( + "bubblepoint", + oliapi_instance, + dbs_file_id, + bubblepoint_input, + ) + + pytest.approx( + saturation_pressure["result"]["calculatedVariables"]["values"][0], rel=1e-3 + ) == 32.04094 diff --git a/watertap/tools/oli_api/util/fixed_keys_dict.py b/watertap/tools/oli_api/util/fixed_keys_dict.py index 03f3d1e5f5..02ce093edb 100644 --- a/watertap/tools/oli_api/util/fixed_keys_dict.py +++ b/watertap/tools/oli_api/util/fixed_keys_dict.py @@ -10,10 +10,12 @@ # "https://github.com/watertap-org/watertap/" ################################################################################# -__author__ = "Paul Vecchiarelli, Ben Knueven" +__author__ = "Paul Vecchiarelli, Ben Knueven, Adam Atia" from collections import UserDict from pyomo.environ import units as pyunits +from pyomo.core.base.units_container import _PyomoUnit +from collections.abc import Iterable class FixedKeysDict(UserDict): @@ -25,7 +27,48 @@ def __setitem__(self, k, v): raise RuntimeError(f" Key {k} not in dictionary.") # also check for valid value if a list of values is given in the default dictionary else: - self.data[k] = v + # if user setting value in pyomo units + if hasattr(v, "is_expression_type"): + if isinstance(v, _PyomoUnit) or v.is_expression_type(): + # if user assigns pyomo units as value to oli_unit, save the str to oli_unit and update pyomo_unit with pyomo units + if "oli_unit" in k: + self.data[k] = str(v) + self.data["pyomo_unit"] = v + # if user assigns pyomo units to pyomo_unit, update oli_unit with str representation of units + if "pyomo_unit" in k: + self.data[k] = v + self.data["oli_unit"] = str(v) + # check if data[k] is iterable first, otherwise checking if oli_unit in data[k] throws exception + elif isinstance(self.data[k], Iterable): + # check if user provides str and that assignment wouldn't overwrite the oli_unit key:value pair + if isinstance(v, str) and ("oli_unit" not in self.data[k]): + # if user assigns str to oli_unit, update pyomo_units with PyomoUnits representation of str + if "oli_unit" in k: + self.data[k] = v + self.data["pyomo_unit"] = getattr(pyunits, v) + else: + pass + elif isinstance(v, str) and ("oli_unit" in self.data[k]): + self.data["oli_unit"] = v + self.data["pyomo_unit"] = getattr(pyunits, v) + elif not isinstance(v, str): + raise RuntimeError( + f"Setting {v} as the value for {k} is not permitted as a value for oli and pyomo units. Please enter units as a string type or pint units." + ) + else: + pass + elif not isinstance(self.data[k], Iterable): + if isinstance(v, str) and "pyomo_unit" in k: + self.data[k] = getattr(pyunits, v) + self.data["oli_unit"] = v + elif not isinstance(v, str): + raise RuntimeError( + f"Setting {v} as the value for {k} is not permitted as a value for oli and pyomo units. Please enter units as a string type or pint units." + ) + else: + pass + else: + self.data[k] = v def __delitem__(self, k): raise Exception(" Deleting keys not supported for this object.") @@ -45,50 +88,55 @@ def pprint(self): print(f" {key}\n - {value}\n") +input_unit_set_temp = { + "molecularConcentration": { + "oli_unit": "mg/L", + "pyomo_unit": pyunits.mg / pyunits.L, + }, + "mass": {"oli_unit": "mg", "pyomo_unit": pyunits.mg}, + "temperature": {"oli_unit": "K", "pyomo_unit": pyunits.K}, + "pressure": {"oli_unit": "Pa", "pyomo_unit": pyunits.Pa}, + "enthalpy": {"oli_unit": "J", "pyomo_unit": pyunits.J}, + "vaporAmountMoles": {"oli_unit": "mol", "pyomo_unit": pyunits.mol}, + "vaporMolFrac": { + "oli_unit": "mol/mol", + "pyomo_unit": pyunits.mol / pyunits.mol, + }, + "totalVolume": {"oli_unit": "L", "pyomo_unit": pyunits.L}, + "pipeDiameter": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, + "pipeFlowVelocity": { + "oli_unit": "m/s", + "pyomo_unit": pyunits.meter / pyunits.second, + }, + "diskDiameter": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, + "diskRotatingSpeed": {"oli_unit": "cycle/s", "pyomo_unit": 1 / pyunits.second}, + "rotorDiameter": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, + "rotorRotation": {"oli_unit": "cycle/s", "pyomo_unit": 1 / pyunits.second}, + "shearStress": {"oli_unit": "Pa", "pyomo_unit": pyunits.Pa}, + "pipeDiameter": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, + "pipeRoughness": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, + "liquidFlowInPipe": { + "oli_unit": "L/s", + "pyomo_unit": pyunits.L / pyunits.second, + }, + "gasFlowInPipe": {"oli_unit": "L/s", "pyomo_unit": pyunits.L / pyunits.second}, + "viscAbs2ndLiq": { + "oli_unit": "Pa-s", + "pyomo_unit": pyunits.Pa * pyunits.second, + }, + "alkalinity": {"oli_unit": "mg HCO3/L", "pyomo_unit": pyunits.mg / pyunits.L}, + "TIC": {"oli_unit": "mol C/L", "pyomo_unit": pyunits.mol / pyunits.L}, + "CO2GasFraction": { + "oli_unit": "mol/mol", + "pyomo_unit": pyunits.mol / pyunits.mol, + }, +} + input_unit_set = FixedKeysDict( - { - "molecularConcentration": { - "oli_unit": "mg/L", - "pyomo_unit": pyunits.mg / pyunits.L, - }, - "mass": {"oli_unit": "mg", "pyomo_unit": pyunits.mg}, - "temperature": {"oli_unit": "K", "pyomo_unit": pyunits.K}, - "pressure": {"oli_unit": "Pa", "pyomo_unit": pyunits.Pa}, - "enthalpy": {"oli_unit": "J", "pyomo_unit": pyunits.J}, - "vaporAmountMoles": {"oli_unit": "mol", "pyomo_unit": pyunits.mol}, - "vaporMolFrac": { - "oli_unit": "mol/mol", - "pyomo_unit": pyunits.mol / pyunits.mol, - }, - "totalVolume": {"oli_unit": "L", "pyomo_unit": pyunits.L}, - "pipeDiameter": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, - "pipeFlowVelocity": { - "oli_unit": "m/s", - "pyomo_unit": pyunits.meter / pyunits.second, - }, - "diskDiameter": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, - "diskRotatingSpeed": {"oli_unit": "cycle/s", "pyomo_unit": 1 / pyunits.second}, - "rotorDiameter": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, - "rotorRotation": {"oli_unit": "cycle/s", "pyomo_unit": 1 / pyunits.second}, - "shearStress": {"oli_unit": "Pa", "pyomo_unit": pyunits.Pa}, - "pipeDiameter": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, - "pipeRoughness": {"oli_unit": "m", "pyomo_unit": pyunits.meter}, - "liquidFlowInPipe": { - "oli_unit": "L/s", - "pyomo_unit": pyunits.L / pyunits.second, - }, - "gasFlowInPipe": {"oli_unit": "L/s", "pyomo_unit": pyunits.L / pyunits.second}, - "viscAbs2ndLiq": { - "oli_unit": "Pa-s", - "pyomo_unit": pyunits.Pa * pyunits.second, - }, - "alkalinity": {"oli_unit": "mg HCO3/L", "pyomo_unit": pyunits.mg / pyunits.L}, - "TIC": {"oli_unit": "mol C/L", "pyomo_unit": pyunits.mol / pyunits.L}, - "CO2GasFraction": { - "oli_unit": "mol/mol", - "pyomo_unit": pyunits.mol / pyunits.mol, - }, - } + {k: FixedKeysDict(v) for k, v in input_unit_set_temp.items()} +) +default_unit_set = FixedKeysDict( + {k: FixedKeysDict(v) for k, v in input_unit_set_temp.items()} ) optional_properties = FixedKeysDict( @@ -137,15 +185,20 @@ def pprint(self): # and reducing hard-coding by using default_input_unit_set references output_unit_set = FixedKeysDict( { - "enthalpy": input_unit_set["enthalpy"]["oli_unit"], - "mass": input_unit_set["mass"]["oli_unit"], - "pt": input_unit_set["pressure"]["oli_unit"], - "total": input_unit_set["mass"]["oli_unit"], - "liq1_phs_comp": input_unit_set["mass"]["oli_unit"], - "solid_phs_comp": input_unit_set["mass"]["oli_unit"], - "vapor_phs_comp": input_unit_set["mass"]["oli_unit"], - "liq2_phs_comp": input_unit_set["mass"]["oli_unit"], - "combined_phs_comp": input_unit_set["mass"]["oli_unit"], - "molecularConcentration": input_unit_set["molecularConcentration"]["oli_unit"], + "enthalpy": default_unit_set["enthalpy"]["oli_unit"], + "mass": default_unit_set["mass"]["oli_unit"], + "pt": default_unit_set["pressure"]["oli_unit"], + "total": default_unit_set["mass"]["oli_unit"], + "liq1_phs_comp": default_unit_set["mass"]["oli_unit"], + "solid_phs_comp": default_unit_set["mass"]["oli_unit"], + "vapor_phs_comp": default_unit_set["mass"]["oli_unit"], + "liq2_phs_comp": default_unit_set["mass"]["oli_unit"], + "combined_phs_comp": default_unit_set["mass"]["oli_unit"], + "molecularConcentration": default_unit_set["molecularConcentration"][ + "oli_unit" + ], } ) + +if __name__ == "__main__": + unit_set = input_unit_set_temp diff --git a/watertap/tools/oli_api/util/tests/test_fixed_keys_dict.py b/watertap/tools/oli_api/util/tests/test_fixed_keys_dict.py index 901be226cc..e02d9d9a8e 100644 --- a/watertap/tools/oli_api/util/tests/test_fixed_keys_dict.py +++ b/watertap/tools/oli_api/util/tests/test_fixed_keys_dict.py @@ -11,8 +11,9 @@ ################################################################################# import pytest - -from watertap.tools.oli_api.util.fixed_keys_dict import output_unit_set +from pyomo.environ import units as pyunits +from pyomo.util.check_units import assert_units_equivalent +from watertap.tools.oli_api.util.fixed_keys_dict import output_unit_set, input_unit_set @pytest.mark.unit @@ -27,3 +28,66 @@ def test_fixed_keys_dict(): output_unit_set._check_value("mass", ["not_kg"]) output_unit_set.pprint() + + +@pytest.mark.unit +def test_input_unit_set(): + unit_set = input_unit_set + # check defaults for one of the properties + assert unit_set["molecularConcentration"]["oli_unit"] == "mg/L" + assert str(unit_set["molecularConcentration"]["pyomo_unit"]) == "mg/L".lower() + assert_units_equivalent( + (unit_set["molecularConcentration"]["pyomo_unit"]), pyunits.mg / pyunits.L + ) + assert hasattr( + (unit_set["molecularConcentration"]["pyomo_unit"]), "is_expression_type" + ) + assert str(unit_set["molecularConcentration"]["pyomo_unit"]) == "mg/L".lower() + + # reset oli_unit with a different unit, provided as string, and check that pyomo_unit follows along + unit_set["molecularConcentration"]["oli_unit"] = "mol/L" + assert unit_set["molecularConcentration"]["oli_unit"] == "mol/L" + assert str(unit_set["molecularConcentration"]["pyomo_unit"]) == "mol/L".lower() + assert_units_equivalent( + (unit_set["molecularConcentration"]["pyomo_unit"]), pyunits.mol / pyunits.L + ) + + # reset pyomo_unit with a different unit, provided as pint units, and check that oli_unit follows along + unit_set["molecularConcentration"]["pyomo_unit"] = pyunits.mg + assert_units_equivalent( + (unit_set["molecularConcentration"]["pyomo_unit"]), pyunits.mg + ) + assert unit_set["molecularConcentration"]["oli_unit"] == "mg" + + # reset pyomo_unit with a different unit, provided as string units, and check that oli_unit follows along + unit_set["molecularConcentration"]["pyomo_unit"] = "mg/L" + assert_units_equivalent( + (unit_set["molecularConcentration"]["pyomo_unit"]), pyunits.mg / pyunits.L + ) + assert unit_set["molecularConcentration"]["oli_unit"] == "mg/L" + + # reset oli_unit with a different unit, provided as pint units, and check that pyomo_unit follows along + unit_set["molecularConcentration"]["oli_unit"] = pyunits.mol / pyunits.L + assert unit_set["molecularConcentration"]["oli_unit"] == "mol/L".lower() + assert str(unit_set["molecularConcentration"]["pyomo_unit"]) == "mol/L".lower() + assert_units_equivalent( + (unit_set["molecularConcentration"]["pyomo_unit"]), pyunits.mol / pyunits.L + ) + + with pytest.raises( + RuntimeError, + match="Setting 1 as the value for oli_unit is not permitted as a value for oli and pyomo units. Please enter units as a string type or pint units.", + ): + unit_set["molecularConcentration"]["oli_unit"] = 1 + + with pytest.raises( + RuntimeError, + match="Setting 1 as the value for pyomo_unit is not permitted as a value for oli and pyomo units. Please enter units as a string type or pint units.", + ): + unit_set["molecularConcentration"]["pyomo_unit"] = 1 + + with pytest.raises( + RuntimeError, + match="Setting 1 as the value for molecularConcentration is not permitted as a value for oli and pyomo units. Please enter units as a string type or pint units.", + ): + unit_set["molecularConcentration"] = 1