Coverage for src/hallmd/models/thruster.py: 80%

201 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-07-10 23:12 +0000

1"""Module for Hall thruster models. 

2 

3!!! Note 

4 Only current implementation is for the 1d fluid [Hallthruster.jl code](https://github.com/UM-PEPL/HallThruster.jl). 

5 Other thruster codes can be implemented similarly here. 

6 

7Includes: 

8 

9- `run_hallthruster_jl` - General wrapper to run HallThruster.jl for a single set of inputs 

10- `hallthruster_jl()` - PEM wrapper to run HallThruster.jl for a set of PEM inputs 

11- `get_jl_env` - Get the path of the julia environment created for HallThruster.jl for a specific git ref 

12- `PEM_TO_JULIA` - Mapping of PEM variable names to a path in the HallThruster.jl input/output structure (defaults) 

13""" 

14 

15import copy 

16import json 

17import os 

18import platform 

19import random 

20import string 

21import subprocess 

22import tempfile 

23import time 

24import warnings 

25from importlib import resources 

26from pathlib import Path 

27from typing import Callable, Literal, Optional 

28 

29import numpy as np 

30from amisc.typing import Dataset 

31 

32from hallmd.utils import AVOGADRO_CONSTANT, FUNDAMENTAL_CHARGE, MOLECULAR_WEIGHTS, load_device 

33 

34__all__ = ["run_hallthruster_jl", "hallthruster_jl", "get_jl_env", "PEM_TO_JULIA", "JL_BINARY"] 

35 

36HALLTHRUSTER_VERSION_DEFAULT = "0.18.7" 

37 

38# Maps PEM variable names to a path in the HallThruster.jl input/output structure (default values here) 

39with open(resources.files("hallmd.models") / "pem_to_julia.json", "r") as fd: 

40 PEM_TO_JULIA = json.load(fd) 

41 

42 

43def get_jl_binary(): 

44 """use the juliaup config file to find out where the default julia binary is stored. 

45 if juliaup is not installed, returns 'julia'. 

46 this lets us avoid running juliaup's wrapper executable, which has led to concurrency issues. 

47 This is designed for use on the cluster, so on windows it just returns `julia`.""" 

48 

49 if platform.system() == "Windows": 

50 return "julia" 

51 

52 home = os.environ["HOME"] 

53 juliaup_dir = Path(f"{home}/.julia/juliaup") 

54 juliaup_json = juliaup_dir / "juliaup.json" 

55 if not os.path.exists(juliaup_json): 

56 return "julia" 

57 

58 with open(juliaup_json, "r") as fd: 

59 jl_config = json.load(fd) 

60 

61 default = jl_config["Default"] 

62 versions = jl_config["InstalledVersions"] 

63 channels = jl_config["InstalledChannels"] 

64 

65 version = versions[channels[default]["Version"]] 

66 path = juliaup_dir / version["Path"] / "bin" / "julia" 

67 return path 

68 

69 

70JL_BINARY = get_jl_binary() 

71 

72 

73def get_jl_env(git_ref: str) -> Path: 

74 """Get the path of the julia environment created for HallThruster.jl for a specific git ref. 

75 

76 :param git_ref: The git ref (i.e. commit hash, version tag, branch, etc.) of HallThruster.jl to use. 

77 """ 

78 global_env_dir = Path("~/.julia/environments/").expanduser() 

79 env_path = global_env_dir / f"hallthruster_{git_ref}" 

80 return env_path 

81 

82 

83def _convert_to_julia(pem_data: dict, julia_data: dict, pem_to_julia: dict): 

84 """Replace all values in the mutable `julia_data` dict with corresponding values in `pem_data`, using the 

85 conversion map provided in `pem_to_julia`. Will "blaze" a path into `julia_data` dict if it does not exist. 

86 

87 :param pem_data: thruster inputs of the form `{'pem_variable_name': value}` 

88 :param julia_data: a `HallThruster.jl` data structure of the form `{'config': {...}, 'simulation': {...}, etc.}` 

89 :param pem_to_julia: a conversion map from a pem variable name to a path in `julia_data`. Use strings for dict keys 

90 and integers for list indices. For example, `{'P_b': ['config', 'background_pressure', 2]}` 

91 will set `julia_data['config']['background_pressure'][2] = pem_data['P_b']`. 

92 """ 

93 for pem_key, value in pem_data.items(): 

94 if pem_key not in pem_to_julia: 

95 raise KeyError(f"Cannot convert PEM data variable {pem_key} since it is not in the provided conversion map") 

96 

97 # Blaze a trail into the julia data structure (set dict defaults for str keys and list defaults for int keys) 

98 julia_path = pem_to_julia.get(pem_key) 

99 pointer = julia_data 

100 for i, key in enumerate(julia_path[:-1]): 

101 if isinstance(pointer, dict) and not pointer.get(key): 

102 pointer.setdefault(key, {} if isinstance(julia_path[i + 1], str) else []) 

103 if isinstance(pointer, list) and len(pointer) <= key: 

104 pointer.extend( 

105 [{} if isinstance(julia_path[i + 1], str) else [] for _ in range(key - len(pointer) + 1)] 

106 ) 

107 pointer = pointer[key] 

108 pointer[julia_path[-1]] = value 

109 

110 

111def _convert_to_pem(julia_data: dict, pem_to_julia: dict): 

112 """Return a `dict` of PEM outputs from a `HallThruster.jl` output structure, using the conversion map provided.""" 

113 pem_data = {} 

114 for pem_key, julia_path in pem_to_julia.items(): 

115 pointer = julia_data 

116 if julia_path[0] == "output": 

117 found_value = True 

118 for key in julia_path: 

119 try: 

120 pointer = pointer[key] 

121 except (KeyError, IndexError): 

122 found_value = False 

123 break 

124 

125 if found_value: 

126 pem_data[pem_key] = pointer 

127 return pem_data 

128 

129 

130def _default_model_fidelity(model_fidelity: tuple, json_config: dict, cfl: float = 0.2) -> dict: 

131 """Built-in (default) method to convert model fidelity tuple to `ncells` and `ncharge` via: 

132 

133 ```python 

134 ncells = 50 * (model_fidelity[0] + 2) 

135 ncharge = model_fidelity[1] + 1 

136 ``` 

137 

138 Also adjusts the time step `dt` to maintain the CFL condition (based on grid spacing and ion velocity). 

139 

140 :param model_fidelity: tuple of integers that determine the number of cells and the number of charge states to use 

141 :param json_config: the current set of configurations for HallThruster.jl 

142 :param cfl: a conservative estimate for CFL condition to determine time step 

143 :returns: a dictionary of simulation parameters that can be converted to Julia via the `pem_to_julia` mapping, 

144 namely `{'num_cells': int, 'ncharge': int, 'dt': float}` 

145 """ 

146 if model_fidelity == (): 

147 model_fidelity = (2, 2) # default to high-fidelity model 

148 

149 num_cells = 50 * (model_fidelity[0] + 2) 

150 ncharge = model_fidelity[1] + 1 

151 

152 # Estimate conservative time step based on CFL and grid spacing 

153 config = json_config.get("config", {}) 

154 domain = config.get("domain", [0, 0.08]) 

155 anode_pot = config.get("discharge_voltage", 300) 

156 cathode_pot = config.get("cathode_coupling_voltage", 0) 

157 propellant = config.get("propellant", "Xenon") 

158 

159 if propellant not in MOLECULAR_WEIGHTS: 

160 warnings.warn( 

161 f"Could not find propellant {propellant} in `hallmd.utils`. " 

162 f"Will default to Xenon for estimating uniform time step from CFL..." 

163 ) 

164 propellant = "Xenon" 

165 

166 mi = MOLECULAR_WEIGHTS[propellant] / AVOGADRO_CONSTANT / 1000 # kg 

167 dx = float(domain[1]) / (num_cells + 1) 

168 u = np.sqrt(2 * ncharge * FUNDAMENTAL_CHARGE * (anode_pot - cathode_pot) / mi) 

169 dt_s = cfl * dx / u 

170 

171 return {"num_cells": num_cells, "ncharge": ncharge, "dt": float(dt_s)} 

172 

173 

174def _format_hallthruster_jl_input( 

175 thruster_inputs: dict, 

176 thruster: dict | str = "SPT-100", 

177 config: Optional[dict] = None, 

178 simulation: Optional[dict] = None, 

179 postprocess: Optional[dict] = None, 

180 model_fidelity: tuple = (2, 2), 

181 output_path: Optional[str | Path] = None, 

182 pem_to_julia: dict | str = "default", 

183 fidelity_function: Callable | str = "default", 

184) -> dict: 

185 """Helper function to format PEM inputs for `Hallthruster.jl` as a `dict` writeable to `json`. See the call 

186 signature of `hallthruster_jl` for more details on arguments. 

187 

188 The return `dict` has the format: 

189 ```json 

190 { 

191 "config": { 

192 "thruster": {...}, 

193 "discharge_voltage": 300, 

194 "domain": [0, 0.08], 

195 "anode_mass_flow_rate": 1e-6, 

196 "background_pressure": 1e-5, 

197 etc. 

198 }, 

199 "simulation": { 

200 "ncells": 202, 

201 "dt": 8.4e-9 

202 "duration": 1e-3, 

203 etc. 

204 } 

205 "postprocess": {...} 

206 } 

207 ``` 

208 

209 :returns: a json `dict` in the format that `HallThruster.run_simulation()` expects to be called 

210 """ 

211 if fidelity_function is None or fidelity_function == "default": 

212 fidelity_function = _default_model_fidelity 

213 

214 json_config = { 

215 "config": {} if config is None else copy.deepcopy(config), 

216 "simulation": {} if simulation is None else copy.deepcopy(simulation), 

217 "postprocess": {} if postprocess is None else copy.deepcopy(postprocess), 

218 } 

219 

220 # Necessary to load thruster specs separately from the config (to protect sensitive data) 

221 if isinstance(thruster, str): 

222 thruster = load_device(thruster) 

223 if thruster is not None: 

224 json_config["config"]["thruster"] = thruster # override 

225 

226 # Make sure we request time-averaged 

227 duration = json_config["simulation"].get("duration", 1e-3) 

228 avg_start_time = json_config["postprocess"].get("average_start_time", 0.5 * duration) 

229 json_config["postprocess"]["average_start_time"] = avg_start_time 

230 

231 # Update/override config with PEM thruster inputs (modify in place) 

232 _convert_to_julia(thruster_inputs, json_config, pem_to_julia) 

233 

234 # Override model fidelity quantities 

235 if model_fidelity is not None: 

236 fidelity_overrides = fidelity_function(model_fidelity, json_config) 

237 _convert_to_julia(fidelity_overrides, json_config, pem_to_julia) 

238 

239 if output_path is not None: 

240 fname = "hallthruster_jl" 

241 if name := json_config["config"].get("thruster", {}).get("name"): 

242 fname += f"_{name}" 

243 if vd := json_config["config"].get("discharge_voltage"): 

244 fname += f"_{round(vd)}V" 

245 if mdot := json_config["config"].get("anode_mass_flow_rate"): 

246 fname += f"_{mdot:.1e}kg_s" 

247 

248 fname += "_" + "".join(random.choices(string.ascii_uppercase + string.digits, k=4)) + ".json" 

249 output_file = str((Path(output_path) / fname).resolve()) 

250 json_config["postprocess"]["output_file"] = output_file 

251 

252 # Handle special conversions for anomalous transport models 

253 if anom_model := json_config["config"].get("anom_model"): 

254 if anom_model.get("type") in ["LogisticPressureShift", "SimpleLogisticShift"]: 

255 anom_model = anom_model.get("model", {}) 

256 

257 match anom_model.get("type", "TwoZoneBohm"): 

258 case "TwoZoneBohm": 

259 if thruster_inputs.get("a_2") is not None: # Only when the PEM a_2 is used 

260 anom_model["c2"] = anom_model["c2"] * anom_model.get("c1", 0.00625) 

261 case "GaussianBohm": 

262 if thruster_inputs.get("anom_max") is not None: # Only when the PEM anom_max is used 

263 anom_model["hall_max"] = anom_model["hall_max"] * anom_model.get("hall_min", 0.00625) 

264 

265 return json_config 

266 

267 

268def run_hallthruster_jl( 

269 json_input: dict | str | Path, jl_env: Optional[str | Path] = None, jl_script: Optional[str | Path] = None, **kwargs 

270) -> dict: 

271 """Python wrapper for `HallThruster.run_simulation(json_input)` in Julia. 

272 

273 :param json_input: either a dictionary containing `config`, `simulation`, and `postprocess` options for 

274 HallThruster.jl, or a string/Path containing a path to a JSON file with those inputs. 

275 :param jl_env: The julia environment containing HallThruster.jl. Defaults to global Julia environment. 

276 :param jl_script: path to a custom Julia script to run. The script should accept the input json file path as 

277 a command line argument. Defaults to just calling `HallThruster.run_simulation(input_file)`. 

278 :param kwargs: additional keyword arguments to pass to `subprocess.run` when calling the Julia script. 

279 

280 :returns: `dict` of `Hallthruster.jl` outputs. The specific outputs depend on the settings 

281 provided in the `postprocess` dict in the input. If `postprocess['output_file']` is present, 

282 this function will also write the requested outputs and restart information to that file. 

283 """ 

284 # Read JSON input from file if path provided 

285 if isinstance(json_input, str | Path): 

286 with open(json_input, "r") as fp: 

287 json_input: dict = json.load(fp) 

288 

289 tempfile_args = dict(suffix=".json", prefix="hallthruster_jl_", mode="w", delete=False, encoding="utf-8") 

290 

291 # Get output file path. If one not provided, create a temporary 

292 temp_out = False 

293 if "output_file" in json_input.get("postprocess", {}): 

294 output_file = Path(json_input["postprocess"].get("output_file")) 

295 elif "output_file" in json_input.get("input", {}).get("postprocess", {}): 

296 output_file = Path(json_input["input"]["postprocess"].get("output_file")) 

297 else: 

298 temp_out = True 

299 fd_out = tempfile.NamedTemporaryFile(**tempfile_args) 

300 output_file = Path(fd_out.name) 

301 fd_out.close() 

302 

303 if json_input.get("input"): 

304 json_input["input"].setdefault("postprocess", {}) 

305 json_input["input"]["postprocess"]["output_file"] = str(output_file.resolve()) 

306 else: 

307 json_input.setdefault("postprocess", {}) 

308 json_input["postprocess"]["output_file"] = str(output_file.resolve()) 

309 

310 # Dump input to temporary file 

311 fd = tempfile.NamedTemporaryFile(**tempfile_args) 

312 input_file = fd.name 

313 json.dump(json_input, fd, ensure_ascii=False, indent=4) 

314 fd.close() 

315 

316 # Run HallThruster.jl on input file 

317 if jl_script is None: 

318 cmd = [ 

319 JL_BINARY, 

320 "--startup-file=no", 

321 "-e", 

322 f'using HallThruster; HallThruster.run_simulation(raw"{input_file}")', 

323 ] 

324 else: 

325 cmd = [JL_BINARY, "--startup-file=no", "--", str(Path(jl_script).resolve()), input_file] 

326 

327 if jl_env is not None: 

328 if Path(jl_env).exists(): 

329 cmd.insert(1, f"--project={Path(jl_env).resolve()}") 

330 else: 

331 raise ValueError( 

332 f"Could not find Julia environment {jl_env}. Please create it first. " 

333 f"See https://github.com/JANUS-Institute/HallThrusterPEM/blob/main/scripts/install_hallthruster.py" 

334 ) 

335 

336 try: 

337 subprocess.run(cmd, **kwargs) 

338 finally: 

339 # Delete temporary input file 

340 os.unlink(input_file) 

341 

342 # Load output data 

343 with open(output_file, "r") as fp: 

344 output_data = json.load(fp) 

345 

346 if temp_out: 

347 os.unlink(output_file) 

348 if d := output_data.get("postprocess"): 

349 if "output_file" in d: 

350 del d["output_file"] 

351 if d := output_data.get("input"): 

352 if d2 := d.get("postprocess"): 

353 if "output_file" in d2: 

354 del d2["output_file"] 

355 

356 return output_data 

357 

358 

359def hallthruster_jl( 

360 thruster_inputs: Dataset = None, 

361 thruster: Literal["SPT-100"] | str | dict = "SPT-100", 

362 config: Optional[dict] = None, 

363 simulation: Optional[dict] = None, 

364 postprocess: Optional[dict] = None, 

365 model_fidelity: tuple = (2, 2), 

366 output_path: Optional[str | Path] = None, 

367 version: str = HALLTHRUSTER_VERSION_DEFAULT, 

368 pem_to_julia: dict | str = "default", 

369 fidelity_function: Callable[[tuple[int, ...]], dict] = "default", 

370 julia_script: Optional[str | Path] = None, 

371 run_kwargs: dict = "default", 

372 shock_threshold: float = None, 

373) -> Dataset: 

374 """Run a single `HallThruster.jl` simulation for a given set of inputs. This function will write a temporary 

375 input file to disk, call `HallThruster.run_simulation()` in Julia, and read the output file back into Python. Will 

376 return time-averaged performance metrics and ion velocity for use with the PEM. 

377 

378 Note that the specific inputs and outputs described here can be configured using the `pem_to_julia` dict. 

379 

380 !!! Warning "Required configuration" 

381 You must specify a thruster, a domain, a mass flow rate, and a discharge voltage to run the simulation. The 

382 thruster must be defined in the `hallmd.devices` directory or as a dictionary with the required fields. 

383 The mass flow rate and discharge voltage are specified in `thruster_inputs` as `mdot_a` (kg/s) and 

384 `V_a` (V), respectively. The domain is specified as a list `[left_bound, right_bound]` in the 

385 `config` dictionary. See the 

386 [HallThruster.jl docs](https://um-pepl.github.io/HallThruster.jl/dev/reference/config/) for more details. 

387 

388 :param thruster_inputs: named key-value pairs of thruster inputs: `P_b`, `V_a`, `mdot_a`, `T_e`, `u_n`, `l_t`, 

389 `a_1`, `a_2`, `delta_z`, `z0`, `p0`, and `V_cc` for background pressure (Torr), anode 

390 voltage, anode mass flow rate (kg/s), electron temperature (eV), neutral velocity (m/s), 

391 transition length (m), anomalous transport coefficients, and cathode coupling voltage. Will 

392 override the corresponding values in `config` if provided. 

393 :param thruster: the name of the thruster to simulate (must be importable from `hallmd.devices`, see 

394 [`load_device`][hallmd.utils.load_device]), or a dictionary that provides geometry and 

395 magnetic field information of the thruster to simulate; see the 

396 [Hallthruster.jl docs](https://um-pepl.github.io/HallThruster.jl/dev/tutorials/simulation//run/). 

397 Will override `thruster` in `config` if provided. If None, will defer to `config`. 

398 Defaults to the SPT-100. 

399 :param config: dictionary of configs for `HallThruster.jl`, see the 

400 [Hallthruster.jl docs](https://um-pepl.github.io/HallThruster.jl/dev/reference/config/) for 

401 options and formatting. 

402 :param simulation: dictionary of simulation parameters for `HallThruster.jl` 

403 :param postprocess: dictionary of post-processing parameters for `Hallthruster.jl` 

404 :param model_fidelity: tuple of integers that determine the number of cells and the number of charge states to use 

405 via `ncells = model_fidelity[0] * 50 + 100` and `ncharge = model_fidelity[1] + 1`. 

406 Will override `ncells` and `ncharge` in `simulation` and `config` if provided. 

407 :param output_path: base path to save output files, will write to current directory if not specified 

408 :param version: version of HallThruster.jl to use; will 

409 search for a global `hallthruster_{version}` environment in the `~/.julia/environments/` directory. 

410 Can also specify a specific git ref (i.e. branch, commit hash, etc.) to use from GitHub. If the 

411 `hallthruster_{version}` environment does not exist, an error will be raised -- you should create 

412 this environment first before using it. 

413 :param pem_to_julia: a `dict` mapping of PEM shorthand variable names to a list of keys that maps into the 

414 `HallThruster.jl` input/output data structure. Defaults to the provided PEM_TO_JULIA dict 

415 defined in [`hallmd.models.thruster`][hallmd.models.thruster]. For example, 

416 `{'P_b': ['config', 'background_pressure']}` will set `config['background_pressure'] = P_b`. 

417 If specified, will override and extend the default mapping. 

418 :param fidelity_function: a callable that takes a tuple of integers and returns a dictionary of simulation 

419 parameters. Defaults to `_default_model_fidelity` which sets `ncells` and `ncharge` based 

420 on the input tuple. The returned simulation parameters must be convertable to Julia via 

421 the `pem_to_julia` mapping. The callable should also take in the current json config dict. 

422 :param julia_script: path to a custom Julia script to run. The script should accept the input json file path as 

423 a command line argument. Defaults to just calling `HallThruster.run_simulation(input_file)`. 

424 :param run_kwargs: additional keyword arguments to pass to `subprocess.run` when calling the Julia script. 

425 Defaults to `check=True`. 

426 :param shock_threshold: if provided, an error will be raised if the ion velocity reaches a maximum before this 

427 threshold axial location (in m) - used to detect and filter unwanted "shock-like" behavior, 

428 for example by providing a threshold of half the domain length. If not provided, then no 

429 filtering is performed (default). 

430 :returns: `dict` of `Hallthruster.jl` outputs: `I_B0`, `I_d`, `T`, `eta_c`, `eta_m`, `eta_v`, and `u_ion` for ion 

431 beam current (A), discharge current (A), thrust (N), current efficiency, mass efficiency, voltage 

432 efficiency, and singly-charged ion velocity profile (m/s), all time-averaged. 

433 """ 

434 if pem_to_julia is None or pem_to_julia == "default": 

435 pem_to_julia = copy.deepcopy(PEM_TO_JULIA) 

436 else: 

437 tmp = copy.deepcopy(PEM_TO_JULIA) 

438 tmp.update(pem_to_julia) 

439 pem_to_julia = tmp 

440 

441 thruster_inputs = thruster_inputs or {} 

442 

443 # Format PEM inputs for HallThruster.jl 

444 json_data = _format_hallthruster_jl_input( 

445 thruster_inputs, 

446 thruster=thruster, 

447 config=config, 

448 simulation=simulation, 

449 postprocess=postprocess, 

450 model_fidelity=model_fidelity, 

451 output_path=output_path, 

452 pem_to_julia=pem_to_julia, 

453 fidelity_function=fidelity_function, 

454 ) 

455 # Get julia environment 

456 jl_environment = get_jl_env(version) if version is not None else None 

457 

458 if run_kwargs is None: 

459 run_kwargs = {} 

460 elif run_kwargs == "default": 

461 run_kwargs = {"check": True} 

462 

463 # Run Julia 

464 t1 = time.time() 

465 sim_results = run_hallthruster_jl(json_data, jl_env=jl_environment, jl_script=julia_script, **run_kwargs) 

466 t2 = time.time() 

467 

468 # Format QOIs for PEM 

469 thruster_outputs = _convert_to_pem(sim_results, pem_to_julia) 

470 

471 # Raise an exception if thrust or beam current are negative (non-physical cases) 

472 thrust = thruster_outputs.get("T", 0) 

473 beam_current = thruster_outputs.get("I_B0", 0) 

474 if thrust < 0 or beam_current < 0: 

475 raise ValueError(f"Exception due to non-physical case: thrust={thrust} N, beam current={beam_current} A") 

476 

477 # Raise an exception for ion velocity that exhibits "shock" behavior 

478 if shock_threshold is not None: 

479 z_coords = thruster_outputs.get("u_ion_coords") 

480 ion_velocity = thruster_outputs.get("u_ion") 

481 if z_coords is not None and ion_velocity is not None: 

482 if (z_max := z_coords[np.argmax(ion_velocity)]) < shock_threshold: 

483 raise ValueError(f"Exception due to shock-like behavior: max ion velocity occurs at z={z_max:.3f} m") 

484 

485 thruster_outputs["model_cost"] = t2 - t1 # seconds 

486 

487 if output_path is not None: 

488 output_file = Path(json_data["postprocess"].get("output_file")) 

489 thruster_outputs["output_path"] = output_file.relative_to(Path(output_path).resolve()).as_posix() 

490 

491 return thruster_outputs