diff --git a/docs/source/api/exports.rst b/docs/source/api/exports.rst index abde0afbc..0bf38fdfc 100644 --- a/docs/source/api/exports.rst +++ b/docs/source/api/exports.rst @@ -15,10 +15,6 @@ Exports :members: :show-inheritance: -.. autoclass:: TXTExports - :members: - :show-inheritance: - .. autoclass:: TrapDensityXDMF :members: :show-inheritance: diff --git a/festim/__init__.py b/festim/__init__.py index 9a7bc7e59..3b199d1ad 100644 --- a/festim/__init__.py +++ b/festim/__init__.py @@ -87,7 +87,7 @@ from .exports.derived_quantities.derived_quantities import DerivedQuantities -from .exports.txt_export import TXTExport, TXTExports +from .exports.txt_export import TXTExport from .settings import Settings diff --git a/festim/exports/exports.py b/festim/exports/exports.py index 45e769a62..c3d61480a 100644 --- a/festim/exports/exports.py +++ b/festim/exports/exports.py @@ -129,8 +129,7 @@ def write(self, label_to_function, dx): label_to_function[export.field], self.V_DG1 ) export.function = label_to_function[export.field] - steady = self.final_time == None - export.write(self.t, steady) + export.write(self.t, self.final_time) self.nb_iterations += 1 def initialise_derived_quantities(self, dx, ds, materials): diff --git a/festim/exports/txt_export.py b/festim/exports/txt_export.py index 3c7688a99..6137b8b8f 100644 --- a/festim/exports/txt_export.py +++ b/festim/exports/txt_export.py @@ -16,19 +16,51 @@ class TXTExport(festim.Export): times (list, optional): if provided, the field will be exported at these timesteps. Otherwise exports at all timesteps. Defaults to None. + filter (bool): if True and the field is projected to a DG function space, + the duplicated vertices in the output file array are filtered except those near interfaces. + Defaults to True. + write_at_last (bool): if True, the data will be exported at + the last export time. Otherwise, the data will be exported + at each export time. Defaults to False. header_format (str, optional): the format of column headers. Defautls to ".2e". + + Attributes: + data (np.array): the data array of the exported field. The first column + is the mesh vertices. Each next column is the field profile at the specific + export time. + header (str): the header of the exported file. + V (fenics.FunctionSpace): the vector-function space for the exported field. + + .. note:: + The exported field is projected to DG if conservation of chemical potential is considered or + ``traps_element_type`` is "DG". + """ - def __init__(self, field, filename, times=None, header_format=".2e") -> None: + def __init__( + self, + field, + filename, + times=None, + filter=True, + write_at_last=False, + header_format=".2e", + ) -> None: super().__init__(field=field) if times: self.times = sorted(times) else: self.times = times self.filename = filename + self.filter = filter + self.write_at_last = write_at_last self.header_format = header_format - self._first_time = True + + self.data = None + self.header = None + self.V = None + self._unique_indices = None @property def filename(self): @@ -44,6 +76,17 @@ def filename(self, value): self._filename = value def is_it_time_to_export(self, current_time): + """ + Checks if the exported field should be written to a file or not + based on the current time and the ``TXTExport.times`` + + Args: + current_time (float): the current simulation time + + Returns: + bool: True if the exported field should be written to a file, else False + """ + if self.times is None: return True for time in self.times: @@ -51,83 +94,138 @@ def is_it_time_to_export(self, current_time): return True return False - def when_is_next_time(self, current_time): - if self.times is None: - return None - for time in self.times: - if current_time < time: - return time - return None + def is_last(self, current_time, final_time): + """ + Checks if the current simulation step equals to the last export time. + based on the final simulation time, ``TXTExport.times``, and the current time + + Args: + current_time (float): the current simulation time. + final_time (float, None): the final simulation time. + + Returns: + bool: True if simulation is steady (final_time is None), if ``TXTExport.times`` are not + provided and the current time equals to the final time, or if + ``TXTExport.times`` are provided and the current time equals to the last time in + ``TXTExport.times``, else False. + """ + + if final_time is None: + # write if steady + return True + elif self.times is None: + if np.isclose(current_time, final_time, atol=0): + # write at the final time if exports at each timestep + return True + elif np.isclose(current_time, self.times[-1], atol=0): + # write at the final time if exports at specific times + return True + return False + + def initialise(self, mesh, project_to_DG=False, materials=None): + """ + Initialises ``TXTExport``. Depending on the ``project_to_DG flag``, defines a function space (DG1 or CG1) + for projection of the exported field. After that, an unsorted array of mesh vertices is created for export. + The array is then used to obtain indices of sorted elements for the data export. + + .. note:: + If DG1 is used and the ``filter`` flag is True, the duplicated vertices in the array are filtered except those near interfaces. + The interfaces are defined by ``material.borders`` in the ``Materials`` list. + + Args: + mesh (fenics.Mesh): the mesh. + project_to_DG (bool): if True, the exported field is projected to a DG1 function space. + Defaults to False. + materials (festim.Materials): the materials. Defaults to None. + """ + + if project_to_DG: + self.V = f.FunctionSpace(mesh, "DG", 1) + else: + self.V = f.FunctionSpace(mesh, "CG", 1) + + x = f.interpolate(f.Expression("x[0]", degree=1), self.V) + x_column = np.transpose([x.vector()[:]]) + + # if filter is True, get indices of duplicates near interfaces + # and indices of the first elements from a pair of duplicates otherwise + if project_to_DG and self.filter: + # Collect all borders + borders = [] + for material in materials: + if material.borders: + for border in material.borders: + borders.append(border) + borders = np.unique(borders) + + # Find indices of the closest duplicates to interfaces + border_indices = [] + for border in borders: + closest_indx = np.abs(x_column - border).argmin() + closest_x = x_column[closest_indx] + for ind in np.where(x_column == closest_x)[0]: + border_indices.append(ind) + + # Find indices of first elements in duplicated pairs and mesh borders + _, mesh_indices = np.unique(x_column, return_index=True) + + # Get unique indices from both arrays preserving the order in unsorted x-array + unique_indices = [] + for indx in np.argsort(x_column, axis=0)[:, 0]: + if (indx in mesh_indices) or (indx in border_indices): + unique_indices.append(indx) + + self._unique_indices = np.array(unique_indices) + + else: + # Get list of sorted indices + self._unique_indices = np.argsort(x_column, axis=0)[:, 0] - def write(self, current_time, steady): - # create a DG1 functionspace - V_DG1 = f.FunctionSpace(self.function.function_space().mesh(), "DG", 1) + self.data = x_column[self._unique_indices] + self.header = "x" + + def write(self, current_time, final_time): + """ + Modifies the header and writes the data to a file depending on + the current and the final times of a simulation. + + Args: + current_time (float): the current simulation time. + final_time (float, None): the final simulation time. + """ - solution = f.project(self.function, V_DG1) - solution_column = np.transpose(solution.vector()[:]) if self.is_it_time_to_export(current_time): + solution = f.project(self.function, self.V) + solution_column = np.transpose(solution.vector()[:]) + # if the directory doesn't exist # create it dirname = os.path.dirname(self.filename) if not os.path.exists(dirname): os.makedirs(dirname, exist_ok=True) - # if steady or it is the first time to export - # write data - # else append new column to the existing file - if steady or self._first_time: - if steady: - header = "x,t=steady" - else: - header = f"x,t={format(current_time, self.header_format)}s" - x = f.interpolate(f.Expression("x[0]", degree=1), V_DG1) - x_column = np.transpose([x.vector()[:]]) - data = np.column_stack([x_column, solution_column]) - self._first_time = False + # if steady, add the corresponding label + # else append new export time to the header + steady = final_time is None + if steady: + self.header += ",t=steady" else: - # Update the header - old_file = open(self.filename) - old_header = old_file.readline().split("\n")[0] - old_file.close() - header = old_header + f",t={format(current_time, self.header_format)}s" - # Append new column - old_columns = np.loadtxt(self.filename, delimiter=",", skiprows=1) - data = np.column_stack([old_columns, solution_column]) - - np.savetxt(self.filename, data, header=header, delimiter=",", comments="") - - -class TXTExports: - """ - Args: - fields (list): list of exported fields ("solute", "1", "retention", - "T"...) - filenames (list): list of the filenames for each field (must end with .txt). - times (list, optional): if provided, fields will be - exported at these timesteps. Otherwise exports at all - timesteps. Defaults to None. - header_format (str, optional): the format of column headers. - Defautls to ".2e". - """ + self.header += f",t={format(current_time, self.header_format)}s" - def __init__( - self, fields=[], filenames=[], times=None, header_format=".2e" - ) -> None: - msg = "TXTExports class will be deprecated in future versions of FESTIM" - warnings.warn(msg, DeprecationWarning) - - self.fields = fields - if len(self.fields) != len(filenames): - raise ValueError( - "Number of fields to be exported " - "doesn't match number of filenames in txt exports" + # Add new column of filtered and sorted data + self.data = np.column_stack( + [self.data, solution_column[self._unique_indices]] ) - if times: - self.times = sorted(times) - else: - self.times = times - self.filenames = filenames - self.header_format = header_format - self.exports = [] - for function, filename in zip(self.fields, self.filenames): - self.exports.append(TXTExport(function, filename, times, header_format)) + + if ( + self.write_at_last and self.is_last(current_time, final_time) + ) or not self.write_at_last: + + # Write data + np.savetxt( + self.filename, + self.data, + header=self.header, + delimiter=",", + comments="", + ) diff --git a/festim/generic_simulation.py b/festim/generic_simulation.py index 878e59851..9c8bbd826 100644 --- a/festim/generic_simulation.py +++ b/festim/generic_simulation.py @@ -370,11 +370,11 @@ def initialise(self): self.h_transport_problem.initialise(self.mesh, self.materials, self.dt) - # raise warning if the derived quantities don't match the type of mesh - # eg. SurfaceFlux is used with cylindrical mesh for export in self.exports: if isinstance(export, festim.DerivedQuantities): for q in export: + # raise warning if the derived quantities don't match the type of mesh + # eg. SurfaceFlux is used with cylindrical mesh if self.mesh.type not in q.allowed_meshes: warnings.warn( f"{type(q)} may not work as intended for {self.mesh.type} meshes" @@ -394,31 +394,41 @@ def initialise(self): f"SurfaceKinetics boundary condition must be defined on surface {q.surface} to export data with festim.AdsorbedHydrogen" ) - self.exports.initialise_derived_quantities( - self.mesh.dx, self.mesh.ds, self.materials - ) - - # needed to ensure that data is actually exported at TXTExport.times - # see issue 675 - for export in self.exports: - if isinstance(export, festim.TXTExport) and export.times: - if not self.dt.milestones: - self.dt.milestones = [] - for time in export.times: - if time not in self.dt.milestones: - msg = "To ensure that TXTExport exports data at the desired times " - msg += "TXTExport.times are added to milestones" - warnings.warn(msg) - self.dt.milestones.append(time) - self.dt.milestones.sort() - - # set Soret to True for SurfaceFlux quantities - if isinstance(export, festim.DerivedQuantities): - for q in export: + # set Soret to True for SurfaceFlux quantities if isinstance(q, festim.SurfaceFlux): q.soret = self.settings.soret q.T = self.T.T + if isinstance(export, festim.TXTExport): + # pre-process data depending on the chemical potential flag, trap element type, + # and material borders + project_to_DG = ( + self.settings.chemical_pot + or self.settings.traps_element_type == "DG" + ) + export.initialise( + self.mesh.mesh, + project_to_DG, + self.materials, + ) + + # needed to ensure that data is actually exported at TXTExport.times + # see issue 675 + if export.times: + if not self.dt.milestones: + self.dt.milestones = [] + for time in export.times: + if time not in self.dt.milestones: + msg = "To ensure that TXTExport exports data at the desired times " + msg += "TXTExport.times are added to milestones" + warnings.warn(msg) + self.dt.milestones.append(time) + self.dt.milestones.sort() + + self.exports.initialise_derived_quantities( + self.mesh.dx, self.mesh.ds, self.materials + ) + def run(self, completion_tone=False): """Runs the model. @@ -516,7 +526,10 @@ def run_post_processing(self): self.update_post_processing_solutions() self.exports.t = self.t - self.exports.write(self.label_to_function, self.mesh.dx) + self.exports.write( + self.label_to_function, + self.mesh.dx, + ) def update_post_processing_solutions(self): """Creates the post-processing functions by splitting self.u. Projects diff --git a/test/unit/test_exports/test_txt_export.py b/test/unit/test_exports/test_txt_export.py index 58f471081..6b180b4f8 100644 --- a/test/unit/test_exports/test_txt_export.py +++ b/test/unit/test_exports/test_txt_export.py @@ -1,11 +1,18 @@ -from festim import TXTExport, Stepsize +from festim import TXTExport, Material import fenics as f import os import pytest +import numpy as np from pathlib import Path class TestWrite: + @pytest.fixture + def mesh(self): + mesh = f.UnitIntervalMesh(10) + + return mesh + @pytest.fixture def function(self): mesh = f.UnitIntervalMesh(10) @@ -33,21 +40,29 @@ def my_export(self, tmpdir): return my_export - def test_file_exists(self, my_export, function): + def test_file_exists(self, my_export, function, mesh): current_time = 1 my_export.function = function - my_export.write(current_time=current_time, steady=False) + my_export.initialise(mesh) + my_export.write( + current_time=current_time, + final_time=None, + ) assert os.path.exists(my_export.filename) - def test_file_doesnt_exist(self, my_export, function): + def test_file_doesnt_exist(self, my_export, function, mesh): current_time = 10 my_export.function = function - my_export.write(current_time=current_time, steady=False) + my_export.initialise(mesh) + my_export.write( + current_time=current_time, + final_time=None, + ) assert not os.path.exists(my_export.filename) - def test_create_folder(self, my_export, function): + def test_create_folder(self, my_export, function, mesh): """Checks that write() creates the folder if it doesn't exist""" current_time = 1 my_export.function = function @@ -57,14 +72,22 @@ def test_create_folder(self, my_export, function): + "/folder2" + my_export.filename[slash_indx:] ) - my_export.write(current_time=current_time, steady=False) + my_export.initialise(mesh) + my_export.write( + current_time=current_time, + final_time=None, + ) assert os.path.exists(my_export.filename) - def test_subspace(self, my_export, function_subspace): + def test_subspace(self, my_export, function_subspace, mesh): current_time = 1 my_export.function = function_subspace - my_export.write(current_time=current_time, steady=False) + my_export.initialise(mesh) + my_export.write( + current_time=current_time, + final_time=None, + ) assert os.path.exists(my_export.filename) @@ -76,6 +99,59 @@ def test_error_filename_not_a_str(self, my_export): with pytest.raises(TypeError, match="filename must be a string"): my_export.filename = 2 + def test_sorted_by_x(self, my_export, function, mesh): + """Checks that the exported data is sorted by x""" + current_time = 1 + my_export.function = function + my_export.initialise(mesh) + my_export.write( + current_time=current_time, + final_time=None, + ) + assert (np.diff(my_export.data[:, 0]) >= 0).all() + + @pytest.mark.parametrize( + "materials,project_to_DG,filter,export_len", + [ + (None, False, False, 11), + ( + [ + Material(id=1, D_0=1, E_D=0, S_0=1, E_S=0, borders=[0, 0.5]), + Material(id=2, D_0=2, E_D=0, S_0=2, E_S=0, borders=[0.5, 1]), + ], + True, + True, + 12, # + 1 duplicate near the interface + ), + ( + [ + Material(id=1, D_0=1, E_D=0, S_0=1, E_S=0, borders=[0, 0.5]), + Material(id=2, D_0=2, E_D=0, S_0=2, E_S=0, borders=[0.5, 1]), + ], + True, + False, + 20, # 2 * (len_vertices - 1) + ), + ], + ) + def test_duplicates( + self, materials, project_to_DG, filter, export_len, my_export, function, mesh + ): + """ + Checks that the exported data does not contain duplicates + except those near interfaces + """ + current_time = 1 + my_export.function = function + my_export.filter = filter + my_export.initialise(mesh, project_to_DG, materials) + my_export.write( + current_time=current_time, + final_time=None, + ) + + assert len(my_export.data) == export_len + class TestIsItTimeToExport: @pytest.fixture @@ -101,24 +177,27 @@ def test_false(self, my_export): assert not my_export.is_it_time_to_export(1.5) -class TestWhenIsNextTime: +class TestIsLast: @pytest.fixture def my_export(self, tmpdir): d = tmpdir.mkdir("test_folder") my_export = TXTExport( "solute", - times=[1, 2, 3], filename="{}/solute_label.txt".format(str(Path(d))), ) return my_export - def test_there_is_a_next_time(self, my_export): - assert my_export.when_is_next_time(2) == 3 - assert my_export.when_is_next_time(1) == 2 - assert my_export.when_is_next_time(0) == 1 - assert my_export.when_is_next_time(0.5) == 1 + def test_final_time_is_none(self, my_export): + assert my_export.is_last(1, None) == True + + @pytest.mark.parametrize("current_time,output", [(1, False), (3, False), (5, True)]) + def test_times_is_none(self, current_time, output, my_export): + final_time = 5 + assert my_export.is_last(current_time, final_time) == output - def test_last(self, my_export): - assert my_export.when_is_next_time(3) is None - assert my_export.when_is_next_time(4) is None + @pytest.mark.parametrize("current_time,output", [(1, False), (2, False), (3, True)]) + def test_times_not_none(self, current_time, output, my_export): + my_export.times = [1, 2, 3] + final_time = 5 + assert my_export.is_last(current_time, final_time) == output diff --git a/test/unit/test_exports/test_txt_exports.py b/test/unit/test_exports/test_txt_exports.py deleted file mode 100644 index 9d0317f50..000000000 --- a/test/unit/test_exports/test_txt_exports.py +++ /dev/null @@ -1,28 +0,0 @@ -from festim import TXTExports -import pytest -from pathlib import Path - - -class TestWrite: - @pytest.fixture(params=[[1, 2, 3], None]) - def my_export(self, tmpdir, request): - d = tmpdir.mkdir("test_folder") - my_export = TXTExports( - fields=["solute", "T"], - filenames=[ - "{}/solute_label.txt".format(str(Path(d))), - "{}/T_label.txt".format(str(Path(d))), - ], - times=request.param, - ) - - return my_export - - def test_txt_exports_times(self, my_export): - for export in my_export.exports: - assert export.times == my_export.times - - -def test_error_when_fields_and_labels_have_different_lengths(): - with pytest.raises(ValueError, match="Number of fields to be exported"): - TXTExports(["solute", "T"], ["solute_label.txt"], [1])