diff --git a/festim/exports/txt_export.py b/festim/exports/txt_export.py index 9afcc54c9..4a5807a04 100644 --- a/festim/exports/txt_export.py +++ b/festim/exports/txt_export.py @@ -9,30 +9,39 @@ class TXTExport(festim.Export): """ - Args: field (str): the exported field ("solute", "1", "retention", "T"...) - label (str): label of the field. Will also be the filename. - folder (str): the export folder + filename (str): the filename (must end with .txt). times (list, optional): if provided, the field 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". """ - def __init__(self, field, label, folder, times=None) -> None: + def __init__(self, field, filename, times=None, header_format=".2e") -> None: super().__init__(field=field) if times: self.times = sorted(times) else: self.times = times - self.label = label - self.folder = folder + self.filename = filename + self.header_format = header_format self._first_time = True @property def filename(self): - return f"{self.folder}/{self.label}.txt" + return self._filename + + @filename.setter + def filename(self, value): + if value is not None: + if not isinstance(value, str): + raise TypeError("filename must be a string") + if not value.endswith(".txt"): + raise ValueError("filename must end with .txt") + self._filename = value def is_it_time_to_export(self, current_time): if self.times is None: @@ -57,11 +66,6 @@ def write(self, current_time, steady): solution = f.project(self.function, V_DG1) solution_column = np.transpose(solution.vector()[:]) if self.is_it_time_to_export(current_time): - if steady: - header = "x,t=steady" - else: - header = "x,t={}s".format(current_time) - # if the directory doesn't exist # create it dirname = os.path.dirname(self.filename) @@ -72,6 +76,10 @@ def write(self, current_time, steady): # 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]) @@ -81,7 +89,7 @@ def write(self, current_time, steady): old_file = open(self.filename) old_header = old_file.readline().split("\n")[0] old_file.close() - header = old_header + ",t={}s".format(current_time) + 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]) @@ -90,16 +98,36 @@ def write(self, current_time, steady): class TXTExports: - def __init__(self, fields=[], times=[], labels=[], folder=None) -> None: + """ + 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". + """ + + def __init__( + self, fields=[], filenames=[], times=None, header_format=".2e" + ) -> None: + msg = "TXTExports class will be depricated in future versions of FESTIM" + warnings.warn(msg, DeprecationWarning) + self.fields = fields - if len(self.fields) != len(labels): + if len(self.fields) != len(filenames): raise ValueError( "Number of fields to be exported " - "doesn't match number of labels in txt exports" + "doesn't match number of filenames in txt exports" ) - self.times = sorted(times) - self.labels = labels - self.folder = folder + if times: + self.times = sorted(times) + else: + self.times = times + self.filenames = filenames + self.header_format = header_format self.exports = [] - for function, label in zip(self.fields, self.labels): - self.exports.append(TXTExport(function, label, folder, times)) + for function, filename in zip(self.fields, self.filenames): + self.exports.append(TXTExport(function, filename, times, header_format)) diff --git a/festim/generic_simulation.py b/festim/generic_simulation.py index 4a44d0654..8e59638a1 100644 --- a/festim/generic_simulation.py +++ b/festim/generic_simulation.py @@ -2,6 +2,7 @@ from festim.h_transport_problem import HTransportProblem from fenics import * import numpy as np +import warnings class Simulation: @@ -273,6 +274,20 @@ def initialise(self): 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.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() + def run(self, completion_tone=False): """Runs the model. diff --git a/test/simulation/test_initialise.py b/test/simulation/test_initialise.py index 7a3439c89..0cea87f89 100644 --- a/test/simulation/test_initialise.py +++ b/test/simulation/test_initialise.py @@ -1,4 +1,5 @@ import festim as F +from pathlib import Path def test_initialise_changes_nb_of_sources(): @@ -71,3 +72,34 @@ def test_initialise_initialise_dt(): # test assert my_model.dt.value(2) == 3 + + +def test_TXTExport_times_added_to_milestones(tmpdir): + """Creates a Simulation object and checks that, if no dt.milestones + are given and TXTExport.times are given, TXTExport.times are + are added to dt.milestones by .initialise() + """ + # tmpdir + d = tmpdir.mkdir("test_folder") + + # build + my_model = F.Simulation() + my_model.mesh = F.MeshFromVertices([1, 2, 3]) + my_model.materials = F.Material(id=1, D_0=1, E_D=0, thermal_cond=1) + my_model.T = F.Temperature(100) + my_model.dt = F.Stepsize(initial_value=3) + my_model.settings = F.Settings( + absolute_tolerance=1e-10, relative_tolerance=1e-10, final_time=4 + ) + txt_export = F.TXTExport( + field="solute", + filename="{}/solute_label.txt".format(str(Path(d))), + times=[1, 2, 3], + ) + my_model.exports = [txt_export] + + # run + my_model.initialise() + + # test + assert my_model.dt.milestones == txt_export.times diff --git a/test/system/test_chemical_potential.py b/test/system/test_chemical_potential.py index 0b14b0254..60b090e70 100644 --- a/test/system/test_chemical_potential.py +++ b/test/system/test_chemical_potential.py @@ -83,7 +83,7 @@ def run(h): my_exports = festim.Exports( [ festim.TXTExport( - "solute", times=[100], label="solute", folder=str(Path(d)) + "solute", times=[100], filename="{}/solute.txt".format(str(Path(d))) ), ] ) diff --git a/test/system/test_misc.py b/test/system/test_misc.py index a7a89c249..40d11a673 100644 --- a/test/system/test_misc.py +++ b/test/system/test_misc.py @@ -134,17 +134,17 @@ def test_txt_export_desired_times(tmp_path): my_model.dt = F.Stepsize(0.1) my_export = F.TXTExport( - "solute", label="mobile_conc", times=[0.2, 0.5], folder=tmp_path + "solute", times=[0.2, 0.5], filename="{}/mobile_conc.txt".format(tmp_path) ) my_model.exports = [my_export] my_model.initialise() my_model.run() - assert os.path.exists("{}/{}.txt".format(my_export.folder, my_export.label)) + assert os.path.exists(my_export.filename) data = np.genfromtxt( - "{}/{}.txt".format(my_export.folder, my_export.label), + my_export.filename, skip_header=1, delimiter=",", ) @@ -163,16 +163,16 @@ def test_txt_export_all_times(tmp_path): my_model.T = F.Temperature(500) my_model.dt = F.Stepsize(0.1) - my_export = F.TXTExport("solute", label="mobile_conc", folder=tmp_path) + my_export = F.TXTExport("solute", filename="{}/mobile_conc.txt".format(tmp_path)) my_model.exports = [my_export] my_model.initialise() my_model.run() - assert os.path.exists("{}/{}.txt".format(my_export.folder, my_export.label)) + assert os.path.exists(my_export.filename) data = np.genfromtxt( - "{}/{}.txt".format(my_export.folder, my_export.label), + my_export.filename, skip_header=1, delimiter=",", ) @@ -190,15 +190,15 @@ def test_txt_export_steady_state(tmp_path): my_model.settings = F.Settings(1e-10, 1e-10, transient=False) my_model.T = F.Temperature(500) - my_export = F.TXTExport("solute", label="mobile_conc", folder=tmp_path) + my_export = F.TXTExport("solute", filename="{}/mobile_conc.txt".format(tmp_path)) my_model.exports = [my_export] my_model.initialise() my_model.run() - assert os.path.exists("{}/{}.txt".format(my_export.folder, my_export.label)) + assert os.path.exists(my_export.filename) - txt = open("{}/{}.txt".format(my_export.folder, my_export.label)) + txt = open(my_export.filename) header = txt.readline().rstrip() txt.close() diff --git a/test/system/test_system.py b/test/system/test_system.py index f30d5fceb..8e648b917 100644 --- a/test/system/test_system.py +++ b/test/system/test_system.py @@ -344,7 +344,7 @@ def run(h): my_exports = festim.Exports( [ festim.TXTExport( - "solute", times=[100], label="solute", folder=str(Path(d)) + "solute", times=[100], filename="{}/solute.txt".format(str(Path(d))) ), ] ) diff --git a/test/unit/test_exports/test_txt_export.py b/test/unit/test_exports/test_txt_export.py index 2c93e2566..58f471081 100644 --- a/test/unit/test_exports/test_txt_export.py +++ b/test/unit/test_exports/test_txt_export.py @@ -25,7 +25,11 @@ def function_subspace(self): @pytest.fixture def my_export(self, tmpdir): d = tmpdir.mkdir("test_folder") - my_export = TXTExport("solute", "solute_label", str(Path(d)), times=[1, 2, 3]) + my_export = TXTExport( + "solute", + times=[1, 2, 3], + filename="{}/solute_label.txt".format(str(Path(d))), + ) return my_export @@ -34,37 +38,54 @@ def test_file_exists(self, my_export, function): my_export.function = function my_export.write(current_time=current_time, steady=False) - assert os.path.exists("{}/{}.txt".format(my_export.folder, my_export.label)) + assert os.path.exists(my_export.filename) def test_file_doesnt_exist(self, my_export, function): current_time = 10 my_export.function = function my_export.write(current_time=current_time, steady=False) - assert not os.path.exists("{}/{}.txt".format(my_export.folder, my_export.label)) + assert not os.path.exists(my_export.filename) def test_create_folder(self, my_export, function): """Checks that write() creates the folder if it doesn't exist""" current_time = 1 my_export.function = function - my_export.folder += "/folder2" + slash_indx = my_export.filename.rfind("/") + my_export.filename = ( + my_export.filename[:slash_indx] + + "/folder2" + + my_export.filename[slash_indx:] + ) my_export.write(current_time=current_time, steady=False) - assert os.path.exists("{}/{}.txt".format(my_export.folder, my_export.label)) + assert os.path.exists(my_export.filename) def test_subspace(self, my_export, function_subspace): current_time = 1 my_export.function = function_subspace my_export.write(current_time=current_time, steady=False) - assert os.path.exists("{}/{}.txt".format(my_export.folder, my_export.label)) + assert os.path.exists(my_export.filename) + + def test_error_filename_endswith_txt(self, my_export): + with pytest.raises(ValueError, match="filename must end with .txt"): + my_export.filename = "coucou" + + def test_error_filename_not_a_str(self, my_export): + with pytest.raises(TypeError, match="filename must be a string"): + my_export.filename = 2 class TestIsItTimeToExport: @pytest.fixture def my_export(self, tmpdir): d = tmpdir.mkdir("test_folder") - my_export = TXTExport("solute", "solute_label", str(Path(d)), times=[1, 2, 3]) + my_export = TXTExport( + "solute", + times=[1, 2, 3], + filename="{}/solute_label.txt".format(str(Path(d))), + ) return my_export @@ -84,7 +105,11 @@ class TestWhenIsNextTime: @pytest.fixture def my_export(self, tmpdir): d = tmpdir.mkdir("test_folder") - my_export = TXTExport("solute", "solute_label", str(Path(d)), times=[1, 2, 3]) + my_export = TXTExport( + "solute", + times=[1, 2, 3], + filename="{}/solute_label.txt".format(str(Path(d))), + ) return my_export diff --git a/test/unit/test_exports/test_txt_exports.py b/test/unit/test_exports/test_txt_exports.py index 1ad980fd5..056abffdd 100644 --- a/test/unit/test_exports/test_txt_exports.py +++ b/test/unit/test_exports/test_txt_exports.py @@ -6,11 +6,16 @@ class TestWrite: - @pytest.fixture - def my_export(self, tmpdir): + @pytest.fixture(params=[[1, 2, 3], None]) + def my_export(self, tmpdir, request): d = tmpdir.mkdir("test_folder") my_export = TXTExports( - ["solute", "T"], [1, 2, 3], ["solute_label", "T_label"], str(Path(d)) + fields=["solute", "T"], + filenames=[ + "{}/solute_label.txt".format(str(Path(d))), + "{}/T_label.txt".format(str(Path(d))), + ], + times=request.param, ) return my_export @@ -22,4 +27,4 @@ def test_txt_exports_times(self, my_export): def test_error_when_fields_and_labels_have_different_lengths(): with pytest.raises(ValueError, match="Number of fields to be exported"): - TXTExports(["solute", "T"], [1], ["solute_label"]) + TXTExports(["solute", "T"], ["solute_label.txt"], [1])