Coverage for app/models.py: 100%
324 statements
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-28 18:30 +0000
« prev ^ index » next coverage.py v7.10.5, created at 2025-08-28 18:30 +0000
1"""Models for simulating and fitting time-resolved photoluminescence and microwave conductivity data using the
2bimolecular-trapping and bimolecular-trapping-detrapping models
4The classes are:
5- Model: defines common methods for all sub-models such as solving the rate equations, fitting data and calculating the
6 contributions. It also contains constants for all models such as parameter symbols. The rate_equations method defined
7 in subclasses need to have a **kwarg to allow parameters from the fitting optimisation to be used.
8 - BTModel: is the general class for the Bimolecular-Trapping model. It defines the rate_equations for this model as
9 well as calculate_trpl and calculate_trmc methods.
10 - BTModelTRPL: is the specific class for fitting TRPL using the BT model. it defines calculate_fit_quantity and
11 calculate_contributions which has a **kwarg to allow parameters from the fitting optimisation to be used.
12 - BTModelTRMC: is the specific class for fitting TRMC using the BT model. it defines calculate_fit_quantity and
13 calculate_contributions which has a **kwarg to allow parameters from the fitting optimisation to be used.
14 - BTDModel: is the general class for the Bimolecular-Trapping-Detrapping model. It defines the rate_equations for
15 this model as well as calculate_trpl and calculate_trmc.
16 - BTDModelTRPL: is the specific class for fitting TRPL using the BTD model. it defines calculate_fit_quantity
17 and calculate_contributions which has a **kwarg to allow parameters from the fitting optimisation to be used.
18 - BTDModelTRMC: is the specific class for fitting TRMC using the BTD model. it defines calculate_fit_quantity
19 and calculate_contributions which has a **kwarg to allow parameters from the fitting optimisation to be used.
20"""
22import itertools
23from typing import Any, Callable
25import numpy as np
26import scipy.integrate as sci
27import streamlit
28import pandas as pd
30from fitting import Fit
31from utility.dict import filter_dicts, list_to_dict, merge_dicts
32from utility.numbers import get_power_html, get_concentrations_html
34# Example parameters for the BT and BTD models
35BT_KWARGS = dict(k_B=50e-20, k_T=10e-3, k_A=1e-40, I=1.0, y_0=0, mu=10.0)
36BTD_KWARGS = dict(k_B=50e-20, k_T=12000e-20, N_T=60e12, p_0=65e12, k_D=80e-20, I=1.0, y_0=0.0, mu_e=20.0, mu_h=30.0)
38# Example photoexcited carrier concentrations
39BT_N0s = [1e15, 1e16, 1e17]
40BTD_N0s = [0.51e14, 1.61e14, 4.75e14, 16.1e14, 43.8e14]
42N0_HTML_QUANTITY_UNIT = "N<sub>0</sub> (cm<sup>-3</sup>)"
44MP_PULSES = 10000 # maximum number of pulses simulated
47class Model(object):
48 """Base class for charge carrier recombination models.
50 This class serves as the parent class for various charge carrier recombination models,
51 encapsulating key parameters and methods used to define, manage, and manipulate
52 model-specific data."""
54 CBT_LABELS = {"A": "Auger (%)", "B": "Bimolecular (%)", "T": "Trapping (%)", "D": "Detrapping (%)"}
55 CONC_LABELS_HTML = {"n_e": "n<sub>e</sub>", "n_t": "n<sub>t</sub>", "n_h": "n<sub>h</sub>", "n": "n"}
56 CONC_COLORS = {"n_e": "red", "n_t": "green", "n_h": "blue", "n": "black"}
57 MODEL = ""
59 # Quantities display
60 PARAM_FULLNAME = {
61 "k_B": "Bimolecular recombination rate constant",
62 "k_T": "Trapping rate constant",
63 "k_D": "Detrapping rate constant",
64 "k_A": "Auger recombination rate constant",
65 "N_T": "Trap state concentration",
66 "p_0": "Doping concentration",
67 "y_0": "Intensity offset",
68 "I": "Intensity factor",
69 "mu": "Carrier mobility",
70 "mu_e": "Electron mobility",
71 "mu_h": "Hole mobility",
72 }
74 # Symbols display
75 PARAM_SYMBOLS = {
76 "k_B": "k_B",
77 "k_T": "k_T",
78 "k_D": "k_D",
79 "k_A": "k_A",
80 "N_T": "N_T",
81 "p_0": "p_0",
82 "y_0": "y_0",
83 "I": "I_0",
84 "mu": "mu",
85 "mu_e": "mu_e",
86 "mu_h": "mu_h",
87 }
88 PARAM_SYMBOLS_HTML = {
89 "N_T": "N<sub>T</sub>",
90 "p_0": "p<sub>0</sub>",
91 "k_B": "k<sub>B</sub>",
92 "k_T": "k<sub>T</sub>",
93 "k_D": "k<sub>D</sub>",
94 "k_A": "k<sub>A</sub>",
95 "y_0": "y<sub>0</sub>",
96 "I": "I<sub>0</sub>",
97 "mu": "μ",
98 "mu_e": "μ<sub>e</sub>",
99 "mu_h": "μ<sub>h</sub>",
100 }
102 def __init__(
103 self,
104 param_ids: list[str],
105 units: dict[str, str],
106 units_html: dict[str, str],
107 factors: dict[str, float],
108 fvalues: dict[str, float | None],
109 gvalues: dict[str, float],
110 gvalues_range: dict[str, list[float]],
111 n_keys: list[str],
112 n_init: Callable,
113 conc_ca_ids: list[str],
114 param_filters: list[str],
115 ):
116 """Initializes the charge carrier recombination model with specified parameters.
117 :param param_ids: model parameter ids required to calculate the fit quantity.
118 :param units: dictionary mapping parameter ids to their units.
119 :param units_html: dictionary mapping parameter ids to their HTML-formatted units.
120 :param factors: dictionary mapping parameter ids to their factor for display purposes.
121 :param fvalues: dictionary mapping parameter ids to their fixed value. Use `None` to indicate the parameter is
122 not fixed.
123 :param gvalues: dictionary mapping parameter ids to their guess values.
124 :param gvalues_range: dictionary mapping parameter ids to their list of guess values.
125 :param n_keys: model carrier concentration keys.
126 :param n_init: function used to calculate the initial carrier concentration.
127 :param conc_ca_ids: list of carrier concentration keys used to determine stabilisation.
128 :param param_filters: parameter relations used to filter the guess parameters combinations."""
130 # Store the input arguments
131 self.param_ids = param_ids
132 self.n_keys = n_keys
133 self.n_init = n_init
134 self.conc_ca_ids = conc_ca_ids
135 self.param_filters = param_filters
137 # Add the y_0 and I parameter units, factors, and default fixed values, guess values and guess value ranges
138 # and add them to the detached parameters
139 self.units = merge_dicts(units, {"y_0": "", "I": ""})
140 self.units_html = merge_dicts(units_html, {"y_0": "", "I": ""})
141 self.factors = merge_dicts(factors, {"y_0": 1.0, "I": 1.0})
142 self.fvalues = merge_dicts(fvalues, {"y_0": 0.0, "I": 1.0})
143 self.gvalues = merge_dicts(gvalues, {"y_0": 0.0, "I": 1.0})
144 self.gvalues_range = merge_dicts(gvalues_range, {"y_0": [0.0], "I": [1.0]})
145 self.detached_parameters = [k for k in ["y_0", "I"] if k in self.param_ids]
147 # Only keep the data for the param ids
148 for d in ("units", "units_html", "factors", "fvalues", "gvalues", "gvalues_range"):
149 setattr(self, d, {key: value for key, value in getattr(self, d).items() if key in self.param_ids})
151 self.factors_html = {key: get_power_html(self.factors[key], None) for key in self.factors}
153 def __eq__(self, other: Any) -> bool:
154 """Used to check if this object is the same as another object"""
156 condition = (
157 self.fvalues == other.fvalues # same fixed values
158 and self.gvalues == other.gvalues # same guess values
159 and self.gvalues_range == other.gvalues_range # same guess value ranges
160 and self.__class__ == other.__class__ # same class name
161 )
163 return condition
165 def get_parameter_label(self, key: str) -> str:
166 """Get the parameter label
167 :param key: parameter id"""
169 if self.units[key] == "":
170 return self.PARAM_SYMBOLS[key]
171 else:
172 return self.PARAM_SYMBOLS[key] + " (" + self.units[key] + ")"
174 @property
175 def fixed_values(self) -> dict[str, float]:
176 """return the dict of fixed values"""
178 return {key: value for key, value in self.fvalues.items() if value is not None}
180 # -------------------------------------------------- CORE METHODS --------------------------------------------------
182 def _rate_equations(self, *args, **kwargs) -> dict[str, float]:
183 """Rate equation method"""
185 return {}
187 def _calculate_concentrations(
188 self,
189 t: np.ndarray,
190 N_0: float,
191 p: int = 1,
192 threshold: float = 0.001,
193 **kwargs,
194 ) -> dict[str, list]:
195 """Calculate the carrier concentrations
196 :param t: time (ns)
197 :param N_0: initial carrier concentration (cm-3)
198 :param p: number of pulses
199 :param threshold: threshold
200 :param kwargs: keyword arguments passed to the rate equations"""
202 var = {key: np.zeros(len(t)) for key in self.n_keys}
203 variables = {key: [] for key in self.n_keys}
205 def rate_equation(x, _t) -> list[float]:
206 """Rate equation wrapper"""
208 dndt = self._rate_equations(*x, **kwargs)
209 return [dndt[key] for key in self.n_keys]
211 for i in range(p):
212 init = self.n_init(N_0)
213 var = np.transpose(sci.odeint(rate_equation, [init[key] + var[key][-1] for key in self.n_keys], t))
214 var = dict(zip(self.n_keys, var))
216 for key in var:
217 variables[key].append(var[key])
219 if threshold and i > 1:
220 ca = np.array([np.max(np.abs(variables[key][-2] - var[key]) / N_0) for key in self.conc_ca_ids])
221 if all(ca < threshold / 100):
222 break
223 elif i == p - 1:
224 raise AssertionError("Attention: threshold condition never reached")
226 return variables
228 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray:
229 """Calculate the TRPL"""
231 return np.array([0])
233 def calculate_contributions(self, *args, **kwargs) -> dict:
234 """Calculate the contributions"""
236 return dict()
238 def get_carrier_accumulation(
239 self,
240 params: list[dict[str, float]],
241 period: float,
242 ) -> dict[str, list]:
243 """Calculate the carrier accumulation effect on the TRPL.
244 :param params: list of arguments passed to calculate_fit_quantity.
245 :param period: excitation repetition period in ns."""
247 # Create a new log timescale
248 x = np.insert(np.logspace(-4, np.log10(period), 10001), 0, 0)
250 carrier_accumulation = {"CA": [], "Pulse 1": [], "Pulse S": [], "t": [x] * len(params)}
252 # For each decay fitted
253 for param in params:
255 # Calculate the fit quantity after 1 pulse and until stabilisation
256 param = merge_dicts({"I": 1.0, "y_0": 0.0}, param)
257 pulse1 = self.calculate_fit_quantity(x, **param)
258 pulse2 = self.calculate_fit_quantity(x, p=MP_PULSES, **param)
260 # Calculate the normalised carrier accumulation in %
261 carrier_accumulation["CA"].append(np.max(np.abs(pulse1 / pulse1[0] - pulse2 / pulse2[0])) * 100)
262 carrier_accumulation["Pulse 1"].append(pulse1)
263 carrier_accumulation["Pulse S"].append(pulse2)
265 # Generate the carrier accumulation dataframe
266 N0s = [d["N_0"] for d in params]
267 N0_labels = get_power_html(N0s, -1)
268 ca_str = [f"{ca:.1f}" for ca in carrier_accumulation["CA"]]
269 ca_dict = dict(zip(N0_labels, ca_str))
270 ca_df = pd.DataFrame(ca_dict, index=["Carrier accumulation (%)"])
271 ca_df.columns.name = N0_HTML_QUANTITY_UNIT
272 carrier_accumulation["CA_df"] = ca_df
274 return carrier_accumulation
276 def get_carrier_concentrations(
277 self,
278 xs_data: list[np.ndarray],
279 params: list[dict[str, float]],
280 period: float,
281 ) -> tuple[list[np.ndarray], str, list[dict[str, np.ndarray]]]:
282 """Calculate the carrier concentrations from the optimised parameters.
283 :param xs_data: x-axis data.
284 :param params: list of arguments passed to calculate_concentrations.
285 :param period: excitation repetition period in ns."""
287 concentrations = [] # concentration dicts for each power/popts
289 if period:
290 n = 201
291 x_data = np.linspace(0, float(period), n)
292 xplot_data = []
293 for param in params:
294 concentration = self._calculate_concentrations(x_data, p=MP_PULSES, **param)
295 nb_pulses = len(list(concentration.values())[0])
296 xplot_data.append(np.linspace(0, nb_pulses, n * nb_pulses))
297 concentrations.append({key: np.concatenate(concentration[key]) for key in concentration})
298 return xplot_data, "Pulse", concentrations
300 else:
301 for x_data, param in zip(xs_data, params):
302 concentration = self._calculate_concentrations(x_data, **param)
303 concentrations.append({key: np.concatenate(concentration[key]) for key in concentration})
304 return xs_data, "Time (ns)", concentrations
306 # ----------------------------------------------------- FITTING ----------------------------------------------------
308 def fit(
309 self,
310 xs_data: list[np.ndarray],
311 ys_data: list[np.ndarray],
312 N0s: list[float],
313 p0: None | dict[str, float] = None,
314 ) -> dict:
315 """Fit the data using the model
316 :param xs_data: list-like of x data
317 :param ys_data: list-like of y data
318 :param N0s: list of initial carrier concentrations (cm-3)
319 :param p0: guess values. If None, use the gvalues dict attribute"""
321 # Add the initial carrier concentration to the fixed parameters and get the guess values
322 fixed_parameters = [merge_dicts(self.fixed_values, dict(N_0=n0)) for n0 in N0s]
323 if p0 is None:
324 p0 = self.gvalues
326 # Fitting
327 fit = Fit(xs_data, ys_data, self.calculate_fit_quantity, p0, self.detached_parameters, fixed_parameters)
329 # Popts, fitted data, R2
330 popts = fit.fit()
331 fit_ydata = fit.calculate_fits(popts)
332 cod = fit.calculate_rss(fit_ydata)
334 # Popt dataframe
335 popt_dict = {}
336 for key in self.param_ids:
337 values = [popt[key] for popt in popts]
338 values_str = [f"{get_power_html(value, 2)} {self.units_html[key]}" for value in values]
339 label = f"{self.PARAM_FULLNAME[key]} ({self.PARAM_SYMBOLS_HTML[key]})"
340 if key in fixed_parameters[0]:
341 label += " (fixed)"
342 popt_dict[label] = values_str
343 popt_dict["Coefficient of determination R<sup>2</sup>"] = [f"{cod:.3f}"] * len(N0s)
344 popt_df = pd.DataFrame.from_dict(
345 popt_dict,
346 orient="index",
347 columns=get_power_html(N0s, -1),
348 )
349 popt_df.columns.name = "N<sub>0</sub> (cm<sup>-3</sup>)"
351 # Contributions
352 contributions = []
353 for x_data, popt in zip(xs_data, popts):
354 concentration = {key: value[0] for key, value in self._calculate_concentrations(x_data, **popt).items()}
355 kwargs = merge_dicts(popt, concentration)
356 contributions.append(self.calculate_contributions(x_data, **kwargs))
357 contributions = list_to_dict(contributions)
358 contributions = {key: np.array(value) for key, value in contributions.items()}
360 # Contributions dataframe
361 contributions_dict = {}
362 for key in contributions:
363 contributions_dict[self.CBT_LABELS[key]] = [f"{value:.1f}" for value in contributions[key]]
364 contributions_df = pd.DataFrame.from_dict(contributions_dict, orient="index", columns=get_power_html(N0s, -1))
365 contributions_df.columns.name = "N<sub>0</sub> (cm<sup>-3</sup>)"
367 # All values together (guess, optimised, R2 and contributions)
368 all_values = dict() # all values
369 hidden_keys = [] # keys not displayed in the parallel plot (fixed values and guess values)
370 for key in self.param_ids:
371 if key in self.detached_parameters:
372 continue
373 label = self.PARAM_SYMBOLS_HTML[key] + " (" + self.factors_html[key] + " " + self.units_html[key] + ")"
374 if key in fixed_parameters[0]:
375 all_values[label + " (fixed)"] = fixed_parameters[0][key] / self.factors[key]
376 hidden_keys.append(label + " (fixed)")
377 else:
378 all_values[label + " (guess)"] = p0[key] / self.factors[key]
379 all_values[label + " (opt.)"] = popts[0][key] / self.factors[key]
380 hidden_keys.append(label + " (guess)")
381 all_values["R<sub>2</sub>"] = cod
382 for key in contributions:
383 all_values["max. " + self.CBT_LABELS[key]] = np.max(contributions[key])
385 return {
386 "xs_data": xs_data,
387 "ys_data": ys_data,
388 "popts": popts,
389 "popt_df": popt_df,
390 "cod": cod,
391 "fit_ydata": fit_ydata,
392 "contributions": contributions,
393 "contributions_df": contributions_df,
394 "N0s": N0s, # keep a copy of the N0s used for the fits
395 "N0s_labels": get_concentrations_html(N0s),
396 "p0": p0,
397 "all_values": all_values,
398 "hidden_keys": hidden_keys,
399 }
401 def grid_fitting(
402 self,
403 progressbar: streamlit.progress,
404 N0s: list[float],
405 **kwargs,
406 ) -> list:
407 """Run a grid fitting analysis
408 :param N0s: initial carrier concentration
409 :param progressbar: progressbar
410 :param kwargs: keyword arguments passed to the fit method"""
412 # Filter out the fixed parameters
413 p0s = {key: self.gvalues_range[key] for key in self.gvalues_range if key not in self.fixed_values}
415 # Generate all the combination of guess values and filter them
416 pkeys, pvalues = zip(*p0s.items())
417 all_p0s = [dict(zip(pkeys, v)) for v in itertools.product(*pvalues)]
418 all_p0s = filter_dicts(all_p0s, self.param_filters, self.fixed_values)
420 # Run the fits
421 fits = []
422 for i, p0 in enumerate(all_p0s):
424 # Update the progressbar if provided
425 if progressbar is not None:
426 progressbar.progress(i / float(len(all_p0s) - 1)) # pragma: no cover
428 # Fit the data
429 try:
430 fits.append(self.fit(p0=p0, N0s=N0s, **kwargs))
431 except ValueError:
432 pass
434 return fits
436 # -------------------------------------------------- CONTRIBUTIONS -------------------------------------------------
438 @staticmethod
439 def get_contribution_recommendation(
440 process: str,
441 val: str = "",
442 ) -> str:
443 """Get the recommendation string for the given contribution
444 :param str process: name of the process
445 :param str val: 'higher' or 'lower'"""
447 string = f"This fit predicts low {process}. The values associated with this process may be inaccurate."
448 if val:
449 string += (
450 f"\nIt is recommended to measure your sample under {val} excitation fluence for this process to become "
451 "significant"
452 )
453 return string
455 def get_contribution_recommendations(self, *args, **kwargs) -> list[str]:
456 """Placeholder: get recommendations for the contributions"""
458 return [""]
460 # ----------------------------------------------------- OTHERS -----------------------------------------------------
462 def _generate_decays(
463 self,
464 period: float,
465 N0s: list[float],
466 parameters: dict[str, float],
467 noise: float = 0.0,
468 normalise: bool = False,
469 **kwargs,
470 ) -> tuple[list[np.ndarray], list[np.ndarray], list[float]]:
471 """Generate example TRMC decays
472 :param period: period in ns
473 :param N0s: photoexcited carrier concentrations
474 :param parameters: parameters used to solve the differential equations
475 :param noise: optional noise argument
476 :param kwargs: keyword arguments passed to the calculate_fit_quantity method
477 :param normalise: if True, normalise the data after adding the noise"""
479 t = np.linspace(0, period, 1001)
480 xs_data = [t] * len(N0s)
481 ys_data = [self.calculate_fit_quantity(t, N_0=n, **merge_dicts(kwargs, parameters)) for n in N0s]
482 if noise:
483 np.random.seed(1537)
484 for y in ys_data:
485 noise_array = np.random.normal(loc=0.0, scale=noise * np.max(y), size=len(y))
486 y += noise_array
487 if normalise:
488 y /= np.max(y)
490 return xs_data, ys_data, N0s
493# ------------------------------------------------------ BT MODEL ------------------------------------------------------
496class BTModel(Model):
497 """Class for the Bimolecular-Trapping model"""
499 MODEL = "BTA"
501 def __init__(self, param_ids: list):
502 """Object constructor"""
504 units = {
505 "k_B": "cm3/ns",
506 "k_T": "ns-1",
507 "k_A": "cm6/ns-1",
508 "mu": "cm2/(V s)",
509 }
510 units_html = {
511 "k_B": "cm<sup>3</sup>/ns",
512 "k_T": "ns<sup>-1</sup>",
513 "k_A": "cm<sup>6</sup>/ns",
514 "mu": "cm<sup>2</sup>/(V s)",
515 }
516 factors = {
517 "k_B": 1e-20,
518 "k_T": 1e-3,
519 "k_A": 1e-40,
520 "mu": 1,
521 }
522 fvalues = {
523 "k_T": None,
524 "k_B": None,
525 "k_A": 0.0,
526 "mu": None,
527 }
528 gvalues = {
529 "k_T": 0.001,
530 "k_B": 1e-20,
531 "k_A": 1e-40,
532 "mu": 10,
533 }
534 gvalues_range = {
535 "k_B": [1e-20, 1e-18],
536 "k_T": [1e-4, 1e-2],
537 "k_A": [1e-32, 1e-30],
538 "mu": [1, 10],
539 }
540 n_keys = ["n"]
541 n_init = lambda N_0: {"n": N_0}
542 conc_ca_ids = ["n"]
544 Model.__init__(
545 self,
546 param_ids,
547 units,
548 units_html,
549 factors,
550 fvalues,
551 gvalues,
552 gvalues_range,
553 n_keys,
554 n_init,
555 conc_ca_ids,
556 [],
557 )
559 @staticmethod
560 def _rate_equations(
561 n: float,
562 k_T: float,
563 k_B: float,
564 k_A: float,
565 **kwargs,
566 ) -> dict[str, float]:
567 """Rate equation of the BT model
568 :param n: carrier concentration (cm-3)
569 :param k_T: trapping rate (ns-1)
570 :param k_B: bimolecular recombination rate (cm3/ns)
571 :param k_A: Auger recombination rate (cm6/ns)
572 :param kwargs: additional keyword arguments that are ignored"""
574 return {"n": -k_T * n - k_B * n**2 - k_A * n**3}
576 def calculate_trpl(
577 self,
578 t: np.ndarray,
579 N_0: float,
580 I: float,
581 y_0: float,
582 **kwargs,
583 ) -> np.ndarray:
584 """Calculate the normalised TRPL intensity
585 :param t: time (ns)
586 :param N_0: initial carrier concentration
587 :param y_0: background intensity
588 :param I: amplitude factor
589 :param kwargs: keyword arguments passed to the calculate_concentrations function"""
591 n = self._calculate_concentrations(t, N_0, **kwargs)["n"][-1]
592 I_TRPL = n**2 / N_0
593 return I * I_TRPL / I_TRPL[0] + y_0
595 def calculate_trmc(
596 self,
597 t: np.ndarray,
598 N_0: float,
599 mu: float,
600 y_0: float,
601 **kwargs,
602 ) -> np.ndarray:
603 """Calculate the TRMC intensity
604 :param t: time (ns)
605 :param N_0: initial carrier concentration
606 :param mu: carrier mobility (cm2/vs)
607 :param y_0: background intensity
608 :param kwargs: keyword arguments passed to the calculate_concentrations function"""
610 n = self._calculate_concentrations(t, N_0, **kwargs)["n"][-1]
611 return (2 * mu * n) / N_0 + y_0
613 def get_contribution_recommendations(
614 self,
615 contributions: dict[str, np.ndarray],
616 threshold: float = 10.0,
617 ) -> list[str]:
618 """Get recommendations for the contributions
619 :param contributions: dictionary associating contribution key to their values
620 :param threshold: threshold below which a warning is given"""
622 recommendations = []
623 for key in contributions:
624 if np.max(contributions[key]) < threshold:
625 if key == "T" and self.fvalues.get("k_T", 1) != 0:
626 recommendations.append(self.get_contribution_recommendation("trapping", "lower"))
627 elif key == "B" and self.fvalues.get("k_B", 1) != 0:
628 recommendations.append(self.get_contribution_recommendation("bimolecular", "higher"))
629 elif key == "A" and self.fvalues.get("k_A", 1) != 0:
630 recommendations.append(self.get_contribution_recommendation("Auger", "higher"))
631 return recommendations
634class BTModelTRPL(BTModel):
636 QUANTITY = "TRPL"
638 def __init__(self) -> None:
639 BTModel.__init__(self, ["k_T", "k_B", "k_A", "y_0", "I"])
641 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray:
643 return self.calculate_trpl(*args, **kwargs)
645 @staticmethod
646 def calculate_contributions(
647 t: np.ndarray,
648 k_T: float,
649 k_B: float,
650 k_A: float,
651 n: np.ndarray,
652 **kwargs,
653 ) -> dict[str, float]:
654 """Calculate the total contributions to the TRPL
655 :param k_T: trapping rate constant (ns-1)
656 :param k_B: bimolecular rate constant (cm3/ns)
657 :param k_A: Auger rate constant (cm6/ns)
658 :param n: carrier concentration (cm-3)
659 :param t: time (ns)"""
661 T = sci.trapezoid(k_T * n**2, t)
662 B = sci.trapezoid(k_B * n**3, t)
663 A = sci.trapezoid(k_A * n**4, t)
664 S = T + B + A
665 return {"T": T / S * 100, "B": B / S * 100, "A": A / S * 100}
667 def generate_decays(self, *args, **kwargs) -> tuple[list[np.ndarray], list[np.ndarray], list[float]]:
668 """Generate decays.
669 :param args: arguments passed to _generate_decays.
670 :param kwargs: keyword arguments passed to _generate_decays."""
672 return self._generate_decays(250.0, BT_N0s, BT_KWARGS, normalise=True, *args, **kwargs)
675class BTModelTRMC(BTModel):
677 QUANTITY = "TRMC"
679 def __init__(self) -> None:
680 BTModel.__init__(self, ["k_T", "k_B", "k_A", "mu", "y_0"])
682 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray:
684 return self.calculate_trmc(*args, **kwargs)
686 @staticmethod
687 def calculate_contributions(
688 t: np.ndarray,
689 k_T: float,
690 k_B: float,
691 k_A: float,
692 mu: float,
693 n: np.ndarray,
694 **kwargs,
695 ) -> dict[str, float]:
696 """Calculate the total contributions to the TRPL
697 :param k_T: trapping rate constant (ns-1)
698 :param k_B: bimolecular rate constant (cm3/ns)
699 :param k_A: Auger rate constant (cm6/ns)
700 :param mu: carrier mobility (cm2/Vs)
701 :param n: carrier concentration (cm-3)
702 :param t: time (ns)"""
704 T = sci.trapezoid(2 * mu * k_T * n, t)
705 B = sci.trapezoid(2 * mu * k_B * n**2, t)
706 A = sci.trapezoid(2 * mu * k_A * n**3, t)
707 S = T + B + A
708 return {"T": T / S * 100, "B": B / S * 100, "A": A / S * 100}
710 def generate_decays(self, *args, **kwargs) -> tuple[list[np.ndarray], list[np.ndarray], list[float]]:
711 """Generate decays.
712 :param args: arguments passed to _generate_decays.
713 :param kwargs: keyword arguments passed to _generate_decays."""
715 return self._generate_decays(1000.0, BT_N0s, BT_KWARGS, *args, **kwargs)
718# ------------------------------------------------------ BTD MODEL -----------------------------------------------------
721class BTDModel(Model):
722 """Class for the Bimolecular-Trapping-Detrapping model"""
724 MODEL = "BTD"
726 def __init__(self, param_ids) -> None:
728 units = {
729 "N_T": "cm-3",
730 "p_0": "cm-3",
731 "k_B": "cm3/ns",
732 "k_T": "cm3/ns",
733 "k_D": "cm3/ns",
734 "mu_e": "cm2/(V s)",
735 "mu_h": "cm2/(V s)",
736 }
737 units_html = {
738 "N_T": "cm<sup>-3</sup>",
739 "p_0": "cm<sup>-3</sup>",
740 "k_B": "cm<sup>3</sup>/ns",
741 "k_T": "cm<sup>3</sup>/ns",
742 "k_D": "cm<sup>3</sup>/ns",
743 "mu_e": "cm<sup>2</sup>/(V s)",
744 "mu_h": "cm<sup>2</sup>/(V s)",
745 }
746 factors = {
747 "k_B": 1e-20,
748 "k_T": 1e-20,
749 "k_D": 1e-20,
750 "p_0": 1e12,
751 "N_T": 1e12,
752 "mu_e": 1,
753 "mu_h": 1,
754 }
755 fvalues = {
756 "k_T": None,
757 "k_B": None,
758 "k_D": None,
759 "N_T": None,
760 "p_0": None,
761 "mu_e": None,
762 "mu_h": None,
763 }
764 gvalues = {
765 "k_B": 30e-20,
766 "k_T": 12000e-20,
767 "k_D": 80e-20,
768 "N_T": 60e12,
769 "p_0": 65e12,
770 "mu_e": 10,
771 "mu_h": 10,
772 }
773 gvalues_range = {
774 "k_B": [1e-20, 1e-18],
775 "k_T": [1e-18, 1e-16],
776 "k_D": [1e-20, 1e-18],
777 "p_0": [1e12, 1e14],
778 "N_T": [1e12, 1e14],
779 "mu_e": [10],
780 "mu_h": [10],
781 }
782 n_keys = ["n_e", "n_t", "n_h"] # need to be ordered same way as rate_equations input
783 n_init = lambda N_0: {"n_e": N_0, "n_t": 0, "n_h": N_0}
784 conc_ca_ids = ["n_e", "n_h"]
786 Model.__init__(
787 self,
788 param_ids,
789 units,
790 units_html,
791 factors,
792 fvalues,
793 gvalues,
794 gvalues_range,
795 n_keys,
796 n_init,
797 conc_ca_ids,
798 ["k_B < k_T", "k_D < k_T"],
799 )
801 @staticmethod
802 def _rate_equations(
803 n_e: float,
804 n_t: float,
805 n_h: float,
806 k_B: float,
807 k_T: float,
808 k_D: float,
809 p_0: float,
810 N_T: float,
811 **kwargs,
812 ) -> dict[str, float]:
813 """Rate equations of the BTD model
814 :param n_e: electron concentration (cm-3)
815 :param n_t: trapped electron concentration (cm-3)
816 :param n_h: hole concentration (cm-3)
817 :param k_B: bimolecular recombination rate constant (cm3/ns)
818 :param k_T: trapping rate constant (cm3/ns)
819 :param k_D: detrapping rate constant (cm3/ns)
820 :param p_0: doping concentration (cm-3)
821 :param N_T: trap states concentration (cm-3)"""
823 B = k_B * n_e * (n_h + p_0)
824 T = k_T * n_e * (N_T - n_t)
825 D = k_D * n_t * (n_h + p_0)
826 dne_dt = -B - T
827 dnt_dt = T - D
828 dnh_dt = -B - D
829 return {"n_e": dne_dt, "n_t": dnt_dt, "n_h": dnh_dt}
831 def calculate_trpl(
832 self,
833 t: np.ndarray,
834 N_0: float,
835 p_0: float,
836 I: float,
837 y_0: float,
838 **kwargs,
839 ) -> np.ndarray:
840 """Calculate the normalised TRPL intensity
841 :param t: time (ns)
842 :param N_0: initial carrier concentration (cm-3)
843 :param y_0: background intensity
844 :param p_0: doping concentration (cm-3)
845 :param I: amplitude factor
846 :param kwargs: keyword arguments passed to the calculate_concentrations function"""
848 n = self._calculate_concentrations(t, N_0, p_0=p_0, **kwargs)
849 I_TRPL = n["n_e"][-1] * (n["n_h"][-1] + p_0) / N_0
850 return I * I_TRPL / I_TRPL[0] + y_0
852 def calculate_trmc(
853 self,
854 t: np.ndarray,
855 N_0: float,
856 p_0: float,
857 mu_e: float,
858 mu_h: float,
859 y_0: float,
860 **kwargs,
861 ) -> np.ndarray:
862 """Calculate the normalised TRPL intensity
863 :param t: time (ns)
864 :param N_0: initial carrier concentration (cm-3)
865 :param y_0: background intensity
866 :param p_0: doping concentration (cm-3)
867 :param mu_e: electron mobility (cm2/Vs)
868 :param mu_h: hole mobility (cm2/Vs)
869 :param kwargs: keyword arguments passed to the calculate_concentrations function"""
871 n = self._calculate_concentrations(t, N_0, p_0=p_0, **kwargs)
872 return (mu_e * n["n_e"][-1] + mu_h * n["n_h"][-1]) / N_0 + y_0
874 def get_contribution_recommendations(
875 self,
876 contributions: dict[str, np.ndarray],
877 threshold: float = 10.0,
878 ) -> list[str]:
879 """Get recommendations for the contributions
880 :param contributions: contribution dictionary
881 :param threshold: threshold below which a warning is given"""
883 recs = []
884 for key in contributions:
885 if np.max(contributions[key]) < threshold:
886 if key == "B" and self.fvalues.get("k_B", 1) != 0:
887 recs.append(self.get_contribution_recommendation("bimolecular", "higher"))
888 elif key == "T" and self.fvalues.get("k_T", 1) != 0 and self.fvalues.get("N_T", 1) != 0:
889 recs.append(self.get_contribution_recommendation("trapping", "lower"))
890 elif key == "D" and self.fvalues.get("k_D", 1) != 0:
891 recs.append(self.get_contribution_recommendation("detrapping"))
892 recs.append(
893 "Note: For the bimolecular-trapping-detrapping model, although a low contribution suggests that the "
894 "parameter associated with the process are not be accurate, a non-negligible contribution does not "
895 "automatically indicate that the parameters retrieved are accurate due to the complex nature of the "
896 "model. It is recommended to perform a grid fitting analysis with this model."
897 )
898 return recs
901class BTDModelTRPL(BTDModel):
903 QUANTITY = "TRPL"
905 def __init__(self) -> None:
906 BTDModel.__init__(self, ["k_B", "k_T", "k_D", "N_T", "p_0", "y_0", "I"])
908 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray:
910 return self.calculate_trpl(*args, **kwargs)
912 @staticmethod
913 def calculate_contributions(
914 t: np.ndarray,
915 k_T: float,
916 k_B: float,
917 k_D: float,
918 N_T: float,
919 p_0: float,
920 n_e: np.ndarray,
921 n_t: np.ndarray,
922 n_h: np.ndarray,
923 **kwargs,
924 ) -> dict[str, float]:
925 """Calculate the total contributions to the TRPL
926 :param k_T: trapping rate constant (cm3/ns)
927 :param k_B: bimolecular rate constant (cm3/ns)
928 :param k_D: Auger detrapping rate constant (cm3/ns)
929 :param N_T: trap state concentration (cm-3)
930 :param p_0: doping concentration (cm-3)
931 :param n_e: electron concentration (cm-3)
932 :param n_t: trapped electron concentration (cm-3)
933 :param n_h: hole concentration (cm-3)
934 :param t: time (ns)"""
936 T = sci.trapezoid(k_T * n_e * (N_T - n_t) * (n_h + p_0), t)
937 B = sci.trapezoid(k_B * n_e * (n_h + p_0) * (n_e + n_h + p_0), t)
938 D = sci.trapezoid(k_D * n_t * (n_h + p_0) * n_e, t)
939 S = T + B + D
940 return {"T": T / S * 100, "B": B / S * 100, "D": D / S * 100}
942 def generate_decays(self, *args, **kwargs) -> tuple[list[np.ndarray], list[np.ndarray], list[float]]:
943 """Generate decays.
944 :param args: arguments passed to _generate_decays.
945 :param kwargs: keyword arguments passed to _generate_decays."""
947 return self._generate_decays(10e3, BTD_N0s, BTD_KWARGS, normalise=True, *args, **kwargs)
950class BTDModelTRMC(BTDModel):
952 QUANTITY = "TRMC"
954 def __init__(self) -> None:
955 BTDModel.__init__(self, ["k_B", "k_T", "k_D", "N_T", "p_0", "mu_e", "mu_h", "y_0"])
957 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray:
959 return self.calculate_trmc(*args, **kwargs)
961 @staticmethod
962 def calculate_contributions(
963 t: np.ndarray,
964 k_T: float,
965 k_B: float,
966 k_D: float,
967 N_T: float,
968 p_0: float,
969 n_e: np.ndarray,
970 n_t: np.ndarray,
971 n_h: np.ndarray,
972 mu_e: float,
973 mu_h: float,
974 **kwargs,
975 ) -> dict[str, float]:
976 """Calculate the total contributions to the TRPL
977 :param k_T: trapping rate constant (cm3/ns)
978 :param k_B: bimolecular rate constant (cm3/ns)
979 :param k_D: Auger detrapping rate constant (cm3/ns)
980 :param N_T: trap state concentration (cm-3)
981 :param p_0: doping concentration (cm-3)
982 :param n_e: electron concentration (cm-3)
983 :param n_t: trapped electron concentration (cm-3)
984 :param n_h: hole concentration (cm-3)
985 :param mu_e: electron mobility (cm2/Vs)
986 :param mu_h: hole mobility (cm2/Vs)
987 :param t: time (ns)"""
989 T = sci.trapezoid(k_T * n_e * (N_T - n_t) * mu_e, t)
990 B = sci.trapezoid(k_B * n_e * (n_h + p_0) * (mu_e + mu_h), t)
991 D = sci.trapezoid(k_D * n_t * (n_h + p_0) * mu_h, t)
992 S = T + B + D
993 return {"T": T / S * 100, "B": B / S * 100, "D": D / S * 100}
995 def generate_decays(self, *args, **kwargs) -> tuple[list[np.ndarray], list[np.ndarray], list[float]]:
996 """Generate decays.
997 :param args: arguments passed to _generate_decays.
998 :param kwargs: keyword arguments passed to _generate_decays."""
1000 return self._generate_decays(50e3, BTD_N0s, BTD_KWARGS, *args, **kwargs)
1003models = {
1004 "BTA": {"TRPL": BTModelTRPL(), "TRMC": BTModelTRMC()},
1005 "BTD": {"TRPL": BTDModelTRPL(), "TRMC": BTDModelTRMC()},
1006}