Coverage for app/models.py: 100%

324 statements  

« 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 

3 

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""" 

21 

22import itertools 

23from typing import Any, Callable 

24 

25import numpy as np 

26import scipy.integrate as sci 

27import streamlit 

28import pandas as pd 

29 

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 

33 

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) 

37 

38# Example photoexcited carrier concentrations 

39BT_N0s = [1e15, 1e16, 1e17] 

40BTD_N0s = [0.51e14, 1.61e14, 4.75e14, 16.1e14, 43.8e14] 

41 

42N0_HTML_QUANTITY_UNIT = "N<sub>0</sub> (cm<sup>-3</sup>)" 

43 

44MP_PULSES = 10000 # maximum number of pulses simulated 

45 

46 

47class Model(object): 

48 """Base class for charge carrier recombination models. 

49 

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.""" 

53 

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 = "" 

58 

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 } 

73 

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": "&mu;", 

98 "mu_e": "&mu;<sub>e</sub>", 

99 "mu_h": "&mu;<sub>h</sub>", 

100 } 

101 

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.""" 

129 

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 

136 

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] 

146 

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}) 

150 

151 self.factors_html = {key: get_power_html(self.factors[key], None) for key in self.factors} 

152 

153 def __eq__(self, other: Any) -> bool: 

154 """Used to check if this object is the same as another object""" 

155 

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 ) 

162 

163 return condition 

164 

165 def get_parameter_label(self, key: str) -> str: 

166 """Get the parameter label 

167 :param key: parameter id""" 

168 

169 if self.units[key] == "": 

170 return self.PARAM_SYMBOLS[key] 

171 else: 

172 return self.PARAM_SYMBOLS[key] + " (" + self.units[key] + ")" 

173 

174 @property 

175 def fixed_values(self) -> dict[str, float]: 

176 """return the dict of fixed values""" 

177 

178 return {key: value for key, value in self.fvalues.items() if value is not None} 

179 

180 # -------------------------------------------------- CORE METHODS -------------------------------------------------- 

181 

182 def _rate_equations(self, *args, **kwargs) -> dict[str, float]: 

183 """Rate equation method""" 

184 

185 return {} 

186 

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""" 

201 

202 var = {key: np.zeros(len(t)) for key in self.n_keys} 

203 variables = {key: [] for key in self.n_keys} 

204 

205 def rate_equation(x, _t) -> list[float]: 

206 """Rate equation wrapper""" 

207 

208 dndt = self._rate_equations(*x, **kwargs) 

209 return [dndt[key] for key in self.n_keys] 

210 

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)) 

215 

216 for key in var: 

217 variables[key].append(var[key]) 

218 

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") 

225 

226 return variables 

227 

228 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray: 

229 """Calculate the TRPL""" 

230 

231 return np.array([0]) 

232 

233 def calculate_contributions(self, *args, **kwargs) -> dict: 

234 """Calculate the contributions""" 

235 

236 return dict() 

237 

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.""" 

246 

247 # Create a new log timescale 

248 x = np.insert(np.logspace(-4, np.log10(period), 10001), 0, 0) 

249 

250 carrier_accumulation = {"CA": [], "Pulse 1": [], "Pulse S": [], "t": [x] * len(params)} 

251 

252 # For each decay fitted 

253 for param in params: 

254 

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) 

259 

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) 

264 

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 

273 

274 return carrier_accumulation 

275 

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.""" 

286 

287 concentrations = [] # concentration dicts for each power/popts 

288 

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 

299 

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 

305 

306 # ----------------------------------------------------- FITTING ---------------------------------------------------- 

307 

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""" 

320 

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 

325 

326 # Fitting 

327 fit = Fit(xs_data, ys_data, self.calculate_fit_quantity, p0, self.detached_parameters, fixed_parameters) 

328 

329 # Popts, fitted data, R2 

330 popts = fit.fit() 

331 fit_ydata = fit.calculate_fits(popts) 

332 cod = fit.calculate_rss(fit_ydata) 

333 

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>)" 

350 

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()} 

359 

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>)" 

366 

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]) 

384 

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 } 

400 

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""" 

411 

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} 

414 

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) 

419 

420 # Run the fits 

421 fits = [] 

422 for i, p0 in enumerate(all_p0s): 

423 

424 # Update the progressbar if provided 

425 if progressbar is not None: 

426 progressbar.progress(i / float(len(all_p0s) - 1)) # pragma: no cover 

427 

428 # Fit the data 

429 try: 

430 fits.append(self.fit(p0=p0, N0s=N0s, **kwargs)) 

431 except ValueError: 

432 pass 

433 

434 return fits 

435 

436 # -------------------------------------------------- CONTRIBUTIONS ------------------------------------------------- 

437 

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'""" 

446 

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 

454 

455 def get_contribution_recommendations(self, *args, **kwargs) -> list[str]: 

456 """Placeholder: get recommendations for the contributions""" 

457 

458 return [""] 

459 

460 # ----------------------------------------------------- OTHERS ----------------------------------------------------- 

461 

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""" 

478 

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) 

489 

490 return xs_data, ys_data, N0s 

491 

492 

493# ------------------------------------------------------ BT MODEL ------------------------------------------------------ 

494 

495 

496class BTModel(Model): 

497 """Class for the Bimolecular-Trapping model""" 

498 

499 MODEL = "BTA" 

500 

501 def __init__(self, param_ids: list): 

502 """Object constructor""" 

503 

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"] 

543 

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 ) 

558 

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""" 

573 

574 return {"n": -k_T * n - k_B * n**2 - k_A * n**3} 

575 

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""" 

590 

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 

594 

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""" 

609 

610 n = self._calculate_concentrations(t, N_0, **kwargs)["n"][-1] 

611 return (2 * mu * n) / N_0 + y_0 

612 

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""" 

621 

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 

632 

633 

634class BTModelTRPL(BTModel): 

635 

636 QUANTITY = "TRPL" 

637 

638 def __init__(self) -> None: 

639 BTModel.__init__(self, ["k_T", "k_B", "k_A", "y_0", "I"]) 

640 

641 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray: 

642 

643 return self.calculate_trpl(*args, **kwargs) 

644 

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)""" 

660 

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} 

666 

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.""" 

671 

672 return self._generate_decays(250.0, BT_N0s, BT_KWARGS, normalise=True, *args, **kwargs) 

673 

674 

675class BTModelTRMC(BTModel): 

676 

677 QUANTITY = "TRMC" 

678 

679 def __init__(self) -> None: 

680 BTModel.__init__(self, ["k_T", "k_B", "k_A", "mu", "y_0"]) 

681 

682 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray: 

683 

684 return self.calculate_trmc(*args, **kwargs) 

685 

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)""" 

703 

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} 

709 

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.""" 

714 

715 return self._generate_decays(1000.0, BT_N0s, BT_KWARGS, *args, **kwargs) 

716 

717 

718# ------------------------------------------------------ BTD MODEL ----------------------------------------------------- 

719 

720 

721class BTDModel(Model): 

722 """Class for the Bimolecular-Trapping-Detrapping model""" 

723 

724 MODEL = "BTD" 

725 

726 def __init__(self, param_ids) -> None: 

727 

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"] 

785 

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 ) 

800 

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)""" 

822 

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} 

830 

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""" 

847 

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 

851 

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""" 

870 

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 

873 

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""" 

882 

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 

899 

900 

901class BTDModelTRPL(BTDModel): 

902 

903 QUANTITY = "TRPL" 

904 

905 def __init__(self) -> None: 

906 BTDModel.__init__(self, ["k_B", "k_T", "k_D", "N_T", "p_0", "y_0", "I"]) 

907 

908 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray: 

909 

910 return self.calculate_trpl(*args, **kwargs) 

911 

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)""" 

935 

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} 

941 

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.""" 

946 

947 return self._generate_decays(10e3, BTD_N0s, BTD_KWARGS, normalise=True, *args, **kwargs) 

948 

949 

950class BTDModelTRMC(BTDModel): 

951 

952 QUANTITY = "TRMC" 

953 

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"]) 

956 

957 def calculate_fit_quantity(self, *args, **kwargs) -> np.ndarray: 

958 

959 return self.calculate_trmc(*args, **kwargs) 

960 

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)""" 

988 

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} 

994 

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.""" 

999 

1000 return self._generate_decays(50e3, BTD_N0s, BTD_KWARGS, *args, **kwargs) 

1001 

1002 

1003models = { 

1004 "BTA": {"TRPL": BTModelTRPL(), "TRMC": BTModelTRMC()}, 

1005 "BTD": {"TRPL": BTDModelTRPL(), "TRMC": BTDModelTRMC()}, 

1006}