Coverage for python/gsfit/gsfit.py: 87%

212 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-14 15:25 +0000

1import os 

2import time as time_py 

3import typing 

4 

5import gsfit_rs 

6import numpy as np 

7from diagnostic_and_simulation_base import DiagnosticAndSimulationBase 

8 

9from .database_readers import get_database_reader 

10from .database_writers import get_database_writer 

11 

12if typing.TYPE_CHECKING: 

13 from imas.ids_toplevel import IDSToplevel 

14 

15np.set_printoptions(linewidth=200) 

16 

17 

18class Gsfit(DiagnosticAndSimulationBase): 

19 """ 

20 GSFit: Grad-Shafranov Fit 

21 

22 Example usage: 

23 ```python 

24 # Note, this example will only run inside Tokamak Energy's network 

25 # This is a reproducton of `examples/example_01_st40_with_experimental_data.py` 

26 

27 from gsfit import Gsfit 

28 

29 pulseNo = 12050 # A real experimental shot 

30 pulseNo_write = pulseNo + 11_000_000 # Write to a "million" modelling pulse number 

31 

32 gsfit_controller = Gsfit( 

33 pulseNo=pulseNo, 

34 run_name="TEST01", 

35 run_description="Test run", 

36 write_to_mds=True, 

37 pulseNo_write=pulseNo_write, 

38 ) 

39 

40 gsfit_controller.run() 

41 ``` 

42 """ 

43 

44 coils: gsfit_rs.Coils 

45 passives: gsfit_rs.Passives 

46 plasma: gsfit_rs.Plasma 

47 bp_probes: gsfit_rs.BpProbes 

48 flux_loops: gsfit_rs.FluxLoops 

49 rogowski_coils: gsfit_rs.RogowskiCoils 

50 isoflux: gsfit_rs.Isoflux 

51 isoflux_boundary: gsfit_rs.IsofluxBoundary 

52 pressure_sensors: gsfit_rs.Pressure 

53 stationary_point: gsfit_rs.StationaryPoint 

54 dialoop: gsfit_rs.Dialoop 

55 tf: gsfit_rs.Tf 

56 wall: gsfit_rs.Wall 

57 

58 # Optional: only a `database_reader` which reads gap definitions builds one. `None` means 

59 # the equilibrium has no gaps 

60 pulse_schedule: gsfit_rs.PulseSchedule | None = None 

61 

62 # Set by `write_results_to_database` when the `imas` database_writer is selected: the 

63 # populated IMAS `equilibrium` IDS. `None` for every other writer 

64 equilibrium_ids: "IDSToplevel | None" = None 

65 

66 # TODO: move to DiagnosticAndSimulationBase 

67 def __getitem__(self, key: str) -> typing.Any: 

68 return self.results[key] 

69 

70 # TODO: move to DiagnosticAndSimulationBase 

71 def print_keys(self) -> None: 

72 """ 

73 Print the keys of the `self.results` nested dictionary, including subkeys. 

74 `self.results` is a 1:1 mapping to the MDSplus database structure. 

75 """ 

76 

77 self.results.print_keys() 

78 

79 # TODO: move to DiagnosticAndSimulationBase 

80 def keys(self, search: str | None = None) -> typing.KeysView[str]: 

81 """ 

82 Return the keys of the `self.results` nested dictionary, only the top level keys. 

83 `self.results` is a 1:1 mapping to the MDSplus database structure. 

84 """ 

85 

86 return self.results.keys() 

87 

88 def run(self, **kwargs: typing.Any) -> None: 

89 """ 

90 Run all components of GSFit 

91 (reading databases & initialisation, solving GS equation, and writing to data store). 

92 

93 :param kwargs: Additional arguments to be passed to the database_reader. This is for FreeGS and FreeGNSKE 

94 

95 This will perform the following steps: 

96 1. Start creating the MDSplus nodes, in a separate process 

97 2. Set the environment variables 

98 3. Setup the timeslices to reconstruct 

99 4. Read in all the machine settings and initalise the following Rust implementations: 

100 `coils`, `passives`, `plasma`, `tf`, `wall`, `bp_probes`, `flux_loops`, `rogowski_coils`, `isoflux`, `isoflux_boundary`, and `stationary_point`, 

101 and the optional `pulse_schedule` 

102 5. Initialise the Greens functions 

103 6. Solve the GS equation 

104 7. Map the results to the MDSplus database structure and store in `self.results` 

105 8. Wait for the MDSplus nodes, then write the results into them 

106 

107 Steps 1 and 8 are skipped for the `imas` database_writer, which writes nothing. 

108 """ 

109 

110 self.logger.info(f"Running Gsfit, for pulseNo={self.pulseNo}") 

111 

112 # Creating the MDSplus nodes needs nothing but the settings, which have already been read, 

113 # so start it now and let it run while we read the databases and solve the GS equation. 

114 # The settings are the user's to change up to this point, so the values in force now are 

115 # the ones the node creation is given; from here on they are fixed. 

116 # `write_results_to_database` waits for it, and checks that it worked, before writing 

117 if self.writes_to_mdsplus(): 

118 self.logger.info("Creating the MDSplus nodes in a separate process") 

119 self.start_mds_node_creation(workflows=self.get_workflow_names()) 

120 

121 self.set_environment_variables() 

122 

123 self.setup_timeslices() 

124 

125 # Read in all the machine settings and initalise the following Rust implementations: 

126 # `coils`, `passives`, `plasma`, `tf`, `wall`, `bp_probes`, `flux_loops`, `rogowski_coils`, `isoflux`, `isoflux_boundary`, and `stationary_point`, 

127 # and the optional `pulse_schedule` 

128 self.setup_objects(**kwargs) 

129 

130 # Calculate the Greens functions for all permutations between current source objects and sensors. 

131 self.calculate_greens() 

132 

133 # Solve the GS equation 

134 if self.settings["GSFIT_code_settings.json"]["type_of_run"]["inverse"]: 

135 self.inverse_solver_rust() 

136 elif self.settings["GSFIT_code_settings.json"]["type_of_run"]["forward"]: 

137 self.logger.warning("Forward GS solver not implemented yet. Skipping forward run!!") 

138 else: 

139 raise ValueError(f"Unknown type_of_run={self.settings['GSFIT_code_settings.json']['type_of_run']}") 

140 

141 self.write_results_to_database() 

142 

143 def writes_to_mdsplus(self) -> bool: 

144 """ 

145 Whether this run will write to MDSplus at all. 

146 

147 `write_to_mds` is the user's switch, but the `imas` database_writer does not write to 

148 MDSplus whatever it is set to: it builds an IMAS `equilibrium` IDS instead. Asking this 

149 rather than `write_to_mds` keeps the MDSplus node creation, which starts before anything 

150 else in `run`, in step with the writing that happens at the end. 

151 """ 

152 

153 database_writer_method = self.settings["GSFIT_code_settings.json"]["database_writer"]["method"] 

154 

155 return self.write_to_mds and database_writer_method != "imas" 

156 

157 def write_results_to_database(self) -> None: 

158 """ 

159 Write the results to the database: 

160 1. Results are collected from the Rust objects and stored in `self.results`, which is 

161 similar to a nested dictionary, and has a 1:1 mapping to the MDSplus database structure. 

162 2. We wait for the MDSplus nodes, which `run` started creating in a separate process, and 

163 check that the creation did not fail. If `run` was not used, the nodes are created here. 

164 3. The results are then written to MDSplus. 

165 

166 The `imas` database_writer does neither 2 nor 3. It returns an IMAS `equilibrium` IDS, 

167 which is kept on `self.equilibrium_ids` for the caller to use, and nothing is written. 

168 """ 

169 

170 # Map the results to the database structure. 

171 # For the MDSplus writers `self.results` is a 1:1 mapping to MDSplus 

172 database_writer_method = self.settings["GSFIT_code_settings.json"]["database_writer"]["method"] 

173 database_writer = get_database_writer(database_writer_method) 

174 if database_writer_method != "imas": 

175 database_writer.map_results_to_database(self) 

176 else: 

177 self.equilibrium_ids = database_writer.map_results_to_database(self) 

178 self.logger.info("IMAS IDS populated; access it via `self.equilibrium_ids`") 

179 

180 # Do the writing to MDSplus 

181 self.logger.info(f"pulseNo = {self.pulseNo} pulseNo_write = {self.pulseNo_write} run_name = {self.run_name}") 

182 if self.writes_to_mdsplus(): 

183 # The nodes must exist, and must have been created without error, before we write into them 

184 self.wait_for_mds_node_creation(workflows=self.get_workflow_names()) 

185 

186 self.logger.info("Writing to database") 

187 self._write_data_to_mds() 

188 if database_writer_method == "tokamak_energy_mdsplus_new": 

189 from .database_writers.tokamak_energy_mdsplus_new.create_mdsplus_links import create_mdsplus_links 

190 

191 create_mdsplus_links(pulseNo_write=self.pulseNo_write, run_name=self.run_name, tree_name=self.analysis_name) 

192 

193 def get_workflow_names(self) -> list[str]: 

194 """ 

195 The names of the input codes which will be stored under `INPUT.WORKFLOW`, 

196 for example `["elmag", "mag", "psu2coil"]`. 

197 

198 Which codes these are depends on the `database_reader`, and whether they are stored at all 

199 depends on the `database_writer`, so the selected `database_writer` is asked. The answer 

200 comes from the settings alone, which means the `INPUT.WORKFLOW` MDSplus nodes can be 

201 created before GSFit has read any data. 

202 """ 

203 

204 database_writer_method = self.settings["GSFIT_code_settings.json"]["database_writer"]["method"] 

205 database_writer = get_database_writer(database_writer_method) 

206 

207 return database_writer.get_workflow_names(self) 

208 

209 def setup_timeslices(self) -> None: 

210 """ 

211 Calculates the timeslices to reconstruct, and stores them in `self.results["TIME"]` 

212 """ 

213 

214 # Extract the timeslice settings from the JSON file 

215 timeslices_settings = self.settings["GSFIT_code_settings.json"]["timeslices"] 

216 

217 # Calculate the timeslices 

218 match timeslices_settings["method"]: 

219 case "arange": 

220 time = np.arange( 

221 timeslices_settings["arange"]["time_start"], 

222 timeslices_settings["arange"]["time_end"], 

223 timeslices_settings["arange"]["dt"], 

224 ) 

225 case "linspace": 

226 time = np.linspace( 

227 timeslices_settings["linspace"]["time_start"], 

228 timeslices_settings["linspace"]["time_end"], 

229 timeslices_settings["linspace"]["n_time"], 

230 ) 

231 case "user_defined": 

232 time = np.array(timeslices_settings["user_defined"]) 

233 case "good_pressure_sensors": 

234 # Reconstruct only the time-slices where the pressure sensors are "good". 

235 # The `BAD_MA` reading lives in the `st40_mdsplus_with_digital_filter` reader. 

236 from .database_readers.st40_mdsplus_with_digital_filter.setup_pressure_sensors import get_good_pressure_sensor_times 

237 

238 time = get_good_pressure_sensor_times(pulseNo=self.pulseNo, settings=self.settings) 

239 case _: 

240 raise ValueError(f"Unknown timeslices method: {timeslices_settings['method']}") 

241 

242 # Store the times to reconstruct 

243 self.results["TIME"] = time 

244 

245 def set_environment_variables(self) -> None: 

246 """ 

247 Set the system environment variables before running. 

248 Presently, this defines the numer of cores for the Rust code 

249 """ 

250 

251 # Set the number of cores (for Rayon, Rust's parallelisation library) 

252 n_rayon_threads: int = self.settings["GSFIT_code_settings.json"]["RAYON_NUM_THREADS"] 

253 os.environ["RAYON_NUM_THREADS"] = str(n_rayon_threads) 

254 self.logger.info(msg=f"RAYON_NUM_THREADS = {n_rayon_threads}") 

255 

256 def inverse_solver_rust(self) -> None: 

257 """ 

258 Solve the "inverse" problem, i.e. reconstruction 

259 """ 

260 

261 # Extract objects from class 

262 coils = self.coils 

263 passives = self.passives 

264 plasma = self.plasma 

265 tf = self.tf 

266 wall = self.wall 

267 bp_probes = self.bp_probes 

268 flux_loops = self.flux_loops 

269 rogowski_coils = self.rogowski_coils 

270 isoflux = self.isoflux 

271 isoflux_boundary = self.isoflux_boundary 

272 pressure_sensors = self.pressure_sensors 

273 stationary_point = self.stationary_point 

274 dialoop = self.dialoop 

275 pulse_schedule = self.pulse_schedule 

276 

277 self.logger.info(msg="About to call: `gsfit_rs.solve_grad_shafranov`") 

278 # Note: the solution to the GS equation is stored inside: `plasma`, `passives`, `bp_probes`, `flux_loops`, and `rogowski_coils` 

279 tic = time_py.time() 

280 gsfit_rs.solve_grad_shafranov( 

281 plasma, 

282 wall, 

283 tf, 

284 coils, 

285 passives, 

286 bp_probes, 

287 flux_loops, 

288 rogowski_coils, 

289 isoflux, 

290 isoflux_boundary, 

291 pressure_sensors, 

292 stationary_point, 

293 dialoop, 

294 pulse_schedule=pulse_schedule, 

295 ) 

296 toc = time_py.time() 

297 self.logger.info(msg=f"Finished: `gsfit_rs.solve_inverse_problem` time = {(toc - tic) * 1e3:,.2f}ms") 

298 

299 def calculate_greens(self) -> None: 

300 """ 

301 Calculates the Greens table for all permutations between current source objects and sensors. 

302 """ 

303 

304 # Get Rust classes out of self 

305 coils = self.coils 

306 passives = self.passives 

307 plasma = self.plasma 

308 bp_probes = self.bp_probes 

309 flux_loops = self.flux_loops 

310 rogowski_coils = self.rogowski_coils 

311 isoflux = self.isoflux 

312 isoflux_boundary = self.isoflux_boundary 

313 pressure_sensors = self.pressure_sensors 

314 stationary_point = self.stationary_point 

315 

316 # Greens with coils 

317 tic = time_py.time() 

318 plasma.greens_with_coils(coils) 

319 bp_probes.greens_with_coils(coils) 

320 flux_loops.greens_with_coils(coils) 

321 rogowski_coils.greens_with_coils(coils) 

322 isoflux.greens_with_coils(coils) 

323 isoflux_boundary.greens_with_coils(coils) 

324 pressure_sensors.greens_with_coils(coils) 

325 stationary_point.greens_with_coils(coils) 

326 toc = time_py.time() 

327 self.logger.info(f"Finished Greens with coils; {(toc - tic) * 1e3:,.2f}ms") 

328 

329 # Greens with passives 

330 tic = time_py.time() 

331 plasma.greens_with_passives(passives) 

332 bp_probes.greens_with_passives(passives) 

333 flux_loops.greens_with_passives(passives) 

334 rogowski_coils.greens_with_passives(passives) 

335 isoflux.greens_with_passives(passives) 

336 isoflux_boundary.greens_with_passives(passives) 

337 pressure_sensors.greens_with_passives(passives) 

338 stationary_point.greens_with_passives(passives) 

339 toc = time_py.time() 

340 self.logger.info(f"Finished Greens with passives; {(toc - tic) * 1e3:,.2f}ms") 

341 

342 # Greens with plasma 

343 tic = time_py.time() 

344 bp_probes.greens_with_plasma(plasma) 

345 flux_loops.greens_with_plasma(plasma) 

346 rogowski_coils.greens_with_plasma(plasma) 

347 isoflux.greens_with_plasma(plasma) 

348 isoflux_boundary.greens_with_plasma(plasma) 

349 pressure_sensors.greens_with_plasma(plasma) 

350 stationary_point.greens_with_plasma(plasma) 

351 toc = time_py.time() 

352 self.logger.info(f"Finished Greens with plasma; {(toc - tic) * 1e3:,.2f}ms") 

353 

354 def setup_objects(self, **kwargs: dict[str, typing.Any]) -> None: 

355 """ 

356 Initialises the Rust objects needed to run the GSFit inverse solver: 

357 `coils`, `passives`, `plasma`, `tf`, `wall`, `bp_probes`, `flux_loops`, `rogowski_coils`, `isoflux`, `isoflux_boundary`, and `stationary_point`, 

358 and the optional `pulse_schedule`, which is `None` unless the `database_reader` implements `setup_pulse_schedule` 

359 

360 Different machines will use different data stores (e.g. MDSplus, or FreeGNSKE object). 

361 New readers for different devices / forward GS solvers can be added to: 

362 `python/gsfit/database_readers/__init__.py` and `python/gsfit/database_readers/<new_reader_name>` 

363 

364 See: `gsfit/database_readers/interface.py` for a description of the interfaces. 

365 """ 

366 

367 # Get the database_reader 

368 database_reader_method = self.settings["GSFIT_code_settings.json"]["database_reader"]["method"] 

369 database_reader = get_database_reader(database_reader_method) 

370 

371 # Initialise and store the Rust implementations 

372 tic = time_py.time() 

373 self.coils = database_reader.setup_coils(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

374 toc = time_py.time() 

375 self.logger.info(msg=f"`coils` initialised; {(toc - tic) * 1e3:,.2f}ms") 

376 

377 tic = time_py.time() 

378 self.bp_probes = database_reader.setup_bp_probes(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

379 toc = time_py.time() 

380 self.logger.info(msg=f"`bp_probes` initialised; {(toc - tic) * 1e3:,.2f}ms") 

381 

382 tic = time_py.time() 

383 self.flux_loops = database_reader.setup_flux_loops(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

384 toc = time_py.time() 

385 self.logger.info(msg=f"`flux_loops` initialised; {(toc - tic) * 1e3:,.2f}ms") 

386 

387 tic = time_py.time() 

388 self.rogowski_coils = database_reader.setup_rogowski_coils(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

389 toc = time_py.time() 

390 self.logger.info(msg=f"`rogowski_coils` initialised; {(toc - tic) * 1e3:,.2f}ms") 

391 

392 tic = time_py.time() 

393 self.passives = database_reader.setup_passives(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

394 toc = time_py.time() 

395 self.logger.info(msg=f"`passives` initialised; {(toc - tic) * 1e3:,.2f}ms") 

396 

397 times_to_reconstruct = self.results["TIME"] 

398 

399 tic = time_py.time() 

400 self.plasma = database_reader.setup_plasma( 

401 pulseNo=self.pulseNo, settings=self.settings, times_to_reconstruct=times_to_reconstruct, **kwargs 

402 ) 

403 toc = time_py.time() 

404 self.logger.info(msg=f"`plasma` initialised; {(toc - tic) * 1e3:,.2f}ms") 

405 

406 tic = time_py.time() 

407 self.wall = database_reader.setup_wall(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

408 toc = time_py.time() 

409 self.logger.info(msg=f"`wall` initialised; {(toc - tic) * 1e3:,.2f}ms") 

410 

411 tic = time_py.time() 

412 self.tf = database_reader.setup_tf(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

413 toc = time_py.time() 

414 self.logger.info(msg=f"`tf` initialised; {(toc - tic) * 1e3:,.2f}ms") 

415 

416 tic = time_py.time() 

417 self.isoflux = database_reader.setup_isoflux_sensors(pulseNo=self.pulseNo, settings=self.settings, times_to_reconstruct=times_to_reconstruct, **kwargs) 

418 toc = time_py.time() 

419 self.logger.info(msg=f"`isoflux` initialised; {(toc - tic) * 1e3:,.2f}ms") 

420 

421 tic = time_py.time() 

422 self.isoflux_boundary = database_reader.setup_isoflux_boundary_sensors(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

423 toc = time_py.time() 

424 self.logger.info(msg=f"`isoflux_boundary` initialised; {(toc - tic) * 1e3:,.2f}ms") 

425 

426 tic = time_py.time() 

427 self.pressure_sensors = database_reader.setup_pressure_sensors(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

428 toc = time_py.time() 

429 self.logger.info(msg=f"`pressure_sensors` initialised; {(toc - tic) * 1e3:,.2f}ms") 

430 

431 tic = time_py.time() 

432 self.stationary_point = database_reader.setup_stationary_point_sensors( 

433 pulseNo=self.pulseNo, 

434 settings=self.settings, 

435 times_to_reconstruct=times_to_reconstruct, 

436 **kwargs, 

437 ) 

438 toc = time_py.time() 

439 self.logger.info(msg=f"`stationary_point` initialised; {(toc - tic) * 1e3:,.2f}ms") 

440 

441 tic = time_py.time() 

442 self.dialoop = database_reader.setup_dialoop(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

443 toc = time_py.time() 

444 self.logger.info(msg=f"`dialoop` initialised; {(toc - tic) * 1e3:,.2f}ms") 

445 

446 tic = time_py.time() 

447 self.pulse_schedule = database_reader.setup_pulse_schedule(pulseNo=self.pulseNo, settings=self.settings, **kwargs) 

448 toc = time_py.time() 

449 if self.pulse_schedule is None: 

450 self.logger.info(msg=f"`pulse_schedule` not used by this database_reader, so there are no gaps; {(toc - tic) * 1e3:,.2f}ms") 

451 else: 

452 self.logger.info(msg=f"`pulse_schedule` initialised; {(toc - tic) * 1e3:,.2f}ms")