#!/usr/bin/env python3
import  sys
import  numpy as np
import  pandas as pd
import  matplotlib.pyplot as plt
import  matplotlib
from    matplotlib import rcParams
from    matplotlib.ticker import LinearLocator
import  yaml
import  logging
from    datetime        import datetime
import  time
import  itertools
import  io
import  shutil
import  cProfile
import  pstats
from    pathlib import Path
from    os import PathLike
from    nicqs.lib import ProgressBar, inductance_for_paths, loop_self_inductance, loop_mutual_inductance, soleno_calculate_field
from    nicqs.lib.cli import var_dump, print_nicqs_logo
from    nicqs.lib.visualization import plot_ABCD
from    nicqs.lib.geometry import create_circuit_information, circuit_class, create_dictionaries, distribute_inversely, get_circuit_inductance, create_seeds, get_circuit_currents, get_solenoid_scaler, discretize_loop
from    nicqs.lib.materials import MaterialProperties
from    nicqs.version import GEOMETRY_VERSION
import  os
import  re

rcParams['axes.labelsize'] = 11
rcParams['xtick.labelsize'] = 11
rcParams['ytick.labelsize'] = 11
rcParams['legend.fontsize'] = 11
rcParams['font.family'] = 'DejaVu Sans'
rcParams['font.sans-serif'] = 'Arial'
rcParams['text.usetex'] = False
rcParams['mathtext.fontset'] = 'dejavusans'
rcParams['figure.autolayout'] = True
rcParams['lines.markersize'] = 5
rcParams['figure.figsize'] = (6, 5)
rcParams['axes.grid'] = True

class NI_coil_geometry:
    """
    This class creates the geometry files needed for the NI coil TEM solver.
    """

    def declare_members(self):
        """
        Declares and initializes all member variables for the geometry class.

        This method initializes over 100 class attributes including:
        - Boolean flags for various calculation sections and output options
        - Input geometry parameters (radii, thickness, spacing, etc.)
        - Solver settings (time steps, tolerances, stopping criteria)
        - Connection indices for electrical, thermal, and structural networks
        - Matrices for inductance, electrical connections, and thermal
          properties
        - Post-processing and visualization parameters
        - Material properties and conductor specifications

        This centralized declaration helps manage the large number of
        attributes needed for the complex thermo-electromagnetic simulation.

        Returns:
            None
        """
        # Bools for sections
        self.bool_soleno_center = True
        self.bool_soleno_grid = True
        self.bool_calc_mutual_inductance = True
        self.bool_output_circuit = True
        self.bool_output_solver_settings = True
        self.bool_output_solver = True
        self.bool_output_geometry = True

        self.bool_minimal_output         = False
        self.mag_field_conductor_bool    = True
        self.mag_field_left_bool         = True
        self.mag_field_on_grid_bool      = True
        self.mag_field_r_zero_bool       = True
        self.mag_field_single_points_bool = True
        self.mag_field_hall_probes_bool  = False
        self.no_parallel                 = False
        self.no_map                      = False
        self.r_zero                      = True
        self.save_mutual                 = False

        # Test
        self.bool_finished: bool = False

        # paths
        self.output_name: str    = None
        self.overwrite: bool     = False
        self.init_path: str      = None
        self.geometry_output_path: str = None
        self.overwrite_yaml: bool = False

        # Inductance
        self.symmetry: bool      = False
        self.inductance_calculation: str = "analytic" # analytic / discrete

        # Geometry Input
        self.inner_radius: list[float]   = None
        self.outer_radius: list[float]   = None
        self.thickness: list[float]      = None
        self.spacing: list[float]        = None
        self.parallel: list[int]         = None
        self.serial: list[int]           = None
        self.material: list[str]         = None
        self.repeat: list[int]           = None
        self.sub_elements: list[int]     = None
        self.turns: list[int]            = None
        self.I_initial: list[float]      = None
        self.T_initial: list[float]      = None
        self.simp: list[bool]            = None
        self.tau: list[float]            = None
        self.Ic: list[(int, float)]      = None
        self.n_value: list[(int, float)] = None
        self.conductor_list              = None
        self.offset: list[float]         = None
        self.Conductor_type                      = None
        self.Conductor_SC_properties             = None
        self.Conductor_materials                 = None
        self.Conductor_materials_cross_section   = None
        self.Conductor_materials_thickness       = None
        self.Conductor_materials_width           = None
        self.Conductor_resistor                  = None
        self.Connection_type                     = None
        self.Conductor_conductivity_materials    = None
        self.Conductor_conductivity_thickness    = None
        self.Conductor_conductivity_width        = None
        self.T_ref                          = None
        self.B_ref                          = None
        self.multiplicity                   = None

        # Coils:
        self.coils: dict                    = None

        # Per-circuit configuration (new unified format).
        # Keys are circuit identifiers (any hashable — int or str); values are
        # dicts with optional keys:
        #   'tau'         -> characteristic time constant in seconds
        #   'resistivity' -> turn-to-turn resistivity in Ohm*cm^2
        # Old flat top-level attrs tau_circuit / resistivity_circuit are
        # converted into this dict by _normalize_circuit_config().
        self.circuit_config: dict           = None

        # Solver settings
        self.solver: str = 'vode'        # vode or lsoda
        self.dt: float = 0.001           # Initial step
        self.t0: list = [0.0, 100.0]
        self.atol: float = 1e-8
        self.rtol: float = 1e-10
        self.min_step: float = 1e-99
        self.max_step: float = 1e-1
        self.sampling_time_dI = 100.0
        self.sampling_time_dT = 1.0
        self.max_timestep = 1.0
        self.min_timestep = 1.0
        self.keep_smallest_timestep: bool = False
        self.electrical_part: bool = True
        self.thermal_part: bool = True
        self.stop_criterion_E: float = 5000.0
        self.stop_criterion_T: float = 300.0
        self.stop_on_E = False
        self.stop_on_T = False
        self.thermal_timer = -10.0

        # Index of the connections
        self.Index_radial_connections    = [] # For resistance calculation
        self.Index_inductive_connections = [] # For magnetic field calculation
        self.Index_struct_connections    = [] # Structural conducting connections
        self.Index_normal_connections    = [] # All normal conducting connection
        self.Index_temperatures          = [] # Corresponding temperatures

        # Index of the connections per region
        self.Index_radial_connections_region = {}  # For resistance calculation
        self.Index_inductive_connections_region = {}  # For magnetic field calculation
        self.Index_struct_connections_region = {}  # Structural conducting connections
        self.Index_normal_connections_region = {}  # All normal conducting connection
        self.Index_temperatures_region = {}  # Corresponding temperatures

        # Index post processing
        self.Index_post = [] # Index per coil, so for every repeat a new entry.
        self.Index_post_inductive = [] # Index per coil, so for every repeat a new entry for all inductive connections.
        self.Index_post_resistive = [] # Index per coil, so for every repeat a new entry for all resistive connections.
        self.Index_post_thermal = [] # Index per coil, so for every repeat a new entry for all temperatures.
        self.element_to_coil_nr = [] # Info with coils numbers per element
        self.thermal_element_to_coil_nr = []
        self.thermal_element_to_solenoid_nr = []

        # Dictionary with indexes
        self.index_dict = {} # Dictionary with indexes for the pancakes, coils and circuits for in inductor current II, IR and T.
        self.vtu_info = {} # Dictionary with all info needed to make the vtu's.

        # Mass matrix solver
        self.mass_matrix_bool = False # If set to true, additional variables that are needed for a mass matrix solver will be written to pkl.
        self.R_matrix = None # Matrix of all resistances on the diag.
        self.A_branch = None # Branch voltage matrix
        self.n_branches = None # Number of branches
        self.n_nodes = None # Number of nodes

        # vtu seed info
        self.seed_points = []
        self.seed_information = []
        self.seeds_radial: int = 5
        self.seeds_angular: int = 5

        # b-array indices
        self.b_V_array_index             = None
        self.b_dIdt_array_index          = None

        # Circuits
        self.circuit             = None
        self.circuits            = None
        self.unique_circuits     = None
        self.circuits_list       = None
        self.circuit_class_list  = {}
        self.tau_circuit         = None
        self.resistivity_circuit = None
        self.resistivity         = None
        self.signs               = None
        self.inductance_dict: dict = {}

        # Initial conditions
        self.initial_conditions = {}
        self.Index_initial_Iind = {}
        self.Index_initial_IR = {}
        self.Index_initial_T = {}

        # Mutual inductance output
        self.mutual_output = None

        # Geometry and plotter Output
        self.positions_center: list          = None
        self.positions_outer: list           = None
        self.positions: list                 = []
        self.positions_solenoids: list       = None
        self.turns_per_element: list         = None
        self.volumes: list                   = None
        self.cross_sections: list            = None
        self.cross_sections_inner: list      = None
        self.cross_sections_outer: list      = None
        self.masses: list                    = None
        self.lengths: list                   = None
        self.conductor: list                 = None
        self.I0: list                        = []
        self.T0: list                        = []
        self.radial_resistance               = []
        self.tau_list                        = []
        self.ref_radial_resistance_material  = None
        self.Tref_array                      = None
        self.Bref_array                      = None
        self.unique_regions                  = None
        self.magnet_mass                     = None
        self.names                           = None
        self.radial_position                 = []
        self.HP_positions: list              = None

        # Electical Matrix
        self.mutual  = None
        self.A       = None
        self.B       = None
        self.C       = None
        self.D       = None
        self.ABCD    = None
        self.inv     = None
        self.inv1    = None
        self.inv2    = None
        self.con_index_array = None
        self.inductance_r_divisions: int = 1
        self.inductance_z_divisions: int = 1

        # ReBCO fit limits
        self.Imax: float = 2500.0
        self.Tmin: float = 2.0
        self.Tmax: float = 100.0
        self.Bmin: float = 0.0
        self.Bmax: float = 25.0
        self.Tn = 50
        self.Bn = 50
        self.In = 50

        # Soleno output
        self.soleno_coordinates: list    = None
        self.xmin: float                 = None
        self.xmax: float                 = None
        self.ymin: float                 = None
        self.ymax: float                 = None
        self.nx: int                     = None
        self.ny: int                     = None
        self.nR0: int                    = 1000
        self.soleno_xv                   = None
        self.soleno_yv                   = None
        self.soleno_xv_plot              = None
        self.soleno_yv_plot              = None
        self.xv                          = None
        self.yv                          = None
        self.V                           = None
        self.Br                          = None
        self.Bz                          = None
        self.Br_left                     = None
        self.Bz_left                     = None
        self.Br_conductor                = None
        self.Bz_conductor                = None
        self.Br_center                   = None
        self.Bz_center                   = None
        self.Br_full                     = None
        self.Bz_full                     = None
        self.Br_line                     = None
        self.Bz_line                     = None
        self.soleno_targets              = None
        self.soleno_targets_left         = None
        self.soleno_sources              = None

        # Output yaml content
        self.output_dict: list           = ['I_initial', 'T_initial', 'tau']
        self.Nr_of_Connections: int      = 0
        self.Nr_of_Nodes: int            = 0

        # Thermal
        self.T_con_inner = None
        self.T_con_outer = None
        self.Thermal_alpha = None
        self.Thermal_area = None
        self.Thermal_index = None
        self.T1_con = None
        self.T2_con = None

        # Figures
        self.line_density = 2.0
        self.custom_minmax = None
        self.custom_R0_xminmax = None
        self.custom_R0_yminmax_1 = None
        self.custom_R0_yminmax_2 = None
        self.custom_fig2_size = None

        self.colormap_name = 'coolwarm'
        self.rotate_90 = False
        self.mirror = False
        self.plot_lines = 'all'
        self.fit_func_name = 'fit'
        self.levels = 51
        self.contourtype = 'contourf'


    def __init__(self, input_yaml: PathLike, output_path: PathLike, *, profile: bool = False):
        """
        Initializes the NI_coil_geometry class and executes the complete
        geometry creation pipeline.

        This constructor orchestrates the entire geometry setup process:
        1. Loads input YAML configuration
        2. Creates output directories and logging infrastructure
        3. Validates input parameters
        4. Calculates derived geometry values
        5. Creates connection indices and initial conditions
        6. Generates electrical and thermal matrices
        7. Computes mutual inductance matrix
        8. Prepares post-processing dictionaries
        9. Exports results to YAML and plots

        Args:
            input_yaml: Path to the input YAML file containing coil geometry
                and simulation parameters
            output_path: Path where output files (YAML, plots, matrices) will
                be saved
            profile: If True, enables Python profiler to measure performance
                of geometry creation steps. Default is False.

        Raises:
            FileNotFoundError: If input_yaml does not exist
            ValueError: If input validation fails in test_input()
        """
        # Print the NIQCS ascii logo
        print_nicqs_logo()

        # Parameters
        self.input_yaml = Path(input_yaml)
        self.output_path = Path(output_path)
        self.profiler_enabled: bool = profile
        self.profiler: cProfile.Profile | None = (
            cProfile.Profile() if profile else None
        )

        # Members are declared in a separate method to keep __init__ readable.
        self.declare_members()

        # Loading the yaml file.
        self.load_yaml()

        # Count NI (non-insulated) coil elements.  After _normalize_to_coils_dict()
        # self.coils is always set and is the single source of truth.
        self.n_coil_elements = sum(
            1 for c in self.coils.values() if not c.get('insulated', False)
        )

        if self.profiler_enabled: self.profiler.enable()
        # Creating the folders.
        self.create_folders()

        # Start the logger once the output directory has been created
        self.start_logger()

        # Testing the input values
        self.test_input()

        # Doing initial calculations.
        self.calc_init_values()

        # Calculating the geometry.
        self.create_indices()

        self.create_initial_conditions()
        self.create_index_dictionary()
        self.create_geometry()

        # External insulated coils (must run after create_geometry so NI coil positions are available)
        if any(c.get('insulated', False) for c in self.coils.values()):
            self.create_geometry_from_dictionary()

        self._save_field_map()

        self.create_power_dissipation_matrix()
        self.create_thermal_node_pairs()
        self.create_radial_thermal_model()

        # For post-processing
        self.create_vtu_dictionary()

        # For the mass matrix solver:
        self.prepare_mass_matrix_variables() # Turned off for now.

        # Exporting the important variables
        self.save_yaml()

        # Making the plots.
        # Use the extended ABCD (NI + insulated) when available; it captures
        # the full system.  Fall back to the NI-only ABCD otherwise.
        abcd_ext = getattr(self, 'ABCD_ext', None)
        abcd_to_plot = abcd_ext if abcd_ext is not None else self.ABCD
        plot_ABCD(abcd_to_plot, self.output_path)

        # Storing the profile information:
        self.profile_dump()

        # Marking the process as finished.
        self.bool_finished = True

    def load_yaml(self):
        """
        Loads the input YAML file and sets class attributes from its contents.

        Reads the YAML configuration file and dynamically assigns each
        key-value pair as an attribute of the class instance. This allows
        flexible input specification without hardcoding all possible parameters.

        The YAML file typically contains:
        - Coil geometry (inner_radius, outer_radius, thickness, spacing)
        - Electrical properties (parallel, serial, circuit definitions)
        - Material specifications (conductor types, properties)
        - Initial conditions (I_initial, T_initial)
        - Solver settings (tolerances, time steps, stopping criteria)

        Returns:
            None

        Raises:
            FileNotFoundError: If input_yaml path does not exist
            yaml.YAMLError: If YAML file is malformed
        """

        # Reads the contents of the yaml file
        with self.input_yaml.open() as file:
            contents = yaml.load(file, Loader=yaml.FullLoader)

        # Sets the values in the class
        for i in contents:
            setattr(self, i, contents[i])

        # Ensure self.coils is always the single source of truth regardless
        # of which YAML format was used (old flat-array or new coils-dict).
        self._normalize_to_coils_dict()

        # Convert legacy flat tau_circuit / resistivity_circuit attrs into
        # circuit_config, or leave circuit_config as-is when new format used.
        self._normalize_circuit_config()

    def _normalize_to_coils_dict(self) -> None:
        """
        Converts the old flat-array YAML format into the unified coils dict.

        After this method runs self.coils is always populated and is the
        authoritative source for all per-coil geometry parameters.  Three
        input situations are handled:

        New format (all coils already in self.coils):
            Nothing to do — the YAML already uses the new format.

        Old NI-only format (flat arrays, no self.coils):
            Wraps each element i into a coils entry named 'NI_<i>' with
            'insulated: false'.

        Old hybrid format (flat NI arrays + self.coils for insulated coils):
            Prepends NI entries (NI_0, NI_1, …) to the existing insulated
            coil dict so the unified dict contains everything in order.

        After conversion the legacy flat arrays are cleared so that all
        downstream code reads exclusively from self.coils.  The arrays are
        rebuilt from the dict later in calc_init_values().
        """
        # New format: self.coils already contains NI coil entries.
        if self.coils is not None and self.inner_radius is None:
            return

        # Nothing to convert — no coil data at all (should not happen).
        if self.inner_radius is None:
            return

        import warnings
        warnings.warn(
            "Flat top-level coil arrays (inner_radius, outer_radius, etc.) "
            "are deprecated and will be removed in a future version.  Use "
            "the coils dict format instead, e.g.:\n"
            "  coils:\n"
            "    NI_0:\n"
            "      insulated: false\n"
            "      inner_radius: 0.1",
            DeprecationWarning,
            stacklevel=2,
        )

        n = len(self.inner_radius)
        ni_entries: dict = {}
        for i in range(n):
            entry: dict = {
                'insulated':    False,
                'inner_radius': self.inner_radius[i],
                'outer_radius': self.outer_radius[i],
                'thickness':    self.thickness[i],
                'turns':        self.turns[i]        if self.turns        is not None else 1,
                'serial':       self.serial[i]       if self.serial       is not None else 1,
                'parallel':     self.parallel[i]     if self.parallel     is not None else 1,
                'repeat':       self.repeat[i]       if self.repeat       is not None else 1,
                'spacing':      self.spacing[i]      if self.spacing      is not None else 0.0,
                'offset':       self.offset[i]       if self.offset       is not None else 0.0,
                'I_initial':    self.I_initial[i]    if self.I_initial    is not None else 1.0,
                'T_initial':    self.T_initial[i]    if self.T_initial    is not None else 5.0,
                'T_ref':        self.T_ref[i]        if self.T_ref        is not None else 4.5,
                'B_ref':        self.B_ref[i]        if self.B_ref        is not None else 0.0,
                'conductor':    self.conductor[i]    if self.conductor    is not None else 'Dummy',
                'circuit':      self.circuit[i]      if self.circuit      is not None else 1,
                'simp':         self.simp[i]         if self.simp         is not None else True,
                'sub_elements': self.sub_elements[i] if self.sub_elements is not None else 200,
            }
            # tau is optional — only include it when the old YAML provided it.
            if self.tau is not None:
                entry['tau'] = self.tau[i]
            ni_entries[f'NI_{i}'] = entry

        # Hybrid format: merge NI entries (first) with existing insulated coils.
        if self.coils is not None:
            self.coils = {**ni_entries, **self.coils}
        else:
            self.coils = ni_entries

        # Clear old flat arrays — self.coils is now the single source of truth.
        # calc_init_values() will rebuild whatever downstream code still needs.
        self.inner_radius = None
        self.outer_radius = None
        self.thickness    = None
        self.turns        = None
        self.serial       = None
        self.parallel     = None
        self.repeat       = None
        self.spacing      = None
        self.offset       = None
        self.I_initial    = None
        self.T_initial    = None
        self.T_ref        = None
        self.B_ref        = None
        self.conductor    = None
        self.circuit      = None
        self.simp         = None
        self.tau          = None
        self.sub_elements = None

    def _normalize_circuit_config(self) -> None:
        """
        Normalises circuit-level resistance parameters into self.circuit_config.

        Two input situations are handled:

        New format (circuit_config already set in YAML):
            Nothing to do — circuit_config is the authoritative source.

        Old format (tau_circuit / resistivity_circuit as flat top-level keys):
            Emits a one-time DeprecationWarning, merges the flat dicts into
            self.circuit_config keyed by circuit identifier, then clears the
            old attributes so that downstream code reads exclusively from
            circuit_config.

        circuit_config keys can be any hashable value (int or str) matching
        the circuit identifiers used in the coil definitions.  Values are dicts
        with optional keys:
          'tau'         -- characteristic time constant (seconds)
          'resistivity' -- turn-to-turn resistivity (Ohm*cm^2)
          'T_ref'       -- reference temperature override (K); takes priority
                          over per-coil T_ref values
          'B_ref'       -- reference field override (T); takes priority over
                          per-coil B_ref values

        Example new-format YAML entry::

            circuit_config:
              1:
                tau: 300.0
                T_ref: 4.2
                B_ref: 5.0
              2:
                resistivity: 1.0e-6
        """
        has_legacy = (
            self.tau_circuit is not None
            or self.resistivity_circuit is not None
        )

        if not has_legacy:
            # New format or nothing set — nothing to do.
            return

        import warnings
        warnings.warn(
            "tau_circuit and resistivity_circuit as top-level YAML keys are "
            "deprecated and will be removed in a future version.  Use "
            "circuit_config instead, e.g.:\n"
            "  circuit_config:\n"
            "    1:\n"
            "      tau: 300.0",
            DeprecationWarning,
            stacklevel=2,
        )

        if self.circuit_config is None:
            self.circuit_config = {}

        # Merge tau_circuit values into circuit_config.
        if self.tau_circuit is not None:
            for k, v in self.tau_circuit.items():
                self.circuit_config.setdefault(k, {})['tau'] = v
            self.tau_circuit = None

        # Merge resistivity_circuit values into circuit_config.
        if self.resistivity_circuit is not None:
            for k, v in self.resistivity_circuit.items():
                self.circuit_config.setdefault(k, {})['resistivity'] = v
            self.resistivity_circuit = None

    def save_yaml(self):
        """
        Saves output YAML files needed for the NI coil solver.

        Creates three YAML files in the output directory:
        1. solver_input.yaml: Circuit data and initial conditions
        2. geometry.yaml: Complete geometry specification including matrices,
           indices, and physical properties
        3. solver_settings.yaml: ODE solver parameters and stopping criteria

        The geometry file contains references to pickle files for large matrices
        (ABCD, mutual inductance, thermal connections, magnetic field grids).

        Files solver_input.yaml and solver_settings.yaml are only overwritten
        if they don't exist or if self.overwrite_yaml is True. This prevents
        accidentally overwriting user-modified circuit definitions or solver
        settings. The geometry.yaml file is always overwritten since it's
        generated from the geometry creation process.

        Returns:
            bool: True upon successful file creation
        """

        dictionary_geometry = {
            'geometry_version': GEOMETRY_VERSION,
            'Nr_of_Nodes': self.Nr_of_Nodes,
            'Nr_of_Connections': self.Nr_of_Connections,
            'Nr_of_Connections_insulated': self.Nr_of_Connections_insulated,
            'serial': self.serial,
            'parallel': self.parallel,
            'conductor': self.conductor,
            'specific_output_path': str(self.output_path),
            'electrical_matrix_path': str(self.output_path / 'ABCD.pkl'),
            'electrical_matrix_insulated_path': str(self.output_path / 'ABCD_insulated.pkl'),
            'mutual_inductance_matrix_path': str(self.output_path / 'mutual.pkl'),
            'mutual_inductance_matrix_insulated_path': str(self.output_path / 'mutual_insulated.pkl'),
            'Bz_grid_path': str(self.output_path / 'Bz_grid.pkl'),
            'Br_grid_path': str(self.output_path / 'Br_grid.pkl'),
            'Bz_conductor_path': str(self.output_path / 'Bz_conductor.pkl'),
            'Br_conductor_path': str(self.output_path / 'Br_conductor.pkl'),
            'Bz_peak_path': str(self.output_path / 'Bz_peak.pkl'),
            'Br_peak_path': str(self.output_path / 'Br_peak.pkl'),
            'Bz_center_path': str(self.output_path / 'Bz_center.pkl'),
            'Br_center_path': str(self.output_path / 'Br_center.pkl'),
            'Bz_full_path': str(self.output_path / 'Bz_full.pkl'),
            'Br_full_path': str(self.output_path / 'Br_full.pkl'),
            'power_dis_matrix_path': str(self.output_path / 'power_dis_matrix.pkl'),
            'thermal_connections_matrix_path': str(self.output_path / 'thermal_connections_matrix.pkl'),
            'T_con_inner': self.T_con_inner,
            'T_con_outer': self.T_con_outer,
            'Thermal_alpha': self.Thermal_alpha,
            'Thermal_area': self.Thermal_area,
            'Thermal_index': self.Thermal_index,
            'T1_con': self.T1_con,
            'T2_con': self.T2_con,
            'Tref': self.T_ref,
            'Bref': self.B_ref,
            'positions_center': [[[i.item() for i in j] for j in k] for k in self.positions_center],
            'positions_outer': [[[[i.item() for i in j] for j in k] for k in l] for l in self.positions_outer],
            'HP_positions': self.HP_positions,
            'turns_per_element': np.array(self.turns_per_element).tolist(),
            'volumes': np.array(self.volumes).tolist(),
            'cross_sections': np.array(self.cross_sections).tolist(),
            'cross_sections_inner': np.array(self.cross_sections_inner).tolist(),
            'cross_sections_outer': np.array(self.cross_sections_outer).tolist(),
            'masses': np.array(self.masses).tolist(),
            'magnet_mass': float(self.magnet_mass),
            'conductor_list': np.array(self.conductor_list).tolist(),
            'Conductor_type': self.Conductor_type,
            'Conductor_SC_properties': self.Conductor_SC_properties,
            'Conductor_materials': self.Conductor_materials,
            'Conductor_materials_cross_section': self.Conductor_materials_cross_section,
            'Conductor_resistor': self.Conductor_resistor,
            'Conductor_conductivity_materials': self.Conductor_conductivity_materials,
            'Conductor_conductivity_thickness': self.Conductor_conductivity_thickness,
            'Conductor_conductivity_width': self.Conductor_conductivity_width,
            'lengths': np.array(self.lengths).tolist(),
            'unique_regions': self.unique_regions,
            'ref_radial_resistance_material': self.ref_radial_resistance_material,
            'Tref_array': self.Tref_array,
            'Bref_array': self.Bref_array,
            'Index_radial_connections': self.Index_radial_connections,
            'Index_inductive_connections': self.Index_inductive_connections,
            'Index_struct_connections': self.Index_struct_connections,
            'Index_normal_connections': self.Index_normal_connections,
            'Index_temperatures': self.Index_temperatures,
            'Index_radial_connections_region': self.Index_radial_connections_region,
            'Index_inductive_connections_region': self.Index_inductive_connections_region,
            'Index_struct_connections_region': self.Index_struct_connections_region,
            'Index_normal_connections_region': self.Index_normal_connections_region,
            'Index_temperatures_region': self.Index_temperatures_region,
            'Index_initial_Iind': self.Index_initial_Iind,
            'Index_initial_IR': self.Index_initial_IR,
            'Index_initial_T': self.Index_initial_T,
            'element_to_coil_nr': self.element_to_coil_nr,
            'thermal_element_to_coil_nr': self.thermal_element_to_coil_nr,
            'thermal_element_to_solenoid_nr': self.thermal_element_to_solenoid_nr,
            'positions': self.positions,
            'Index_post_inductive': self.Index_post_inductive,
            'Index_post_resistive': self.Index_post_resistive,
            'Index_post_thermal': self.Index_post_thermal,
            'Index_post': self.Index_post,
            'index_dict': self.index_dict,
            'inductance_dict': self.inductance_dict,
            'b_V_array_index': [i.item() for i in self.b_V_array_index],
            'b_dIdt_array_index': [i.item() for i in self.b_dIdt_array_index],
            'grid_information': [self.xmin, self.xmax, self.ymin, self.ymax, self.nx, self.ny],
            'names': self.names,
            'I0': self.I0,
            'T0': self.T0,
            'radial_resistance': self.radial_resistance,
            'tau_list': self.tau_list,
            'circuits': self.circuits,
            'unique_circuits': self.unique_circuits,
            'circuits_list': self.circuits_list,
            'signs': self.signs,
            'vtu_info': self.vtu_info,
            'seed_points': self.seed_points,
        }

        # Advertise extended (insulated-coil) matrix file paths so the solver
        # can find them without re-inverting ABCD_ext at startup.
        if hasattr(self, 'ABCD_ext'):
            _Ne_ext = len(self.index_dict.get('insulated_coils', {}))
            dictionary_geometry['Nr_of_Connections_ext'] = _Ne_ext
            # Override the NI-only mutual path with the combined (Nc+Ne)^2
            # inductance matrix so the solver can use a single unified current
            # vector I = [I_NI | I_ext] for energy calculations everywhere.
            dictionary_geometry['mutual_inductance_matrix_path'] = str(
                self.output_path / 'mutual_combined.pkl')
            dictionary_geometry['ABCD_ext_path'] = str(
                self.output_path / 'ABCD_ext.pkl')
            dictionary_geometry['inv1_ext_path'] = str(
                self.output_path / 'inv1_ext.pkl')
            dictionary_geometry['inv2_ext_path'] = str(
                self.output_path / 'inv2_ext.pkl')
            # B-field matrices are only written when the corresponding flag is
            # set, so guard their paths accordingly.
            if hasattr(self, 'ext_coils_Bz_conductor'):
                dictionary_geometry['ext_coils_Bz_conductor_path'] = str(
                    self.output_path / 'ext_coils_Bz_conductor.pkl')
                dictionary_geometry['ext_coils_Br_conductor_path'] = str(
                    self.output_path / 'ext_coils_Br_conductor.pkl')
            # Combined full spatial-grid matrix [NI | ext], shape:
            # n_grid_points x (Nc+Ne).  Used in VTU rendering together with
            # I_combined = [I_NI | I_ext], matching the unified-current design
            # used everywhere else (mutual_combined, Bz_Conductor_combined).
            if hasattr(self, 'ext_coils_Bz_full'):
                dictionary_geometry['Bz_full_combined_path'] = str(
                    self.output_path / 'Bz_full_combined.pkl')
                dictionary_geometry['Br_full_combined_path'] = str(
                    self.output_path / 'Br_full_combined.pkl')
        else:
            dictionary_geometry['Nr_of_Connections_ext'] = 0

        dictionary_solver_settings = {
            'solver': self.solver,
            'dt': self.dt,
            't0': self.t0,
            'atol': self.atol,
            'rtol': self.rtol,
            'min_step': self.min_step,
            'max_step': self.max_step,
            'sampling_time_dI': self.sampling_time_dI,
            'sampling_time_dT': self.sampling_time_dT,
            'max_timestep': self.max_timestep,
            'min_timestep': self.min_timestep,
            'keep_smallest_timestep': self.keep_smallest_timestep,
            'electrical_part': self.electrical_part,
            'thermal_part': self.thermal_part,
            'thermal_timer': self.thermal_timer,
            'stop_criterion_E': self.stop_criterion_E,
            'stop_criterion_T': self.stop_criterion_T,
            'stop_on_E': self.stop_on_E,
            'stop_on_T': self.stop_on_T,
            'colormap_name': self.colormap_name,
            'fit_func_name': self.fit_func_name,
        }

        dictionary_circuits = {}
        current_per_circuit = get_circuit_currents(self.I_initial, self.circuit)
        # Extend current_per_circuit with ext coil circuits so the loop
        # below works uniformly for NI and insulated coils.  If a circuit
        # is shared between both coil types the NI entry already present
        # takes precedence (same PSU, same ramp target current).
        # Add circuits for insulated coils that are not already covered by NI circuits.
        _max_ni_circ = max(self.unique_circuits) if self.unique_circuits else 0
        for name, c in self.coils.items():
            if not c.get('insulated', False):
                continue
            circ = int(c.get('circuit', _max_ni_circ + 1))
            if circ not in current_per_circuit:
                current_per_circuit[circ] = float(c.get('I_initial', 0.0))
        for i in self.unique_circuits:
            dictionary_circuits[i] = {'t': [0, 10.0, 10.0, 100.0], 'dIdt': [current_per_circuit[i]/10.0, current_per_circuit[i]/10.0, 0.0, 0.0]}

        path_dict = {
            'specific_output_path': str(self.output_path),
            'geometry_yaml_path': str(self.output_path / "geometry.yaml"),
            'solver_settings_path': str(self.output_path / "solver_settings.yaml")
        }

        dictionary_input = {
            'circuit_data': dictionary_circuits,
            'initial_conditions': self.initial_conditions,
            'path_dict': path_dict,
        }

        if not (self.output_path / "solver_input.yaml").is_file() or self.overwrite_yaml:
            with (self.output_path / "solver_input.yaml").open("w") as f:
                yaml.dump(dictionary_input, f, default_flow_style=False)
        else:
            logging.warning(
                "solver_input.yaml already exists and was not overwritten. "
                "Set self.overwrite_yaml to True to overwrite."
            )

        with (self.output_path / "geometry.yaml").open("w") as f:
            yaml.dump(dictionary_geometry, f, default_flow_style=False)

        if not (self.output_path / "solver_settings.yaml").is_file() or self.overwrite_yaml:
            with (self.output_path / "solver_settings.yaml").open("w") as f:
                yaml.dump(dictionary_solver_settings, f, default_flow_style=False)
        else:
            logging.warning(
                "solver_settings.yaml already exists and was not overwritten. "
                "Set self.overwrite_yaml to True to overwrite."
            )

        return True

    def test_input(self):
        """
        Validates input parameters for basic consistency and physical validity.

        Performs sanity checks on geometry input arrays:
        - Verifies inner_radius and outer_radius arrays have matching lengths
        - Checks that inner_radius < outer_radius for each coil element

        Validation failures are logged as warnings. This is a lightweight check;
        more detailed validation occurs during geometry creation when actual
        values are used in calculations.

        Returns:
            None

        Side Effects:
            Logs warnings for each validation failure detected
        """

        failed = 0

        # Read radii directly from the coils dict (self.coils is always set
        # after _normalize_to_coils_dict() and contains all coil types).
        ni_coils = [c for c in self.coils.values() if not c.get('insulated', False)]
        inner = [float(c['inner_radius']) for c in ni_coils]
        outer = [float(c['outer_radius']) for c in ni_coils]

        if len(inner) != len(outer):
            logging.warning('Not all input arrays have the same length')
            failed += 1

        for r_in, r_out in zip(inner, outer):
            if r_in >= r_out:
                logging.warning('An input radius is larger or equal than an outer radius.')
                failed += 1

    def calc_init_values(self):
        """
        Calculates derived values and sets defaults for missing parameters.

        This method performs several initialization tasks:
        1. Sets default values for optional input parameters (offset, T_initial,
           I_initial, spacing, parallel, serial, repeat, sub_elements, etc.)
        2. Creates matplotlib figure and axis objects for visualization
        3. Identifies unique conductor regions from Conductor_type dictionary
        4. Calculates conductor cross-sections from thickness and width if not
           explicitly provided
        5. Computes stored energy volume for each coil element
        6. Calculates and logs the total magnet mass based on material densities
           and geometry

        All defaults and calculated values are logged at INFO level for
        transparency. Default values are chosen to be physically reasonable
        for typical NI-HTS magnet configurations.

        Returns:
            None

        Side Effects:
            - Sets multiple class attributes to default or calculated values
            - Logs info messages for each defaulted parameter
            - Disables numpy divide-by-zero warnings
        """

        np.seterr(divide='ignore')

        # Build all per-NI-coil lists from the unified coils dict.
        # self.coils is always set after _normalize_to_coils_dict() runs in
        # load_yaml(), so this block replaces the old "if None: set default"
        # pattern and works for both old and new YAML formats.
        ni_coils = [(n, c) for n, c in self.coils.items()
                    if not c.get('insulated', False)]

        self.inner_radius = [float(c['inner_radius']) for _, c in ni_coils]
        self.outer_radius = [float(c['outer_radius']) for _, c in ni_coils]
        self.thickness    = [float(c['thickness'])    for _, c in ni_coils]
        self.turns        = [int(c.get('turns',        1))   for _, c in ni_coils]
        self.serial       = [int(c.get('serial',       1))   for _, c in ni_coils]
        self.parallel     = [int(c.get('parallel',     1))   for _, c in ni_coils]
        self.repeat       = [int(c.get('repeat',       1))   for _, c in ni_coils]
        self.sub_elements = [int(c.get('sub_elements', 200)) for _, c in ni_coils]
        self.spacing      = [float(c.get('spacing',  0.0)) for _, c in ni_coils]
        self.offset       = [float(c.get('offset',   0.0)) for _, c in ni_coils]
        self.I_initial    = [float(c.get('I_initial', 1.0)) for _, c in ni_coils]
        self.T_initial    = [float(c.get('T_initial', 5.0)) for _, c in ni_coils]
        self.T_ref        = [float(c.get('T_ref',     4.5)) for _, c in ni_coils]
        self.B_ref        = [float(c.get('B_ref',     0.0)) for _, c in ni_coils]
        self.circuit      = [int(c.get('circuit',      1))  for _, c in ni_coils]
        self.simp         = [bool(c.get('simp',      True)) for _, c in ni_coils]
        self.conductor    = [str(c.get('conductor', 'Dummy')) for _, c in ni_coils]

        # tau is optional.  Use None for coils that do not set it so that
        # mode-selection can distinguish "not set" from a numeric value —
        # required when some coils use tau and others use resistivity (mixed
        # mode).  The old behaviour used 10.0 as a silent fallback, which
        # would mask the missing value.
        if any('tau' in c for _, c in ni_coils):
            self.tau = [
                float(c['tau']) if 'tau' in c else None
                for _, c in ni_coils
            ]
        else:
            self.tau = None

        # Per-coil resistivity from the coil dicts.  Only populated when the
        # top-level YAML has not already set self.resistivity (old flat format),
        # so backward-compatible behaviour is preserved.
        if self.resistivity is None and any(
            'resistivity' in c for _, c in ni_coils
        ):
            self.resistivity = [
                float(c['resistivity']) if 'resistivity' in c else None
                for _, c in ni_coils
            ]

        # Pre-compute the inductance scaling factor for every NI coil and store
        # it in the coil dict so that inductance calculations can read it from
        # the dict rather than calling a method with flat-array indices.
        for _, c in ni_coils:
            c['solenoid_scaler'] = get_solenoid_scaler(
                turns=int(c.get('turns', 1)),
                serial=int(c.get('serial', 1)),
                parallel=int(c.get('parallel', 1)),
                no_parallel=self.no_parallel,
            )

        # Fall back to a dummy conductor definition when nothing was specified.
        if self.Conductor_type is None:
            logging.info('!! Conductor not provided, setting it to "Dummy". !!')
            self.Conductor_type = {'Dummy': 'Normal'}
            self.Conductor_materials = {'Dummy': ['Cu100']}
            self.Conductor_materials_width = {'Dummy': 0.001}
            self.Conductor_materials_thickness = {'Dummy': [0.001]}
            self.Conductor_materials_cross_section = {'Dummy': [0.001]}
            self.Conductor_resistor = {'Dummy': 'Cu100'}

        if self.bool_minimal_output:
            self.mag_field_left_bool = False
            self.mag_field_conductor_bool = True
            self.mag_field_on_grid_bool = True
            self.mag_field_r_zero_bool = False
            self.mag_field_single_points_bool = False

        if self.no_map:
            self.mag_field_on_grid_bool = False

        if self.r_zero:
            self.mag_field_r_zero_bool = True

        if self.HP_positions is not None and type(self.HP_positions) == list:
            self.mag_field_hall_probes_bool = True

        self.multiplicity = create_dictionaries(self.turns, self.serial)

        self.unique_regions = list(self.Conductor_type.keys())

        if self.Conductor_materials_cross_section is None:
            if self.Conductor_materials_thickness is not None and self.Conductor_materials_width is not None:
                self.Conductor_materials_cross_section = dict()
                for i in self.Conductor_materials_thickness.keys():
                    self.Conductor_materials_cross_section[i] = [k.item() for k in np.array([np.float64(j) for j in self.Conductor_materials_thickness[i]]) * np.float64(self.Conductor_materials_width[i])]
            else:
                logging.info('!! Conductor_materials_thickness and or Conductor_materials_width not provided !!')

        # Calculat the volume used to calculate the stored energy
        self.calculate_stored_energy_volume()

        # Calculating the mass of the coil.
        self.magnet_mass = 0.0
        for i in range(self.n_coil_elements):
            volume = self.repeat[i] * np.pi * (self.outer_radius[i]**2 - self.inner_radius[i]**2) * self.thickness[i]
            mat_thickness = np.array([np.float64(j) for j in self.Conductor_materials_thickness[self.conductor[i]]])
            density_multiplier = mat_thickness/np.sum(mat_thickness) * np.array([MaterialProperties.density_selector(j) for j in self.Conductor_materials[self.conductor[i]]])
            magnet_mass = np.sum(density_multiplier * volume)
            logging.info('Mass of coil elements nr. ' + str(i) + ': ' + str(np.round(magnet_mass,3)) + ' kg')
            self.magnet_mass += magnet_mass

    def create_folders(self):
        """
        Creates the output directory structure and copies input file.

        Creates the output directory (and any necessary parent directories)
        if it doesn't already exist. Also copies the input YAML file to the
        output directory as 'input_yaml.yaml' for reproducibility and reference.

        Returns:
            None

        Side Effects:
            - Creates directory at self.output_path
            - Copies input YAML file to output_path/input_yaml.yaml
            - Prints success message to console
        """
        self.output_path.mkdir(parents=True, exist_ok=True)
        print(f"Successfully created the directory {self.output_path}")
        # Copying input yaml to output folder
        shutil.copyfile(self.input_yaml, self.output_path / 'input_yaml.yaml')

    def start_logger(self):
        """
        Initializes the logging system for geometry creation and solver output.

        Configures Python's logging module with:
        - INFO level logging
        - Output to both file (output_path/logfile) and console
        - Timestamp, log level, and message formatting

        The log file is overwritten (mode='w') on each run to avoid accumulating
        old log entries. This should only be called after create_folders() has
        created the output directory.

        Returns:
            None

        Side Effects:
            - Configures global logging with basicConfig
            - Creates logfile in output directory
            - Adds StreamHandler for console output
            - Logs initialization message with timestamp
        """
        # Starting the logging
        logging.basicConfig(level=logging.INFO, filename=self.output_path / "logfile", filemode="w",
                            format="%(asctime)-15s %(levelname)-8s %(message)s")
        logging.getLogger().addHandler(logging.StreamHandler())
        logging.info('Logging started after path creation.')

        logging.info('NI-Coil Model, Date: ' + str(datetime.now().strftime("%Y-%m-%d %H:%M")))
        time.sleep(0.2)

    def create_geometry(self):
        """
        Creates the 3D coil geometry and computes magnetic field distributions.

        This is the main geometry generation method that:
        1. Creates spatial meshgrid for field calculations (r-z plane)
        2. Generates center and outer positions for each coil element
        3. Calculates volumes, cross-sections, masses, and lengths
        4. Creates solenoid coordinates for each discretized element
        5. Builds indices mapping elements to coil and pancake numbers
        6. Generates electrical connection matrices (calls create_electrical_matrix)
        7. Computes magnetic field distributions on grid, conductor locations,
           and special points (center line, Hall probe positions)
        8. Calculates mutual inductance matrix between all elements
        9. Creates seed points for VTU visualization

        The geometry accounts for:
        - Multiple repeated pancakes with spacing
        - Serial and parallel subdivisions within each pancake
        - Vertical offset for each coil group
        - Material properties and conductor specifications

        The magnetic field is calculated using analytical solenoid formulas,
        discretizing each pancake into sub-elements for accurate field
        computation.

        Returns:
            None

        Side Effects:
            - Sets numerous geometry attributes (positions, volumes, etc.)
            - Creates electrical connection matrices (A, B, C, D, ABCD)
            - Computes and stores mutual inductance matrix
            - Calculates magnetic field arrays (Br, Bz) at various locations
            - Saves matrices to pickle files in output directory
        """
        self.soleno_xv, self.soleno_yv = np.meshgrid(np.linspace(self.xmin, self.xmax, self.nx), np.linspace(self.ymin, self.ymax, self.ny))

        if self.rotate_90:
            if self.mirror:
                self.soleno_xv_plot, self.soleno_yv_plot = np.meshgrid(np.linspace(self.ymin, self.ymax, self.ny), np.linspace(-self.xmax, self.xmax, 2*self.nx-1))
            else:
                self.soleno_xv_plot, self.soleno_yv_plot = np.meshgrid(np.linspace(self.ymin, self.ymax, self.ny), np.linspace(self.xmin, self.xmax, self.nx))
        else:
            if self.mirror:
                self.soleno_xv_plot, self.soleno_yv_plot = np.meshgrid(np.linspace(-self.xmax, self.xmax, 2*self.nx-1), np.linspace(self.ymin, self.ymax, self.ny))
            else:
                self.soleno_xv_plot, self.soleno_yv_plot = self.soleno_xv, self.soleno_yv

        self.Br = np.zeros(self.soleno_xv.shape)
        self.Bz = np.zeros(self.soleno_xv.shape)

        self.positions_center   = [[] for i in range(self.n_coil_elements)]
        self.positions_outer    = [[] for i in range(self.n_coil_elements)]
        self.positions_solenoids = [[] for i in range(self.n_coil_elements)]

        self.volumes            = []
        self.cross_sections     = []
        self.cross_sections_inner     = []
        self.cross_sections_outer     = []
        self.masses             = []
        self.conductor_list     = []
        self.turns_per_element  = []
        self.lengths            = []

        self.soleno_coordinates     = [[] for i in range(self.n_coil_elements)]
        self.soleno_targets         = []
        self.soleno_targets_left    = []
        self.soleno_sources         = []

        # Creating some indices.
        self.element_to_coil_nr = []
        self.inductance_element_to_coil_nr = []
        self.thermal_element_to_coil_nr = []
        self.thermal_element_to_solenoid_nr = []
        # Build an ordered list of NI coil (name, params) pairs once so that
        # both index loops below share the same iteration order.
        ni_coil_items = [
            (name, c) for name, c in self.coils.items()
            if not c.get('insulated', False)
        ]

        coil_nr = 0
        coil_ind_nr = 0
        s_nr = 0
        for i, (name, c) in enumerate(ni_coil_items):
            serial_i   = int(c.get('serial',   1))
            parallel_i = int(c.get('parallel', 1))
            repeat_i   = int(c.get('repeat',   1))
            self.thermal_element_to_solenoid_nr += [s_nr] * serial_i * repeat_i
            s_nr += 1
            for j in range(repeat_i):
                self.element_to_coil_nr          += [coil_nr]     * serial_i * parallel_i
                self.inductance_element_to_coil_nr += [coil_ind_nr] * serial_i * parallel_i
                self.thermal_element_to_coil_nr  += [coil_nr]     * serial_i
                coil_nr += 1
            coil_ind_nr += 1

        for i, (name, c) in enumerate(ni_coil_items):
            # Extract per-coil parameters directly from the coils dict so that
            # this loop does not depend on any flat array class attribute.
            inner_radius_i = float(c['inner_radius'])
            outer_radius_i = float(c['outer_radius'])
            thickness_i    = float(c['thickness'])
            turns_i        = int(c.get('turns',    1))
            serial_i       = int(c.get('serial',   1))
            parallel_i     = int(c.get('parallel', 1))
            repeat_i       = int(c.get('repeat',   1))
            spacing_i      = float(c.get('spacing', 0.0))
            offset_i       = float(c.get('offset',  0.0))
            conductor_key  = str(c.get('conductor', 'Dummy'))
            simp_i         = bool(c.get('simp', True))

            # Calculating the top:
            if repeat_i >= 1:
                top    =  0.5*(repeat_i*thickness_i + (repeat_i-1)*spacing_i) + offset_i
                bottom = -0.5*(repeat_i*thickness_i + (repeat_i-1)*spacing_i) + offset_i
            else:
                top    = 0.0 + offset_i
                bottom = 0.0 + offset_i

            dr = (outer_radius_i - inner_radius_i) / serial_i / 2.0
            dz = thickness_i / parallel_i / 2.0

            z_pos = np.linspace(0, thickness_i, parallel_i*2+1)[1::2]
            r_pos = np.linspace(inner_radius_i, outer_radius_i, serial_i*2+1)[1::2]

            R_in, R_out, thickn = inner_radius_i, outer_radius_i, 0.5*thickness_i

            # Inductive connections
            for j in range(repeat_i):
                self.Index_post.append([])

                # Calculating the outer positions of the individual pancake coils, mainly for plotting purposes:
                cntr = top - j * (thickness_i + spacing_i) - thickn
                self.positions_solenoids[i].append([[R_in, R_out, R_out, R_in, R_in], [cntr - thickn, cntr - thickn, cntr + thickn, cntr + thickn, cntr - thickn]])

                self.seed_information.append([R_in, cntr])

                for k in range(serial_i):
                    self.masses.append(0.0)
                    for l in range(parallel_i):
                        # Center position
                        self.positions_center[i].append([r_pos[k], top-z_pos[l]-j*(thickness_i + spacing_i)])

                        # Outer positions for plotting the coil positions
                        p = self.positions_center[i][-1]
                        r1, z1 = p[0] - dr, p[1] - dz
                        r2, z2 = p[0] - dr, p[1] + dz
                        r3, z3 = p[0] + dr, p[1] + dz
                        r4, z4 = p[0] + dr, p[1] - dz
                        self.positions_outer[i].append([[r1, r2, r3, r4, r1], [z1, z2, z3, z4, z1]])

                        # Post processing
                        self.positions.append([[r1.item(), r2.item(), r3.item(), r4.item()],[z1.item(), z2.item(), z3.item(), z4.item()]]) # This will be used for post-processing. All outer points of all elements.
                        self.Index_post[-1].append(len(self.positions)-1)

                        # Soleno
                        self.soleno_coordinates[i].append([r1, r3, z1, z2, turns_i/serial_i])
                        self.soleno_sources.append([r1, r3, z1, z2, turns_i/serial_i])
                        self.soleno_targets.append([p[0], p[1]])
                        self.soleno_targets_left.append([p[0]-dr, p[1]])
                        self.volumes.append(np.pi*(r3**2-r1**2)*(z2-z1))
                        self.cross_sections.append((r3-r1)*(z2-z1))
                        mat_thickness = np.array([np.float64(v) for v in self.Conductor_materials_thickness[conductor_key]])
                        density_multiplier = mat_thickness / np.sum(mat_thickness) * np.array([MaterialProperties.density_selector(v) for v in self.Conductor_materials[conductor_key]])
                        self.masses[-1] += np.sum(self.volumes[-1]*density_multiplier)
                        self.conductor_list.append(conductor_key)
                        self.turns_per_element.append(turns_i/serial_i)
                        self.lengths.append(self.turns_per_element[-1] * 2 * np.pi * self.positions_center[i][-1][0]) # Takes the latest element [-1] and position 0 is the radial position.

            # Radial connections
            for j in range(repeat_i):
                for k in range(serial_i):
                    if simp_i:
                        self.volumes.append(0.0)
                        self.turns_per_element.append(0.0)
                        self.lengths.append(0.0)
                        self.cross_sections.append((outer_radius_i-inner_radius_i)*thickness_i/serial_i) # This is in the azimuthal direction.
                        self.radial_position.append([r_pos[k]-dr, r_pos[k]+dr])
                        self.cross_sections_inner.append(2*np.pi*(r_pos[k]-dr) * thickness_i) # Inner surface area of the element (2*pi*r*t)
                        self.cross_sections_outer.append(2*np.pi*(r_pos[k]+dr) * thickness_i) # Outer surface area of the element (2*pi*r*t)
                    else:
                        for l in range(parallel_i):
                            self.volumes.append(0.0)
                            self.turns_per_element.append(0.0)
                            self.lengths.append(0.0)
                            self.cross_sections.append(0.0)
                            self.radial_position.append([r_pos[k] - dr, r_pos[k] + dr])
                            self.cross_sections_inner.append(2 * np.pi * (r_pos[k] - dr) * thickness_i / l)  # Inner surface area of the element (2*pi*r*t)
                            self.cross_sections_outer.append(2 * np.pi * (r_pos[k] + dr) * thickness_i / l) # Outer surface area of the element (2*pi*r*t)

        # Creating the electrical connections
        self.create_electrical_matrix()

        if self.mag_field_on_grid_bool:
            # Calculating the magnetic field on the grid.
            # Prepare grid
            grid_target = []
            for j in np.linspace(self.xmin, self.xmax, self.nx):
                for k in np.linspace(self.ymin, self.ymax, self.ny):
                    grid_target.append([j, k])
            
            # Calculate fields
            self.Br_full, self.Bz_full = soleno_calculate_field(
                self.soleno_sources, 
                grid_target,
                distance_limit=25.0,
                job_name="B-Field (Grid)     "
            )
            logging.info('Finished grid list magnetic field calculations. (1/6)')
            
            # Saving the variables
            var_dump(self.output_path, self.Br_full, 'Br_full')
            var_dump(self.output_path, self.Bz_full, 'Bz_full')

            # Making the plots
            index_offset = 0
            self.inductance_output_from_grid = np.zeros((len(self.inner_radius,)))
            self.inductance_current_index = []
            for i in list(range(self.n_coil_elements)):
                I = np.zeros((self.Br_full.shape[1],))
                if self.I_initial[i] == 0.0:
                    I_coil = 1.0
                else:
                    I_coil = self.I_initial[i]

                I[list(np.array(self.con_index_array_per_coil[i]) + index_offset - self.con_index_array_per_coil[i][0])] += I_coil / self.parallel[i]
                self.inductance_current_index.append([list(np.array(self.con_index_array_per_coil[i]) + index_offset - self.con_index_array_per_coil[i][0])])
                index_offset += len(self.con_index_array_per_coil[i])
                Br = np.dot(self.Br_full, I).reshape(self.xv.shape, order='F')
                Bz = np.dot(self.Bz_full, I).reshape(self.xv.shape, order='F')
                if abs(self.I_initial[i]) > 0.0:
                    self.Br += Br
                    self.Bz += Bz
                E = 0.5 * sum(sum((Br ** 2 + Bz ** 2) * self.V)) / (4 * np.pi * 1e-7)
                L = E / I_coil ** 2 * 2
                self.inductance_output_from_grid[i] = L
                logging.info('Coil element:' + str(i + 1))
                logging.info('E (from field map):' + str(E) + ' J')
                logging.info('L (from field map):' + str(L * 1e3) + ' mH')

            # Mutual inductance matrix
            self.mutual_output_from_grid = np.zeros((self.n_coil_elements, self.n_coil_elements))
            for i in range(self.n_coil_elements):
                for j in range(i, self.n_coil_elements):
                    if i == j:
                        self.mutual_output_from_grid[i, i] = self.inductance_output_from_grid[i]
                    else:
                        I = np.zeros((self.Br_full.shape[1],))
                        I[self.inductance_current_index[i]] += 1.0 / self.parallel[i]
                        I[self.inductance_current_index[j]] += 1.0 / self.parallel[i]

                        Br = np.dot(self.Br_full, I).reshape(self.xv.shape, order='F')
                        Bz = np.dot(self.Bz_full, I).reshape(self.xv.shape, order='F')

                        E = 0.5 * sum(sum((Br ** 2 + Bz ** 2) * self.V)) / (4 * np.pi * 1e-7)
                        L = (E * 2 - self.inductance_output_from_grid[i] - self.inductance_output_from_grid[j]) / 2
                        self.mutual_output_from_grid[i, j] = L
                        self.mutual_output_from_grid[j, i] = L
            np.savetxt(self.output_path / 'mutual_reduced_grid.csv', self.mutual_output_from_grid, delimiter=",")

            # Total inductance and stored energy:
            E_total = np.sum(0.5 * self.mutual_output_from_grid * np.outer(np.array(self.I_initial), np.array(self.I_initial)))
            L_total = np.sum(self.mutual_output_from_grid * np.outer(np.sign(self.I_initial), np.sign(self.I_initial)))

            logging.info('Total L (from field map):' + str(L_total) + ' H')
            logging.info('Total E (from field map):' + str(E_total * 1e-6) + ' MJ')

            var_dump(self.output_path, self.Br, 'Br_grid')
            var_dump(self.output_path, self.Bz, 'Bz_grid')
        else:
            logging.info('Skipped calculating the magnetic field on the grid. (1/6)')

        if self.mag_field_conductor_bool:
            # Calculating the magnetic field on the conductor.
            self.Br_conductor, self.Bz_conductor = soleno_calculate_field(
                self.soleno_sources,
                self.soleno_targets,
                job_name = "B-Field (Conductor)"
            )
            logging.info('Finished conductor magnetic field calculations. (2/6)')
            # Saving the variables
            var_dump(self.output_path, self.Br_conductor, 'Br_conductor')
            var_dump(self.output_path, self.Bz_conductor, 'Bz_conductor')
        else:
            logging.info('Skipped calculating the magnetic field in the center of the conductor elements. (2/6)')

        # Calculating the magnetic field on the conductor - Left side.
        if self.mag_field_left_bool:
            self.Br_left, self.Bz_left = soleno_calculate_field(
                self.soleno_sources,
                self.soleno_targets_left,
                job_name = "B-Field (Conductor Edges)"
            )
            logging.info('Finished peak magnetic field calculations. (3/6)')
            # Saving the variables
            var_dump(self.output_path, self.Br_left, 'Br_peak')
            var_dump(self.output_path, self.Bz_left, 'Bz_peak')
        else:
            logging.info('Skipped calculating the peak field (inner side of the volumes). (3/6)')

        if self.mag_field_r_zero_bool:
            # Calculating the magnetic field at r = 0
            # Central field:
            self.plot_central_field()

            # Prepare targets for centerline field computation
            line_target = []
            for k in np.linspace(self.ymin, self.ymax, 150):
                line_target.append([0, k])
            # Perform calculation
            self.Br_line, self.Bz_line = soleno_calculate_field(
                self.soleno_sources,
                line_target,
                job_name = "B-Field Centerline "
            )
            logging.info('Finished r=0 magnetic field calculations. (4/6)')
            # Saving the variables
            var_dump(self.output_path, self.Br_line, 'Br_line')
            var_dump(self.output_path, self.Bz_line, 'Bz_line')
        else:
            logging.info('Skipped calculating the r=0 magnetic field. (4/6)')

        if self.mag_field_single_points_bool:
            # Calculating the magnetic field on the centerpoint
            self.Br_center, self.Bz_center = soleno_calculate_field(
                self.soleno_sources,
                [[0.0, 0.0]],
                job_name=None  # Suppress output for this very short job
            )
            logging.info('Finished single point magnetic field calculations. (5/6)')
            # Saving the variables
            var_dump(self.output_path, self.Br_center, 'Br_center')
            var_dump(self.output_path, self.Bz_center, 'Bz_center')

        else:
            logging.info('Skipped calculating the central magnetic field. (5/6)')

        if self.mag_field_hall_probes_bool:
            # Calculating the field on the hall probe locations.
            # Calculating the magnetic field on the centerpoint
            Br_HP, Bz_HP = soleno_calculate_field(
                self.soleno_sources,
                self.HP_positions,
                job_name=None  # Suppress output for this very short job
            )
            logging.info('Finished calculating the field on the hall probe locations. (6/6)')
            # Saving the variables
            var_dump(self.output_path, Br_HP, 'Br_HP')
            var_dump(self.output_path, Bz_HP, 'Bz_HP')
        else:
            logging.info('Skipped calculating the field on the hall probe locations. (6/6)')

        self.b_V_array_index = np.arange(self.Nr_of_Nodes, self.Nr_of_Nodes + self.Nr_of_Connections)
        self.b_dIdt_array_index = np.arange(0, self.Nr_of_Nodes)

        # Creates a list of radial resistance based on the time constant of the coils
        # self.radial_resistance = np.zeros((self.Nr_of_Connections,))

        # Ordered NI coil names — used to map the numeric coil index back to
        # the key from self.coils for human-readable logging below.
        ni_coil_names = [
            n for n, c in self.coils.items() if not c.get('insulated', False)
        ]

        # Radial resistance
        self.unique_circuits, self.circuits_list, self.circuits, self.circuit_coil_list, self.signs = create_circuit_information(self.ABCD, self.n_coil_elements, self.node_index_array_per_coil, self.circuit, self.I_initial)

        for i in self.unique_circuits:
            self.circuit_class_list[i] = circuit_class

        self.ref_radial_resistance_material = [''] * self.Nr_of_Connections
        self.Tref_array = [0.0] * self.Nr_of_Connections
        self.Bref_array = [0.0] * self.Nr_of_Connections

        # Preprocessing:
        # tau_circuit > resistivity_circuit > tau > resistivity.
        # circuit_config is the authoritative source for circuit-level params
        # (old flat attrs were already merged into it by load_yaml).  Extract
        # plain lookup dicts so the if-elif blocks below remain straightforward.
        tau_circuit_map: dict | None = None
        resistivity_circuit_map: dict | None = None
        if self.circuit_config is not None:
            _tau = {k: v['tau'] for k, v in self.circuit_config.items()
                    if 'tau' in v}
            tau_circuit_map = _tau if _tau else None
            _res = {k: v['resistivity'] for k, v in self.circuit_config.items()
                    if 'resistivity' in v}
            resistivity_circuit_map = _res if _res else None

        # Normalise per-coil tau and resistivity to lists of float-or-None so
        # that mixed mode (some coils set tau, others set resistivity) can be
        # detected and validated uniformly, regardless of input format.
        _tau_per_coil: list = (
            list(self.tau) if self.tau is not None
            else [None] * self.n_coil_elements
        )
        if self.resistivity is None:
            _res_per_coil: list = [None] * self.n_coil_elements
        elif isinstance(self.resistivity, (float, int, np.float32, np.float64)):
            # Scalar from old flat YAML format — broadcast to all coils.
            _res_per_coil = [float(self.resistivity)] * self.n_coil_elements
        else:
            _res_per_coil = list(self.resistivity)  # per-coil list, may have Nones

        # Remove circuit 0 from circuit-level maps if present.
        # Circuit 0 is reserved for non-powered elements.
        if tau_circuit_map is not None and 0 in tau_circuit_map:
            logging.warning('Circuit 0 detected in tau_circuit_map. Removing it as circuit 0 is reserved for non-powered elements.')
            tau_circuit_map = {k: v for k, v in tau_circuit_map.items() if k != 0}

        if resistivity_circuit_map is not None and 0 in resistivity_circuit_map:
            logging.warning('Circuit 0 detected in resistivity_circuit_map. Removing it as circuit 0 is reserved for non-powered elements.')
            resistivity_circuit_map = {k: v for k, v in resistivity_circuit_map.items() if k != 0}

        # Getting the inductance per circuit:
        # Getting the sign per coil element:
        sign = np.sign(self.I_initial)
        inductance = self.mutual_output * np.outer(sign, sign)  # Getting the correct mutual inductance.

        # Getting the inductance per circuit.
        self.inductance_dict = get_circuit_inductance(self.circuit, inductance)

        # Two-phase radial resistance calculation.
        #
        # Phase 1 handles circuits defined in circuit_config (tau or
        # resistivity set per circuit).  Phase 2 handles the remaining coils
        # using per-coil tau or resistivity from the coil dicts.  Both phases
        # fill the same array, so circuit_config and per-coil settings can
        # coexist in a single geometry without conflict.
        radial_resistance = np.zeros_like(self.lengths)  # filled by both phases
        tau_list = np.zeros_like(self.lengths)            # not used; kept for forward-compat
        circuits_handled: set = set()   # circuits processed in Phase 1
        any_resistance_set: bool = False

        # ------------------------------------------------------------------ #
        # Phase 1a — tau_circuit: tau set per circuit in circuit_config.      #
        # ------------------------------------------------------------------ #
        if tau_circuit_map is not None:
            any_resistance_set = True
            # Making sure that the keys are the same in both the inductance dictionary and in tau_circuit_map.
            matches = list(set(self.inductance_dict) & set(tau_circuit_map))
            mismatches = list(set(self.inductance_dict) ^ set(tau_circuit_map))
            if not mismatches:
                [print('\033[93mMismatch between inductance and tau dictionary for the following key:', i, '\033[0m') for i in mismatches]

            # Calculating the resistance corresponding to the circuit
            resistance = {}
            for i in matches:
                resistance[i] = self.inductance_dict[i] / tau_circuit_map[i]

            for i in matches:
                # Keep per-coil area arrays separate before concatenation so we
                # can split R_radial_list back per coil for detailed reporting.
                A_circ_per_coil = []
                m_array = []
                for j in self.circuit_coil_list[i]:
                    # circuit_coil_list contains a list of coils that are within that circuit.
                    # index with all inductive elements for that coil element
                    n = self.con_index_array_per_coil[j]  # Needed for the lengths
                    m_array.append(self.con_index_array_radial_per_coil[j])  # Needed for the locations on where to place the resistances.
                    A_circ_per_coil.append(np.average(np.array(self.lengths[n[0]:n[-1] + 1]).reshape(-1, self.parallel[j]), axis=1) * self.thickness[j] * 10000.0 / self.multiplicity[j] ** 2)
                A_circ = np.concatenate(A_circ_per_coil)  # Concatenate list of numpy array to one array.
                m_array = np.concatenate(m_array)  # Positions of all resistors.
                R_radial_list = list(distribute_inversely(resistance[i], A_circ))  # Distributes the resistance physically and evenly between magnets, assuming the same contact resistance * length of conductor for all of them.

                # Circuit-level summary: tau was set in the YAML; resistivity
                # is derived from the set tau and the circuit inductance.
                resistivity_circuit = float(np.mean(np.array(R_radial_list) * A_circ))
                logging.info(
                    '\033[96mCircuit %s [tau set]: tau = %.3E s, '
                    'resistivity = %.3E Ohm*cm^2 (calculated)\033[0m'
                    % (i, tau_circuit_map[i], resistivity_circuit)
                )

                # Per-coil breakdown: split the flat R_radial_list back by coil
                # (each coil's share has the same length as its A_circ_per_coil
                # entry) and report individual tau and resistivity.
                offset = 0
                for k, j in enumerate(self.circuit_coil_list[i]):
                    a_j = A_circ_per_coil[k]
                    r_j = R_radial_list[offset:offset + len(a_j)]
                    offset += len(a_j)
                    resistivity_j = float(np.mean(np.array(r_j) * a_j))
                    tau_j = float(self.mutual_output[j, j] / np.sum(r_j))
                    logging.info(
                        '  %s (circuit %s): '
                        'tau = %.3E s, resistivity = %.3E Ohm*cm^2'
                        % (ni_coil_names[j], i, tau_j, resistivity_j)
                    )

                # Check if the lists have the same length:
                if len(A_circ) == len(m_array) == len(R_radial_list):
                    print('\033[96mArray length check for circuit %i completed, all OK!\033[0m' % i)
                else:
                    print('\033[93mLength check in function \'distribute_resistance_circuit\' has failed for circuit %i.\033[0m' % i)

                # Putting the resistance back at the right spots in the list.
                radial_resistance[m_array] = R_radial_list
                circuits_handled.add(i)

        # ------------------------------------------------------------------ #
        # Phase 1b — resistivity_circuit: resistivity set per circuit.        #
        # ------------------------------------------------------------------ #
        if resistivity_circuit_map is not None:
            any_resistance_set = True
            resistance = {}
            tau_circuit = {}

            # This function distributes a set resistivity per circuit instead of per coil.
            for i in set(resistivity_circuit_map):
                # Keep per-coil area arrays separate before concatenation so we
                # can split R_radial_list back per coil for detailed reporting.
                A_circ_per_coil = []
                m_array = []
                for j in self.circuit_coil_list[i]: # TODO should be from the new index_dict.
                    # circuit_coil_list contains a list of coils that are within that circuit.
                    # index with all inductive elements for that coil element
                    n = self.con_index_array_per_coil[j]  # Needed for the lengths
                    m_array.append(self.con_index_array_radial_per_coil[j])  # Needed for the locations on where to place the resistances.
                    A_circ_per_coil.append(np.average(np.array(self.lengths[n[0]:n[-1] + 1]).reshape(-1, self.parallel[j]), axis=1) * self.thickness[j] * 10000 / self.multiplicity[j] ** 2)  # times 10000 to get cm^2 from m^2 and divided by the multiplicity of the coil.
                A_circ = np.concatenate(A_circ_per_coil)  # Concatenate list of numpy array to one array.
                m_array = np.concatenate(m_array)  # Positions of all resistors.
                R_radial_list = list(resistivity_circuit_map[i] * 1 / A_circ)  # Distributes the resistance physically and evenly between magnets, assuming the same contact resistance * length of conductor for all of them.

                # Check if the lists have the same length:
                if len(A_circ) == len(m_array) == len(R_radial_list):
                    print('\033[96mArray length check for circuit %i completed, all OK!\033[0m' % i)
                else:
                    print('\033[93mLength check in function \'distribute_resistance_circuit\' has failed for circuit %i.\033[0m' % i)

                # Circuit-level summary: resistivity was set in the YAML; tau
                # is derived from the set resistivity and the circuit inductance.
                resistance[i] = np.sum(R_radial_list)
                tau_circuit[i] = self.inductance_dict[i] / resistance[i]
                logging.info(
                    '\033[96mCircuit %s [resistivity set]: tau = %.5E s (calculated), '
                    'resistivity = %.5E Ohm*cm^2\033[0m'
                    % (i, tau_circuit[i], resistivity_circuit_map[i])
                )

                # Per-coil breakdown: split the flat R_radial_list back by coil
                # and report individual tau and resistivity for each element.
                offset = 0
                for k, j in enumerate(self.circuit_coil_list[i]):
                    a_j = A_circ_per_coil[k]
                    r_j = R_radial_list[offset:offset + len(a_j)]
                    offset += len(a_j)
                    resistivity_j = float(np.mean(np.array(r_j) * a_j))
                    tau_j = float(self.mutual_output[j, j] / np.sum(r_j))
                    logging.info(
                        '  %s (circuit %s): '
                        'tau = %.3E s, resistivity = %.3E Ohm*cm^2'
                        % (ni_coil_names[j], i, tau_j, resistivity_j)
                    )

                # Putting the resistance back at the right spots in the list.
                radial_resistance[m_array] = R_radial_list
                circuits_handled.add(i)

        # ------------------------------------------------------------------ #
        # Phase 2 — per-coil: handle coils in circuits not covered by Phase 1 #
        # and not in circuit 0 (non-powered).                                  #
        # Each coil uses whichever of tau or resistivity it has set.           #
        # ------------------------------------------------------------------ #
        _per_coil_indices = [
            i for i in range(self.n_coil_elements)
            if self.circuit[i] not in circuits_handled and self.circuit[i] != 0
        ]

        if _per_coil_indices:
            # Validate: every such coil must have exactly one of tau or
            # resistivity.  If both are set, prefer tau and warn.
            for _idx in _per_coil_indices:
                _t = _tau_per_coil[_idx]
                _r = _res_per_coil[_idx]
                if _t is None and _r is None:
                    raise ValueError(
                        'Coil "%s": neither tau nor resistivity is set. '
                        'Provide one in the coil dict or in circuit_config.'
                        % ni_coil_names[_idx]
                    )
                if _t is not None and _r is not None:
                    logging.warning(
                        'Coil "%s": both tau and resistivity are set; '
                        'using tau, resistivity ignored.', ni_coil_names[_idx]
                    )
                    _res_per_coil[_idx] = None

            any_resistance_set = True

            # Accumulate total resistance per circuit for the circuit summary.
            circuit_resistance: dict = {}

            for i in _per_coil_indices:
                n = self.con_index_array_per_coil[i]
                A_circ = (
                    np.average(
                        np.array(self.lengths[n[0]:n[-1] + 1]).reshape(
                            -1, self.parallel[i]
                        ),
                        axis=1,
                    )
                    * self.thickness[i] * 10000.0 / self.multiplicity[i] ** 2
                )

                tau_i = _tau_per_coil[i]
                res_i = _res_per_coil[i]

                if tau_i is not None:
                    # Tau was set — derive resistance, then back-calculate resistivity.
                    R_radial = self.mutual_output[i, i] / tau_i
                    Radial_resistance = distribute_inversely(R_radial, A_circ)
                    resistivity_i = float(np.mean(np.array(Radial_resistance) * A_circ))
                    logging.info(
                        '%s (circuit %s) [tau set]: '
                        'tau = %.3E s, resistivity = %.3E Ohm*cm^2 (calculated)'
                        % (ni_coil_names[i], self.circuit[i], tau_i, resistivity_i)
                    )
                else:
                    # Resistivity was set — distribute, then back-calculate tau.
                    Radial_resistance = res_i * 1 / A_circ
                    tau_i_derived = float(
                        self.mutual_output[i, i] / np.sum(Radial_resistance)
                    )
                    logging.info(
                        '%s (circuit %s) [resistivity set]: '
                        'tau = %.5E s (calculated), resistivity = %.5E Ohm*cm^2'
                        % (ni_coil_names[i], self.circuit[i], tau_i_derived, res_i)
                    )

                radial_resistance[self.con_index_array_radial_per_coil[i]] = Radial_resistance

                # Accumulate resistance into the circuit total.
                cid = self.circuit[i]
                circuit_resistance[cid] = (
                    circuit_resistance.get(cid, 0.0) + float(np.sum(Radial_resistance))
                )

            # Circuit-level summary for Phase 2 circuits.
            for cid, R_ckt in circuit_resistance.items():
                tau_ckt = self.inductance_dict[cid] / R_ckt
                # Mean resistivity across all radial connections in this circuit.
                r_idx = np.concatenate([
                    self.con_index_array_radial_per_coil[j]
                    for j in self.circuit_coil_list[cid]
                ]).astype(int)
                A_ckt = np.concatenate([
                    np.average(
                        np.array(
                            self.lengths[
                                self.con_index_array_per_coil[j][0]:
                                self.con_index_array_per_coil[j][-1] + 1
                            ]
                        ).reshape(-1, self.parallel[j]),
                        axis=1,
                    ) * self.thickness[j] * 10000.0 / self.multiplicity[j] ** 2
                    for j in self.circuit_coil_list[cid]
                ])
                rho_ckt = float(np.mean(radial_resistance[r_idx] * A_ckt))
                logging.info(
                    '\033[96mCircuit %s [mixed]: '
                    'tau = %.3E s (calculated), '
                    'resistivity = %.3E Ohm*cm^2 (calculated)\033[0m'
                    % (cid, tau_ckt, rho_ckt)
                )

        if not any_resistance_set:
            print('\033[93mNo input has been provided to calculated the turn-to-turn resistivity, nor the time constant. This may lead to unexpected behavior or failure of the simulation.\033[0m')

        self.radial_resistance = radial_resistance.tolist()
        self.tau_list = tau_list.tolist()

        # Generating an array with the reference material. R_radial will follow rho(T,B) of this material.
        for i in range(self.n_coil_elements):
            for j in [int(j) for j in self.con_index_array_radial_per_coil[i]]:
                self.ref_radial_resistance_material[j] = self.Conductor_resistor[self.conductor[i]]

        for i in range(self.n_coil_elements):
            for j in self.index_dict['coils'][i]['IR']:
                self.Tref_array[j] = self.T_ref[i]
                self.Bref_array[j] = self.B_ref[i]

        # Override per-coil T_ref / B_ref with circuit-level values when
        # provided in circuit_config.  Circuit-level values take priority
        # because a single circuit may contain coils that were individually
        # assigned different (possibly default) reference conditions, and the
        # user wants a uniform reference for the whole circuit.
        if self.circuit_config is not None:
            for i in range(self.n_coil_elements):
                cid = self.circuit[i]
                cfg = self.circuit_config.get(cid, {})
                t_ref_circuit = cfg.get('T_ref', None)
                b_ref_circuit = cfg.get('B_ref', None)
                if t_ref_circuit is not None or b_ref_circuit is not None:
                    for j in self.index_dict['coils'][i]['IR']:
                        if t_ref_circuit is not None:
                            self.Tref_array[j] = float(t_ref_circuit)
                        if b_ref_circuit is not None:
                            self.Bref_array[j] = float(b_ref_circuit)

        # Creates a list of connection types: superconducting 'SC', structural 'STRCT' and radial 'RAD'
        logging.info('Number of connections: ' + str(self.Nr_of_Connections))
        logging.info('Number of nodes: ' + str(self.Nr_of_Nodes))
        logging.info('Connections and nodes match matrix (' + str(self.ABCD.shape[0]) + ',' + str(self.ABCD.shape[0]) + '): ' + str((self.Nr_of_Connections + self.Nr_of_Nodes) == self.ABCD.shape[0]))
        time.sleep(0.2)

    def create_geometry_from_dictionary(self):
        """
        Creates geometry for external insulated magnets defined in the coils dictionary.

        Each entry in self.coils describes an externally applied insulated coil placed
        around (or near) the NI coil stack.  The method computes:

        1. Solenoid source descriptors for each external coil
           ([r_inner, r_outer, z_bottom, z_top, turns])
        2. Self-inductance and pairwise mutual inductance between external coils
           (stored in self.ext_coils_mutual_matrix and self.ext_coils_self_inductance)
        3. Mutual inductance between every external coil and every NI coil element,
           reduced to per-coil level (stored in self.ext_coils_ni_mutual_per_coil)
        4. Magnetic field produced by the external coils at the NI conductor locations
           (self.ext_coils_Br_conductor, self.ext_coils_Bz_conductor) and on the
           full field grid (self.ext_coils_Br_full, self.ext_coils_Bz_full)

        Expected coils dictionary format (from YAML):

            coils:
              my_coil:
                inner_radius: 0.05        # [m]
                outer_radius: 0.07        # [m]
                thickness:    0.010       # [m]
                turns:        200
                offset:       0.0         # [m], optional, z-axis centre offset
                inductance_r_divisions: 4  # optional, overrides global value
                inductance_z_divisions: 4  # optional, overrides global value

        Must be called after create_geometry() so that NI coil positions and
        soleno_targets are already available.

        Returns:
            None

        Side Effects:
            - Sets self.ext_coils_sources
            - Sets self.ext_coils_self_inductance
            - Sets self.ext_coils_mutual_matrix
            - Sets self.ext_coils_ni_mutual_per_coil
            - Optionally sets self.ext_coils_Br_conductor, self.ext_coils_Bz_conductor
            - Optionally sets self.ext_coils_Br_full, self.ext_coils_Bz_full
            - Saves CSV and pickle files to output directory
        """

        # Only process insulated coils — NI coils are handled by create_geometry().
        coil_names = [n for n, c in self.coils.items() if c.get('insulated', False)]
        n_ext = len(coil_names)
        logging.info(f'Processing {n_ext} external insulated coil(s) from coils dictionary.')

        # ------------------------------------------------------------------
        # 1. Parse parameters and build solenoid source descriptors
        #
        #    Supports repeat/spacing/offset like the main NI coil geometry.
        #    One solenoid source entry is pushed per repeat pancake so that
        #    soleno_calculate_field can account for individual pancake positions.
        # ------------------------------------------------------------------
        self.ext_coils_sources = []  # flat list: one [r_in, r_out, z_bot, z_top, turns] per pancake
        ext_params = []

        for name in coil_names:
            c = self.coils[name]
            r_in      = float(c['inner_radius'])
            r_out     = float(c['outer_radius'])
            thickness = float(c['thickness'])
            turns     = float(c['turns'])
            offset    = float(c.get('offset',  0.0))
            repeat    = int(c.get('repeat',    1))
            spacing   = float(c.get('spacing', 0.0))
            n_r = int(c.get('inductance_r_divisions', self.inductance_r_divisions))
            n_z = int(c.get('inductance_z_divisions', self.inductance_z_divisions))

            # Z positions for each pancake — same formula as create_geometry
            top    = 0.5 * (repeat * thickness + (repeat - 1) * spacing) + offset
            z_tops = [top - j * (thickness + spacing)             for j in range(repeat)]
            z_bots = [top - j * (thickness + spacing) - thickness for j in range(repeat)]

            for j in range(repeat):
                self.ext_coils_sources.append([r_in, r_out, z_bots[j], z_tops[j], turns])

            ext_params.append({
                'name':         name,
                'inner_radius': r_in,
                'outer_radius': r_out,
                'z_bots':       z_bots,
                'z_tops':       z_tops,
                'turns':        turns,
                'repeat':       repeat,
                'n_r':          n_r,
                'n_z':          n_z,
                'I_initial':    float(c.get('I_initial', 0.0)),
            })

        # Repeat counts and cumulative source indices (used for field grouping)
        repeat_counts = [p['repeat'] for p in ext_params]
        cum_reps = np.cumsum([0] + repeat_counts).astype(int)

        # ------------------------------------------------------------------
        # 2. Build section coordinates for analytic inductance calculation
        #
        #    Each pancake is represented by an (n_r × n_z) interior grid.
        #    All repeats of a coil are concatenated into one list so that
        #    inter-pancake mutual inductance is captured automatically.
        # ------------------------------------------------------------------
        def _section_coords(p):
            r_arr = np.linspace(p['inner_radius'], p['outer_radius'], p['n_r'] + 2)[1:-1]
            scale = p['turns']
            coords = []
            for z_bot, z_top in zip(p['z_bots'], p['z_tops']):
                z_arr = np.linspace(z_bot, z_top, p['n_z'] + 2)[1:-1]
                coords += [(r, z, scale) for r, z in itertools.product(r_arr, z_arr)]
            return coords

        ext_coords   = [_section_coords(p) for p in ext_params]
        ext_k        = [len(coords) for coords in ext_coords]          # total grid pts per coil
        k_per_rep    = [p['n_r'] * p['n_z']    for p in ext_params]    # grid pts per single pancake

        # Flatten all external coil coordinates into a single list
        all_ext   = list(itertools.chain.from_iterable(ext_coords))
        n_all_ext = len(all_ext)

        # ------------------------------------------------------------------
        # 3. Self-inductance and mutual inductance among external coils
        #
        #    Divisor is k_per_rep (grid points per single pancake), NOT ext_k
        #    (total grid points over all repeats).  This ensures the formula
        #    sums — rather than averages — across repeat pancakes, giving the
        #    correct total inductance for a series stack.
        # ------------------------------------------------------------------
        mutual_ext_raw = np.zeros((n_all_ext, n_all_ext))

        for i in range(n_all_ext):
            ri, zi, si = all_ext[i]
            for j in range(i, n_all_ext):
                rj, zj, sj = all_ext[j]
                if i == j:
                    val = loop_self_inductance(ri, 1e-3) * si * sj
                    mutual_ext_raw[i, j] = val
                else:
                    val = loop_mutual_inductance(ri, rj, zi, zj) * si * sj
                    mutual_ext_raw[i, j] = val
                    mutual_ext_raw[j, i] = val

        cum = np.cumsum([0] + ext_k)
        self.ext_coils_mutual_matrix = np.zeros((n_ext, n_ext))
        for i in range(n_ext):
            for j in range(n_ext):
                block = mutual_ext_raw[cum[i]:cum[i+1], cum[j]:cum[j+1]]
                self.ext_coils_mutual_matrix[i, j] = np.sum(block) / (k_per_rep[i] * k_per_rep[j])

        self.ext_coils_self_inductance = [self.ext_coils_mutual_matrix[i, i] for i in range(n_ext)]

        # ------------------------------------------------------------------
        # 4. Mutual inductance between external coils and NI coil elements
        #
        #    Divisor is k_per_rep[i] * k_ni so that contributions from every
        #    repeat pancake of the external coil are summed (not averaged).
        # ------------------------------------------------------------------
        # Build per-coil lookup for NI elements in the same order as create_geometry()
        # so that inductance_element_to_coil_nr indices remain valid.
        ni_coil_items_local = [
            (name, c) for name, c in self.coils.items()
            if not c.get('insulated', False)
        ]

        ni_coords = []
        k_ni_per_elem: list[int] = []
        for coil_idx, pos in enumerate(self.positions):
            # pos = [[r1, r2, r3, r4], [z1, z2, z3, z4]]
            coil_ind = self.inductance_element_to_coil_nr[coil_idx]
            coil_params = ni_coil_items_local[coil_ind][1]
            n_r_ni = int(coil_params.get('inductance_r_divisions', self.inductance_r_divisions))
            n_z_ni = int(coil_params.get('inductance_z_divisions', self.inductance_z_divisions))
            r_arr = np.linspace(pos[0][0], pos[0][2], n_r_ni + 2)[1:-1]
            z_arr = np.linspace(pos[1][0], pos[1][1], n_z_ni + 2)[1:-1]
            scale = coil_params['solenoid_scaler']
            ni_coords.append([(r, z, scale) for r, z in itertools.product(r_arr, z_arr)])
            k_ni_per_elem.append(n_r_ni * n_z_ni)

        n_ni_elem = len(self.positions)

        # Shape: (n_ext, n_ni_elem) — element-level, ready for ABCD matrix insertion
        self.ext_coils_ni_mutual = np.zeros((n_ext, n_ni_elem))

        for i in range(n_ext):
            ext_block = ext_coords[i]
            for j in range(n_ni_elem):
                ni_block = ni_coords[j]
                mut_sum = 0.0
                for (ri, zi, si) in ext_block:
                    for (rj, zj, sj) in ni_block:
                        mut_sum += loop_mutual_inductance(ri, rj, zi, zj) * si * sj
                self.ext_coils_ni_mutual[i, j] = mut_sum / (k_per_rep[i] * k_ni_per_elem[j])

        # Log results
        for i, name in enumerate(coil_names):
            logging.info(f'External coil "{name}": L = {self.ext_coils_self_inductance[i]*1e3:.3f} mH')
            # Only log NI mutual coupling when NI elements exist.
            if n_ni_elem > 0:
                logging.info(f'  Mutual with NI elements (min/max): '
                             f'{self.ext_coils_ni_mutual[i].min()*1e6:.3f} / '
                             f'{self.ext_coils_ni_mutual[i].max()*1e6:.3f} uH')

        # Save to CSV and pickle
        np.savetxt(self.output_path / 'ext_coils_mutual.csv',
                   self.ext_coils_mutual_matrix, delimiter=',')
        np.savetxt(self.output_path / 'ext_coils_ni_mutual.csv',
                   self.ext_coils_ni_mutual, delimiter=',')
        var_dump(self.output_path, self.ext_coils_mutual_matrix, 'ext_coils_mutual')
        var_dump(self.output_path, self.ext_coils_ni_mutual,     'ext_coils_ni_mutual')

        # ------------------------------------------------------------------
        # 5. Extend ABCD matrix with external insulated coil(s)
        #
        #    Layout of the extended matrix (column and row groups):
        #
        #    cols:          [NI conns: Nc]  [ext conns: Ne]  [NI nodes: Nn]  [ext nodes: Ne]
        #    rows: NI KCL   [     A      ]  [      0      ]  [     0      ]  [      0      ]
        #         ext KCL   [     0      ]  [     I_Ne    ]  [     0      ]  [      0      ]
        #         NI KVL    [     C      ]  [   cross_C   ]  [     D      ]  [      0      ]
        #         ext KVL   [ cross_C^T  ]  [    L_ext    ]  [     0      ]  [    -I_Ne    ]
        #
        #    KCL ext row: I_ext flows into the free node (positive terminal).
        #    KVL ext row: L·dI_ext/dt + M·dI_ni/dt − V⁺ = 0  (negative terminal grounded).
        # ------------------------------------------------------------------
        Nc = self.Nr_of_Connections
        Nn = self.Nr_of_Nodes
        Ne = n_ext
        new_size = Nc + Ne + Nn + Ne

        ABCD_ext = np.zeros((new_size, new_size))

        # -- KCL rows (top block) --
        # NI KCL: existing A at [0:Nn, 0:Nc]
        ABCD_ext[0:Nn, 0:Nc] = self.A
        # ext KCL: identity — each ext connection feeds its own free node
        ABCD_ext[Nn:Nn+Ne, Nc:Nc+Ne] = np.eye(Ne)

        # -- KVL rows (bottom block) --
        # NI KVL: existing mutual inductance at [Nn+Ne:Nn+Ne+Nc, 0:Nc]
        ABCD_ext[Nn+Ne:Nn+Ne+Nc, 0:Nc] = self.C  # = self.mutual

        # cross_C [Nc × Ne]: mutual inductance between each NI connection and each ext coil.
        # ext_coils_ni_mutual[i_ext, k] is mutual with NI position k;
        # con_index_array[k] gives the corresponding row/column index in self.mutual.
        cross_C = np.zeros((Nc, Ne))
        for i_ext in range(Ne):
            for k, conn_idx in enumerate(self.con_index_array):
                cross_C[conn_idx, i_ext] = self.ext_coils_ni_mutual[i_ext, k]

        # NI KVL: cross_C at [Nn+Ne:Nn+Ne+Nc, Nc:Nc+Ne]
        ABCD_ext[Nn+Ne:Nn+Ne+Nc, Nc:Nc+Ne] = cross_C
        # NI KVL: existing D (= -A^T) at [Nn+Ne:Nn+Ne+Nc, Nc+Ne:Nc+Ne+Nn]
        ABCD_ext[Nn+Ne:Nn+Ne+Nc, Nc+Ne:Nc+Ne+Nn] = self.D

        # ext KVL: symmetric cross coupling at [Nn+Ne+Nc:, 0:Nc]
        ABCD_ext[Nn+Ne+Nc:, 0:Nc] = cross_C.T
        # ext KVL: self and mutual inductance among ext coils at [Nn+Ne+Nc:, Nc:Nc+Ne]
        ABCD_ext[Nn+Ne+Nc:, Nc:Nc+Ne] = self.ext_coils_mutual_matrix
        # ext KVL: -I_Ne for grounded negative terminal at [Nn+Ne+Nc:, Nc+Ne+Nn:]
        ABCD_ext[Nn+Ne+Nc:, Nc+Ne+Nn:] = -np.eye(Ne)

        self.ABCD_ext = ABCD_ext
        self.inv_ext  = np.linalg.inv(ABCD_ext)
        self.inv1_ext = self.inv_ext[0:Nc+Ne, :]   # connection (I) solution rows
        self.inv2_ext = self.inv_ext[Nc+Ne:, :]    # node voltage (V) solution rows

        var_dump(self.output_path, self.ABCD_ext, 'ABCD_ext')
        pd.DataFrame(self.ABCD_ext).to_csv(self.output_path / 'ABCD_ext.csv', index=False)
        # Save the pre-inverted matrix slices so the solver doesn't need to
        # re-invert the large ABCD_ext matrix at startup.
        var_dump(self.output_path, self.inv1_ext, 'inv1_ext')
        var_dump(self.output_path, self.inv2_ext, 'inv2_ext')

        # Build and save the combined (Nc+Ne) x (Nc+Ne) inductance matrix so
        # the solver can treat NI and ext coil currents as a single unified
        # vector I = [I_NI | I_ext] for energy and B-field calculations.
        #
        # Block layout:
        #   [[  C (Nc x Nc)         cross_C (Nc x Ne)             ],
        #    [  cross_C.T (Ne x Nc) ext_coils_mutual (Ne x Ne)    ]]
        #
        # self.C contains the NI mutual inductance matrix (same as ABCD_ext
        # [Nn+Ne : Nn+Ne+Nc, 0 : Nc]).  cross_C holds the NI-ext coupling.
        L_combined = np.block([
            [self.C,      cross_C],
            [cross_C.T,   self.ext_coils_mutual_matrix],
        ])
        var_dump(self.output_path, L_combined, 'mutual_combined')

        logging.info(
            f'Extended ABCD matrix built: {new_size}×{new_size} '
            f'({Nc} NI connections + {Ne} ext connections, '
            f'{Nn} NI nodes + {Ne} grounded ext nodes)'
        )

        # Populate index_dict['insulated_coils'] now that Nc and Nn are known.
        # Indices follow the ABCD_ext connection ordering: NI conns [0..Nc-1],
        # ext conns [Nc..Nc+Ne-1] — the same convention as 'II'/'IR' for NI coils.
        # The 'circuit' key stores the power-supply circuit number so the solver
        # can look up the correct ramp profile for each insulated coil.
        # Fallback circuit number for ext coils that omit the 'circuit' key.
        # When there are no NI circuits (insulated-only geometry) use 1.
        _default_circ = (max(self.unique_circuits) + 1
                         if self.unique_circuits else 1)
        for i, name in enumerate(coil_names):
            circ = int(self.coils[name].get('circuit', _default_circ))
            self.index_dict['insulated_coils'][name] = {
                'I':        list(range(cum_reps[i], cum_reps[i + 1])),
                'II':       [Nc + i],
                'IR':       [],
                'node_pos': Nn + i,
                'circuit':  circ,
            }

        # ------------------------------------------------------------------
        # Register ext coil circuits in the same unique_circuits /
        # circuits_list / signs data structures as the NI coil circuits.
        # This lets the solver handle both kinds through one unified loop
        # without any ext-specific branching for the ramp-rate application.
        #
        # In b_ext the ext KCL row for ext coil i sits at index Nn+i
        # (after the Nn NI KCL rows).  We point circuits_list to that row
        # and set signs[Nn+i] = +1 (ext coils always ramped positively).
        # ------------------------------------------------------------------
        _signs_list = list(self.signs)
        for i, name in enumerate(coil_names):
            circ = self.index_dict['insulated_coils'][name]['circuit']
            ext_kcl_row = Nn + i   # row index inside b_ext
            # Extend the signs list if the current array is not long enough
            # (in practice Nn+i < Nn+Nc so this is a no-op).
            while len(_signs_list) <= ext_kcl_row:
                _signs_list.append(0)
            _signs_list[ext_kcl_row] = 1   # positive ramp sign

            # Register the circuit only once (multiple ext coils may share
            # a single power-supply circuit).
            if circ not in self.circuits_list:
                self.circuits_list[circ] = [ext_kcl_row]
                if circ not in self.unique_circuits:
                    self.unique_circuits.append(circ)
            else:
                if ext_kcl_row not in self.circuits_list[circ]:
                    self.circuits_list[circ].append(ext_kcl_row)
        self.signs = _signs_list

        # ------------------------------------------------------------------
        # 6. Magnetic field produced by external coils
        # ------------------------------------------------------------------

        # Helper: sum per-repeat columns → one column per external coil
        def _group_by_coil(B_raw):
            return np.column_stack([
                B_raw[:, cum_reps[i]:cum_reps[i+1]].sum(axis=1)
                for i in range(n_ext)
            ])

        # Field at NI conductor locations (needed by the solver for Lorentz force
        # and peak-field calculations)
        if self.mag_field_conductor_bool:
            Br_cond_raw, Bz_cond_raw = soleno_calculate_field(
                self.ext_coils_sources,
                self.soleno_targets,
                job_name='B-Field Ext (Conductor)'
            )
            self.ext_coils_Br_conductor = _group_by_coil(Br_cond_raw)
            self.ext_coils_Bz_conductor = _group_by_coil(Bz_cond_raw)
            var_dump(self.output_path, self.ext_coils_Br_conductor, 'ext_coils_Br_conductor')
            var_dump(self.output_path, self.ext_coils_Bz_conductor, 'ext_coils_Bz_conductor')
            logging.info('Finished external coil field calculation at conductor locations.')

        # Field on the full spatial grid
        if self.mag_field_on_grid_bool:
            grid_target = [
                [r, z]
                for r in np.linspace(self.xmin, self.xmax, self.nx)
                for z in np.linspace(self.ymin, self.ymax, self.ny)
            ]
            Br_grid_raw, Bz_grid_raw = soleno_calculate_field(
                self.ext_coils_sources,
                grid_target,
                distance_limit=25.0,
                job_name='B-Field Ext (Grid)    '
            )
            self.ext_coils_Br_full = _group_by_coil(Br_grid_raw)
            self.ext_coils_Bz_full = _group_by_coil(Bz_grid_raw)
            var_dump(self.output_path, self.ext_coils_Br_full, 'ext_coils_Br_full')
            var_dump(self.output_path, self.ext_coils_Bz_full, 'ext_coils_Bz_full')

            # Build combined full-grid matrices [NI | ext], shape:
            # n_grid_points x (Nc+Ne).  This lets the solver use a single
            # dot-product with the unified current vector I = [I_NI | I_ext],
            # consistent with mutual_combined and Bz_Conductor_combined.
            if self.Bz_full is not None:
                Bz_full_combined = np.hstack([self.Bz_full, self.ext_coils_Bz_full])
                Br_full_combined = np.hstack([self.Br_full, self.ext_coils_Br_full])
                var_dump(self.output_path, Bz_full_combined, 'Bz_full_combined')
                var_dump(self.output_path, Br_full_combined, 'Br_full_combined')

            logging.info('Finished external coil field calculation on the grid.')

    @staticmethod
    def _apply_field_transform(
        Br: np.ndarray,
        Bz: np.ndarray,
        rotate_90: bool,
        mirror: bool,
    ) -> tuple[np.ndarray, np.ndarray]:
        """
        Apply rotate_90 and mirror transforms to field arrays for plotting.

        When rotate_90 is True the axes are transposed so that the axial
        direction runs horizontally.  When mirror is True the half-geometry
        is reflected about the axis of symmetry to show the full cross-section.

        The sign convention for Br under mirroring is anti-symmetric (the
        radial field flips sign on the negative-r side), while Bz is
        symmetric.

        Args:
            Br: 2-D radial field array on the plot grid.
            Bz: 2-D axial field array on the plot grid.
            rotate_90: Whether the plot axes are rotated 90 degrees.
            mirror: Whether to mirror about the symmetry axis.

        Returns:
            (Br_plot, Bz_plot) ready for contour/streamline plotting.
        """
        if rotate_90:
            Br_plot = Br.transpose()
            Bz_plot = Bz.transpose()
            if mirror:
                Br_plot = np.vstack([-1 * np.flipud(Br_plot[1:, :]), Br_plot])
                Bz_plot = np.vstack([np.flipud(Bz_plot[1:, :]), Bz_plot])
        else:
            Br_plot = Br
            Bz_plot = Bz
            if mirror:
                Br_plot = np.hstack([-1 * np.fliplr(Br_plot[:, :-1]), Br_plot])
                Bz_plot = np.hstack([np.fliplr(Bz_plot[:, :-1]), Bz_plot])
        return Br_plot, Bz_plot

    def _set_field_map_axes(self, ax: matplotlib.axes.Axes) -> None:
        """
        Set axis labels and limits on a field map plot axes object.

        Labels and limits depend on whether the plot is rotated 90 degrees
        (axial direction horizontal) and whether the half-geometry is
        mirrored about the symmetry axis.

        Args:
            ax: The matplotlib Axes to configure.
        """
        if self.rotate_90:
            ax.set_xlabel('Axial position, m')
            ax.set_ylabel('Radial position, m')
            ax.set_xlim(self.ymin, self.ymax)
            ax.set_ylim(-self.xmax if self.mirror else self.xmin, self.xmax)
        else:
            ax.set_xlabel('Radial position, m')
            ax.set_ylabel('Axial position, m')
            ax.set_xlim(-self.xmax if self.mirror else self.xmin, self.xmax)
            ax.set_ylim(self.ymin, self.ymax)

    def _save_field_map(self) -> None:
        """
        Saves a single combined field map as Field_Map.svg.

        Produces one contour/streamline plot of the total magnetic field
        (NI coils + any external insulated coils).  NI coil outlines are
        drawn in black; insulated coil outlines (when present) are drawn
        in dashed blue.

        Called unconditionally from __init__ after all geometry steps so
        that exactly one Field_Map.svg is always produced.  Does nothing
        when mag_field_on_grid_bool is False (field matrices not computed).
        """
        if not self.mag_field_on_grid_bool:
            return

        # Combine NI field with external coil contributions (if any).
        has_ext = hasattr(self, 'ext_coils_Bz_full')
        if has_ext:
            ext_coil_names = [
                n for n, c in self.coils.items() if c.get('insulated', False)
            ]
            I_ext_vec = np.array([
                float(self.coils[n].get('I_initial', 0.0)) for n in ext_coil_names
            ])
            Br_combined = self.Br + self.ext_coils_Br_full.dot(I_ext_vec).reshape(
                self.xv.shape, order='F')
            Bz_combined = self.Bz + self.ext_coils_Bz_full.dot(I_ext_vec).reshape(
                self.xv.shape, order='F')
        else:
            Br_combined = self.Br
            Bz_combined = self.Bz

        fig, ax = plt.subplots()

        # NI coil outlines
        for i in range(self.n_coil_elements):
            src = (
                self.positions_outer[i]     if self.plot_lines == 'all'   else
                self.positions_solenoids[i] if self.plot_lines == 'outer' else []
            )
            if self.rotate_90:
                [ax.plot(j[1], j[0], color='black') for j in src]
                if self.mirror:
                    [ax.plot(j[1], -1 * np.array(j[0]), color='black') for j in src]
            else:
                [ax.plot(j[0], j[1], color='black') for j in src]
                if self.mirror:
                    [ax.plot(-1 * np.array(j[0]), j[1], color='black') for j in src]

        # Insulated coil outlines (dashed blue)
        if has_ext:
            for name in ext_coil_names:
                c = self.coils[name]
                r_in      = float(c['inner_radius'])
                r_out     = float(c['outer_radius'])
                thickness = float(c['thickness'])
                offset    = float(c.get('offset',  0.0))
                repeat    = int(c.get('repeat',    1))
                spacing   = float(c.get('spacing', 0.0))
                top = 0.5 * (repeat * thickness + (repeat - 1) * spacing) + offset
                for j in range(repeat):
                    z_top_j = top - j * (thickness + spacing)
                    z_bot_j = z_top_j - thickness
                    out_r = [r_in, r_out, r_out, r_in, r_in]
                    out_z = [z_bot_j, z_bot_j, z_top_j, z_top_j, z_bot_j]
                    if self.rotate_90:
                        ax.plot(out_z, out_r, color='blue', linestyle='--', linewidth=1.5)
                        if self.mirror:
                            ax.plot(out_z, [-r for r in out_r], color='blue',
                                    linestyle='--', linewidth=1.5)
                    else:
                        ax.plot(out_r, out_z, color='blue', linestyle='--', linewidth=1.5)
                        if self.mirror:
                            ax.plot([-r for r in out_r], out_z, color='blue',
                                    linestyle='--', linewidth=1.5)

        # Apply rotate/mirror transform to combined field arrays
        Br_plot, Bz_plot = self._apply_field_transform(
            Br_combined, Bz_combined, self.rotate_90, self.mirror
        )

        if self.custom_minmax is not None:
            levels = np.linspace(
                self.custom_minmax[0], self.custom_minmax[1], self.levels)
        else:
            levels = np.linspace(
                0, np.max((Br_plot ** 2 + Bz_plot ** 2) ** 0.5), self.levels)

        B_mag = (Br_plot ** 2 + Bz_plot ** 2) ** 0.5
        if self.contourtype == 'contourf':
            CS = ax.contourf(self.soleno_xv_plot, self.soleno_yv_plot, B_mag,
                             cmap=self.colormap_name, levels=levels)
        else:
            CS = ax.contour(self.soleno_xv_plot, self.soleno_yv_plot, B_mag,
                            cmap=self.colormap_name, levels=levels, linewidths=2.0)

        if self.rotate_90:
            ax.streamplot(self.soleno_xv_plot, self.soleno_yv_plot,
                          Bz_plot, Br_plot, density=self.line_density, color='white')
        else:
            ax.streamplot(self.soleno_xv_plot, self.soleno_yv_plot,
                          Br_plot, Bz_plot, density=self.line_density, color='white')

        cbar = fig.colorbar(CS)
        cbar.ax.set_ylabel('Magnetic Field, T')

        self._set_field_map_axes(ax)

        ax.set_aspect('equal', 'box')
        fig.tight_layout()
        fig.savefig(self.output_path / 'Field_Map.svg', dpi=300)
        plt.close(fig)
        logging.info('Saved field map (Field_Map.svg).')

    def plot_central_field(self):
        """
        Plots the magnetic field along the central axis (r=0) of the coil.

        Calculates and visualizes the axial (Bz) magnetic field component
        along the centerline of the magnet system. The plot shows field
        magnitude vs. axial position, overlaid with the coil geometry outline
        on a twin axis for reference.

        The plot always uses axial position z on the x-axis and Bz on the
        left y-axis (ax2). A twin right y-axis (ax2b) overlays the coil
        cross-section with z on x and r on y (positive side only — mirroring
        is omitted as the system is axisymmetric). This orientation is fixed
        and independent of self.rotate_90.

        This method:
        1. Creates the figure and axes sized to the z/r aspect ratio
        2. Draws coil geometry outlines on ax2b (controlled by plot_lines)
        3. Creates target points along the z-axis (r=0)
        4. Computes Bz contribution from each coil element
        5. Sums contributions accounting for initial currents
        6. Plots field on ax2 and saves figure as 'Field_R0.svg'

        Custom axis limits (custom_R0_xminmax, custom_R0_yminmax_1/2) and
        figure size (custom_fig2_size) are respected when set.

        Returns:
            None

        Side Effects:
            - Saves Field_R0.svg to output directory
        """
        # --- Figure and axes creation ---
        # The R0 plot always has axial position z on the x-axis (matching the
        # field curve on ax2) and radial position r on the y-axis of ax2b.
        # This is independent of self.rotate_90, which only affects the field
        # map. The coil cross-section is shown for the positive-r side only
        # (axisymmetry makes mirroring redundant and visually cluttered).
        z_range = self.ymax - self.ymin
        r_range = self.xmax  # positive side only
        fig_height = 5.0
        fig_width = max(6.0, fig_height * z_range / r_range)
        figsize = (fig_width, fig_height)

        if self.custom_fig2_size is not None and len(self.custom_fig2_size) == 2:
            figsize = (self.custom_fig2_size[0], self.custom_fig2_size[1])

        fig2 = plt.figure(figsize=figsize)
        ax2 = fig2.add_subplot(111)
        ax2b = ax2.twinx()

        # --- Coil geometry outlines on the twin axis ---
        # j[0] = r (radial), j[1] = z (axial) — always plot z on x, r on y.
        for i in range(self.n_coil_elements):
            if self.plot_lines == 'all':
                [ax2b.plot(j[1], j[0], color='black', linewidth=2.0)
                 for j in self.positions_outer[i]]
            elif self.plot_lines == 'outer':
                [ax2b.plot(j[1], j[0], color='black', linewidth=2.0)
                 for j in self.positions_solenoids[i]]

        # --- Field calculation ---
        soleno_targets_R0 = [[0, i] for i in np.linspace(self.ymin, self.ymax, self.nR0)]

        Br, Bz = soleno_calculate_field(
            self.soleno_sources,
            soleno_targets_R0,
            job_name="B-Field Centerline "
        )

        index_offset = 0
        Bz_plot = np.zeros((Bz.shape[0],))
        for i in range(self.n_coil_elements):
            I = np.zeros((Bz.shape[1],))
            I_coil = self.I_initial[i]
            I[list(np.array(self.con_index_array_per_coil[i]) + index_offset - self.con_index_array_per_coil[i][0])] += I_coil / self.parallel[i]
            index_offset += len(self.con_index_array_per_coil[i])
            Bzt = np.dot(Bz, I)
            if abs(self.I_initial[i]) > 0.0:
                Bz_plot += Bzt

        # --- Axis formatting ---
        ax2.plot(np.linspace(self.ymin, self.ymax, self.nR0), Bz_plot, label='B$_z$, r=0')
        ax2.legend()
        ax2.set_xlabel('Axial position, m')
        ax2.set_ylabel('Magnetic Field, T')
        ax2b.set_ylabel('Radial position, m')

        # ax2b always has z on x and r on y (positive side only).
        ax2b.set_xlim(self.ymin, self.ymax)
        ax2b.set_ylim(0, self.xmax)

        if self.custom_R0_xminmax is not None:
            ax2b.set_xlim(self.custom_R0_xminmax[0], self.custom_R0_xminmax[1])

        if self.custom_R0_yminmax_1 is not None:
            ax2.set_ylim(self.custom_R0_yminmax_1[0], self.custom_R0_yminmax_1[1])

        if self.custom_R0_yminmax_2 is not None:
            ax2b.set_ylim(self.custom_R0_yminmax_2[0], self.custom_R0_yminmax_2[1])

        # Synchronize y-axis gridlines: LinearLocator places exactly N ticks
        # evenly across whatever the axis limits are, so with the same N on
        # both axes the gridlines land on the same horizontal positions.
        # Grid is drawn only from ax2 to avoid double lines.
        n_yticks = 6
        ax2.yaxis.set_major_locator(LinearLocator(n_yticks))
        ax2b.yaxis.set_major_locator(LinearLocator(n_yticks))
        ax2.grid(axis='y', linestyle='--', alpha=0.4)
        ax2b.grid(False)

        fig2.tight_layout()
        fig2.savefig(self.output_path / 'Field_R0.svg')
        plt.close(fig2)

    def create_indices(self):
        """
        Initializes region-based index dictionaries for electrical and
        thermal connections.

        Creates dictionary keys for each unique conductor region (material
        type) in the magnet. These dictionaries will be populated later to
        track:
        - Inductive connections (azimuthal current paths in pancakes)
        - Normal conducting connections (radial current paths)
        - Radial connections (turn-to-turn in superconductors)
        - Structural connections (radial in normal conductors)
        - Temperature nodes

        Region-based indexing enables efficient material property lookups
        during the solver phase, as all elements of the same conductor type
        share common material properties (Ic, resistivity, thermal properties).

        Returns:
            None

        Side Effects:
            - Adds empty list entries to Index_*_connections_region dicts
            - Adds empty list entries to Index_temperatures_region dict
        """

        # 1. Adding region keys to index dictionaries.
        for i in self.unique_regions:
            # Adding keys to dict:
            self.Index_inductive_connections_region[i] = []
            self.Index_normal_connections_region[i] = []
            self.Index_radial_connections_region[i] = []
            self.Index_struct_connections_region[i] = []
            self.Index_temperatures_region[i] = []

    def create_initial_conditions(self):
        """
        Creates initial condition arrays and indices for the ODE solver.

        Builds the complete initial state vector for the thermo-electromagnetic
        simulation by:
        1. Creating I0 (initial currents) and T0 (initial temperatures) arrays
           for all connections and nodes
        2. Building index mappings (Index_initial_Iind, Index_initial_IR,
           Index_initial_T) for each coil element
        3. Populating connection type indices (inductive, radial, structural)
        4. Organizing indices by conductor region for material property lookups
        5. Creating post-processing indices for result extraction

        The method distinguishes between:
        - Inductive connections: Azimuthal current paths (initially zero)
        - Normal connections: Radial current paths (initially zero)
        - Temperature nodes: One per serial subdivision per pancake

        Initial conditions can be overridden by loading from file (see
        initial_conditions dict).

        Returns:
            None

        Side Effects:
            - Sets Nr_of_Connections and Nr_of_Nodes
            - Populates I0, T0, initial_conditions dict
            - Populates Index_initial_*, Index_*_connections indices
            - Populates Index_post_* arrays for post-processing
        """
        ind_T = 0
        ind_I = 0

        thermal_nodes = 0
        for i in range(self.n_coil_elements):
            for j in range(self.repeat[i]):
                self.Index_post_thermal += list(itertools.chain.from_iterable([[nn] * self.parallel[i] for nn in range(thermal_nodes,thermal_nodes + self.serial[i])]))
                thermal_nodes += self.serial[i]

        for i in range(self.n_coil_elements):
            Nr_of_Connections_1 = self.repeat[i] * self.serial[i] * self.parallel[i] # Azimuthal connections
            Nr_of_Connections_2 = self.repeat[i] * self.serial[i] if self.simp[i] else self.repeat[i] * self.serial[i] * self.parallel[i] # Radial connections
            Nr_of_Nodes = self.repeat[i] * self.serial[i]

            # For post processing:
            self.Index_post_inductive += range(self.Nr_of_Connections, self.Nr_of_Connections+Nr_of_Connections_1)
            self.Index_post_resistive += list(itertools.chain.from_iterable([[nn]*self.parallel[i] for nn in range(self.Nr_of_Connections+Nr_of_Connections_1, self.Nr_of_Connections+Nr_of_Connections_1+Nr_of_Connections_2)]))

            self.Nr_of_Connections += Nr_of_Connections_1 + Nr_of_Connections_2
            self.Nr_of_Nodes += Nr_of_Nodes

            # New version, new format
            self.I0 += [[0.0] * Nr_of_Connections_1] + [[0.0] * Nr_of_Connections_2]
            self.T0 += [[self.T_initial[i]] * Nr_of_Nodes]

            self.initial_conditions[i]= {'T': self.T_initial[i], 'I_ind': 0.0, 'I_R': 0.0, 'Load_from_file': False, 'File_path': '', 'Line_index': -1}
            self.Index_initial_Iind[i] = []
            self.Index_initial_IR[i] = []
            self.Index_initial_T[i] = []

            # Temperature index
            for j in range(Nr_of_Nodes):
                self.Index_initial_T[i].append(ind_T+j)
                for k in range(self.parallel[i]):
                    self.Index_temperatures.append(ind_T+j)
            for j in range(Nr_of_Nodes):
                self.Index_temperatures.append(ind_T+j)
            ind_T += Nr_of_Nodes

            # Connection index
            ind_con_induc = []
            ind_con_norm = []
            ind_con_rad = []
            ind_con_strct = []

            # Connections 1: azimuthal connections, Connection 2: radial connections
            for j in range(Nr_of_Connections_1):
                self.Index_inductive_connections.append(ind_I+j)
                ind_con_induc.append(ind_I+j)
                self.Index_initial_Iind[i].append(ind_I+j)
            for j in range(Nr_of_Connections_2):
                self.Index_normal_connections.append(ind_I+Nr_of_Connections_1+j)
                ind_con_norm.append(ind_I+Nr_of_Connections_1+j)
                self.Index_initial_IR[i].append(ind_I + Nr_of_Connections_1 + j)
                if self.Conductor_type[self.conductor[i]] == 'Superconductor':
                    self.Index_radial_connections.append(ind_I+Nr_of_Connections_1+j)
                    ind_con_rad.append(ind_I + Nr_of_Connections_1 + j)
                else:
                    self.Index_struct_connections.append(ind_I+Nr_of_Connections_1+j)
                    ind_con_strct.append(ind_I + Nr_of_Connections_1 + j)
            ind_I += Nr_of_Connections_1 + Nr_of_Connections_2

            # Adding value to existing key:
            for j in ind_con_induc:
                self.Index_inductive_connections_region[self.conductor[i]].append(j)
            for j in ind_con_norm:
                self.Index_normal_connections_region[self.conductor[i]].append(j)
            for j in ind_con_rad:
                self.Index_radial_connections_region[self.conductor[i]].append(j)
            for j in ind_con_strct:
                self.Index_struct_connections_region[self.conductor[i]].append(j)

        # Initial conditions for external insulated coils (from the coils dictionary).
        # Keyed by coil name (string) to distinguish from NI coil entries (keyed by int).
        # No temperature or radial current — insulated coils are not part of the thermal model.
        for name, c in self.coils.items():
            if not c.get('insulated', False):
                continue
            self.initial_conditions[name] = {
                'I_ind':          0.0,
                'Load_from_file': False,
                'File_path':      '',
                'Line_index':     -1,
            }

    def calculate_inductance_numeric(self):
        """
        Calculates mutual inductance matrix using discrete numerical method.

        Discretizes each coil element into 3D current loop segments and
        numerically integrates the Neumann formula for mutual inductance
        between all pairs of segments. This method is more accurate than
        the analytic approach but significantly slower.

        Each element at position_center is discretized into sub_elements
        circular segments. The Neumann formula is then evaluated for all
        unique pairs of segments (including self-inductance).

        The method populates self.mutual, which stores inductance between
        individual discretized elements. This matrix is later reduced to
        per-coil inductances in calculate_integral_inductance().

        Returns:
            None

        Side Effects:
            - Populates self.mutual matrix (Nr_of_Connections x Nr_of_Connections)
            - Displays progress bar during calculation

        Note:
            Computation time scales as O(N²) where N is the total number of
            discretized elements. For large magnets, use the analytic method
            instead by setting inductance_calculation='analytic'.
        """
        ni_coil_items = [
            (name, c) for name, c in self.coils.items()
            if not c.get('insulated', False)
        ]

        list_of_sub_elements = []
        # making the index for the mutual inductance matrix
        for i in range(self.n_coil_elements):
            scaler = ni_coil_items[i][1]['solenoid_scaler']
            for coord in self.positions_center[i]:
                list_of_sub_elements.append(
                    discretize_loop(coord[0], coord[1], scaler, self.sub_elements[i])
                )

        # Calculate inductance for all permutations
        prog_bar = ProgressBar(int(len(list_of_sub_elements)*(len(list_of_sub_elements)+1)/2), 'Mutual inductance  :', interval=10)
        processed = 0
        for i, j in itertools.combinations_with_replacement(range(len(list_of_sub_elements)), 2):
            prog_bar.update(processed)
            # Calulate inductance
            is_self_inductance = (i == j)
            L = inductance_for_paths(list_of_sub_elements[i], list_of_sub_elements[j], is_self_inductance)
            # Assemble array
            self.mutual[self.con_index_array[i], self.con_index_array[j]] = L
            self.mutual[self.con_index_array[j], self.con_index_array[i]] = L
            processed += 1

    def calculate_inductance_analytic(self):
        """
        Calculates mutual inductance matrix using analytic formulas.

        Uses analytical expressions for mutual inductance between circular
        current loops (from Babic & Akyel) to compute the inductance matrix.
        This method is much faster than the discrete numerical approach but
        makes the thin solenoid approximation.

        The method:
        1. Subdivides each rectangular coil element into (r,z) grid points
        2. Computes mutual inductance between all loop pairs using
           analytical formulas
        3. Averages inductances across subdivisions to represent the
           finite cross-section of each element
        4. Populates both self.mutual (full) and self.mutual_insulated
           (without radial connections)

        Per-coil inductance_r_divisions and inductance_z_divisions values from
        the coils dictionary are used when present; otherwise the global class
        attributes (self.inductance_r_divisions, self.inductance_z_divisions)
        serve as the fallback.  This allows different NI coils to use different
        integration grid densities.  Warnings are logged if element aspect
        ratios suggest that increasing either division count would improve
        accuracy.

        Returns:
            None

        Side Effects:
            - Populates self.mutual and self.mutual_insulated matrices
            - Logs warnings for aspect ratio issues
            - Displays progress bar during calculation

        Note:
            Self-inductance uses a thin wire approximation with 1mm radius.
            For very thin conductors or high aspect ratio elements, set
            inductance_r_divisions / inductance_z_divisions per coil in the
            coils dictionary or globally via the top-level YAML keys.
        """
        # The inductance matrix is global, and easier to calculate over the complete set of section
        # coordinates. It makes the index trick to only calculate one of the triangular matrices easier.
        # A solenoid specific scale factor is packed in with the coordinates for calculation
        all_section_coords = []

        # Build per-coil division lookup in the same ordering as create_geometry()
        # so that inductance_element_to_coil_nr indices are valid.  Per-coil values
        # from the coils dict override the global class attribute when present.
        ni_coil_items = [
            (name, c) for name, c in self.coils.items()
            if not c.get('insulated', False)
        ]

        aspect_warning_given_r = False
        aspect_warning_given_z = False

        # Per-element grid sizes tracked for the variable-block reduction below.
        k_per_element: list[int] = []

        for coil_idx, solenoid_section_coords in enumerate(self.positions):
            # Per-coil r/z divisions — fall back to the global value when absent.
            coil_ind = self.inductance_element_to_coil_nr[coil_idx]
            coil_params = ni_coil_items[coil_ind][1]
            n = int(coil_params.get('inductance_r_divisions', self.inductance_r_divisions))
            m = int(coil_params.get('inductance_z_divisions', self.inductance_z_divisions))

            aspect = (solenoid_section_coords[0][0] - solenoid_section_coords[0][2]) / (solenoid_section_coords[1][0] - solenoid_section_coords[1][1])
            if n < np.round(aspect) and not aspect_warning_given_r:
                logging.warning('Inductance calculation of coil_id: ' + str(coil_idx) + ' may be more accurate by setting \'inductance_r_divisions\' to ' + str(int(np.ceil(aspect))) + '.')
                aspect_warning_given_r = True
            if m < np.round(1/aspect) and not aspect_warning_given_z:
                logging.warning('Inductance calculation of coil_id: ' + str(coil_idx) + ' may be more accurate by setting \'inductance_z_divisions\' to ' + str(int(np.ceil(1/aspect))) + '.')
                aspect_warning_given_z = True

            r = np.linspace(solenoid_section_coords[0][0], solenoid_section_coords[0][2], n+2)[1:-1]
            z = np.linspace(solenoid_section_coords[1][0], solenoid_section_coords[1][1], m+2)[1:-1]
            all_section_coords += [(i[0], i[1], coil_params['solenoid_scaler']) for i in itertools.product(r, z)]
            k_per_element.append(n * m)

        # When there are no NI coil positions (e.g. insulated-only geometry)
        # all_section_coords will be empty.  Skip the loop and reduction —
        # self.mutual and self.mutual_insulated remain zero-initialised.
        if not all_section_coords:
            self.mutual_insulated = np.zeros((0, 0))
            return

        prog_bar = ProgressBar(int(len(all_section_coords) * (len(all_section_coords) + 1) / 2), 'Mutual inductance  :', interval=1000)
        processed = 0

        # Calculate inductance matrix
        # (ri, zi) and (rj, zj) are coordinates of the sections

        mutual_temp = np.zeros((len(all_section_coords), len(all_section_coords)))

        for (i, (ri, zi, scaler_i)), (j, (rj, zj, scaler_j)) in \
                itertools.combinations_with_replacement(enumerate(all_section_coords), 2):

            prog_bar.update(processed)
            # Calculate either the self-inductance or mutual inductance
            if i == j:
                mutual_temp[i, j] = loop_self_inductance(ri, 1e-3) * scaler_i * scaler_j
            else:
                mut = loop_mutual_inductance(ri, rj, zi, zj) * scaler_i * scaler_j
                mutual_temp[i, j] = mut
                mutual_temp[j, i] = mut
            processed += 1

        # Variable block sizes: elements may have different n*m grid counts.
        # block_offsets holds the starting row/col index in mutual_temp for each element.
        block_offsets = np.cumsum([0] + k_per_element[:-1]).astype(int)
        mutual_temp = np.add.reduceat(
            np.add.reduceat(mutual_temp, block_offsets, axis=0),
            block_offsets, axis=1)
        # Normalise each (i, j) block by k_i * k_j to get the average inductance.
        k_arr = np.array(k_per_element, dtype=float)
        mutual_temp = mutual_temp / np.outer(k_arr, k_arr)

        # Filling the mutual inductance matrix.
        for i, j in itertools.combinations_with_replacement(range(mutual_temp.shape[0]), 2):
            # Symmetric
            self.mutual[self.con_index_array[i], self.con_index_array[j]] = mutual_temp[i, j]
            self.mutual[self.con_index_array[j], self.con_index_array[i]] = mutual_temp[j, i]
        self.mutual_insulated = mutual_temp

        # Inductance of radial elements.
        # for i in range(len(self.radial_position)):
        #     self.mutual[self.Index_radial_connections[i], self.Index_radial_connections[i]] = radial_inductance_numeric(self.radial_position[i][0], self.radial_position[i][1])
        # TODO Commented this out because it would fail for copper elements.

    def calculate_integral_inductance(self):
        """
        Sums up the mutual inductance per coil and per element.
        """
        self.inductance_element = np.zeros((self.mutual.shape[0], ))

        # The enumeration returns pairs of (coil_index, coil_connection_indices).
        # The combinations_with_replacement function returns every unique pair of pairs
        for (i, conn_indices_i), (j, conn_indices_j) in \
            itertools.combinations_with_replacement(enumerate(self.con_index_array_per_coil), 2):

            # Inductance per coil
            mutual_in_mat = self.mutual[conn_indices_i[0]:conn_indices_i[-1]+1, conn_indices_j[0]:conn_indices_j[-1]+1]

            coil_inductance = np.sum(mutual_in_mat)
            # Correction for parallel sections acting as one conductor
            if not self.no_parallel:
                coil_inductance /= (self.parallel[i] * self.parallel[j])
            
            self.mutual_output[i, j] = coil_inductance
            self.mutual_output[j, i] = coil_inductance
            
            if i == j:
                logging.info(f'Self inductance of coil nr {i + 1}: {1e3 * coil_inductance} mH')


    def create_inductance_matrix(self):
        """
        Creates the complete mutual inductance matrix for all connections.

        Orchestrates the inductance calculation by:
        1. Initializing mutual inductance matrices (mutual, mutual_insulated,
           mutual_output)
        2. Calling either calculate_inductance_numeric() or
           calculate_inductance_analytic() based on inductance_calculation
           setting
        3. Reducing element-level inductances to per-coil inductances via
           calculate_integral_inductance()
        4. Saving reduced inductance matrix to CSV for inspection

        The mutual matrix is used by the solver to compute dI/dt from
        voltage drops. The mutual_output matrix provides per-coil
        self/mutual inductances for debugging and verification.

        Returns:
            None

        Side Effects:
            - Creates self.mutual (Nr_of_Connections x Nr_of_Connections)
            - Creates self.mutual_insulated (for light solver)
            - Creates self.mutual_output (n_coil_elements x n_coil_elements)
            - Saves mutual_reduced.csv to output directory
            - Logs self-inductance of each coil

        Raises:
            SystemExit: If inductance_calculation has invalid value
        """

        # Matrix for individual line element inductances
        self.mutual = np.zeros(self.C.shape) # This one is used in the main torch solver.
        self.mutual_insulated = np.zeros(self.C_ins.shape) # This one does not include the radial resistors and is used in the light solver for insulated magnets.
        # Matrix for integral solenoid inductances
        self.mutual_output = np.zeros((self.n_coil_elements, self.n_coil_elements)) # Mainly for debugging, a matrix with inductances per coil.

        # Calculate mutual inductance matrix for all simulation elements
        if self.inductance_calculation == 'discrete':
            self.calculate_inductance_numeric()
        elif self.inductance_calculation == 'analytic':
            self.calculate_inductance_analytic()
        else:
            print(f"Invalid parameter for 'inductance_calculation: {self.inductance_calculation}'")
            exit(1)

        # Group simulation elements into per-solenoid inductance values.
        self.calculate_integral_inductance()

        # Outputting the mutual inductance matrix per coil element
        # Getting the sign per coil element:
        sign = np.sign(self.I_initial)
        inductance = self.mutual_output * np.outer(sign, sign)  # Getting the correct mutual inductance.
        np.savetxt(self.output_path / 'mutual_reduced.csv', inductance, delimiter=",")


    def calculate_stored_energy_volume(self):
        """
        Creates volume mesh for stored magnetic energy calculation.

        Generates a cylindrical volume mesh (r-z plane) with appropriate
        volume weighting for each grid cell. The volume of an annular ring
        is V = π(r_outer² - r_inner²) * Δz.

        The edge cells at r=0 are treated specially since they're solid
        cylinders rather than annular rings.

        This volume mesh (self.V) is used by the solver to integrate
        magnetic energy density (B²/(2μ₀)) over the volume surrounding
        the magnet.

        Returns:
            bool: True upon completion

        Side Effects:
            - Creates self.xv, self.yv meshgrid arrays
            - Creates self.V volume array with same shape as meshgrid

        Note:
            The mesh resolution (nx, ny) should be chosen to accurately
            capture field gradients. Too coarse a mesh underestimates
            stored energy.
        """
        # Volume for stored Energy calculation
        x = np.linspace(self.xmin, self.xmax, self.nx)
        y = np.linspace(self.ymin, self.ymax, self.ny)
        self.xv, self.yv = np.meshgrid(x, y)
        dx = (self.xmax - self.xmin) / (self.nx - 1)
        dy = (self.ymax - self.ymin) / (self.ny - 1)
        self.V = self.xv.copy()
        for i in range(self.nx):
            if i == 0:
                self.V[:, i] = np.ones(self.V[:, i].shape) * np.pi * ((x[i] + 0.5 * dx) ** 2) * dy
            else:
                self.V[:, i] = np.ones(self.V[:, i].shape) * np.pi * ((x[i] + 0.5 * dx) ** 2 - (x[i] - 0.5 * dx) ** 2) * dy
        return True


    def generate_electrical_indices(self):
        """
        Creates local-to-global index mappings for electrical matrix access.

        Builds index arrays that map:
        - Coil element number → global connection indices in ABCD matrix
        - Coil element number → global node indices in ABCD matrix

        These mappings are essential for:
        - Assembling the electrical connection matrix (ABCD)
        - Extracting per-coil quantities during post-processing
        - Distributing radial resistance across appropriate connections
        - Applying initial conditions to the correct state vector elements

        The method distinguishes between:
        - con_index_array_per_coil: Indices for azimuthal (inductive)
          connections
        - con_index_array_radial_per_coil: Indices for radial (turn-to-turn)
          connections
        - node_index_array_per_coil: Indices for voltage nodes

        Returns:
            None

        Side Effects:
            - Creates self.con_index_array (flat list of all connection indices)
            - Creates self.con_index_array_per_coil (nested list per coil)
            - Creates self.con_index_array_radial_per_coil (nested list per coil)
            - Creates self.node_index_array_per_coil (nested list per coil)
        """
        # Global connection index
        con_index = 0
        # Global node index in ABCD 
        node_index = 0
        # Array of global indices for all nodes in simulation
        self.con_index_array = []
        # Array of global indices per element
        self.con_index_array_per_coil = [[] for i in range(self.n_coil_elements)]
        self.con_index_array_radial_per_coil = [[] for i in range(self.n_coil_elements)]
        # Loopup table to convert the local node index into the global ABCD node index
        self.node_index_array_per_coil = [[] for i in range(self.n_coil_elements)]

        for i in range(self.n_coil_elements):
            # Calulate number of nodes and paths
            nr_of_nodes = self.serial[i]*self.repeat[i] if self.simp[i] else self.serial[i]*self.parallel[i]*self.repeat[i]
            nr_of_connections1 = self.serial[i] * self.parallel[i] * self.repeat[i]
            nr_of_connections2 = self.serial[i] * self.repeat[i] if self.simp[i] else self.serial[i]*self.parallel[i]*self.repeat[i]
            # Create mappings
            self.con_index_array += list(np.arange(nr_of_connections1) + con_index)
            self.con_index_array_per_coil[i] = list(np.arange(nr_of_connections1) + con_index)
            self.con_index_array_radial_per_coil[i] = list(np.arange(nr_of_connections2) + con_index + nr_of_connections1)
            self.node_index_array_per_coil[i] = list(np.arange(nr_of_nodes) + node_index)
            # Increment global index counter
            con_index += nr_of_connections1 + nr_of_connections2
            node_index += nr_of_nodes

    def prepare_mass_matrix_variables(self):
        """
        Prepares matrices and variables for mass matrix solver formulation.

        Creates the alternative formulation of the electrical equations
        suitable for mass matrix ODE solvers. In this formulation:
        - L * dI/dt = V (inductance × current derivative = voltage)
        - The system is represented as A_branch matrix mapping currents to
          node voltages

        This formulation is more efficient for certain solver types but
        requires different treatment of parallel subdivisions. It's
        incompatible with the no_parallel flag.

        The method creates:
        - A_branch: Branch-to-node incidence matrix
        - R_matrix: Diagonal matrix of radial resistances
        - L_matrix: Inductance matrix with small regularization
        - Injection_Extraction: Node pairs for current injection

        Saves all matrices to mass_matrix.npz in the output directory.

        Returns:
            None

        Side Effects:
            - Sets self.A_branch, self.R_matrix
            - Sets self.n_branches, self.n_nodes
            - Sets self.Injection_Extraction
            - Saves mass_matrix.npz to output directory
            - Logs info if no_parallel=True (mass matrix not created)

        Note:
            Mass matrix formulation is currently experimental and not used
            by the default torch solver.
        """

        if self.no_parallel:
            logging.info('Mass matrix is not made as the no_parallel bool is set to True')
        else:
            # The inductance matrix:
            L_matrix = self.mutual
            self.n_branches = L_matrix.shape[0]

            # The connection matrix looks a bit different compared to the other solvers:
            self.n_nodes = 0
            for i in range(self.n_coil_elements):
                self.n_nodes += (self.serial[i]+1)*self.repeat[i]

            self.A_branch = np.zeros((self.n_branches, self.n_nodes))

            # Initializing variables
            n = 0
            m = 0
            self.Injection_Extraction = []

            for i in range(self.n_coil_elements):
                n_nodes_pancake = self.serial[i]

                # The azimuthal connections
                for r in range(self.repeat[i]):
                    first_node = n
                    last_node = n + n_nodes_pancake
                    for s in range(self.serial[i]):
                        for p in range(self.parallel[i]):
                            self.A_branch[m, n] = 1
                            self.A_branch[m, n+1] = -1
                            m+=1
                        n+=1

                    # The radial connections
                    n = first_node
                    for s in range(self.serial[i]):
                        self.A_branch[m, n] = 1
                        self.A_branch[m, n + 1] = -1
                        m+=1
                        n+=1

                    n+=1
                    self.Injection_Extraction.append((first_node, last_node))

            # Radial resistance
            self.R_matrix = np.diag(self.radial_resistance)

            reg_factor = 1e-12
            L_matrix += np.where(self.R_matrix != 0, reg_factor, 0)

            # First remove the file if it exists.
            filename = self.output_path / 'mass_matrix.npz'
            try:
                os.remove(filename)
            except OSError:
                pass

            # Then we save it.
            np.savez(filename, R_matrix=self.R_matrix, A_branch=self.A_branch, L_matrix=L_matrix, n_branches=self.n_branches, n_nodes=self.n_nodes, Inj_Ext=self.Injection_Extraction)

    def create_electrical_matrix(self):
        """
        Creates the electrical connection matrix (ABCD) and related matrices.

        Constructs the system matrices that define Kirchhoff's current and
        voltage laws for the network:
        - A matrix: Maps node voltages to connection currents (KVL)
        - B matrix: Currently unused (zeros)
        - C matrix: Diagonal matrix of connection parameters
        - D matrix: Maps connection currents to node current balance (KCL)
        - ABCD matrix: Combined system matrix [C, D; A, B]

        The method creates both:
        1. Full ABCD matrix (includes radial/turn-to-turn connections)
        2. Insulated ABCD matrix (only inductive connections, for
           light solver)

        The ABCD formulation enables the solver to simultaneously solve
        for currents and voltages using:
        [C  D] [dI/dt]   [V_induced]
        [A  B] [  V  ] = [   0     ]

        Returns:
            None

        Side Effects:
            - Calls generate_electrical_indices()
            - Creates self.A, self.B, self.C, self.D matrices
            - Creates self.ABCD combined matrix
            - Creates self.A_ins, self.B_ins, self.C_ins, self.D_ins, ABCD_ins
            - Saves ABCD.pkl and ABCD_insulated.pkl to output directory

        Note:
            The simplified circuit model (simp=True) uses fewer nodes by
            treating parallel connections as a single lumped element.
        """
        self.generate_electrical_indices()

        # Populate A-Matrix
        A_sub = []
        for i in range(self.n_coil_elements):
             # Calulate number of nodes and paths
            nr_of_nodes = self.serial[i]*self.repeat[i] if self.simp[i] else self.serial[i]*self.parallel[i]*self.repeat[i]
            nr_azimuthal_connections = self.serial[i] * self.parallel[i] * self.repeat[i]
            nr_of_connections2 = self.serial[i] * self.repeat[i] if self.simp[i] else self.serial[i]*self.parallel[i]*self.repeat[i]
            if self.simp[i]:
                A_temp1 = np.zeros((nr_of_nodes, nr_azimuthal_connections))
                A_temp2 = np.zeros((nr_of_nodes, nr_of_connections2))
                for j in range(nr_of_nodes):
                    for k in range(self.parallel[i]):
                        A_temp1[j, k+j*self.parallel[i]] = 1
                    A_temp2[j, j] = 1
            else:
                A_temp1 = np.eye(nr_of_nodes)
                A_temp2 = np.eye(nr_of_nodes)
            A_sub.append(np.hstack((A_temp1, A_temp2)))

        # Makes the ABCD matrix for an insulated magnet, only for the light solver.
        # Determining array shapes
        n = 0
        m = 0
        for i in range(self.n_coil_elements):
         m += self.serial[i] * self.parallel[i] * self.repeat[i]
         n += self.serial[i] * self.repeat[i]
        self.Nr_of_Connections_insulated = m

        self.A_ins = np.zeros((n, m))
        ii, jj = 0, 0
        for i in range(self.n_coil_elements):
            for r in range(self.repeat[i]):
                for j in range(self.serial[i]):
                    for k in range(self.parallel[i]):
                         self.A_ins[ii, jj] = 1
                         jj += 1
                    ii += 1
        self.B_ins = np.zeros((n, n))
        self.C_ins = np.zeros((m, m))
        self.D_ins = -1 * np.transpose(self.A_ins)

        # Makes the ABCD matrix for a non-insulated magnet.
        # Determining array shapes
        A_shape = [0, 0]
        A_x = [0]
        A_y = [0]
        for a in A_sub:
            A_shape[0] += a.shape[0]
            A_shape[1] += a.shape[1]
            A_x.append(A_x[-1] + a.shape[0])
            A_y.append(A_y[-1] + a.shape[1])

        self.A = np.zeros(A_shape)
        for i in range(len(A_sub)):
            self.A[A_x[i]:A_x[i+1], A_y[i]:A_y[i+1]] = A_sub[i]
        self.B = np.zeros((A_shape[0], A_shape[0]))
        self.C = np.ones((A_shape[1], A_shape[1]))
        self.D = -1*np.transpose(self.A)

        # Fills the mutual inductance part of the matrix
        self.create_inductance_matrix()
        self.C = self.mutual
        self.C_ins = self.mutual_insulated

        # Insulated coil: assemble and invert ABCD
        AB = np.hstack((self.A_ins, self.B_ins))
        CD = np.hstack((self.C_ins, self.D_ins))
        ABCD_insulated = np.vstack((AB, CD))
        self.ABCD_insulated = ABCD_insulated
        self.inv_insulated = np.linalg.inv(ABCD_insulated)
        self.inv1_insulated = self.inv_insulated[0: A_shape[1], :]  # The current part
        self.inv2_insulated = self.inv_insulated[A_shape[1]:, :]  # The voltage part
        var_dump(self.output_path, self.mutual_insulated, 'mutual_insulated')
        var_dump(self.output_path, self.ABCD_insulated, 'ABCD_insulated')

        # NI coil: assemble and invert ABCD
        AB = np.hstack((self.A, self.B))
        CD = np.hstack((self.C, self.D))
        ABCD = np.vstack((AB, CD))
        self.ABCD = ABCD
        self.inv = np.linalg.inv(ABCD)
        self.inv1 = self.inv[0: A_shape[1], :]  # The current part
        self.inv2 = self.inv[A_shape[1]:, :]    # The voltage part

        var_dump(self.output_path, self.mutual, 'mutual')
        var_dump(self.output_path, self.ABCD, 'ABCD')

        df_mutual = pd.DataFrame(self.mutual)
        if self.save_mutual:
            df_mutual.to_csv(self.output_path / 'mutual.csv', index=False)
        del df_mutual

        df_ABCD = pd.DataFrame(self.ABCD)
        df_ABCD.to_csv(self.output_path / 'ABCD.csv', index=False)
        del df_ABCD

        return True

    def create_vtu_dictionary(self):
        """
        Creates dictionary with geometry information for VTU post-processing.

        Builds self.vtu_info dictionary containing spatial coordinates and
        dimensions of all coil elements. This information is used by the
        post-processing tools to create VTU files (VTK Unstructured Grid
        format) for visualization in ParaView or similar tools.

        The dictionary structure is:
        vtu_info = {
            'inductive': {
                'r_centers': List of radial center positions (m)
                'z_centers': List of axial center positions (m)
                'dr': List of radial widths (m)
                'dz': List of axial heights (m)
            },
            'insulated_coils': {        # only present if self.coils is set
                'coil_name': {
                    'r_centers': List of radial center positions (m), one per repeat pancake
                    'z_centers': List of axial center positions (m), one per repeat pancake
                    'dr': List of radial widths (m)
                    'dz': List of axial heights (m)
                }
            }
        }

        The 'inductive' key contains information for all inductive (azimuthal)
        connections of the NI coils. These elements carry the main coil current
        and are used to visualize current density, magnetic field, forces, and
        power dissipation.

        The 'insulated_coils' key (if present) contains one entry per external
        insulated coil defined in the coils dictionary, with one geometry entry
        per repeat pancake derived from ext_coils_sources.

        Also creates seed_points for streamline visualization by calling
        create_seeds() with the seed_information.

        Returns:
            None

        Side Effects:
            - Populates self.vtu_info dictionary
            - Creates self.seed_points array (for field line visualization)
        """

        ### First the inductive elements.

        # Flattening the self.positions_center array and extracting the values for r and z.
        flattened_centers = [item for sublist in self.positions_center for item in sublist]
        r_centers = [a.item() for a, b in flattened_centers]
        z_centers = [b.item() for a, b in flattened_centers]

        # The dr and dz values are derived for the self.positions array.
        dr = [block[0][2] - block[0][0] for block in self.positions]
        dz = [block[1][2] - block[1][0] for block in self.positions]

        # Adding these values to the dictionary.
        self.vtu_info['inductive'] = {
            'r_centers': r_centers,
            'z_centers': z_centers,
            'dr': dr,
            'dz': dz
        }

        ### External insulated coils (populated by create_geometry_from_dictionary).
        # Each entry in ext_coils_sources is [r_in, r_out, z_bot, z_top, turns].
        # Source indices per coil are already stored in index_dict['insulated_coils'][name]['I'].
        if self.index_dict.get('insulated_coils'):
            insulated_vtu = {}
            for name, idx_info in self.index_dict['insulated_coils'].items():
                srcs = [self.ext_coils_sources[i] for i in idx_info['I']]
                insulated_vtu[name] = {
                    'r_centers': [(s[0] + s[1]) / 2 for s in srcs],
                    'z_centers': [(s[2] + s[3]) / 2 for s in srcs],
                    'dr':        [s[1] - s[0]        for s in srcs],
                    'dz':        [s[3] - s[2]        for s in srcs],
                }
            self.vtu_info['insulated_coils'] = insulated_vtu

        # Build seeds for all magnets by sweeping r = 0 → xmax at each
        # pancake's z-height.  Using the full grid radius guarantees seeds
        # land in every coil's cross-section regardless of radial position,
        # covering both the NI bore and any outer insulated coils.
        z_all = [z for _, z in self.seed_information]  # NI pancake z-heights
        if self.index_dict.get('insulated_coils'):
            for idx_info in self.index_dict['insulated_coils'].values():
                for i in idx_info['I']:
                    s = self.ext_coils_sources[i]
                    z_all.append((s[2] + s[3]) / 2)
        seed_info_full = [[self.xmax, z] for z in z_all]
        self.seed_points = create_seeds(seed_info_full, self.seeds_radial, self.seeds_angular)

    def create_index_dictionary(self):
        """
        Creates hierarchical index dictionary for result post-processing.

        Builds self.index_dict with a four-level hierarchy:
        - Circuits: Groups of NI coils connected electrically
        - Coils: Individual NI coil elements (may contain multiple pancakes)
        - Pancakes: Individual repeated elements within a NI coil
        - Insulated_coils: External insulated coils from the coils dictionary

        Each NI level stores:
        - Relationships to other levels (e.g., which pancakes belong to
          which coil)
        - Index arrays for accessing specific quantities:
          * 'I': Indices in flattened position array
          * 'II': Indices for inductive (azimuthal) currents
          * 'IR': Indices for radial (turn-to-turn) currents

        Each insulated_coils entry (keyed by coil name) stores indices that
        follow the ordering of the extended electrical matrix ABCD_ext, where
        NI connections occupy [0..Nc-1] and external connections follow at
        [Nc..Nc+Ne-1] (same convention as 'II'/'IR' for NI coils):
          * 'I':        source indices in ext_coils_sources (one per repeat
                        pancake) — geometry/position indices
          * 'II':       [Nc + i] — single inductive connection index in the
                        combined connection array (after all Nc NI connections)
          * 'IR':       []       — no radial connections (insulated coil)
          * 'node_pos': Nn + i   — positive terminal node index in ABCD_ext

        Note: 'insulated_coils' is initialised here but populated with actual
        ABCD_ext indices by create_geometry_from_dictionary(), which runs after
        create_geometry() so that Nc and Nn are known.

        This hierarchical structure enables efficient extraction of
        per-circuit, per-coil, or per-pancake quantities during
        post-processing without needing to reconstruct the geometry.

        Example structure:
        index_dict = {
            'circuits': {0: {'coils': [0, 1], 'pancakes': [0, 1, 2, 3],
                            'I': [...], 'II': [...], 'IR': [...]}},
            'coils': {0: {'pancakes': [0, 1], 'I': [...], 'II': [...],
                          'IR': [...]}},
            'pancakes': {0: {'I': [...], 'II': [...], 'IR': [...]}},
            'insulated_coils': {
                'coil_name': {'I': [0, 1], 'II': [Nc], 'IR': [],
                              'node_pos': Nn}}
        }

        Returns:
            None

        Side Effects:
            - Populates self.index_dict with complete NI coil hierarchy
            - Initialises self.index_dict['insulated_coils'] = {} (populated
              later by create_geometry_from_dictionary if self.coils is set)
        """

        # Populating the dictionary with the standard keys. Which will have dictionaries of their own.
        self.index_dict['coils'] = {}
        self.index_dict['pancakes'] = {}
        self.index_dict['circuits'] = {}
        # Initialised here; ABCD_ext indices are filled in by create_geometry_from_dictionary().
        self.index_dict['insulated_coils'] = {}

        # Populating the dictionaries.
        # Adding the pancakes to the coil dict.
        p = 0
        for i in range(self.n_coil_elements):
            self.index_dict['coils'][i] =  {'pancakes': list(range(p, p+self.repeat[i]))}
            p += self.repeat[i]

        # Adding coils and pancakes to the circuit dict.
        for i in range(len(self.circuit)):
            if self.circuit[i] in self.index_dict['circuits'].keys():
                # Adding a value to the list.
                self.index_dict['circuits'][self.circuit[i]]['coils'] += [i]
                self.index_dict['circuits'][self.circuit[i]]['pancakes'] += self.index_dict['coils'][i]['pancakes']
            else:
                # Creating the dictionary.
                self.index_dict['circuits'][self.circuit[i]] = {'coils': [i], 'pancakes': self.index_dict['coils'][i]['pancakes'].copy()}

        # Adding the indexes to the pancake dict, then also to the coil and circuit dicts.
        p = 0
        i_last = 0 # For indexting the inductive elements in the inductive elements array.
        ii_last = 0 # For indexing the inductive elements.
        ir_last = 0 # For indexing the radial elements.
        for i in range(self.n_coil_elements):
            # The rules for indexing are:
            # parallel * serial amount of elements per pancake.
            # parallel * serial * repeat amount of elements per coil.
            # then serial * repeat amount of radial elements.

            # Updating ir_last to start just after the last index of the inductive elements.
            ir_last += self.parallel[i] * self.serial[i] * self.repeat[i]

            for j in range(self.repeat[i]):
                # The amount of elements in the pancake.
                elements_in_pancakes = self.parallel[i] * self.serial[i]

                # Adding the list to the dictionary.
                self.index_dict['pancakes'][p] = {'I': list(range(i_last, i_last + elements_in_pancakes)),
                                                  'II': list(range(ii_last, ii_last + elements_in_pancakes)),
                                                  'IR': list(range(ir_last, ir_last + self.serial[i]))}
                # Updating the variables.
                i_last += elements_in_pancakes
                ii_last += elements_in_pancakes
                ir_last += self.serial[i]
                p+=1

            # Updating ii_last to start just after the last index of the radial elements. Not needed for i_last.
            ii_last += self.serial[i] * self.repeat[i]

        # The last part, all the indices are added to the coils and circuits dictionary.
        # For the different circuits:
        for i in self.index_dict['circuits'].keys():
            index_II = []
            index_IR = []
            index_I = []
            for j in self.index_dict['circuits'][i]['pancakes']:
                index_II += self.index_dict['pancakes'][j]['II']
                index_IR += self.index_dict['pancakes'][j]['IR']
                index_I += self.index_dict['pancakes'][j]['I']
            self.index_dict['circuits'][i]['II'] = index_II
            self.index_dict['circuits'][i]['IR'] = index_IR
            self.index_dict['circuits'][i]['I'] = index_I

        # For the different coils:
        for i in self.index_dict['coils'].keys():
            index_II = []
            index_IR = []
            index_I = []
            for j in self.index_dict['coils'][i]['pancakes']:
                index_II += self.index_dict['pancakes'][j]['II']
                index_IR += self.index_dict['pancakes'][j]['IR']
                index_I += self.index_dict['pancakes'][j]['I']
            self.index_dict['coils'][i]['II'] = index_II
            self.index_dict['coils'][i]['IR'] = index_IR
            self.index_dict['coils'][i]['I'] = index_I

    def create_radial_thermal_model(self):
        """
        Builds the radial thermal diffusion model for each pancake.

        Constructs the finite-difference conduction matrix T_con_radial and
        the associated cooling geometry arrays. The model assumes:
        - Radial heat conduction between adjacent nodes (Fourier's law)
        - Adiabatic boundaries in the axial direction between pancakes
        - 1D thermal diffusion per pancake

        Creates:
        - T_con_radial: NxN finite-difference conduction matrix
          (edge nodes: -1/+1 stencil; interior nodes: +1/-2/+1 stencil)
        - T_con_inner: innermost node index per pancake (inner cooling BC)
        - T_con_outer: outermost node index per pancake (outer cooling BC)
        - Thermal_alpha: radial conduction coefficient per node
        - Thermal_area: coolant contact area per material per node
        - Thermal_index: node indices grouped by conductor type

        The thermal diffusion equation solved is:
        C * dT/dt = -α * T_con_radial * T + P_dissipated + Q_cooling

        Returns:
            None

        Side Effects:
            - Sets self.T_con_inner, self.T_con_outer
            - Sets self.Thermal_alpha, self.Thermal_area, self.Thermal_index
            - Saves thermal_radial_connections_matrix.pkl to output directory
        """
        if self.Conductor_conductivity_materials:
            # Creates the thermal radial connection matrix.
            T_con_radial = np.zeros((self.Nr_of_Nodes, self.Nr_of_Nodes))
            self.T_con_inner = [] # Inner elements per pancake. (possibly needed for helium cooling)
            self.T_con_outer = [] # Outer elements per pancake. (possibly needed for outside cooling)
            self.Thermal_alpha = []
            self.Thermal_area = {}
            self.Thermal_index = {}

            # Creates some emptry matrices - Tim: Not sure why.
            for i in self.Conductor_conductivity_materials.keys():
                self.Thermal_area[i] = {}
                self.Thermal_index[i] = []
                for j in self.Conductor_conductivity_materials[i]:
                    self.Thermal_area[i][j] = []
            n = 0

            # Creates a matrix with all thermal connections in the radial direction.
            for i in range(self.n_coil_elements):
                for j in range(self.repeat[i]):
                    self.T_con_inner.append(n)
                    for k in range(self.serial[i]):
                        if self.Conductor_conductivity_thickness:
                            self.Thermal_alpha.append(0.5 * self.turns[i]/self.serial[i] * self.Conductor_conductivity_thickness[self.conductor[i]])
                            self.Thermal_index[self.conductor[i]].append(n)
                            for m in self.Conductor_conductivity_materials[self.conductor[i]]:
                                self.Thermal_area[self.conductor[i]][m].append((self.positions_center[i][k*self.parallel[i]][0] * 2 * np.pi * self.Conductor_conductivity_width[self.conductor[i]][m]).item())
                        if self.serial[i] > 1:
                            if k == 0:
                                T_con_radial[n, n] = -1
                                T_con_radial[n, n + 1] = 1
                            elif k == (self.serial[i] - 1):
                                T_con_radial[n, n] = -1
                                T_con_radial[n, n - 1] = 1
                            else:
                                T_con_radial[n, n - 1] = 1
                                T_con_radial[n, n] = -2
                                T_con_radial[n, n + 1] = 1
                        n += 1
                    self.T_con_outer.append(n-1)

            var_dump(self.output_path, T_con_radial, 'thermal_radial_connections_matrix')

    def create_power_dissipation_matrix(self) -> None:
        """
        Builds and saves the power dissipation mapping matrix.

        Constructs a (Nr_of_Nodes x Nr_of_Connections) matrix where entry
        [i, j] is 1 if electrical connection j dissipates power into thermal
        node i, and 0 otherwise.  Multiple parallel connections heating the
        same node each contribute a 1, so their power is summed by the solver.

        Side Effects:
            - Saves power_dis_matrix.pkl to output directory
        """
        power_dissipation_matrix = np.zeros((self.Nr_of_Nodes, self.Nr_of_Connections))
        for i in range(self.Nr_of_Nodes):
            ind_power = [
                j for j in range(len(self.Index_temperatures))
                if self.Index_temperatures[j] == i
            ]
            for j in ind_power:
                power_dissipation_matrix[i, j] = 1
        var_dump(self.output_path, power_dissipation_matrix, 'power_dis_matrix')

    def create_thermal_node_pairs(self) -> None:
        """
        Builds the T1_con / T2_con adjacency lists for radial heat transfer.

        For each pancake, records pairs of adjacent thermal nodes
        (T1_con[k], T2_con[k]) that share a conductive interface in the
        radial direction.  Only nodes within the same serial subdivision are
        paired; pancake boundaries are adiabatic.

        These lists are consumed by the solver to assemble the thermal
        stiffness terms node-by-node.

        Side Effects:
            - Sets self.T1_con, self.T2_con
        """
        self.T1_con = []
        self.T2_con = []
        n = 0
        for i in range(self.n_coil_elements):
            for j in range(self.repeat[i]):
                for k in range(self.serial[i]):
                    if self.serial[i] > 1:
                        if k < (self.serial[i] - 1):
                            self.T1_con.append(n)
                            self.T2_con.append(n + 1)
                    n += 1

    def profile_dump(self):
        """
        Saves Python profiler statistics to file.

        If profiling was enabled (via profile=True in constructor), this
        method:
        1. Stops the profiler
        2. Sorts statistics by cumulative time
        3. Saves top 25 functions to profiler.txt (human-readable)
        4. Saves complete profiler data to profile.pstats (for analysis tools)

        The profile data helps identify performance bottlenecks in the
        geometry creation pipeline. Common bottlenecks include:
        - Inductance calculation (especially numeric method)
        - Magnetic field calculation on fine grids
        - Matrix assembly operations

        Use tools like snakeviz or gprof2dot to visualize profile.pstats.

        Returns:
            None

        Side Effects:
            - Saves profiler.txt and profile.pstats to output directory
            - Only if profiler_enabled=True, otherwise does nothing
        """
        if self.profiler_enabled:
            self.profiler.disable()
            s = io.StringIO()
            stats = pstats.Stats(self.profiler, stream=s).sort_stats('cumulative')
            stats.print_stats(25)
            self.profiler.dump_stats(self.output_path / 'profile.pstats')
            with (self.output_path / 'profiler.txt').open('w+') as f:
                f.write(s.getvalue())

if __name__ == "__main__":
    start_time = time.time()
    try:
        if len(sys.argv) > 2:
            print('Loading the provided yaml file: ' + sys.argv[1])
            NICO = NI_coil_geometry(sys.argv[1], sys.argv[2], profile=True)
        elif len(sys.argv) > 1:
            print('Loading the provided yaml file: ' + sys.argv[1], profile=True)
            NICO = NI_coil_geometry(sys.argv[1], 'simulation_result')
        else:
            print('Invalid input: provide an input yaml path and an output path.')
            sys.exit(1)
        print(f'Preprocessor completed in {time.time() - start_time:.2f}s')
    except Exception:
        logging.exception("Geometry preprocessing failed with unhandled exception")
        sys.exit(1)
