diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eca1088fd3cdf2a01ed5074f8917351ab960457a..e897bcdbb610a2c4258c68b50fabe3e6dc34d823 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -74,7 +74,7 @@ repos: - id: ruff-format - repo: https://github.com/commitizen-tools/commitizen - rev: v4.9.0 + rev: v4.9.1 hooks: - id: commitizen stages: [commit-msg] diff --git a/changelog/fragments/1627.changed.rst b/changelog/fragments/1627.changed.rst new file mode 100644 index 0000000000000000000000000000000000000000..df506f56216dd62b85907014985218fd6aa6e608 --- /dev/null +++ b/changelog/fragments/1627.changed.rst @@ -0,0 +1,5 @@ +The following functions now include the argument ``delimiter`` which allows users to choose the +delimiter character for CSV files: ``write_design_space``, ``read_design_space``, ``DesignSpace.from_file``, ``DesignSpace.to_file``, ``DesignSpace.from_csv`` and ``DesignSpace.to_csv``. +- The ``**options`` argument in ``DesignSpace.from_file`` has been deprecated. +- The ``**table_options`` argument in ``DesignSpace.to_csv`` has been deprecated. +- The ``header_char`` argument in ``DesignSpace.to_csv`` has been deprecated. diff --git a/src/gemseo/__init__.py b/src/gemseo/__init__.py index bf617d58cb6152bbd66c93717c556dbea79e3010..4b4553f37eebf5d5a83a99101886c2c6a06bca54 100644 --- a/src/gemseo/__init__.py +++ b/src/gemseo/__init__.py @@ -1290,6 +1290,7 @@ def print_configuration() -> None: def read_design_space( file_path: str | Path, header: Iterable[str] = (), + delimiter: str = "", ) -> DesignSpace: """Read a design space from a CSV or HDF file. @@ -1305,6 +1306,8 @@ def read_design_space( file_path: The path to the file. header: The names of the fields saved in the CSV file. If empty, read them in the first row of the CSV file. + delimiter: The string used to separate values for CSV files. If empty, any + consecutive whitespaces act as delimiter. Returns: The design space. @@ -1328,14 +1331,17 @@ def read_design_space( """ from gemseo.algos.design_space import DesignSpace - return DesignSpace.from_file(file_path, header=header) + return DesignSpace.from_file(file_path, header=header, delimiter=delimiter) def write_design_space( design_space: DesignSpace, output_file: str | Path, fields: Sequence[str] = (), + delimiter: str = " ", + # TODO: API: remove. header_char: str = "", + # TODO: API: remove. **table_options: Any, ) -> None: """Save a design space to a CSV or HDF file. @@ -1345,10 +1351,20 @@ def write_design_space( output_file: The path to the file. fields: The fields to be exported. If empty, export all fields. + delimiter: The string used to separate values for CSV files. + This argument is not compatible with ``header_char`` nor + ``**table_options``. header_char: The header character. - **table_options: The names and values of additional attributes - for the :class:`.PrettyTable` view - generated by :meth:`.DesignSpace.get_pretty_table`. + **table_options: + For HDF files: + The ``append`` option may be passed here. + See :meth:`.DesignSpace.to_hdf`. + For CSV files: + The names and values of additional attributes + for the :class:`.PrettyTable` view + generated by :meth:`.DesignSpace.get_pretty_table`. + These options will be removed in GEMSEO 7. + Examples: >>> from gemseo import create_design_space, write_design_space @@ -1357,7 +1373,11 @@ def write_design_space( >>> write_design_space(design_space, "file.csv") """ design_space.to_file( - output_file, fields=fields, header_char=header_char, **table_options + output_file, + fields=fields, + delimiter=delimiter, + header_char=header_char, + **table_options, ) diff --git a/src/gemseo/algos/design_space.py b/src/gemseo/algos/design_space.py index 155a7d7cb2cecace0673037b13777fedc02df5ae..1bbf6b9646315cf83c9fa4b04e3956aac5588270 100644 --- a/src/gemseo/algos/design_space.py +++ b/src/gemseo/algos/design_space.py @@ -78,6 +78,7 @@ from numpy import round as np_round from numpy import vectorize from numpy import where from numpy import zeros_like +from pandas import DataFrame from prettytable import PrettyTable from gemseo.algos._variable import TYPE_MAP @@ -2067,6 +2068,9 @@ class DesignSpace: cls, file_path: str | Path, hdf_node_path: str = "", + header: Iterable[str] = (), + delimiter: str = "", + # TODO: API: remove. **options: Any, ) -> DesignSpace: """Create a design space from a file. @@ -2078,35 +2082,59 @@ class DesignSpace: hdf_node_path: The path of the HDF node from which the database should be imported. If empty, the root node is considered. + header: The names of the fields saved in the CSV file. + If empty, read them in the first row of the CSV file. + delimiter: The string used to separate values for CSV files. If empty, + any consecutive whitespaces act as delimiter. **options: The keyword reading options. + These options are ignored and will be removed in GEMSEO 7. Returns: The design space defined in the file. """ if h5py.is_hdf5(file_path): return cls.from_hdf(file_path, hdf_node_path) - return cls.from_csv(file_path, **options) + return cls.from_csv(file_path, header=header, delimiter=delimiter) - def to_file(self, file_path: str | Path, **options) -> None: + def to_file( + self, + file_path: str | Path, + delimiter: str = " ", + # TODO: API: remove. + **options: Any, + ) -> None: """Save the design space. Args: file_path: The file path to save the design space. If the extension starts with `"hdf"`, the design space will be saved in an HDF file. + delimiter: The string used to separate values for CSV files. This argument + is not compatible with the argument ``**options``. **options: The keyword reading options. + For HDF files: + The ``append`` option may be passed here. + See :meth:`.DesignSpace.to_hdf`. + For CSV files: + The names and values of additional attributes + for the :class:`.PrettyTable` view + generated by :meth:`.DesignSpace.get_pretty_table`. + These options will be removed in GEMSEO 7. """ file_path = Path(file_path) if file_path.suffix.startswith((".hdf", ".h5")): self.to_hdf(file_path, append=options.get("append", False)) else: - self.to_csv(file_path, **options) + self.to_csv(file_path, delimiter=delimiter, **options) def to_csv( self, output_file: str | Path, fields: Sequence[str] = (), + # TODO: API: remove. header_char: str = "", + delimiter: str = " ", + # TODO: API: remove. **table_options: Any, ) -> None: """Export the design space to a CSV file. @@ -2116,27 +2144,89 @@ class DesignSpace: fields: The fields to be exported. If empty, export all fields. header_char: The header character. + This argument will be removed in GEMSEO 7. + delimiter: The string used to separate values. This argument is not + compatible with ``header_char`` nor ``**table_options``. **table_options: The names and values of additional attributes for the :class:`.PrettyTable` view generated by :meth:`.DesignSpace.get_pretty_table`. + These options will be removed in GEMSEO 7. """ output_file = Path(output_file) - table = self.get_pretty_table(fields=fields) - table.border = False - for option, val in table_options.items(): - table.__setattr__(option, val) - with output_file.open("w") as outf: - table_str = header_char + table.get_string() - outf.write(table_str) + + if table_options or header_char: + table = self.get_pretty_table(fields=fields) + table.border = False + for option, val in table_options.items(): + table.__setattr__(option, val) + with output_file.open("w") as outf: + table_str = header_char + table.get_string() + outf.write(table_str) + else: + dataframe = self.__to_dataframe() + dataframe.to_csv( + output_file, + sep=delimiter or " ", + index=False, + columns=fields or self.TABLE_NAMES, + na_rep="None", + ) + + def __to_dataframe(self) -> DataFrame: + """Export the design space to a ``DataFrame``. + + Returns: + The ``DesignSpace`` as a ``DataFrame``. + """ + variable_names = [] + variable_values = [] + lower_bounds = [] + upper_bounds = [] + variable_types = [] + for name, variable in self._variables.items(): + curr = self.__current_value.get(name) + + for i in range(variable.size): + variable_names.append(name) + variable_types.append(variable.type) + lower_bounds.append(variable.lower_bound[i]) + upper_bounds.append(variable.upper_bound[i]) + + if curr is None: + value = None + else: + value = curr[i] + # The current value of a float variable can be a complex array + # when approximating gradients with complex step. + if variable.type == "float": + value = value.real + + variable_values.append(value) + + data = { + "name": variable_names, + "value": variable_values, + "lower_bound": lower_bounds, + "upper_bound": upper_bounds, + "type": variable_types, + } + return DataFrame(data) @classmethod - def from_csv(cls, file_path: str | Path, header: Iterable[str] = ()) -> DesignSpace: + def from_csv( + cls, + file_path: str | Path, + header: Iterable[str] = (), + delimiter: str = "", + ) -> DesignSpace: """Create a design space from a CSV file. Args: file_path: The path to the CSV file. header: The names of the fields saved in the file. If empty, read them in the file. + delimiter: The string used to separate values. If empty, any consecutive + whitespaces act as delimiter. Returns: The design space defined in the file. @@ -2146,8 +2236,8 @@ class DesignSpace: in its header. """ design_space = cls() - float_data = genfromtxt(file_path, dtype="float") - str_data = genfromtxt(file_path, dtype="str") + float_data = genfromtxt(file_path, delimiter=delimiter or None, dtype="float") + str_data = genfromtxt(file_path, delimiter=delimiter or None, dtype="str") if header: start_read = 0 else: diff --git a/tests/algos/test_design_space.py b/tests/algos/test_design_space.py index 887eb08c8a8209fb8bc0d88641832ddbfeede35f..6f62bc79c1b6633acab28c3d483da73a4e33e36f 100644 --- a/tests/algos/test_design_space.py +++ b/tests/algos/test_design_space.py @@ -1002,14 +1002,15 @@ def test_hdf5_with_node(tmp_wd): @pytest.mark.parametrize("suffix", [".csv", ".h5", ".hdf", ".hdf5", ".txt"]) -def test_to_from_file(tmp_wd, suffix) -> None: +@pytest.mark.parametrize("delimiter", [None, ","]) +def test_to_from_file(tmp_wd, suffix, delimiter) -> None: """Check that the methods to_file() and from_file() work correctly.""" file_path = Path("foo").with_suffix(suffix) design_space = get_sobieski_design_space() - design_space.to_file(file_path) + design_space.to_file(file_path, delimiter) assert h5py.is_hdf5(file_path) == file_path.suffix.startswith((".h5", ".hdf")) - read_design_space = DesignSpace.from_file(file_path) + read_design_space = DesignSpace.from_file(file_path, delimiter=delimiter) check_ds(design_space, read_design_space, file_path) @@ -2060,3 +2061,13 @@ def test_normalize_vect_with_inout_argument() -> None: inout = array([0]) space.normalize_vect(array([0]), out=inout) assert_array_equal(inout, array([0])) + + +# TODO: API: remove this test along with **table_options +def test_to_csv_pretty_table(tmp_wd): + """Test that the pretty table options are taken into account.""" + ref_ds = get_sobieski_design_space() + f_path = Path("sobieski_design_space.csv") + ref_ds.to_csv(f_path, border=True) + table_text = f_path.read_text() + assert "-" in table_text diff --git a/tests/test_gemseo.py b/tests/test_gemseo.py index ac28bc542ac2047f611221ca67a270845164c4c2..7e2fc33ece7322c23cc5b2d071d12fd441a4c378 100644 --- a/tests/test_gemseo.py +++ b/tests/test_gemseo.py @@ -82,6 +82,7 @@ from gemseo import import_database from gemseo import import_discipline from gemseo import monitor_scenario from gemseo import print_configuration +from gemseo import read_design_space from gemseo import sample_disciplines from gemseo import wrap_discipline_in_job_scheduler from gemseo import write_design_space @@ -686,8 +687,9 @@ def test_create_design_space() -> None: design_space.check() -def test_write_design_space(tmp_wd) -> None: - """Test that a design space can be exported to a text or h5 file. +@pytest.mark.parametrize("file_name", ["design_space.csv", "design_space.h5"]) +def test_read_write_design_space(tmp_wd, file_name) -> None: + """Test that a design space can be exported/imported to/from a text or h5 file. Args: tmp_wd: Fixture to move into a temporary directory. @@ -696,8 +698,18 @@ def test_write_design_space(tmp_wd) -> None: design_space.add_variable( "name", type_="float", lower_bound=-1, upper_bound=1, value=0 ) - write_design_space(design_space, "design_space.csv") - write_design_space(design_space, "design_space.h5") + design_space.add_variable( + "another_name", + type_="integer", + lower_bound=-1, + upper_bound=1, + value=[0, 0], + size=2, + ) + write_design_space(design_space, file_name) + assert Path(file_name).exists() + design_space_from_file = read_design_space(file_path=file_name) + assert design_space == design_space_from_file def test_create_cache() -> None: