#!/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
import  yaml
import  logging
from    datetime        import datetime
import  time
import  itertools
from    itertools import combinations_with_replacement
import  io
import  shutil
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
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

        # 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  # Was 100 originally
        self.sampling_time_dT = 1.0  # Was 0.5 originally
        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

        # 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 = []

        # 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

        # Force
        self.Lt = None # Length * turns (Has to be multiplied by the current -> then Fz = Br x ILt

        # 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.fig1 = None
        self.fig2 = None
        self.fig3 = plt.figure()

        self.ax1 = None
        self.ax2 = None
        self.ax2b = None
        self.ax3 = self.fig3.add_subplot(111)

        self.custom_R0_xminmax = None
        self.custom_R0_yminmax_1 = None
        self.custom_R0_yminmax_2 = None
        self.custom_fig1_size = 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'
        self.insulated = False


    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

        # We have a ludicrous amount of members, so we are declaring them in a separate function for now.
        self.declare_members()

        # Loading the yaml file.
        self.load_yaml()

        # Declare (some) of the member variables
        self.n_coil_elements = len(self.inner_radius)

        if self.profiler_enabled: 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()
        self.create_thermal_matrices()
        self.create_thermal_matrices_v2() #TODO check why there are two versions

        # 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.
        plot_ABCD(self.ABCD, 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])

    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': self.magnet_mass.item(),
            '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,
            'Lt': self.Lt,
            '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,
        }

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

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

        for i in range(len(self.inner_radius)):
            if self.inner_radius[i] >= self.outer_radius[i]:
                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
            - Creates matplotlib figure objects (fig1, fig2, ax1, ax2, ax2b)
            - Disables numpy divide-by-zero warnings
        """

        np.seterr(divide='ignore')

        if self.offset is None:
            logging.info('!! Offset not provided, setting it to 0.0. !!')
            self.offset = [0.0] * self.n_coil_elements

        if self.T_initial is None:
            logging.info('!! T_initial not provided, setting it to 1.0. !!')
            self.T_initial = [5.0] * self.n_coil_elements

        if self.I_initial is None:
            logging.info('!! I_initial not provided, setting it to 1.0. !!')
            self.I_initial = [1.0] * self.n_coil_elements

        if self.T_ref is None:
            logging.info('!! T_ref not provided, setting it to 5.0. !!')
            self.T_ref = [5.0] * self.n_coil_elements

        if self.B_ref is None:
            logging.info('!! B_ref not provided, setting it to 0.0. !!')
            self.B_ref = [0.0] * self.n_coil_elements

        if self.simp is None:
            logging.info('!! Simplified circuit data not provided, setting it to True. !!')
            self.simp = [True] * self.n_coil_elements

        if self.spacing is None:
            logging.info('!! Spacing data not provided, setting it to 0. !!')
            self.spacing = [0.0] * self.n_coil_elements

        if self.parallel is None:
            logging.info('!! Parallel network data not provided, setting it to 1. !!')
            self.parallel = [1] * self.n_coil_elements

        if self.serial is None:
            logging.info('!! Serial network data not provided, setting it to 1. !!')
            self.serial = [1] * self.n_coil_elements

        if self.repeat is None:
            logging.info('!! Repeat data not provided, setting it to 1. !!')
            self.repeat = [1] * self.n_coil_elements

        if self.sub_elements is None:
            logging.info('!! Sub element data not provided, setting it to 200. !!')
            self.sub_elements = [200] * self.n_coil_elements

        if self.turns is None:
            logging.info('!! Turns data not provided, setting it to 1. !!')
            self.turns = [1] * self.n_coil_elements

        if self.circuit is None:
            logging.info('!! Turns data not provided, setting it to 1. !!')
            self.circuit = [1] * self.n_coil_elements

        if self.conductor is None:
            logging.info('!! Conductor not provided, setting it to "Dummy". !!')
            self.conductor = ['Dummy'] * self.n_coil_elements
            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)

        # figures. TODO: This should not be here. Split plotting off into it's own class or function
        if self.rotate_90:
            if self.mirror:
                self.fig1 = plt.figure(figsize=(2 + 6 / (2 * self.xmax / (self.ymax - self.ymin)), 6))
                self.fig2 = plt.figure(figsize=(2 + 6 / (2 * self.xmax / (self.ymax - self.ymin)), 6))
            else:
                self.fig1 = plt.figure(figsize=(2 + 6 / (self.xmax / (self.ymax - self.ymin)), 6))
                self.fig2 = plt.figure(figsize=(2 + 6 / (self.xmax / (self.ymax - self.ymin)), 6))
        else:
            if self.mirror:
                self.fig1 = plt.figure(figsize=(8, 6 / (2 * self.xmax / (self.ymax - self.ymin))))
                self.fig2 = plt.figure(figsize=(8, 6 / (2 * self.xmax / (self.ymax - self.ymin))))
            else:
                self.fig1 = plt.figure(figsize=(8, 6 / (2 * self.xmax / (self.ymax - self.ymin))))
                self.fig2 = plt.figure(figsize=(8, 6 / (2 * self.xmax / (self.ymax - self.ymin))))

        # Overwriting the figures if there is a custom fig size
        if self.custom_fig1_size is not None and len(self.custom_fig1_size) == 2:
            self.fig1 = plt.figure(figsize=(self.custom_fig1_size[0],self.custom_fig1_size[1]))

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

        self.ax1 = self.fig1.add_subplot(111)
        self.ax2 = self.fig2.add_subplot(111)
        self.ax2b = self.ax2.twinx()

        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([self.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
            - Updates matplotlib figures with coil geometry plots
        """
        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 = []
        coil_nr = 0
        coil_ind_nr = 0
        s_nr = 0
        for i in range(self.n_coil_elements):
            self.thermal_element_to_solenoid_nr += [s_nr] * self.serial[i] * self.repeat[i]
            s_nr += 1
            for j in range(self.repeat[i]):
                self.element_to_coil_nr += [coil_nr] * self.serial[i] * self.parallel[i]
                self.inductance_element_to_coil_nr += [coil_ind_nr] * self.serial[i] * self.parallel[i]
                self.thermal_element_to_coil_nr += [coil_nr] * self.serial[i]
                coil_nr += 1
            coil_ind_nr += 1

        for i in range(self.n_coil_elements):

            # Calculating the top:
            if self.repeat[i] >= 1:
                top = 0.5*(self.repeat[i]*self.thickness[i]+(self.repeat[i]-1)*self.spacing[i]) + self.offset[i] # Middle of the top coil
                bottom = -0.5*(self.repeat[i]*self.thickness[i]+(self.repeat[i]-1)*self.spacing[i]) + self.offset[i] # Middle of the bottom coil
            else:
                top = 0.0 + self.offset[i]
                bottom = 0.0 + self.offset[i]

            dr = (self.outer_radius[i]-self.inner_radius[i])/self.serial[i]/2.0
            dz = self.thickness[i]/self.parallel[i]/2.0

            z_pos = np.linspace(0, self.thickness[i], self.parallel[i]*2+1)[1::2]
            r_pos = np.linspace(self.inner_radius[i], self.outer_radius[i], self.serial[i]*2+1)[1::2]

            R_in, R_out, thickn = self.inner_radius[i], self.outer_radius[i], 0.5*self.thickness[i]

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

                # Calculating the outer positions of the individual pancake coils, mainly for plotting purposes:
                cntr = top - j * (self.thickness[i] + self.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(self.serial[i]):
                    self.masses.append(0.0)
                    for l in range(self.parallel[i]):
                        # Center position
                        self.positions_center[i].append([r_pos[k], top-z_pos[l]-j*(self.thickness[i] + self.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, self.turns[i]/self.serial[i]])
                        self.soleno_sources.append([r1, r3, z1, z2, self.turns[i]/self.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(j) for j in self.Conductor_materials_thickness[self.conductor[i]]])
                        density_multiplier = mat_thickness / np.sum(mat_thickness) * np.array([self.density_selector(j) for j in self.Conductor_materials[self.conductor[i]]])
                        self.masses[-1] += np.sum(self.volumes[-1]*density_multiplier)
                        self.conductor_list.append(self.conductor[i])
                        self.turns_per_element.append(self.turns[i]/self.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(self.repeat[i]):
                for k in range(self.serial[i]):
                    if self.simp[i]:
                        self.volumes.append(0.0)
                        self.turns_per_element.append(0.0)
                        self.lengths.append(0.0)
                        self.cross_sections.append((self.outer_radius[i]-self.inner_radius[i])*self.thickness[i]/self.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) * self.thickness[i]) # Inner surface area of the element (2*pi*r*t)
                        self.cross_sections_outer.append(2*np.pi*(r_pos[k]+dr) * self.thickness[i]) # Outer surface area of the element (2*pi*r*t)
                    else:
                        for l in range(self.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) * self.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) * self.thickness[i] / l) # Outer surface area of the element (2*pi*r*t)

            if self.plot_lines == 'all':
                if self.rotate_90:
                    [self.ax1.plot(j[1], j[0], color='black') for j in self.positions_outer[i]]
                    if self.mirror:
                        [self.ax1.plot(j[1], -1*np.array(j[0]), color='black') for j in self.positions_outer[i]]
                    [self.ax2b.plot(j[1], -1*np.array(j[0]), color='black') for j in self.positions_outer[i]]
                    [self.ax2b.plot(j[1], j[0], color='black') for j in self.positions_outer[i]]
                else:
                    [self.ax1.plot(j[0], j[1], color='black') for j in self.positions_outer[i]]
                    if self.mirror:
                        [self.ax1.plot(-1*np.array(j[0]), j[1], color='black') for j in self.positions_outer[i]]
                    [self.ax2b.plot(j[0], j[1], color='black') for j in self.positions_outer[i]]
                    [self.ax2b.plot(-1 * np.array(j[0]), j[1], color='black') for j in self.positions_outer[i]]
            elif self.plot_lines == 'outer':
                if self.rotate_90:
                    [self.ax1.plot(j[1], j[0], color='black') for j in self.positions_solenoids[i]]
                    if self.mirror:
                        [self.ax1.plot(j[1], -1 * np.array(j[0]), color='black') for j in self.positions_solenoids[i]]
                    [self.ax2b.plot(j[1], -1 * np.array(j[0]), color='black') for j in self.positions_solenoids[i]]
                    [self.ax2b.plot(j[1], j[0], color='black') for j in self.positions_solenoids[i]]
                else:
                    [self.ax1.plot(j[0], j[1], color='black') for j in self.positions_solenoids[i]]
                    if self.mirror:
                        [self.ax1.plot(-1 * np.array(j[0]), j[1], color='black') for j in self.positions_solenoids[i]]
                    [self.ax2b.plot(j[0], j[1], color='black') for j in self.positions_solenoids[i]]
                    [self.ax2b.plot(-1 * np.array(j[0]), j[1], color='black') for j in self.positions_solenoids[i]]

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

                if self.rotate_90:
                    Br_plot = Br.transpose()
                    Bz_plot = Bz.transpose()
                    if self.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 self.mirror:
                        Br_plot = np.hstack([-1*np.fliplr(Br_plot[:, 1:]), Br_plot])
                        Bz_plot = np.hstack([np.fliplr(Bz_plot[:, 1:]), Bz_plot])

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

                CS = self.ax1.contourf(self.soleno_xv_plot, self.soleno_yv_plot, (Br_plot ** 2 + Bz_plot ** 2) ** 0.5, cmap=self.colormap_name, levels=levels)
                if self.rotate_90:
                    SP = self.ax1.streamplot(self.soleno_xv_plot, self.soleno_yv_plot, Bz_plot, Br_plot, density=self.line_density, color='white')
                else:
                    SP = self.ax1.streamplot(self.soleno_xv_plot, self.soleno_yv_plot, Br_plot, Bz_plot, density=self.line_density, color='white')
                cbar = self.fig1.colorbar(CS)
                cbar.ax.set_ylabel('Magnetic Field, T')

                if self.mirror:
                    self.ax1.set_xlabel('Axial position, m')
                    self.ax1.set_ylabel('Radial position, m')
                else:
                    self.ax1.set_ylabel('Axial position, m')
                    self.ax1.set_xlabel('Radial position, m')

                if self.rotate_90:
                    if self.mirror:
                        self.ax1.set_xlim(self.ymin, self.ymax)
                        self.ax1.set_ylim(-self.xmax, self.xmax)
                    else:
                        self.ax1.set_xlim(self.ymin, self.ymax)
                        self.ax1.set_ylim(self.xmin, self.xmax)
                else:
                    if self.mirror:
                        self.ax1.set_xlim(-self.xmax, self.xmax)
                        self.ax1.set_ylim(self.ymin, self.ymax)
                    else:
                        self.ax1.set_xlim(self.xmin, self.xmax)
                        self.ax1.set_ylim(self.ymin, self.ymax)
                self.ax1.set_aspect('equal', 'box')
                self.fig1.savefig(self.output_path / f'Field_Map_v1_section_{i}.svg', dpi=300)

                cbar.remove()
                CS.remove()
                SP.lines.remove()
                for art in self.ax1.get_children():
                    if not isinstance(art, matplotlib.patches.FancyArrowPatch):
                        continue
                    art.remove()

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

            if self.rotate_90:
                Br_plot = self.Br.transpose()
                Bz_plot = self.Bz.transpose()
                if self.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 = self.Br
                Bz_plot = self.Bz
                if self.mirror:
                    Br_plot = np.hstack([-1 * np.fliplr(Br_plot[:, :-1]), Br_plot])
                    Bz_plot = np.hstack([np.fliplr(Bz_plot[:, :-1]), Bz_plot])

            # Custom colormap range
            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)

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


            if self.rotate_90:
                SP = self.ax1.streamplot(self.soleno_xv_plot, self.soleno_yv_plot, Bz_plot, Br_plot, density=self.line_density, color='white')
            else:
                SP = self.ax1.streamplot(self.soleno_xv_plot, self.soleno_yv_plot, Br_plot, Bz_plot, density=self.line_density, color='white')
            cbar = self.fig1.colorbar(CS)
            cbar.ax.set_ylabel('Magnetic Field, T')

            if self.rotate_90:
                if self.mirror:
                    self.ax1.set_xlim(self.ymin, self.ymax)
                    self.ax1.set_ylim(-self.xmax, self.xmax)
                else:
                    self.ax1.set_xlim(self.ymin, self.ymax)
                    self.ax1.set_ylim(self.xmin, self.xmax)
            else:
                if self.mirror:
                    self.ax1.set_xlim(-self.xmax, self.xmax)
                    self.ax1.set_ylim(self.ymin, self.ymax)
                else:
                    self.ax1.set_xlim(self.xmin, self.xmax)
                    self.ax1.set_ylim(self.ymin, self.ymax)
            self.ax1.set_aspect('equal', 'box')
            self.fig1.savefig(self.output_path / 'Field_Map_v1_Full.svg', dpi=300)

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

            # Calculate total force:
            # TODO this needs to be cleaner, make use of index_dict. Lt doesnt seem to be used in the solver.
            index_offset = 0
            self.force_output_from_grid = np.zeros((len(self.inner_radius, )))
            self.force_current_index = []
            I = np.zeros((self.Br_conductor.shape[1],))
            ILt = np.zeros((self.Br_conductor.shape[1],)) # Length times turns
            Lt = np.zeros((self.Br_conductor.shape[1],)) # Length times turns
            for i in range(self.n_coil_elements):
                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.force_current_index.append(list(np.array(self.con_index_array_per_coil[i]) + index_offset - self.con_index_array_per_coil[i][0]))
                if self.no_parallel: # These two values are in principle the same wether no_parallel is true or false. If statement is here because in the future this may change.
                    ILt[list(np.array(self.con_index_array_per_coil[i]) + index_offset - self.con_index_array_per_coil[i][0])] += np.array(self.lengths)[self.con_index_array_per_coil[i]] * self.I_initial[i] / self.parallel[i]
                    Lt[list(np.array(self.con_index_array_per_coil[i]) + index_offset - self.con_index_array_per_coil[i][0])] += np.array(self.lengths)[self.con_index_array_per_coil[i]] / self.parallel[i]
                else:
                    ILt[list(np.array(self.con_index_array_per_coil[i]) + index_offset - self.con_index_array_per_coil[i][0])] += np.array(self.lengths)[self.con_index_array_per_coil[i]] * self.I_initial[i] / self.parallel[i]
                    Lt[list(np.array(self.con_index_array_per_coil[i]) + index_offset - self.con_index_array_per_coil[i][0])] += np.array(self.lengths)[self.con_index_array_per_coil[i]] / self.parallel[i]
                index_offset += len(self.con_index_array_per_coil[i])

            self.Lt = Lt.tolist()
        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 variablesc
            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')

            # Adding numbered labels to the fieldmap and resaving it
            numtxt = 0
            for num_region in range(self.n_coil_elements):
                for num in self.positions_center[num_region]:
                    self.ax1.text(num[0], num[1], str(numtxt), ha='center', va='center')
                    numtxt += 1
            self.fig1.savefig(self.output_path / 'Field_Map.svg', dpi=300)
        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,))

        # 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.
        # If multiple of these input parameters are provided, it will select the one based on this priority list.
        priority = ["tau_circuit", "resistivity_circuit", "tau", "resistivity"]  # priority order

        # Selects the first not-none attribute.
        selected_mode = self.select_attribute_name(priority)
        logging.info('To calculated the turn-to-turn resistance the %s parameter was used.' % selected_mode)

        # Remove circuit 0 from tau_circuit and resistivity_circuit if present
        # Circuit 0 is reserved for non-powered elements
        if self.tau_circuit is not None and 0 in self.tau_circuit:
            logging.warning('Circuit 0 detected in tau_circuit. Removing it as circuit 0 is reserved for non-powered elements.')
            del self.tau_circuit[0]

        if self.resistivity_circuit is not None and 0 in self.resistivity_circuit:
            logging.warning('Circuit 0 detected in resistivity_circuit. Removing it as circuit 0 is reserved for non-powered elements.')
            del self.resistivity_circuit[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)

        # The different functions.
        if selected_mode == 'tau_circuit':
            # This function distributes the resistance per circuit instead of per coil.

            # Making sure that the keys are the same in both the inductance dictionary and in the tau_circuit dictionary.
            matches = list(set(self.inductance_dict) & set(self.tau_circuit))
            mismatches = list(set(self.inductance_dict) ^ set(self.tau_circuit))
            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] / self.tau_circuit[i]

            # Calculating the lengths
            radial_resistance = np.zeros_like(self.lengths)  # The lengths array has the right length for this array. TODO Needs to be improved with a dict or class with basic connection information etc. r = np.zeros(basic_info.nr_connections).
            tau_list = np.zeros_like(self.lengths)  # Not used, zeros for now. Later it should get the inductance per element info, resistance info and calculate it.

            for i in matches:
                A_circ = []
                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.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)  # 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.

                resistivity = np.mean(np.array(R_radial_list) * A_circ)
                logging.info('\033[96mThe resistivity for circuit %i is calculated to be %f Ohmcm^2.\033[0m' % (i, float(resistivity)))

                # 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

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

        elif selected_mode == 'resistivity_circuit':
            radial_resistance = np.zeros_like(self.lengths)  # The lengths array has the right length for this array.
            tau_list = np.zeros_like(self.lengths)  # Not used, zeros for now. Later it should get the inductance per element info, resistance info and calculate it.

            resistance = {}
            tau_circuit = {}

            # This function distributes a set resistivity per circuit instead of per coil.
            for i in set(self.resistivity_circuit):
                A_circ = []
                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.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)  # Concatenate list of numpy array to one array.
                m_array = np.concatenate(m_array)  # Positions of all resistors.
                R_radial_list = list(self.resistivity_circuit[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)

                # Printing the time constants.
                resistance[i] = np.sum(R_radial_list)
                tau_circuit[i] = self.inductance_dict[i] / resistance[i]
                logging.info('The characteristic time for circuit %i is %f seconds.' % (i, tau_circuit[i]))

                # Putting the resistance back at the right spots in the list.
                radial_resistance[m_array] = R_radial_list

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


        elif selected_mode == 'tau':
            # Calculates the radial resistance
            self.radial_resistance = np.zeros_like(self.lengths)
            self.tau_list = np.zeros_like(self.lengths).tolist()  # Not used, zeros for now. Later it should get the inductance per element info, resistance info and calculate it.

            for i in range(self.n_coil_elements):
                # index with all inductive elements for that coil element
                n = self.con_index_array_per_coil[i]

                # Calculating the total resistance of the coil
                R_radial = self.mutual_output[i, i] / self.tau[i]

                # Calculating the total area, the resistance is divided accordingly.
                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
                Radial_resistance = distribute_inversely(R_radial, A_circ) # Distributes the total resistance inversely proportional to A_circ

                # Inserting the radial resistances in the right place in the radial_resistance array.
                self.radial_resistance[self.con_index_array_radial_per_coil[i]] = Radial_resistance

                # Check of the resistivity:
                resistivity = np.mean(np.array(Radial_resistance) * A_circ)
                logging.info('The resistivity for coil element %i is calculated to be %.3E Ohmcm^2.' % (i, float(resistivity)))

                # Making it a list:
            self.radial_resistance = self.radial_resistance.tolist()

        elif selected_mode == 'resistivity':
            # Assuring that the resistivity array has the correct type and length.
            if type(self.resistivity) in {float, np.float32, np.float64}:
                self.resistivity = [self.resistivity] * self.n_coil_elements
            elif type(self.resistivity) in {list, np.ndarray}:
                if len(self.resistivity) != self.n_coil_elements:
                    print('\033[93mThe resistivity array has the wrong length.\033[0m')

            self.radial_resistance = np.zeros_like(self.lengths)
            self.tau_list = np.zeros_like(self.lengths).tolist()  # Not used, zeros for now. Later it should get the inductance per element info, resistance info and calculate it.

            # Setting the resistivity.
            for i in range(self.n_coil_elements):
                # index with all inductive elements for that coil element
                n = self.con_index_array_per_coil[i]

                # Calculating the total area, the resistance is divided accordingly.
                A_circ = np.average(np.array(self.lengths[n[0]:n[-1] + 1]).reshape(-1, self.parallel[i]), axis=1) * self.thickness[i] * 10000 / self.multiplicity[i]**2  # From m^2 to cm^2.
                Radial_resistance = self.resistivity[i] * 1/A_circ

                # Inserting the radial resistances in the right place in the radial_resistance array.
                self.radial_resistance[self.con_index_array_radial_per_coil[i]] = Radial_resistance

                # Check of the tau:
                tau = self.mutual_output[i, i] / np.sum(Radial_resistance)
                logging.info('The characteristic time for coil element %i is %f seconds.' % (i, tau))

            self.radial_resistance = self.radial_resistance.tolist() # Changing it to a non-numpy list.
        else:
            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')

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

        # 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 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 and is overlaid with the coil geometry
        for reference.

        This method:
        1. Creates target points along the z-axis (r=0)
        2. Computes Bz contribution from each coil element
        3. Sums contributions accounting for initial currents
        4. Plots result on ax2 with geometry on ax2b (twin axis)
        5. Saves figure as 'Field_R0.svg' in output directory

        The plot accounts for parallel subdivisions when distributing
        currents and respects custom axis limits if specified.

        Returns:
            None

        Side Effects:
            - Updates ax2 and ax2b matplotlib axes
            - Saves Field_R0.svg to output directory
        """
        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

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

        if self.rotate_90:
            self.ax2b.set(xlim=(self.ymin, self.ymax), ylim=(-self.xmax, self.xmax))
        else:
            self.ax2b.set_xlim(-self.xmax, self.xmax)
            self.ax2b.set_ylim(self.ymin, self.ymax)

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

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

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

        self.fig2.tight_layout()

        self.fig2.savefig(self.output_path / 'Field_R0.svg')

    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] = []
        return None

    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)

    @staticmethod
    def discretize_loop(r, z, correction, subdivisions):
        """
        Transforms a 2D axisymmetric point into a 3D discretized current loop.

        Converts a single point (r, z) in the cylindrical r-z plane into a
        3D circular loop subdivided into straight-line segments. This
        discretization is necessary for numerical inductance calculations
        using the Neumann formula, which integrates over current paths.

        Each segment is represented by its center position (x, y, z) and
        tangent vector (dx, dy, dz) weighted by the correction factor.

        Args:
            r: Radial position of the loop center (m)
            z: Axial position of the loop center (m)
            correction: Scaling factor applied to each segment (typically
                turns/serial or turns/(serial*parallel))
            subdivisions: Number of segments to divide the loop into

        Returns:
            np.ndarray: 7×subdivisions array where each column is:
                [x, y, z, dx, dy, dz, correction]
                x, y, z: Segment center coordinates
                dx, dy, dz: Segment tangent vector components
                correction: Weighting factor for this segment

        Note:
            The discretization uses endpoint=False to avoid double-counting
            at 0 and 2π. Tangent vectors are computed analytically for
            circular geometry.
        """
        angle = np.linspace(0, 2*np.pi, subdivisions, endpoint=False)
        # length of segment
        dx = -r * np.sin(angle) * 2*np.pi / subdivisions
        dy =  r * np.cos(angle) * 2*np.pi / subdivisions
        dz =  np.zeros((subdivisions,))
        return np.column_stack((
            r*np.cos(angle),
            r*np.sin(angle),
            np.repeat(z, subdivisions),
            dx,
            dy,
            dz,
            np.repeat(correction, subdivisions)
        )).T


    def get_solenoid_scaler(self, solenoid_index):
        """
        Calculates the inductance scaling factor for a given coil element.

        Returns the effective number of turns per discretized element,
        accounting for series and parallel subdivisions. This factor scales
        the mutual inductance between individual discretized elements to
        represent the actual magnet geometry.

        The scaling depends on the no_parallel flag:
        - If no_parallel=False (default): Parallel subdivisions represent
          current paths in parallel (same inductance). Scale by
          turns/serial only.
        - If no_parallel=True: Parallel subdivisions represent physical
          sub-turns that contribute additively to inductance. Scale by
          turns/(serial*parallel).

        Args:
            solenoid_index: Index of the coil element (0 to n_coil_elements-1)

        Returns:
            float: Scaling factor (effective turns per element)

        Note:
            This method is called during inductance calculation to correctly
            weight the contribution of each discretized element to the total
            mutual inductance matrix.
        """
        # If no_parallel is set, then the parallel parameter is used as a means of subdivision, and will contribute to the total inductance
        if self.no_parallel:
            return self.turns[solenoid_index] / (self.serial[solenoid_index] * self.parallel[solenoid_index])
        else:
            return self.turns[solenoid_index] / (self.serial[solenoid_index])


    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'.
        """
        list_of_sub_elements = []
        # making the index for the mutual inductance matrix
        for i in range(self.n_coil_elements):
            scaler = self.get_solenoid_scaler(i)
            for coord in self.positions_center[i]:
                list_of_sub_elements.append(
                    NI_coil_geometry.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 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)

        Warnings are logged if element aspect ratios suggest that increasing
        inductance_r_divisions or inductance_z_divisions 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, increase
            inductance_r_divisions and inductance_z_divisions for better
            accuracy.
        """
        # 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 = []

        n = self.inductance_r_divisions
        m = self.inductance_z_divisions

        aspect_warning_given_r = False
        aspect_warning_given_z = False

        for coil_idx, solenoid_section_coords in enumerate(self.positions):
            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 aspect_warning_given_r == False:
                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 aspect_warning_given_z == False:
                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], self.get_solenoid_scaler(self.inductance_element_to_coil_nr[coil_idx])) for i in itertools.product(r, z)]

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

        k = n * m
        mutual_temp = np.add.reduceat(np.add.reduceat(mutual_temp, np.arange(0, mutual_temp.shape[0], k), axis=0), np.arange(0, mutual_temp.shape[1], k), axis=1) / k ** 2

        # Filling the mutual inductance matrix.
        for i, j in 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 \
            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

        ###########################################################################
        # I
        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:
        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)
            }
        }

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

        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
        }

        self.seed_points = create_seeds(self.seed_information, 5, 5)

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

        Builds self.index_dict with a three-level hierarchy:
        - Circuits: Groups of coils connected electrically
        - Coils: Individual coil elements (may contain multiple pancakes)
        - Pancakes: Individual repeated elements within a coil

        Each 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

        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': [...]}}
        }

        Returns:
            None

        Side Effects:
            - Populates self.index_dict with complete hierarchy
        """

        # 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'] = {}

        # 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 circuits:
        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_thermal_matrices_v2(self):
        """
        Creates thermal connection matrices (version 2 - radial only).

        Generates matrices and arrays for thermal diffusion modeling in the
        radial direction within each pancake. This version assumes:
        - Radial heat conduction between adjacent nodes (Fourier's law)
        - Adiabatic boundaries in axial direction between pancakes
        - 1D thermal diffusion equation per pancake

        Creates:
        - T_con_radial: Finite difference matrix for radial thermal diffusion
          (uses -1, +1 stencil for edges; -2, +1, +1 for interior)
        - T_con_inner: List of innermost node indices per pancake (for
          cooling boundary conditions)
        - T_con_outer: List of outermost node indices per pancake (for
          cooling boundary conditions)
        - Thermal_alpha: Heat conduction coefficients per node
        - Thermal_area: Contact areas per material per node (for cooling)
        - Thermal_index: Node indices organized 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

        Note:
            Version 2 is simpler than create_thermal_matrices() but less
            accurate for magnets with significant axial heat transfer.
        """
        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:
                                # Thermal_resistance_matrix[n, n] = 1.0
                                # Thermal_resistance_matrix[n, n+1] = 1.0
                                T_con_radial[n, n] = -1
                                T_con_radial[n, n + 1] = 1
                            elif k == (self.serial[i] - 1):
                                # Thermal_resistance_matrix[n, n] = 1.0
                                # Thermal_resistance_matrix[n, n + 1] = 1.0
                                T_con_radial[n, n] = -1
                                T_con_radial[n, n - 1] = 1
                            else:
                                # Thermal_resistance_matrix[n, n] = 1.0
                                # Thermal_resistance_matrix[n, n + 1] = 1.0
                                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_thermal_matrices(self):
        """
        Creates thermal connection matrices and power dissipation mapping.

        Generates matrices that connect:
        1. Power dissipation from electrical connections to thermal nodes
        2. Thermal conduction between adjacent nodes (radial only)

        The power_dissipation_matrix maps:
        - Rows: Thermal nodes (one per serial subdivision per pancake)
        - Columns: Electrical connections (inductive + radial)
        - Values: 1 if connection's dissipation heats that node, 0 otherwise

        Multiple parallel connections heating the same node have their
        power summed (multiple 1's in the same row).

        Also creates T1_con and T2_con lists defining pairs of thermally
        connected nodes for radial heat transfer between adjacent
        subdivisions within each pancake.

        Returns:
            bool: True upon completion

        Side Effects:
            - Saves power_dis_matrix.pkl to output directory
            - Sets self.T1_con, self.T2_con (pairs of connected nodes)

        Note:
            This method creates the power mapping but delegates the actual
            thermal connection matrix to create_thermal_matrices_v2().
        """

        # Creates a matrix that connects the power dissipated to the correct thermal node.
        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')

        # Creates the thermal connection matrix.
        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

        return True

    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:
            profiler.disable()
            s = io.StringIO()
            stats = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
            stats.print_stats(25)
            profiler.dump_stats(self.output_path / 'profile.pstats')
            with (self.output_path / 'profiler.txt').open('w+') as f:
                f.write(s.getvalue())

    def plot_mutual(self):
        """
        Plots the mutual inductance matrix sparsity pattern.

        Creates a binary visualization of the mutual inductance matrix
        structure, showing which connections are magnetically coupled
        (mutual inductance > 0).

        The plot helps visualize:
        - Block diagonal structure (strong coupling within coils)
        - Off-diagonal blocks (coupling between coils)
        - Sparsity pattern (most connections have negligible coupling)

        Overlays both row and column patterns with low opacity to show
        matrix symmetry.

        Returns:
            None

        Side Effects:
            - Updates ax2 matplotlib axis with mutual inductance pattern

        Note:
            This is primarily a debugging visualization and is not
            automatically called during normal geometry creation.
        """
        mutual = np.where(abs(self.mutual) > 0, 1, 0)
        for i in range(mutual.shape[0]):
            self.ax2.plot([i] * mutual.shape[0], np.arange(mutual.shape[0]), mutual[i, :], color='black', alpha=0.1)
            self.ax2.plot(np.arange(mutual.shape[0]), [i] * mutual.shape[0], mutual[:, i], color='black', alpha=0.1)

    def select_attribute_name(self, priority):
        """
        Selects first non-None attribute from a priority list.

        Helper method used to determine which input parameter was provided
        for turn-to-turn resistance specification. Checks attributes in
        priority order and returns the name of the first one that is not None.

        Args:
            priority: List of attribute names to check (strings)

        Returns:
            str or None: Name of first non-None attribute, or None if all
                are None

        Example:
            >>> priority = ["tau_circuit", "resistivity_circuit", "tau"]
            >>> self.tau_circuit = None
            >>> self.resistivity_circuit = {1: 0.1}
            >>> self.select_attribute_name(priority)
            'resistivity_circuit'

        Note:
            Used in create_geometry() to select between tau_circuit,
            resistivity_circuit, tau, and resistivity parameters.
        """
        for name in priority:
            if getattr(self, name) is not None:
                return name
        return None

    @staticmethod
    def density_selector(mat):
        """
        Returns material density in kg/m³ for common conductor materials.

        Looks up the density of standard materials used in magnet
        construction. Numbers in material names (e.g., "Cu100" for RRR=100
        copper) are stripped before lookup since density is independent
        of RRR value.

        Supported materials:
        - Copper (Cu): 8960 kg/m³
        - Hastelloy: 8890 kg/m³
        - Silver (Ag): 10490 kg/m³
        - Aluminum (Al, Al6061): 2700 kg/m³
        - Others: 8000 kg/m³ (generic metal, with warning)

        Args:
            mat: Material name string (case-insensitive)

        Returns:
            float: Material density in kg/m³

        Side Effects:
            Prints warning if material is not recognized

        Example:
            >>> NI_coil_geometry.density_selector("Cu100")
            8960.0
            >>> NI_coil_geometry.density_selector("Al6061")
            2700.0
        """
        # mat_rrr = re.findall(r'\d+', mat)  # Allows materials like "Cu20" -> mat = 'Cu' and rrr = '20.0'
        mat = re.sub(r'\d+', "", mat)
        if mat in ['Cu', 'cu', 'copper']:
            return 8960.0
        elif mat in ['Hastelloy', 'hastelloy', 'Hast', 'hast']:
            return 8890.0
        elif mat in ['Ag', 'Silver', 'silver']:
            return 10490.0
        elif mat in ['Al', 'Aluminium', 'Aluminum', 'Al6061']:
            return 2700.0
        else:
            print('!!Material not recognized!! Giving it a generatic density of 8000 kg/m3, Material:', mat)
            return 8000.0

if __name__ == "__main__":
    import cProfile, pstats
    profiler = cProfile.Profile()
    start_time = time.time()
    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('No input yaml file provided, using the test.yaml file.')
        NICO = NI_coil_geometry('geometry/input_yaml/test_geometry_6_AMS_100.yaml', 'geometry/output/test_geometry_6_output', profile=True)
    print(f'Preprocessor completed in {time.time() - start_time:.2f}s')
