freepaths 2.4.3__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {freepaths-2.4.3 → freepaths-2.5.0}/PKG-INFO +1 -2
  2. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/__main__.py +1 -1
  3. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/animation.py +15 -16
  4. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/config.py +12 -1
  5. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/default_config.py +12 -41
  6. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/interface_smmm.py +14 -1
  7. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/main_mfp_sampling.py +56 -16
  8. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/main_tracing.py +131 -40
  9. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/maps.py +148 -18
  10. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/materials.py +309 -101
  11. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/output_plots.py +8 -27
  12. freepaths-2.5.0/freepaths/phonon.py +283 -0
  13. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/run_particle.py +16 -14
  14. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths.egg-info/PKG-INFO +1 -2
  15. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths.egg-info/requires.txt +0 -1
  16. {freepaths-2.4.3 → freepaths-2.5.0}/setup.py +1 -1
  17. freepaths-2.4.3/freepaths/phonon.py +0 -167
  18. {freepaths-2.4.3 → freepaths-2.5.0}/README.md +0 -0
  19. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/data.py +0 -0
  20. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/electron.py +0 -0
  21. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/flight.py +0 -0
  22. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/move.py +0 -0
  23. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/options.py +0 -0
  24. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/output_electron_plots.py +0 -0
  25. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/output_info.py +0 -0
  26. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/output_structure.py +0 -0
  27. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/particle.py +0 -0
  28. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/post_computations.py +0 -0
  29. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/progress.py +0 -0
  30. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/scatterers.py +0 -0
  31. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/scattering.py +0 -0
  32. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/scattering_interfaces.py +0 -0
  33. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/scattering_primitives.py +0 -0
  34. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/scattering_semicircle.py +0 -0
  35. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/scattering_types.py +0 -0
  36. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths/sources.py +0 -0
  37. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths.egg-info/SOURCES.txt +0 -0
  38. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths.egg-info/dependency_links.txt +0 -0
  39. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths.egg-info/entry_points.txt +0 -0
  40. {freepaths-2.4.3 → freepaths-2.5.0}/freepaths.egg-info/top_level.txt +0 -0
  41. {freepaths-2.4.3 → freepaths-2.5.0}/pyproject.toml +0 -0
  42. {freepaths-2.4.3 → freepaths-2.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: freepaths
3
- Version: 2.4.3
3
+ Version: 2.5.0
4
4
  Summary: Phonon Monte Carlo simulator
5
5
  Home-page: https://github.com/anufrievroman/freepaths
6
6
  Author: Roman Anufriev
@@ -21,7 +21,6 @@ Description-Content-Type: text/markdown
21
21
  Requires-Dist: numpy
22
22
  Requires-Dist: matplotlib
23
23
  Requires-Dist: scipy
24
- Requires-Dist: imageio
25
24
  Requires-Dist: colorama
26
25
  Dynamic: author
27
26
  Dynamic: author-email
@@ -7,7 +7,7 @@ from colorama import Fore, Style
7
7
 
8
8
  from freepaths.options import SimulationMode
9
9
 
10
- __version__ = "2.4.3"
10
+ __version__ = "2.5.0"
11
11
 
12
12
  colorama.init()
13
13
 
@@ -3,9 +3,9 @@
3
3
  import sys
4
4
  import os
5
5
  import shutil
6
- import imageio
7
6
  import numpy as np
8
7
  import matplotlib.pyplot as plt
8
+ from PIL import Image
9
9
 
10
10
  from freepaths.config import cf
11
11
  from freepaths.output_structure import draw_structure_top_view
@@ -14,7 +14,7 @@ from freepaths.output_structure import draw_structure_top_view
14
14
  def generate_frames_xy():
15
15
  """Generate animation frames with phonon paths"""
16
16
 
17
- data = np.genfromtxt("Data/Phonon paths.csv",
17
+ data = np.genfromtxt("Data/Particle paths.csv",
18
18
  unpack=False,
19
19
  delimiter=',',
20
20
  skip_header=1,
@@ -77,21 +77,20 @@ def generate_frames_xy():
77
77
  def generate_animation_xy():
78
78
  """Generate animation of phonon path in XY plane"""
79
79
  sys.stdout.write(f"\rAnimation: creating animation file")
80
- images = []
81
- filenames = os.listdir("Frames/")
82
- for filename in filenames:
83
- images.append(imageio.imread(f"Frames/{filename}"))
84
80
 
85
- # Create a GIF file:
86
- imageio.mimsave("Animated paths XY.gif", images,
87
- duration=1000 * 1/cf.output_animation_fps, subrectangles=True)
88
-
89
- # Create an MP4 file:
90
- # writer = imageio.get_writer('Animated paths XY.mp4', fps=cf.output_animation_fps)
91
-
92
- # for im in images:
93
- # writer.append_data(imageio.imread(im))
94
- # writer.close()
81
+ # Sort the frames so the GIF plays in the order they were rendered.
82
+ # os.listdir returns entries in arbitrary filesystem order (unsorted on
83
+ # macOS/APFS), which produced out-of-order animations. Filtering to the
84
+ # frame_*.png files also guards against stray files (e.g. .DS_Store).
85
+ filenames = sorted(f for f in os.listdir("Frames/") if f.endswith(".png"))
86
+
87
+ # Assemble the GIF with Pillow (already a matplotlib dependency), so no
88
+ # extra imaging library is required. Frames are converted to a palette
89
+ # image for GIF; duration is milliseconds per frame, loop=0 loops forever.
90
+ frames = [Image.open(f"Frames/{name}").convert("RGB").convert("P", palette=Image.ADAPTIVE)
91
+ for name in filenames]
92
+ frames[0].save("Animated paths XY.gif", save_all=True, append_images=frames[1:],
93
+ duration=1000 / cf.output_animation_fps, loop=0, optimize=True)
95
94
 
96
95
 
97
96
  def delete_frames():
@@ -91,6 +91,7 @@ class Config:
91
91
  self.number_of_virtual_timesteps = NUMBER_OF_VIRTUAL_TIMESTEPS
92
92
  self.ignore_faulty_particles = IGNORE_FAULTY_PARTICLES
93
93
  self.gradient_fit_range = GRADIENT_FIT_RANGE
94
+ self.temperature_profile_x_range = TEMPERATURE_PROFILE_X_RANGE
94
95
 
95
96
  # Material parameters:
96
97
  self.media = MEDIA
@@ -98,7 +99,12 @@ class Config:
98
99
 
99
100
  # Internal scattering:
100
101
  self.include_internal_scattering = INCLUDE_INTERNAL_SCATTERING
101
- self.use_dispersion_heat_capacity = USE_DISPERSION_HEAT_CAPACITY
102
+ self.phonon_hydrodynamic = PHONON_HYDRODYNAMIC
103
+ self.number_of_hydrodynamic_preruns = NUMBER_OF_HYDRODYNAMIC_PRERUNS
104
+ self.hydrodynamic_preruns_weight = HYDRODYNAMIC_PRERUNS_WEIGHT
105
+ self.number_of_hydrodynamic_prerun_particles = NUMBER_OF_HYDRODYNAMIC_PRERUN_PARTICLES
106
+ self.hydrodynamic_normal_resistive = HYDRODYNAMIC_NORMAL_RESISTIVE
107
+ self.isotope_c13_concentration = ISOTOPE_C13_CONCENTRATION
102
108
  self.max_number_of_scattering_events = MAX_NUMBER_OF_SCATTERING_EVENTS
103
109
  self.grain_size = GRAIN_SIZE
104
110
  self.grain_size_std = GRAIN_SIZE_STD
@@ -171,6 +177,11 @@ class Config:
171
177
  f"See the documentation at {WEBSITE}")
172
178
  sys.exit()
173
179
 
180
+ if not (0.0 <= self.temperature_profile_x_range[0] < self.temperature_profile_x_range[1] <= 1.0):
181
+ logging.error("Parameter TEMPERATURE_PROFILE_X_RANGE must be a pair (start, end) with 0 <= start < end <= 1.\n" +
182
+ f"See the documentation at {WEBSITE}")
183
+ sys.exit()
184
+
174
185
  if self.number_of_timeframes <= self.number_of_stabilization_timeframes:
175
186
  logging.error("Parameter NUMBER_OF_STABILIZATION_TIMEFRAMES exceeds or equal to NUMBER_OF_TIMEFRAMES.\n" +
176
187
  "Leave at least one timeframe after NUMBER_OF_STABILIZATION_TIMEFRAMES.\n" +
@@ -55,9 +55,6 @@ MEAN_MAPPING_CONSTANT = 5e-6 # [m²]
55
55
 
56
56
  FERMI_LEVEL_LOWER_BOUND = -0.2 # [eV] lower end of post-processing Fermi level sweep
57
57
  FERMI_LEVEL_UPPER_BOUND = 0.1 # [eV] upper end of post-processing Fermi level sweep;
58
- # far above the band edge the Fermi window leaves the sampled energy range and
59
- # the MC results become noisy, so there is little point in sweeping further
60
-
61
58
 
62
59
  # Animation:
63
60
  OUTPUT_PATH_ANIMATION = False
@@ -68,6 +65,7 @@ NUMBER_OF_PIXELS_X = 7
68
65
  NUMBER_OF_PIXELS_Y = 67
69
66
  IGNORE_FAULTY_PARTICLES = False
70
67
  GRADIENT_FIT_RANGE = (0.1, 0.9)
68
+ TEMPERATURE_PROFILE_X_RANGE = (0.0, 1.0)
71
69
 
72
70
  # Material parameters:
73
71
  MEDIA = "Si"
@@ -76,47 +74,20 @@ MEDIA_FERMI_LEVEL = None
76
74
  # Internal scattering:
77
75
  INCLUDE_INTERNAL_SCATTERING = True
78
76
 
79
- # Phonon frequencies and branches are always sampled from the tabulated dispersion
80
- # (weight k^2 dk per bin, heat-capacity weighting, group-velocity weighting at the
81
- # source), so particles are equal-energy deviational bundles and the thermal maps
82
- # record a constant weight per phonon. At inelastic (anharmonic) internal scattering
83
- # events the branch and frequency are re-drawn from the collision-rate-weighted
84
- # distribution (Peraud & Hadjiconstantinou, PRB 84, 205331 (2011)), restoring local
85
- # thermal equilibrium of the phonon population. Elastic internal events (impurity/alloy
86
- # scattering) conserve the mode and only randomize the direction. This applies to the
87
- # phonon tracing mode; MFP sampling and electron modes keep the particle identity by
88
- # construction.
89
-
90
- # Convert deposited particle energy into the temperature profile using the
91
- # dispersion-only heat capacity (Material.dispersion_heat_capacity, summed only
92
- # over the branches in the tabulated dispersion) instead of the experimental
93
- # heat capacity fit (Material.assign_heat_capacity). On by default because it
94
- # makes the reported temperature and kappa self-consistent with the
95
- # dispersion-based sampling/rethermalization scheme and the model's own RTA
96
- # integral. Set to False to use the real material heat capacity instead (e.g.
97
- # including optical branches absent from the tabulated dispersion):
98
- USE_DISPERSION_HEAT_CAPACITY = True
99
-
100
- # MFP sampling mode only (no effect in phonon tracing, where particles must keep
101
- # going for the full budget to build the flux map): end a phonon's flight early
102
- # once it has accumulated this many free-path segments, instead of always running
103
- # to NUMBER_OF_TIMESTEPS. A short-tau, low-velocity phonon takes tiny hops and
104
- # diffuses so slowly that it essentially never reaches a domain boundary, but its
105
- # mean free path already converges after a modest number of scattering events, so
106
- # running it to the full timestep budget is wasted compute. 1000 is on by default,
107
- # since it was validated (bulk Si MFP-sampling convergence study, Data/BulkSi_Phonon_MFP/)
108
- # to give the same result as 5000 while being far cheaper. Set to None to disable
109
- # (run to the timestep budget or a boundary, as before):
77
+ # Phonon hydrodynamic transport.
78
+ PHONON_HYDRODYNAMIC = False
79
+ NUMBER_OF_HYDRODYNAMIC_PRERUNS = 5
80
+ HYDRODYNAMIC_PRERUNS_WEIGHT = 0.8
81
+ NUMBER_OF_HYDRODYNAMIC_PRERUN_PARTICLES = None
82
+
83
+ # Diagnostic control: when True, Normal events still fire but redraw the direction
84
+ # randomly instead of biased toward the local drift
85
+ HYDRODYNAMIC_NORMAL_RESISTIVE = False
86
+ ISOTOPE_C13_CONCENTRATION = 0.0
87
+
110
88
  MAX_NUMBER_OF_SCATTERING_EVENTS = 1000
111
89
 
112
90
  # Grain boundary scattering (polycrystalline materials):
113
- # GRAIN_SIZE sets the mean grain diameter [m]. None disables grain boundary scattering entirely.
114
- # GRAIN_SIZE_STD sets the standard deviation [m]; the grain size is drawn per phonon from a
115
- # lognormal distribution with this mean and std. Set to 0 for monodisperse (single grain size).
116
- # GRAIN_ROUGHNESS is the RMS disorder width of the grain boundary [m], used in a Soffer-type
117
- # specularity factor: at low frequencies (long wavelengths) the boundary appears smooth and
118
- # phonons pass through; at high frequencies it acts as a classical diffuse scatterer.
119
- # Typical values: GRAIN_SIZE 100 nm – 10 µm, GRAIN_ROUGHNESS 0.5–2 nm.
120
91
  GRAIN_SIZE = None
121
92
  GRAIN_SIZE_STD = 0.0
122
93
  GRAIN_ROUGHNESS = 1e-9
@@ -31,6 +31,19 @@ def alpha_spec(theta_i, vg_i, vg_j, rho_i, rho_j):
31
31
  """
32
32
  Specular transmission coefficient (AMM) between two materials.
33
33
 
34
+ Ran & Cao, PRB 110, 024302 (2024), Eq. (20):
35
+
36
+ alpha = 4*A*B / (A + B)**2, A = Z_j/Z_i, B = cos(theta_j)/cos(theta_i)
37
+
38
+ which is the standard oblique-incidence acoustic result 4*W_i*W_j/(W_i + W_j)**2
39
+ with the angle-corrected impedances W = Z/cos(theta), i.e. the COSINES IN THE
40
+ DENOMINATOR ARE CROSSED: (Z_i*cos(theta_j) + Z_j*cos(theta_i))**2. Writing them
41
+ uncrossed agrees at normal incidence but over-estimates transmission at oblique
42
+ incidence (Si/SiGe LA at 1 THz: 0.990 vs 0.928 at 60 deg).
43
+
44
+ Note this is Eq. (20), not Eq. (25): the MTM detailed-balance prefactor
45
+ min{1, P_j*C_j*vg_j**3 / (P_i*C_i*vg_i**3)} of Eq. (25) is deliberately not applied.
46
+
34
47
  Parameters:
35
48
  theta_i -- incidence angle
36
49
  vg_i, vg_j -- group velocities of media i and j
@@ -49,7 +62,7 @@ def alpha_spec(theta_i, vg_i, vg_j, rho_i, rho_j):
49
62
  Z_j = rho_j * vg_j
50
63
 
51
64
  num = 4 * Z_i * Z_j * abs(math.cos(theta_i)) * abs(math.cos(theta_j))
52
- den = (Z_i * abs(math.cos(theta_i)) + Z_j * abs(math.cos(theta_j))) ** 2
65
+ den = (Z_i * abs(math.cos(theta_j)) + Z_j * abs(math.cos(theta_i))) ** 2
53
66
  return num / den
54
67
  except:
55
68
  return 0.0
@@ -21,10 +21,9 @@ from freepaths.phonon import Phonon
21
21
  from freepaths.flight import Flight
22
22
  from freepaths.options import SimulationMode
23
23
  from freepaths.data import ScatteringData, GeneralData, SegmentData, PathData, TriangleScatteringData
24
- from freepaths.materials import Si, SiC, Graphite, SiGe
24
+ from freepaths.materials import create_material, get_media_class
25
25
  from freepaths.maps import ScatteringMap
26
26
  from freepaths.output_info import output_general_information, output_scattering_information, output_parameter_warnings
27
- from freepaths.materials import get_media_class
28
27
  from freepaths.output_plots import plot_data
29
28
 
30
29
 
@@ -78,17 +77,8 @@ def _run_branch(branch_number, shared_list, shared_progress):
78
77
  # Each worker builds its own material instance so the dispersion is
79
78
  # sampled with the correct number of points (number_of_particles + 1
80
79
  # intervals → number_of_particles midpoints):
81
- if cf.media == "Si":
82
- material = Si(cf.temp, num_points=cf.number_of_particles + 1)
83
- elif cf.media == "SiGe":
84
- material = SiGe(cf.temp, num_points=cf.number_of_particles + 1)
85
- elif cf.media == "SiC":
86
- material = SiC(cf.temp, num_points=cf.number_of_particles + 1)
87
- elif cf.media == "Graphite":
88
- material = Graphite(cf.temp, num_points=cf.number_of_particles + 1)
89
- else:
90
- logging.error(f"Material {cf.media} is not supported")
91
- return
80
+ material = create_material(cf.media, cf.temp, num_points=cf.number_of_particles + 1,
81
+ isotope_c13_concentration=cf.isotope_c13_concentration)
92
82
 
93
83
  # Local data structures — merged into the global ones after all workers finish:
94
84
  scatter_stats = ScatteringData()
@@ -100,6 +90,9 @@ def _run_branch(branch_number, shared_list, shared_progress):
100
90
 
101
91
  total_thermal_conductivity = 0.0
102
92
  total_kappa_variance = 0.0
93
+ total_kappa1 = 0.0 # Callaway kappa1 accumulator (uses combined tau_C)
94
+ total_k2_num = 0.0 # Callaway kappa2 numerator sum: w * tau_C / tau_N
95
+ total_k2_den = 0.0 # Callaway kappa2 denominator sum: w * tau_C / (tau_N * tau_R)
103
96
  last_pct = -1
104
97
 
105
98
  for index in range(cf.number_of_particles):
@@ -122,11 +115,31 @@ def _run_branch(branch_number, shared_list, shared_progress):
122
115
  part = scipy.constants.hbar * omega / (scipy.constants.k * cf.temp)
123
116
  c_p = scipy.constants.k * part**2 * math.exp(part) / (math.exp(part) - 1)**2
124
117
 
125
- # Thermal conductivity contribution from this k-point (Ref. Phys. Rev. 132 2461, 1963):
118
+ # Thermal conductivity contribution from this k-point (Ref. Phys. Rev. 132 2461, 1963).
119
+ # The measured mean free path is limited by resistive (Umklapp + isotope) and boundary
120
+ # scattering, so mean_relax_time is the Callaway resistive time tau_R (Normal scattering
121
+ # does not fire in the MFP-sampling mode). weight is the common per-mode integrand factor.
126
122
  mean_relax_time = flight.mean_free_path / phonon.speed
127
- flight.thermal_conductivity = (1 / (6 * math.pi**2)) * c_p * phonon.speed**2 * mean_relax_time * k_vector**2 * d_k_vector
123
+ weight = (1 / (6 * math.pi**2)) * c_p * phonon.speed**2 * k_vector**2 * d_k_vector
124
+ flight.thermal_conductivity = weight * mean_relax_time # single-mode RTA (SMRT)
128
125
  total_thermal_conductivity += flight.thermal_conductivity
129
126
 
127
+ # Callaway dual-relaxation correction (Callaway, Phys. Rev. 113, 1046 (1959)):
128
+ # kappa = kappa1 + kappa2, with 1/tau_C = 1/tau_R + 1/tau_N. kappa1 uses the combined
129
+ # tau_C (Normal provisionally counted as resistive); kappa2 is the momentum-recovery term
130
+ # kappa2 = (sum w*tau_C/tau_N)^2 / (sum w*tau_C/(tau_N*tau_R)), accumulated linearly here
131
+ # and combined in main(). tau_C/tau_N = tau_C*normal_rate. Only computed when
132
+ # PHONON_HYDRODYNAMIC is on (the Normal / momentum-recovery treatment); otherwise
133
+ # normal_rate = 0 and kappa1 collapses to the standard SMRT conductivity.
134
+ normal_rate = material.phonon_normal_rate(omega) if cf.phonon_hydrodynamic else 0.0
135
+ if normal_rate > 0 and mean_relax_time > 0:
136
+ tau_C = 1.0 / (1.0 / mean_relax_time + normal_rate)
137
+ total_kappa1 += weight * tau_C
138
+ total_k2_num += weight * tau_C * normal_rate
139
+ total_k2_den += weight * tau_C * normal_rate / mean_relax_time
140
+ else:
141
+ total_kappa1 += weight * mean_relax_time
142
+
130
143
  # This mode's kappa is linear in its mean free path, so its variance
131
144
  # propagates directly through the same proportionality (kappa_i / mfp_i):
132
145
  if flight.mean_free_path > 0:
@@ -143,6 +156,9 @@ def _run_branch(branch_number, shared_list, shared_progress):
143
156
  shared_list.append({
144
157
  'total_thermal_conductivity': total_thermal_conductivity,
145
158
  'total_kappa_variance': total_kappa_variance,
159
+ 'total_kappa1': total_kappa1,
160
+ 'total_k2_num': total_k2_num,
161
+ 'total_k2_den': total_k2_den,
146
162
  'scatter_stats': scatter_stats.dump_data(),
147
163
  'general_stats': general_stats.dump_data(),
148
164
  'segment_stats': segment_stats.dump_data(),
@@ -213,10 +229,16 @@ def main(input_file, mode: SimulationMode):
213
229
  scatter_maps = ScatteringMap()
214
230
  total_thermal_conductivity = 0.0
215
231
  total_kappa_variance = 0.0
232
+ total_kappa1 = 0.0
233
+ total_k2_num = 0.0
234
+ total_k2_den = 0.0
216
235
 
217
236
  for result in shared_list:
218
237
  total_thermal_conductivity += result['total_thermal_conductivity']
219
238
  total_kappa_variance += result['total_kappa_variance']
239
+ total_kappa1 += result['total_kappa1']
240
+ total_k2_num += result['total_k2_num']
241
+ total_k2_den += result['total_k2_den']
220
242
  scatter_stats.read_data(result['scatter_stats'])
221
243
  general_stats.read_data(result['general_stats'])
222
244
  segment_stats.read_data(result['segment_stats'])
@@ -267,6 +289,19 @@ def main(input_file, mode: SimulationMode):
267
289
  fmt='%2.4e', delimiter=',',
268
290
  header="K_material SEM [W/mK], K_effective SEM [W/mK]", encoding='utf-8')
269
291
 
292
+ # Callaway dual-relaxation conductivity kappa = kappa1 + kappa2 (momentum-recovery term).
293
+ # Only written in hydrodynamic mode (the Normal-process treatment); in the standard RTA the
294
+ # -s output is unchanged. Kept in a separate file so the positional indexers of
295
+ # "Thermal conductivity from MFP.csv" are not disturbed. See main_mfp_sampling._run_branch.
296
+ kappa2 = kappa_callaway = None
297
+ if cf.phonon_hydrodynamic:
298
+ kappa2 = (total_k2_num ** 2 / total_k2_den) if total_k2_den > 0 else 0.0
299
+ kappa_callaway = total_kappa1 + kappa2
300
+ np.savetxt("Data/Thermal conductivity Callaway.csv",
301
+ np.array([[total_kappa1, kappa2, kappa_callaway]]),
302
+ fmt='%2.4e', delimiter=',',
303
+ header="kappa1 [W/mK], kappa2 [W/mK], kappa_Callaway [W/mK]", encoding='utf-8')
304
+
270
305
  sys.stdout.write("\rAnalyzing the data...")
271
306
  plot_data(mode, cf)
272
307
 
@@ -274,7 +309,12 @@ def main(input_file, mode: SimulationMode):
274
309
  output_scattering_information(scatter_stats)
275
310
  output_parameter_warnings(mode, general_stats)
276
311
  sys.stdout.write(f'\rSee the results in {Fore.GREEN}Results/{cf.output_folder_name}{Style.RESET_ALL}\n')
277
- sys.stdout.write(f"\rMaterial thermal conductivity = {Fore.GREEN}{total_thermal_conductivity:.5f} ± {kappa_sem:.5f}{Style.RESET_ALL} W/m·K\n")
312
+ if cf.phonon_hydrodynamic:
313
+ sys.stdout.write(f"\rMaterial thermal conductivity (SMRT) = {Fore.GREEN}{total_thermal_conductivity:.5f} ± {kappa_sem:.5f}{Style.RESET_ALL} W/m·K\n")
314
+ sys.stdout.write(f"\rMaterial thermal conductivity (Callaway) = {Fore.GREEN}{kappa_callaway:.5f}{Style.RESET_ALL} W/m·K "
315
+ f"(kappa1 {total_kappa1:.2f} + kappa2 {kappa2:.2f})\n")
316
+ else:
317
+ sys.stdout.write(f"\rMaterial thermal conductivity = {Fore.GREEN}{total_thermal_conductivity:.5f} ± {kappa_sem:.5f}{Style.RESET_ALL} W/m·K\n")
278
318
  if cf.holes:
279
319
  sys.stdout.write(f"\rEffective thermal conductivity = {Fore.GREEN}{effective_thermal_conductivity:.5f} ± {effective_kappa_sem:.5f}{Style.RESET_ALL} W/m·K "
280
320
  f"(porosity {porosity:.1%}, Eucken factor {eucken_factor:.3f})\n")
@@ -21,8 +21,8 @@ from freepaths.options import SimulationMode
21
21
  from freepaths.data import ScatteringData, GeneralData, SegmentData, PathData, TriangleScatteringData
22
22
  from freepaths.post_computations import ElectronPostComputation
23
23
  from freepaths.progress import Progress
24
- from freepaths.materials import Si, SiC, Graphite, SiGe
25
- from freepaths.maps import ScatteringMap, ThermalMaps
24
+ from freepaths.materials import create_material
25
+ from freepaths.maps import ScatteringMap, ThermalMaps, DriftField
26
26
  from freepaths.output_info import output_general_information, output_scattering_information, output_parameter_warnings, output_electron_information
27
27
  from freepaths.animation import create_animation
28
28
  from freepaths.output_plots import plot_data
@@ -35,20 +35,15 @@ class ParticleSimulator:
35
35
  It is meant to be used as a worker for multiprocessing
36
36
  """
37
37
 
38
- def __init__(self, worker_id, mode: SimulationMode, total_particles, shared_list, output_trajectories_of):
38
+ def __init__(self, worker_id, mode: SimulationMode, total_particles, shared_list, output_trajectories_of, drift_field=None):
39
39
 
40
40
  # Initialize the material:
41
- if cf.media == "Si":
42
- self.material = Si(cf.temp)
43
- elif cf.media == "SiGe":
44
- self.material = SiGe(cf.temp)
45
- elif cf.media == "SiC":
46
- self.material = SiC(cf.temp)
47
- elif cf.media == "Graphite":
48
- self.material = Graphite(cf.temp)
49
- else:
50
- logging.error(f"Material {cf.media} is not supported")
51
- sys.exit()
41
+ self.material = create_material(cf.media, cf.temp,
42
+ isotope_c13_concentration=cf.isotope_c13_concentration)
43
+
44
+ # Frozen hydrodynamic drift field for this pass (None in the bootstrap pass and
45
+ # in non-hydrodynamic runs); read by momentum-conserving Normal scattering events:
46
+ self.material.drift_field = drift_field
52
47
 
53
48
  # Carrier surface-depletion dead-layer width (0 unless SURFACE_POTENTIAL and doping are set):
54
49
  cf.depletion_width = depletion_width(self.material)
@@ -130,11 +125,11 @@ class ParticleSimulator:
130
125
  self.result_queue.append(collected_data)
131
126
 
132
127
 
133
- def worker_process(worker_id, mode: SimulationMode, total_particles, shared_list, output_trajectories_of, finished_workers):
128
+ def worker_process(worker_id, mode: SimulationMode, total_particles, shared_list, output_trajectories_of, finished_workers, drift_field=None):
134
129
  signal.signal(signal.SIGINT, signal.SIG_IGN)
135
130
  try:
136
131
  # Create a particle simulator and run the simulation:
137
- simulator = ParticleSimulator(worker_id, mode, total_particles, shared_list, output_trajectories_of)
132
+ simulator = ParticleSimulator(worker_id, mode, total_particles, shared_list, output_trajectories_of, drift_field)
138
133
  simulator.simulate_particles(render_progress=1 if worker_id == 0 else 0)
139
134
 
140
135
  # Declare that the calculation is finished:
@@ -165,25 +160,24 @@ def display_workers_finished(finished_workers):
165
160
  pass
166
161
 
167
162
 
168
- def main(input_file, mode: SimulationMode):
163
+ def run_pass(mode: SimulationMode, drift_field=None, total_particles=None):
169
164
  """
170
- This is the main function.
171
- It should be used to simulate electron paths or phonon paths at low temperatures.
165
+ Launch all worker processes for one full sweep of the ensemble (one "pass"), with
166
+ the given frozen drift field, and return the collected result_list. In a
167
+ non-hydrodynamic run this is called exactly once; in hydrodynamic mode it is called
168
+ once per self-consistency pass, each time with the drift field frozen at the value
169
+ converged from the previous pass. total_particles overrides cf.number_of_particles
170
+ for this pass (used to run cheaper preruns than the reported run).
172
171
  """
173
-
174
- sys.stdout.write(f'Simulation of {Fore.GREEN}{cf.output_folder_name}{Style.RESET_ALL}\n')
175
- start_time = time.time()
176
-
177
- # Create manager for managing variable acces for multiple workers:
172
+ if total_particles is None:
173
+ total_particles = cf.number_of_particles
178
174
  manager = multiprocessing.Manager()
179
-
180
- # These variables created with the manager can safely be accessed by multiple workers:
181
175
  shared_list = manager.list()
182
176
  finished_workers = manager.Value('i', 0)
183
177
 
184
178
  # Divide all the particles among the workers:
185
- workload_per_worker = cf.number_of_particles // cf.num_workers
186
- remaining_particles = cf.number_of_particles % cf.num_workers
179
+ workload_per_worker = total_particles // cf.num_workers
180
+ remaining_particles = total_particles % cf.num_workers
187
181
 
188
182
  # Divide number of output trajectories to save among workers:
189
183
  output_trajectories_per_worker = cf.output_trajectories_of_first // cf.num_workers
@@ -196,7 +190,7 @@ def main(input_file, mode: SimulationMode):
196
190
  for i in range(cf.num_workers):
197
191
  worker_particles = workload_per_worker + (1 if i < remaining_particles else 0)
198
192
  output_trajectory_of = output_trajectories_per_worker + (1 if i < remaining_output_trajectories else 0)
199
- process = multiprocessing.Process(target=worker_process, args=(i, mode, worker_particles, shared_list, output_trajectory_of, finished_workers))
193
+ process = multiprocessing.Process(target=worker_process, args=(i, mode, worker_particles, shared_list, output_trajectory_of, finished_workers, drift_field))
200
194
  processes.append(process)
201
195
  process.start()
202
196
 
@@ -217,9 +211,11 @@ def main(input_file, mode: SimulationMode):
217
211
  # Wait for the worker count to finish but continue after 3 seconds:
218
212
  worker_count_process.join(timeout=3)
219
213
  worker_count_process.terminate() # should not be necessary but sometimes process does not terminate
214
+ return list(shared_list)
220
215
 
221
- # Initiate data structures to collect the data from the workers:
222
- # material = Material(cf.media, num_points=cf.number_of_phonons+1)
216
+
217
+ def merge_results(result_list, mode: SimulationMode):
218
+ """Merge the per-worker data dictionaries from one pass into unified data structures."""
223
219
  scatter_stats = ScatteringData()
224
220
  places_stats = TriangleScatteringData()
225
221
  general_stats = GeneralData()
@@ -228,17 +224,9 @@ def main(input_file, mode: SimulationMode):
228
224
  scatter_maps = ScatteringMap()
229
225
  thermal_maps = ThermalMaps() if mode is SimulationMode.PHONON_TRACING else None
230
226
 
231
- # Collect the results:
232
- sys.stdout.write('\nCollecting data from workers...\r')
233
-
234
- # Convert the shared list to a normal list so it's easier to use
235
- result_list = list(shared_list)
236
-
237
- # Check that all workers actually returned some data
238
227
  if len(result_list) != cf.num_workers:
239
228
  sys.stdout.write(f'WARNING: of {cf.num_workers} workers only the results of {len(result_list)} were collected\n')
240
229
 
241
- # Put the data from every worker into it's respective place:
242
230
  execution_time_list = []
243
231
  for collected_data in result_list:
244
232
  scatter_stats.read_data(collected_data['scatter_stats'])
@@ -251,12 +239,103 @@ def main(input_file, mode: SimulationMode):
251
239
  thermal_maps.read_data(collected_data['thermal_maps'])
252
240
  execution_time_list.append(collected_data['execution_time'])
253
241
 
242
+ return scatter_stats, places_stats, general_stats, segment_stats, path_stats, scatter_maps, thermal_maps, execution_time_list
243
+
244
+
245
+ def main(input_file, mode: SimulationMode):
246
+ """
247
+ This is the main function.
248
+ It should be used to simulate electron paths or phonon paths at low temperatures.
249
+ """
250
+
251
+ sys.stdout.write(f'Simulation of {Fore.GREEN}{cf.output_folder_name}{Style.RESET_ALL}\n')
252
+ start_time = time.time()
253
+
254
+ # Hydrodynamic mode first traces the ensemble in NUMBER_OF_HYDRODYNAMIC_PRERUNS
255
+ # preruns that only build the self-consistent drift field (frozen within each prerun,
256
+ # under-relaxed between them), then a final reported run that reads the converged field
257
+ # and supplies the reported statistics and maps. The reported run does NOT update the
258
+ # field (there is no pass after it), so unlike a plain "report the last of N passes"
259
+ # scheme the reported run reads the fully converged field. Everything non-hydrodynamic
260
+ # runs a single pass.
261
+ hydrodynamic = cf.phonon_hydrodynamic and mode is SimulationMode.PHONON_TRACING
262
+ number_of_preruns = cf.number_of_hydrodynamic_preruns if hydrodynamic else 0
263
+ number_of_passes = number_of_preruns + 1
264
+
265
+ # Material instance for the momentum-susceptibility constant (drift-field derivation):
266
+ hydro_material = create_material(cf.media, cf.temp,
267
+ isotope_c13_concentration=cf.isotope_c13_concentration) if hydrodynamic else None
268
+
269
+ drift_field = None
270
+ drift_convergence = [] # per-prerun (n, mean|u_fresh|, mean|u_field|, rel_change) for the convergence test
271
+ flux_profiles = [] # per-pass mid-length cross-width |J_y|(x), to watch it become parabolic
272
+ # Preruns only need to converge the field, so they can use fewer particles than the
273
+ # reported run (NUMBER_OF_HYDRODYNAMIC_PRERUN_PARTICLES; None = same as the reported run):
274
+ prerun_particles = cf.number_of_hydrodynamic_prerun_particles
275
+ for pass_index in range(number_of_passes):
276
+ is_reported_run = (pass_index == number_of_passes - 1)
277
+ pass_particles = None
278
+ if hydrodynamic and not is_reported_run and prerun_particles:
279
+ pass_particles = prerun_particles
280
+ if hydrodynamic:
281
+ n = pass_particles if pass_particles else cf.number_of_particles
282
+ label = "reported run" if is_reported_run else f"prerun {pass_index + 1}/{number_of_preruns}"
283
+ sys.stdout.write(f'\nHydrodynamic {label} ({n} particles)\n')
284
+
285
+ result_list = run_pass(mode, drift_field, pass_particles)
286
+
287
+ # Collect the results of this pass:
288
+ sys.stdout.write('\nCollecting data from workers...\r')
289
+ (scatter_stats, places_stats, general_stats, segment_stats, path_stats,
290
+ scatter_maps, thermal_maps, execution_time_list) = merge_results(result_list, mode)
291
+
292
+ # During preruns, update the self-consistent drift field for the next pass. The
293
+ # freshly measured field is under-relaxed against the previous one (Robbins-Monro)
294
+ # for stability. The reported run still measures its drift (for the saved profile)
295
+ # but does not feed it back:
296
+ if hydrodynamic and thermal_maps is not None:
297
+ thermal_maps.calculate_drift_velocity(hydro_material)
298
+
299
+ # Cross-width heat-flux profile at mid-length, one per pass, so the profile can
300
+ # be watched evolving from blunt (bootstrap, no drift) toward parabolic (Poiseuille):
301
+ ny = thermal_maps.heat_flux_map_y.shape[0]
302
+ band = thermal_maps.heat_flux_map_y[int(0.35 * ny):int(0.65 * ny), :]
303
+ flux_profiles.append(np.abs(band).mean(axis=0))
304
+
305
+ if not is_reported_run:
306
+ fresh = DriftField(thermal_maps.drift_velocity_x, thermal_maps.drift_velocity_y, thermal_maps.drift_velocity_z)
307
+ previous = drift_field
308
+ if drift_field is None:
309
+ drift_field = fresh
310
+ else:
311
+ g = cf.hydrodynamic_preruns_weight
312
+ drift_field = DriftField(
313
+ (1 - g) * drift_field.u_x + g * fresh.u_x,
314
+ (1 - g) * drift_field.u_y + g * fresh.u_y,
315
+ (1 - g) * drift_field.u_z + g * fresh.u_z,
316
+ )
317
+
318
+ # Convergence diagnostics: mean drift magnitude of the freshly measured
319
+ # field and of the (under-relaxed) field carried to the next prerun, plus
320
+ # the relative L1 change of that field vs the previous prerun. When the
321
+ # relative change flattens toward its noise floor, the preruns are adequate.
322
+ def _mean_mag(f):
323
+ mag = np.hypot(f.u_x, f.u_y)
324
+ return float(mag[mag > 0].mean()) if np.any(mag > 0) else 0.0
325
+ if previous is None:
326
+ rel_change = np.nan
327
+ else:
328
+ delta = np.abs(drift_field.u_x - previous.u_x) + np.abs(drift_field.u_y - previous.u_y)
329
+ scale = np.abs(drift_field.u_x) + np.abs(drift_field.u_y)
330
+ rel_change = float(delta.sum() / (scale.sum() + 1e-30))
331
+ drift_convergence.append((pass_index + 1, _mean_mag(fresh), _mean_mag(drift_field), rel_change))
332
+
254
333
  # Give some info about the variability in the worker calculation time:
255
334
  if cf.num_workers > 1:
256
335
  sys.stdout.write(f'Shortest process execution time: {round(min(execution_time_list))}s\n')
257
336
  sys.stdout.write(f'Longest process execution time: {round(max(execution_time_list))}s\n')
258
337
 
259
- # Run thermal calculations:
338
+ # Run thermal calculations on the final pass:
260
339
  if thermal_maps is not None:
261
340
  thermal_maps.calculate_thermal_conductivity()
262
341
  thermal_maps.calculate_heat_flux_modulus()
@@ -277,6 +356,18 @@ def main(input_file, mode: SimulationMode):
277
356
  shutil.copy(input_file, "Results/" + cf.output_folder_name)
278
357
  os.chdir("Results/" + cf.output_folder_name)
279
358
 
359
+ # Save the hydrodynamic-drift-field convergence trace (one row per prerun):
360
+ if drift_convergence:
361
+ np.savetxt("Data/Drift convergence.csv", np.array(drift_convergence), fmt="%1.4e", delimiter=",",
362
+ header="prerun, mean|u_fresh| (m/s), mean|u_field| (m/s), rel_change_of_field", encoding="utf-8")
363
+ if flux_profiles:
364
+ prof = np.array(flux_profiles).T # rows = x pixels, cols = passes
365
+ nx = prof.shape[0]
366
+ x = (np.arange(nx) + 0.5) * cf.width / nx - cf.width / 2
367
+ header = "x (m), " + ", ".join([f"pass{i + 1}" for i in range(prof.shape[1])])
368
+ np.savetxt("Data/Drift convergence profiles.csv", np.column_stack([x, prof]), fmt="%1.4e",
369
+ delimiter=",", header=header, encoding="utf-8")
370
+
280
371
  # Try to load pre-computed phonon thermal conductivity to enable ZT calculation:
281
372
  if mode is SimulationMode.ELECTRON:
282
373
  kappa_ph = None