__init__.py

docstring
    Standard Python initialiser handling imports from modules and version number
body
from necbol.components import *
from necbol.visualiser import *
from necbol.analyser import *
from necbol.modeller import *
from necbol.optimisers import *

from importlib.metadata import version
try:
    __version__ = version("necbol")
except:
    __version__ = ""
print(f"\nNECBOL V{__version__} by Dr Alan Robinson G1OJS\n\n")



analyser.py

body

🧾 def vswr(model, Z0 = 50):

docstring
        Return the antenna VSWR at the feed point assuming a 50 ohm system
        Or another value if specified
body
    try:
        with open(model.nec_out) as f:
            while "ANTENNA INPUT PARAMETERS" not in f.readline():
                pass
            for _ in range(4):
                l = f.readline()
            if model.verbose:
                print("Z line:", l.strip())
            r = float(l[60:72])
            x = float(l[72:84])
    except (RuntimeError, ValueError):
        raise ValueError(f"Something went wrong reading input impedance from {nec_out}")

    z_in = r + x * 1j
    gamma = (z_in - Z0) / (z_in + Z0)
    return (1 + abs(gamma)) / (1 - abs(gamma))

def get_gains_at_gain_point(model):
    try:
        pattern = _read_radiation_pattern(model.nec_out, model.az_datum_deg, model.el_datum_deg)
        gains_at_point = [d for d in pattern if (abs(d['elevation_deg'] - model.el_datum_deg) < 0.1) and (abs(d['azimuth_deg'] - model.az_datum_deg) < 0.1)][0]
        
    except (RuntimeError, ValueError):
        print("Trying to read gains at {azimuth_deg}, {elevation_deg}")
        raise ValueError(f"Something went wrong reading gains from {nec_out}")

    return gains_at_point


def plot_pattern_gains(model, azimuth_deg = None, elevation_deg = None, components = ['vert_gain_dBi', 'horiz_gain_dBi'], gain_scale_max = 0, gain_scale_range_dB = 30):
    import matplotlib.pyplot as plt
    import numpy as np

    if(elevation_deg is not None and azimuth_deg is not None):
        Print("Can't plot a 3D pattern, please select azimuth or elevation only")
        return

    if (elevation_deg is None and azimuth_deg is None):
        elevation_deg = model.el_datum_deg

    title = model.model_name

    # Filter data for fixed elevation (theta)
    if(elevation_deg is not None):
        print(f"Reading gain pattern for elevation = {elevation_deg}")
        pattern_data = _read_radiation_pattern(model.nec_out, azimuth_deg = azimuth_deg, elevation_deg = None)
        
        cut = [d for d in pattern_data if abs(d['elevation_deg'] - elevation_deg) < 0.1]
        angle_deg = [d['azimuth_deg'] for d in cut]
        title += f' elevation = {elevation_deg}°'

    # Filter data for fixed azimuth (phi)
    if(azimuth_deg is not None):
        print(f"Reading gain pattern for azimuth = {azimuth_deg}")
        pattern_data = _read_radiation_pattern(model.nec_out, azimuth_deg = None, elevation_deg = elevation_deg)
        cut = [d for d in pattern_data if abs(d['azimuth_deg'] - azimuth_deg) < 0.1]
        angle_deg = [d['elevation_deg'] for d in cut]
        title += f' azimuth = {azimuth_deg}°'
 
    fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})

    print("Plotting gain pattern")
    scl_max = gain_scale_max
    for comp in components:
        gain = [d[comp] for d in cut]
        scl_max = max(scl_max,max(gain))
        angle_rad = np.radians(angle_deg)
        ax.plot(angle_rad, gain, label = comp)
        print(comp, f" max gain: {max(gain)}")
    ax.legend()
    ax.set_title(title)
    ax.grid(True)

    scl_max = 5 * (1+ int(scl_max/5))
    ax.set_rmax(scl_max)
    ax.set_rmin(scl_max - gain_scale_range_dB )
    ax.set_rlabel_position(90)

    # Enable interactive mode for non-blocking plotting
    plt.ion()

    # Display the plot window in non-blocking mode
    plt.show(block=False)




#==================================================================
# Internal functions
#==================================================================

import numpy as np
import copy



def _get_complex_component(pat_data, component):
    m = np.array([d[component + '_mag'] for d in pat_data])
    p = np.radians([d[component + '_phase_deg'] for d in pat_data])
    Z = m * np.exp(1j * p)
    return Z


def _plot_difference_field(model_A, model_B, **kwargs):
    import copy
    # needs work to check if patterns don't match az, el 1:1 and if model datums are different
    pattern_A = _read_radiation_pattern(model_A.nec_out)
    pattern_B = _read_radiation_pattern(model_B.nec_out)
    model_diff = copy.deepcopy(model_A)
    model_diff.set_name(f"Scattered field {model_A.model_name} minus {model_A.model_name}")
    diff_pattern = _subtract_field_patterns(pattern_A, pattern_B)
    _write_radiation_pattern(diff_pattern, model_diff.nec_out)
    plot_pattern_gains(model_diff, **kwargs)

def _subtract_field_patterns(pat1, pat2):
    Z_theta_1 = _get_complex_component(pat1, 'E_theta')
    Z_theta_2 = _get_complex_component(pat2, 'E_theta')
    Z_phi_1 = _get_complex_component(pat1, 'E_phi')
    Z_phi_2 = _get_complex_component(pat2, 'E_phi')

    output_pattern = []
    for i, d in enumerate(pat1):
        E_theta = Z_theta_1[i] - Z_theta_2[i]
        E_phi = Z_phi_1[i] - Z_phi_2[i]
        diff_dict = _compute_full_farfield_metrics(E_theta, E_phi)
        diff_dict.update({'azimuth_deg':d['azimuth_deg']})
        diff_dict.update({'elevation_deg':d['elevation_deg']})
        output_pattern.append(diff_dict)

    return output_pattern


def _write_radiation_pattern(pattern, file_path):
    field_formats = [
        "8.2f", "9.2f", "11.2f", "8.2f", "8.2f",
        "11.5f", "9.2f", "8s",
        "15.5e", "9.2f", "15.5e", "9.2f"
    ]
    op_keys = [
        'elevation_deg', 'azimuth_deg', 'vert_gain_dBi', 'horiz_gain_dBi', 'total_gain_dBi',
        'axial_ratio_dB', 'tilt_deg', 'sense',
        'E_theta_mag', 'E_theta_phase_deg', 'E_phi_mag', 'E_phi_phase_deg'
    ]

    n_theta, n_phi, theta_step, phi_step = _extract_pattern_grid_info(pattern)
    print(n_theta, n_phi, theta_step, phi_step )
    
    with open(file_path, "w") as f:
        f.write(f" ***** DATA CARD NO.  5   RP   0   {n_theta}    {n_phi}  1003 0 0  {theta_step:.1f}  {phi_step:.1f} 0.0 0.0\n\n")
        f.write("                                                - - - RADIATION PATTERNS - - -\n")
        f.write("\n")
        f.write("  - - ANGLES - -           - POWER GAINS -       - - - POLARIZATION - - -    - - - E(THETA) - - -    - - - E(PHI) - - -\n")
        f.write("  THETA     PHI        VERT.   HOR.    TOTAL      AXIAL     TILT   SENSE     MAGNITUDE    PHASE      MAGNITUDE    PHASE \n")
        f.write(" DEGREES  DEGREES       DB      DB      DB        RATIO     DEG.              VOLTS/M    DEGREES      VOLTS/M    DEGREES\n")
        
        for row in pattern:
            line = ""
            for fmt, key in zip(field_formats, op_keys):
                val = row[key]
                if(key == 'elevation_deg'):
                    val = 90-val
                if(key == 'sense'):
                    val = ' '+val
                line += f"{val:{fmt}}"
            f.write(line.rstrip() + "\n")

        f.write("\n RUN TIME =   0\n")


🧾 def _read_radiation_pattern(filepath, azimuth_deg = None, elevation_deg = None):

docstring
        Read the radiation pattern into a Python dictionary:
        'azimuth_deg': float,
        'elevation_deg': float,
        'vert_gain_dBi': float,
        'horiz_gain_dBi': float,
        'total_gain_dBi': float,
        'axial_ratio_dB': float,
        'tilt_deg': float,
        'sense': string,
        'E_theta_mag': float,
        'E_theta_phase_deg': float,
        'E_phi_mag': float,
        'E_phi_phase_deg': float
body
    data = []
    thetas = set()
    phis = set()
    in_data = False
    start_lineNo = 1e9
    with open(filepath) as f:
        lines = f.readlines()
    for lineNo, line in enumerate(lines):
        if ('RADIATION PATTERNS' in line):
            in_data = True
            start_lineNo = lineNo + 5

        if (lineNo > start_lineNo and line=="\n"):
            in_data = False
            
        if (in_data and lineNo >= start_lineNo):
            theta = float(line[1:8])
            phi = float(line[10:17])
            thetas.add(theta)
            phis.add(phi)
            if (elevation_deg is not None and theta != 90 - elevation_deg):
                continue
            if (azimuth_deg is not None and phi != azimuth_deg):
                continue
            data.append({
                'azimuth_deg': phi,
                'elevation_deg': 90 - theta,
                'vert_gain_dBi': float(line[21:28]),
                'horiz_gain_dBi': float(line[29:36]),
                'total_gain_dBi': float(line[37:44]),
                'axial_ratio_dB': float(line[48:55]),
                'tilt_deg': float(line[57:64]),
                'sense': line[65:72].strip(),
                'E_theta_mag': float(line[74:87]),
                'E_theta_phase_deg': float(line[88:96]),
                'E_phi_mag': float(line[98:111]),
                'E_phi_phase_deg': float(line[112:120])
            })

    if (len(data) == 0):
        print(f"Looking for gain at phi = {azimuth_deg}, theta = {90 - elevation_deg} in")
        print(f"Thetas = {thetas}")
        print(f"Phis = {phis}")
        raise EOFError(f"Failed to read needed data in {filepath}. Check for NEC errors.")

    return data




import math
import cmath

def _compute_full_farfield_metrics(E_theta, E_phi):
    # Phase & magnitude
    E_theta_phase_deg = 180*cmath.phase(E_theta)/cmath.pi
    E_phi_phase_deg = 180*cmath.phase(E_phi)/cmath.pi
    E_theta_mag = abs(E_theta)
    E_phi_mag = abs(E_phi)

    # Total field magnitude squared
    total_power = abs(E_theta)**2 + abs(E_phi)**2
    total_gain_dBi = 10 * math.log10(total_power) if total_power > 0 else -999

    # RHCP and LHCP components
    E_rhcp = (E_theta - 1j * E_phi) / math.sqrt(2)
    E_lhcp = (E_theta + 1j * E_phi) / math.sqrt(2)

    rhcp_power = abs(E_rhcp)**2
    lhcp_power = abs(E_lhcp)**2

    rhcp_gain_dBi = 10 * math.log10(rhcp_power) if rhcp_power > 0 else -999
    lhcp_gain_dBi = 10 * math.log10(lhcp_power) if lhcp_power > 0 else -999

    # Axial ratio in dB
    max_pol_power = max(rhcp_power, lhcp_power)
    min_pol_power = min(rhcp_power, lhcp_power)
    if min_pol_power == 0:
        axial_ratio_dB = 999
    else:
        axial_ratio_dB = 10 * math.log10(max_pol_power / min_pol_power)

    # Polarization sense
    if axial_ratio_dB == 999:
        polarization_sense = "Linear"
    elif rhcp_power > lhcp_power:
        polarization_sense = "RHCP"
    else:
        polarization_sense = "LHCP"

    # Polarization ellipse tilt (major axis orientation in θ–φ plane)
    delta = math.radians(E_theta_phase_deg - E_phi_phase_deg)
    if E_theta_mag != 0 and E_phi_mag != 0:
        tilt_rad = 0.5 * math.atan2(
            2 * E_theta_mag * E_phi_mag * math.cos(delta),
            E_theta_mag**2 - E_phi_mag**2
        )
        polarization_tilt_deg = math.degrees(tilt_rad)
    else:
        polarization_tilt_deg = 0.0

    # Vertical and horizontal gain projections
    #   - vertical polarization corresponds to E_theta
    #   - horizontal polarization corresponds to E_phi
    # (this is the convention used in 4NEC2's output)

    vert_power = abs(E_theta)**2
    horiz_power = abs(E_phi)**2

    vert_gain_dBi = 10 * math.log10(vert_power) if vert_power > 0 else -999
    horiz_gain_dBi = 10 * math.log10(horiz_power) if horiz_power > 0 else -999

    return {
        'vert_gain_dBi': vert_gain_dBi,
        'horiz_gain_dBi': horiz_gain_dBi,
        'total_gain_dBi': total_gain_dBi,
        'axial_ratio_dB': axial_ratio_dB,
        'tilt_deg': polarization_tilt_deg,
        'sense': polarization_sense,
        'E_theta_mag': E_theta_mag,
        'E_theta_phase_deg': E_theta_phase_deg,
        'E_phi_mag': E_phi_mag,
        'E_phi_phase_deg': E_phi_phase_deg,
        'rhcp_gain_dBi': rhcp_gain_dBi,
        'lhcp_gain_dBi': lhcp_gain_dBi
    }


def _extract_pattern_grid_info(pattern):
    # Compute theta and phi for each entry
    for entry in pattern:
        entry['theta_deg'] = 90.0 - entry['elevation_deg']
        entry['phi_deg'] = entry['azimuth_deg']

    # Extract sorted unique theta and phi values
    theta_vals = sorted(set(round(e['theta_deg'], 5) for e in pattern))
    phi_vals = sorted(set(round(e['phi_deg'], 5) for e in pattern))

    # Determine steps (assuming uniform grid)
    if len(theta_vals) > 1:
        theta_step = round(theta_vals[1] - theta_vals[0], 5)
    else:
        theta_step = 0.0

    if len(phi_vals) > 1:
        phi_step = round(phi_vals[1] - phi_vals[0], 5)
    else:
        phi_step = 0.0

    n_theta = len(theta_vals)
    n_phi = len(phi_vals)

    return n_theta, n_phi, theta_step, phi_step



def _get_available_results(model):
    # need to add here whether over full pattern or e.g. azimuth cut
 #   model.max_total_gain = max(d['total_gain_dBi'] for d in pattern_data)
    model.vswr = vswr(model)

    

modeller.py

body
import numpy as np
import math
import warnings
import subprocess
import os
import time

#=================================================================================
# NEC Wrapper functions for writing .nec file and reading output
#=================================================================================

class NECModel:

def __init__(self, working_dir, nec_exe_path, model_name = "Unnamed_Antennna", verbose=False):
        self.verbose = verbose
        self.working_dir = working_dir
        self.nec_exe = nec_exe_path
        self.nec_bat = working_dir + "\\nec.bat"
        self.nec_in = working_dir + "\\" + model_name +  ".nec"
        self.nec_out = working_dir + "\\" + model_name +  ".out"
        self.files_txt = working_dir + "\\files.txt"
        self.model_name = model_name
        self.nSegs_per_wavelength = 40
        self.segLength_m = 0
        self._units = _units()
        self.default_wire_sigma = None
        self.MHz = None
        self.MHz_stop = None
        self.MHz_step = None
        self.origin_height_m = 0
        self.segLength_m
        self.el_datum_deg = 0
        self.az_datum_deg = 0
        self.az_step_deg = 10
        self.el_step_deg = 5
        self.ground_sigma = 0
        self.ground_Er = 1.0
        self.geometry = []
        self.EX_tag = 999
        self.LOADS = []
        self.LOADS_start_tag = 8000
        self.max_total_gain = None
        self.vswr = None
        self.tags_info = []

🧾 def set_name(self, name):

docstring
            Set the name of the model. This is used in NEC input file generation and is reflected in the NEC
            output file name. It is permissible to use this function to re-set the name after a NEC run has completed,
            so that the analysis continues (with updated input parameters) and outputs more than one test case
body
        self.model_name = name
        self.nec_in = self.working_dir + "\\" + self.model_name +  ".nec"
        self.nec_out = self.working_dir + "\\" + self.model_name +  ".out"
        self._write_runner_files()

🧾 def set_wire_conductivity(self, sigma):

docstring
            Set wire conductivity to be assumed for all wires that don't have an explicitly-set load.
body
        self.default_wire_sigma = sigma
        self.LOADS.append({'iTag': 0, 'load_type': 'conductivity', 'RoLuCp': (sigma, 0, 0), 'alpha': None})
  
🧾 def set_frequency(self, MHz):

docstring
            Request NEC to perform all analysis at the specified frequency. 
body
        self.MHz = MHz
        lambda_m = 300/MHz
        self.segLength_m = lambda_m / self.nSegs_per_wavelength

🧾 def set_gain_point(self, azimuth_deg, elevation_deg):

docstring
            Set the azimuth and elevation of a single gain point that
            must appear in the output radiation pattern
body
        self.az_datum_deg = azimuth_deg
        self.el_datum_deg = elevation_deg


🧾 def set_angular_resolution(self, az_step_deg, el_step_deg):

docstring
            Set resolution required in az and el in degrees
            If a ground is specified, NEC will be asked for a hemisphere, otherwise a sphere
body
        self.az_step_deg = az_step_deg
        self.el_step_deg = el_step_deg

🧾 def set_ground(self, eps_r, sigma, **origin_height):

docstring
            Sets the ground relative permitivity and conductivity. Currently limited to simple choices.
            If eps_r = 1, nec is told to use no ground (free space model), and you may omit the origin height parameter
            If you don't call this function, free space will be assumed.
            Othewise you should set the origin height so that the antenna reference point X,Y,Z = (0,0,0) is set to be
            the specified distance above ground.
            Parameters:
                eps_r (float): relative permittivity (relative dielectric constant) of the ground
                sigma (float): conductivity of the ground in mhos/meter
                origin_height_{units_string} (float): Height of antenna reference point X,Y,Z = (0,0,0)
body
        self.ground_Er = eps_r
        self.ground_sigma = sigma
        if(eps_r >1.0):
            self.origin_height_m = self._units._from_suffixed_dimensions(origin_height)['origin_height_m']
            if(self.el_datum_deg <= 0):
                self.el_datum_deg = 1

🧾 def place_RLC_load(self, geomObj, R_Ohms, L_uH, C_pf, load_type = 'series', load_alpha_object=-1, load_wire_index=-1, load_alpha_wire=-1):

docstring
            inserts a single segment containing an RLC load into an existing geometry object
            Position within the object is specied as
            EITHER:
              load_alpha_object (range 0 to 1) as a parameter specifying the length of
                                wire traversed to reach the item by following each wire in the object,
                                divided by the length of all wires in the object
                                (This is intended to be used for objects like circular loops where there
                                are many short wires each of the same length)
            OR:
              load_wire_index AND load_alpha_wire
              which specify the i'th wire (0 to n-1) in the n wires in the object, and the distance along that
              wire divided by that wire's length

            NEC LD card specification: https://www.nec2.org/part_3/cards/ld.html
body
        iTag = self.LOADS_start_tag + len(self.LOADS)
        self.LOADS.append({'iTag': iTag, 'load_type': load_type, 'RoLuCp': (R_Ohms, L_uH, C_pf), 'alpha': None})
        self._insert_special_segment(geomObj, iTag, load_alpha_object, load_wire_index, load_alpha_wire)

🧾 def place_feed(self, geomObj, feed_alpha_object=-1, feed_wire_index=-1, feed_alpha_wire=-1):

docstring
            Inserts a single segment containing the excitation point into an existing geometry object.
            Position within the object is specied as
            EITHER:
              feed_alpha_object (range 0 to 1) as a parameter specifying the length of
                                wire traversed to reach the item by following each wire in the object,
                                divided by the length of all wires in the object
                                (This is intended to be used for objects like circular loops where there
                                are many short wires each of the same length)
            OR:
              feed_wire_index AND feed_alpha_wire
              which specify the i'th wire (0 to n-1) in the n wires in the object, and the distance along that
              wire divided by that wire's length
body
        self._insert_special_segment(geomObj, self.EX_tag, feed_alpha_object, feed_wire_index, feed_alpha_wire)
   
🧾 def add(self, geomObj, wireframe_color = 'darkgoldenrod'):

docstring
            Add a completed component to the specified model: model_name.add(component_name). Any changes made
            to the component after this point are ignored.
body
        geomObj.wireframe_color = wireframe_color
        self.tags_info.append({'base_tag':geomObj.base_tag,'wf_col':geomObj.wireframe_color})
        self.geometry.append(geomObj)

🧾 def write_nec(self):

docstring
            Write the entire model to the NEC input file ready for analysis. At this point, the function
            "show_wires_from_file" may be used to see the specified geometry in a 3D view.
body
        print("Writing NEC input file")
        self._write_runner_files()
        
        # open the .nec file
        with open(self.nec_in, "w") as f:
            f.write("CM\nCE\n")
            
            # Write GW lines for all geometry
            for geomObj in self.geometry:
                for w in geomObj._get_wires():
                    #print(f"Write wire with tag {w['iTag']}")
                    A = np.array(w["a"], dtype=float)
                    B = np.array(w["b"], dtype=float)
                    if(w['nS'] == 0): # calculate and update number of segments only if not already present
                        w['nS'] = 1+int(np.linalg.norm(B-A) / self.segLength_m)
                    f.write(f"GW {w['iTag']} {w['nS']} ")
                    f.write(' '.join([f"{A[i]:.3f} " for i in range(3)]))
                    f.write(' '.join([f"{B[i]:.3f} " for i in range(3)]))
                    f.write(f" {w['wr']}\n")

            # Write GE card, Ground Card, and GM card to set origin height
            if self.ground_Er == 1.0:
                f.write("GE 0\n")
            else:
                f.write(f"GM 0 0 0 0 0 0 0 {self.origin_height_m:.3f}\n")
                f.write("GE -1\n")
                f.write(f"GN 2 0 0 0 {self.ground_Er:.3f} {self.ground_sigma:.3f} \n")
            
            # Write EK card
            f.write("EK\n")

            # Write out the loads
            for LD in self.LOADS:
                LDTYP = ['series','parallel','series_per_metre','parallel_per_metre','impedance_not_used','conductivity'].index(LD['load_type'])
                LDTAG = LD['iTag']
                R_Ohms, L_uH , C_pF = LD['RoLuCp']
                # these strings are set programatically so shouldn't need an error trap
                f.write(f"LD {LDTYP} {LDTAG} 0 0 {R_Ohms:8.4e} {L_uH * 1e-6:8.4e} {C_pF * 1e-12:8.4e}\n")

            # Feed
            f.write(f"EX 0 {self.EX_tag} 1 0 1 0\n")

            # Frequency
            # update to include sweep
            f.write(f"FR 0 1 0 0 {self.MHz:.3f} 0\n")

            # Pattern points
            n_phi = 1 + int(360 / self.az_step_deg)
            d_phi = 360 / (n_phi - 1)
            phi_start_deg = self.az_datum_deg
            if self.ground_Er == 1.0:  # free space, no ground card, full sphere is appropriate
                theta_start_deg, d_theta, n_theta = self._set_theta_grid_from_el_datum(self.el_datum_deg, self.el_step_deg, hemisphere = False)
                f.write(f"RP 0 {n_theta} {n_phi} 1003 {theta_start_deg} {phi_start_deg} {d_theta} {d_phi}\n")
            else:                       # ground exists, upper hemisphere is appropriate
                theta_start_deg, d_theta, n_theta = self._set_theta_grid_from_el_datum(self.el_datum_deg, self.el_step_deg, hemisphere = True)
                f.write(f"RP 0 {n_theta} {n_phi} 1003 {theta_start_deg} {phi_start_deg} {d_theta} {d_phi}\n")
                
            f.write("EN")

🧾 def run_nec(self):

docstring
        Pass the model file to NEC for analysis and wait for the output.
body
        try:
            os.remove(self.nec_out)
        except FileNotFoundError:
            pass

        print("Running NEC")
        subprocess.Popen([self.nec_bat], creationflags=subprocess.CREATE_NO_WINDOW)

        factored = False
        pattern_started = False
        st = time.time()

        while True:
            try:
                with open(self.nec_out, "r") as f:
                    lines = f.readlines()
                    for line in lines:
                        if "FACTOR=" in line and not factored:
                            factored = True
                            print("Matrix factored")
                        if "RADIATION" in line and not pattern_started:
                            pattern_started = True
                            print("Calculating pattern")
                        if "ERROR" in line:
                            raise Exception(f"NEC Error: {line.strip()}")
                        if "RUN" in line:
                            print("NEC run completed")
                            return  # success
            except FileNotFoundError:
                pass  # Output not yet created
                if time.time() - st > 10:
                    raise Exception("Timeout waiting for NEC to start")

            time.sleep(0.5)


#===============================================================
# internal functions for class NECModel
#===============================================================

🧾 def _set_frequency_sweep(self, MHz, MHz_stop, MHz_step):

docstring
            Set parameters for frequency sweep. This also sets the angular pattern resolution
            to 10 degrees in azimuth and elevation to limit the size of output files           
body
        self.MHz_stop = MHz_stop
        self.MHz_step = MHz_step
        self.MHz = MHz
        lambda_m = 300/MHz_stop
        self.segLength_m = lambda_m / self.nSegs_per_wavelength
        self.set_angular_resolution(10,10)


def _set_theta_grid_from_el_datum(self, el_datum_deg, el_step_deg, hemisphere = True):
        theta_datum = 90 - el_datum_deg
        d_theta = el_step_deg
        theta_range = 90 if hemisphere else 180

        theta_start = theta_datum % d_theta
        # Fix float errors (e.g. 0.0000001)
        theta_start = round(theta_start, 6)

        # Clip to max range
        max_theta = theta_start + d_theta * (int((theta_range - theta_start) / d_theta))
        n_theta = 1 + int((max_theta - theta_start) / d_theta)
        return theta_start, d_theta, n_theta

🧾 def _write_runner_files(self):

docstring
            Write the .bat file to start NEC, and 'files.txt' to tell NEC the name of the input and output files
body
        for filepath, content in [
            (self.nec_bat, f"{self.nec_exe} < {self.files_txt} \n"),
            (self.files_txt, f"{self.nec_in}\n{self.nec_out}\n")
        ]:
            directory = os.path.dirname(filepath)
            if directory and not os.path.exists(directory):
                os.makedirs(directory)  # create directory if it doesn't exist
            try:
                with open(filepath, "w") as f:
                    f.write(content)
            except Exception as e:
                print(f"Error writing file {filepath}: {e}")

🧾 def _insert_special_segment(self, geomObj, item_iTag, item_alpha_object, item_wire_index, item_alpha_wire):

docstring
            inserts a single segment with a specified iTag into an existing geometry object
            position within the object is specied as either item_alpha_object or item_wire_index, item_alpha_wire
            (see calling functions for more details)
body
        wires = geomObj._get_wires()
        if(item_alpha_object >=0):
            item_wire_index = min(len(wires)-1,int(item_alpha_object*len(wires))) # 0 to nWires -1
            item_alpha_wire = item_alpha_object - item_wire_index
        w = wires[item_wire_index]       

        # calculate wire length vector AB, length a to b and distance from a to feed point
        A = np.array(w["a"], dtype=float)
        B = np.array(w["b"], dtype=float)
        AB = B-A
        wLen = np.linalg.norm(AB)
        feedDist = wLen * item_alpha_wire

        if (wLen <= self.segLength_m):
            # feed segment is all of this wire, so no need to split
            # print(f"Change wire itag from {w['iTag']} to {item_iTag}")
            w['nS'] = 1
            w['iTag'] = item_iTag
        else:
            # split the wire AB into three wires: A to C, CD (feed segment), D to B
            nS1 = int(feedDist / self.segLength_m)              # no need for min of 1 as we always have the feed segment
            C = A + AB * (nS1 * self.segLength_m) / wLen        # feed segment end a
            D = A + AB * ((nS1+1) * self.segLength_m) / wLen    # feed segment end b
            nS2 = int((wLen-feedDist) / self.segLength_m)       # no need for min of 1 as we always have the feed segment
            # write results back to geomObj: modify existing wire to end at C, add feed segment CD and final wire DB
            # (nonzero nS field is preserved during segmentation in 'add')
            w['b'] = tuple(C)
            w['nS'] = nS1
            geomObj._add_wire(item_iTag , 1, *C, *D, w["wr"])
            geomObj._add_wire(w["iTag"] , nS2, *D, *B, w["wr"])
            

#=================================================================================
# The geometry object that holds a single component plus its methods
#=================================================================================

class GeometryObject:

def __init__(self, wires):
        self.wires = wires  # list of wire dicts with iTag, nS, x1, y1, ...
        self._units = _units()
        self.wireframe_color = None

🧾 def translate(self, **params):

docstring
            Translate an object by dx, dy, dz
            Arguments are dx_{units}, dy_{units}, dz_{units}
body
        params_m = self._units._from_suffixed_dimensions(params)
        for w in self.wires:
            w['a'] = tuple(map(float,np.array(w['a']) + np.array([params_m.get('dx_m'), params_m.get('dy_m'), params_m.get('dz_m')])))
            w['b'] = tuple(map(float,np.array(w['b']) + np.array([params_m.get('dx_m'), params_m.get('dy_m'), params_m.get('dz_m')])))

🧾 def rotate_ZtoY(self):

docstring
            Rotate the object through 90 degrees around X
body
        R = np.array([[1, 0, 0],[0,  0, 1],[0,  -1, 0]])
        return self._rotate(R)
    
🧾 def rotate_ZtoX(self):

docstring
            Rotate the object through 90 degrees around Y
body
        R = np.array([[0, 0, 1],[0,  1, 0],[-1,  0, 0]])
        return self._rotate(R)

🧾 def rotate_around_X(self, angle_deg):

docstring
            Rotate the object through angle_deg degrees around X
body
        ca, sa = self._cos_sin(angle_deg)
        R = np.array([[1, 0, 0],
                      [0, ca, -sa],
                      [0, sa, ca]])
        return self._rotate(R)

🧾 def rotate_around_Y(self, angle_deg):

docstring
            Rotate the object through angle_deg degrees around Y
body
        ca, sa = self._cos_sin(angle_deg)
        R = np.array([[ca, 0, sa],
                      [0, 1, 0],
                      [-sa, 0, ca]])
        return self._rotate(R)

🧾 def rotate_around_Z(self, angle_deg):

docstring
            Rotate the object through angle_deg degrees around Z
body
        ca, sa = self._cos_sin(angle_deg)
        R = np.array([[ca, -sa, 0],
                      [sa, ca, 0],
                      [0, 0, 1]])
        return self._rotate(R)

🧾 def connect_ends(self, other, tol=1e-3, verbose = False):

docstring
            Check both ends of the wire to see if they lie on any wires in the specified object,
            and if so, split the wires of the specified object so that NEC considers them to be
            a valid T junction. Usage is:

            wire.connect_ends(object, [tol in m], [verbose])

            if verbose is True, details of the wire connection(s) are printed
body
        wires_to_add=[]
        for ws in self.wires:
            if(verbose):
                print(f"\nChecking if ends of wire from {ws['a']} to {ws['b']} should connect to any of {len(other.wires)} other wires:")
            for es in [ws["a"], ws["b"]]:
                for wo in other.wires:
                    if (self._point_should_connect_to_wire(es,wo,tol)):
                        wire_seg_status = f"{wo['nS']} segment" if wo['nS'] > 0 else 'unsegmented'
                        length_orig = np.linalg.norm(np.array(wo["a"]) - np.array(wo["b"]))
                        b_orig = wo["b"]
                        wo['b']=tuple(es)
                        length_shortened = np.linalg.norm(np.array(wo["a"]) - np.array(wo["b"]))
                        nS_shortened = max(1, int(wo['nS']*length_shortened/length_orig))
                        nS_orig = wo['nS']
                        wo['nS'] = nS_shortened
                        nS_remainder = max(1,nS_orig - nS_shortened)
                        wires_to_add.append( (wo['iTag'], nS_remainder, *wo['b'], *b_orig, wo['wr']) )
                        length_remainder = np.linalg.norm(np.array(wo["b"]) - np.array(b_orig))
                        if(verbose):
                            print(f"Inserting end of wire at {wo['b']} into {wire_seg_status} wire {length_orig}m wire from {wo['a']} to {b_orig}:")
                            print(f"    by shortening wire to end at {wo['b']}: {length_shortened}m, using {nS_shortened} segments")
                            print(f"    and adding wire from {wo["b"]} to {b_orig}:  {length_remainder}m using {nS_remainder} segments")
                        break #(for efficiency only)
        for params in wires_to_add:
            other._add_wire(*params)

#===============================================================
# internal functions for class GeometryObject
#===============================================================

def _cos_sin(self,angle_deg):
        angle_rad = math.pi*angle_deg/180
        ca = math.cos(angle_rad)
        sa = math.sin(angle_rad)
        return ca, sa
    
def _rotate(self, R):
        for w in self.wires:
            a = np.array(w['a'])
            b = np.array(w['b'])
            w['a'] = tuple(map(float, R @ a))
            w['b'] = tuple(map(float, R @ b))

def _add_wire(self, iTag, nS, x1, y1, z1, x2, y2, z2, wr):
        self.wires.append({"iTag":iTag, "nS":nS, "a":(x1, y1, z1), "b":(x2, y2, z2), "wr":wr})

def _get_wires(self):
        return self.wires

def _point_should_connect_to_wire(self,P, wire, tol=1e-3):
        P = np.array(P, dtype=float)
        A = np.array(wire['a'], dtype=float)
        B = np.array(wire['b'], dtype=float)
        AB = B - A
        AP = P - A
        AB_len = np.linalg.norm(AB)
        # can't connect to a zero length wire using the splitting method
        if AB_len == 0:
            return False
        
        # Check perpendicular distance from wire axis
        # if we aren't close enough to the wire axis to need to connect, return false
        # NOTE: need to align tol with nec's check of volumes intersecting
        perp_dist = np.linalg.norm(np.cross(AP, AB)) / AB_len
        if perp_dist > tol: 
            return False    

        # Project point onto the wire to get fractional position
        alpha = np.dot(AP, AB) / (AB_len**2)
        if not (0 <= alpha <= 1):
            return False  # point is on the wire axis but not between the wire ends

        # If we are within allowable tolerance of the wire ends, don't split the wire
        dist_from_end = min(alpha*AB_len, (1-alpha)*AB_len)
        if (dist_from_end < tol):
            return False

        # IF the wire is already segmented (e.g. in a grid), check how far from the
        # *nearest* segment boundary this projected alpha is
        if(wire['nS']>0):
            segment_pitch = 1 / wire['nS']
            nearest_alpha = round(alpha / segment_pitch) * segment_pitch
            alpha_dist = abs(alpha - nearest_alpha)
            alpha_tol = tol / AB_len  # convert spatial tol to alpha-space
            if alpha_dist < alpha_tol:
                return False  # near a segment end — NEC will handle this as a normal junction

        return True  # wire needs to be split to allow the connection

def _point_on_object(self,geom_object, wire_index, alpha_wire):
        if(wire_index> len(geom_object.wires)):
            wire_index = len(geom_object.wires)
            alpha_wire = 1.0
        w = geom_object.wires[wire_index]
        A = np.array(w["a"], dtype=float)
        B = np.array(w["b"], dtype=float)
        P = A + alpha_wire * (B-A)
        return P
         
#=================================================================================
# Units processor
#=================================================================================

class _units:

🧾 def _from_suffixed_dimensions(self, params: dict, whitelist=[]) -> dict:

docstring
        Converts suffixed values like 'd_mm' to meters.

        Output keys have '_m' suffix unless they already end with '_m',
        in which case they are passed through unchanged (assumed meters).
body
        _UNIT_FACTORS = {
        "m": 1.0,
        "mm": 1000.0,
        "cm": 100.0,
        "in": 39.3701,
        "ft": 3.28084,
        }

        out = {}
        names_seen = []
        for key, value in params.items():
    
            if not isinstance(value, (int, float)):
                continue  # skip nested dicts or other structures

            name = key
            suffix = ""
            if "_" in name:
                name, suffix = name.rsplit("_", 1)
                
            if(name in names_seen):
                warnstr = f"Duplicate value of '{name}' seen: ignoring latest ({key} = {value})"
                warnings.warn(warnstr)
                continue

            names_seen.append(name)

            if suffix in _UNIT_FACTORS:
                # Convert value, output key with '_m' suffix
                out[name + "_m"] = value / _UNIT_FACTORS[suffix]
                continue

            if key in whitelist:
                continue
            
            # fallback: no recognised suffix, assume metres
            warnings.warn(f"No recognised units specified for {name}: '{suffix}' specified, metres assumed")
            # output key gets '_m' suffix added
            out[name + "_m"] = value

        return out




visualiser.py

body
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import numpy as np

import copy

🧾 def show_wires_from_file(model, n_strands = 8, color = 'darkgoldenrod', alpha = 0.3, view_az = 30, view_el = 30):

docstring
        Opens the specified nec input file (*.nec) and reads the geometry,
        then displays the geometry in a 3D projection. The feed is highligted in red.
        Loads are highlighted in green.
body
    model_wires = copy.deepcopy(model)
    model_wires.wires = []
    with open(model.nec_in, 'r') as f:
        for line in f:
            if line.startswith("GW"):
                parts = line.strip().split()
                if len(parts) >= 9:
                    # NEC input is: GW tag seg x1 y1 z1 x2 y2 z2 radius
                    x1, y1, z1 = map(float, parts[3:6])
                    x2, y2, z2 = map(float, parts[6:9])
                    tag = int(parts[1])
                    radius = parts[9]
                    model_wires.wires.append(((x1, y1, z1), (x2, y2, z2), tag, radius))


    print("Drawing geometry. Please close the geometry window to continue.")
    fig = plt.figure()
    ax = fig.add_subplot(111, projection='3d')
    ax.view_init(view_el, view_az)
    title = "Geometry for " + model.model_name

    s,c = _trig(n_strands)

    for start, end, tag, radius in model_wires.wires:
        wire_color = color
        for item in model.tags_info:
            if (tag == item['base_tag']):
                wire_color = item['wf_col']
        if (tag == model.EX_tag):
            wire_color = 'red'
        if (tag in [load['iTag'] for load in model.LOADS]):
            wire_color = 'green'
        _render_wire(ax, start, end, radius, s,c, color = wire_color, alpha=alpha)

    plt.draw()  # ensure autoscale limits are calculated

    # Get axis limits
    xlim, ylim, zlim = ax.get_xlim(), ax.get_ylim(), ax.get_zlim()
    mids = [(lim[0] + lim[1]) / 2 for lim in (xlim, ylim, zlim)]
    spans = [lim[1] - lim[0] for lim in (xlim, ylim, zlim)]
    max_range = max(spans)

    # Set equal range around each midpoint
    ax.set_xlim(mids[0] - max_range/2, mids[0] + max_range/2)
    ax.set_ylim(mids[1] - max_range/2, mids[1] + max_range/2)
    ax.set_zlim(mids[2] - max_range/2, mids[2] + max_range/2)

    ax.set_xlabel('X')
    ax.set_ylabel('Y')
    ax.set_zlabel('Z')
    ax.set_title(title)

    if (model.vswr is not None):
        fig.figure.text(0.02,0.05,f"Vswr: {model.vswr:.2f} ")
    if (model.max_total_gain is not None):
        fig.figure.text(0.02,0.1,f"Max total gain: {model.max_total_gain:.2f} dBi")
    
    plt.tight_layout()

    plt.show()
    



def _trig(n_strands):
    if(n_strands <2):
        return([0],[0]) # dummy values not used
    angles = np.linspace(0, 2*np.pi, n_strands, endpoint=False)
    cosines = np.cos(angles)
    sines = np.sin(angles)
    return sines, cosines

def _fast_perpendicular(v, threshold=100):
    vx, vy, vz = abs(v[0]), abs(v[1]), abs(v[2])
    if threshold * vz > vx and threshold * vz > vy:
        # v is nearly aligned with z-axis; project to XZ
        u = np.array([-v[2], 0.0, v[0]])
    else:
        # project to XY
        u = np.array([-v[1], v[0], 0.0])
    return u 

def _render_wire(ax, start, end, radius, sines,cosines, color, alpha):
    if(len(sines) <2):
        s = start
        e = end
        ax.plot([s[0], e[0]], [s[1], e[1]], [s[2], e[2]], color=color, alpha = alpha)
        return
    
    axis = np.array(end) - np.array(start)
    fast_perp = _fast_perpendicular(axis)
    ortho_perp = np.cross(axis, fast_perp)  # axis × u
    u = (float(radius) * fast_perp) / np.linalg.norm(fast_perp)
    v = (float(radius) * ortho_perp) / np.linalg.norm(ortho_perp)
    # Generate strand offsets by rotating u around axis
    for i, s in enumerate(sines):
        offset = s * u + cosines[i]* v
        s = np.array(start) + offset
        e = np.array(end) + offset
        ax.plot([s[0], e[0]], [s[1], e[1]], [s[2], e[2]], color=color, alpha = alpha)






optimisers.py

body
import random, sys

🧾 class RandomOptimiser:

docstring
        Initialise the optimisation parameters. Details to be written - please see examples for help with parameters.
body

def __init__(self, build_fn, param_init, cost_fn, bounds={}, delta_init=0.2, stall_limit=50, max_iter=250, min_delta=0.001):
        self.build_fn = build_fn
        self.param_names = list(param_init.keys())
        self.x_baseline = param_init.copy()
        self.bounds = bounds
        self.cost_fn = cost_fn
        self.delta_x = delta_init
        self.min_delta = min_delta
        self.stall_limit = stall_limit
        self.max_iter = max_iter

def _format_params(self, params):
        s="{"
        for k, v in params.items():
            s = s + f"'{k}': {v:.2f}, "
        return s[0:-2]+"}"

def _same_line_print(self,text):
        sys.stdout.write(f"\r{text}          ")
        sys.stdout.flush()

def _random_variation(self, x):
        x_new = x.copy()
        for name in self.param_names:
            factor = 1 + random.uniform(-self.delta_x, self.delta_x)
            val = x[name] * factor
            x_new[name] = val
            if(name in self.bounds):
                minv, maxv = self.bounds[name]
                x_new[name] = max(min(x_new[name], maxv), minv)
        return x_new

🧾 def optimise(self, verbose=False, tty=True, show_geometry = True):

docstring
            This random optimiser works by simultaneously adjusting all input parameters by a random multiplier (1 + x)
            and comparing the user-specified cost function with the best achieved so far. If the test gives a better
            cost, the test is adopted as the new baseline.

            Note that of course this won't produce good results for any parameters that start off close to or at
            zero and/or have an allowable range with zero close to the middle. Future versions of this optimiser may allow
            specifications to make this work, but for now you should arrange for the input parameters and their
            likely useful range to be away from zero, by using an offset.

            If any parameters seem likely to drift into non-useful ranges, use the 'bounds' specification in the
            initialisation to limit their max and min values.
body
        best_params = self.x_baseline.copy()
        best_model = self.build_fn(**best_params)
        best_model.set_angular_resolution(10,10)
        best_model.write_nec()
        best_model.run_nec()
        result = self.cost_fn(best_model)
        best_cost = result['cost']
        best_info = result['info']
        stall_count = 0
        print("\nSTARTING optimiser. Press CTRL-C to stop")
        initial_message = f"[] INITIAL: {best_info} with {self._format_params(best_params)}"
        print(initial_message)

        try:
            for i in range(self.max_iter):
                test_params = self._random_variation(best_params)
                test_model = self.build_fn(**test_params)
                test_model.set_angular_resolution(10,10)
                test_model.write_nec()
                test_model.run_nec()
                result = self.cost_fn(test_model)
                test_cost = result['cost']
                test_info = result['info']

                if test_cost < best_cost:
                    best_cost = test_cost
                    best_params = test_params
                    best_info = test_info
                    stall_count = 0
                    if(not tty):
                        print("")
                    self._same_line_print(f"[{i}] IMPROVED: {best_info} with {self._format_params(best_params)}")
                    print("")
                else:
                    stall_count += 1
                    if(tty):
                        self._same_line_print(f"[{i}] {test_info}")
                    else:
                        sys.stdout.write(".")

                if stall_count >= self.stall_limit:
                    self.delta_x /= 2
                    if(self.delta_x < self.min_delta):
                        if(not tty):
                            print("")
                        self._same_line_print(f"[{i}] Delta below minimum")
                        print("")
                        break
                    stall_count = 0
                    if(not tty):
                        print("")
                    self._same_line_print(f"[{i}] STALLED: Reducing delta to {self.delta_x}")
                    print("")

        except KeyboardInterrupt:
            print("\nINTERRUPTED by user input")
            
        best_model = self.build_fn(**best_params)
        best_model.write_nec()
        best_model.run_nec()
        result = self.cost_fn(best_model)
        final_info = result['info']
        print("\nFINISHED optimising\n")
        print("# Optimiser Results (copy and paste into your antenna file for reference). \nNote that you can copy the information between the {} to paste in as your new starting parameters.)")
        print("# "+ initial_message)
        print(f"# []   FINAL: {final_info} with {self._format_params(best_params)}")
        
        return best_model, best_params, final_info

components.py

body
import numpy as np
import math
from necbol.modeller import GeometryObject,_units

#=================================================================================
# Cannonical components
#=================================================================================

class components:

🧾 def __init__(self, starting_tag_nr = 0):

docstring
        Sets object_counter to starting_tag_nr (tags number identifies an object)
        and loads the _units module class _units()
body
        self.object_counter = starting_tag_nr
        self._units = _units()

🧾 def _new_geometry_object(self):

docstring
        increment the object counter and return a GeometryObject with the counter's new value
body
        self.object_counter += 1
        iTag = self.object_counter
        obj = GeometryObject([])
        obj.base_tag = iTag
        return obj

🧾 def copy_of(self, existing_obj):

docstring
        Returns a clone of existing_obj with a new iTag
body
        obj = self._new_geometry_object()
        for w in existing_obj.wires:
            obj._add_wire(obj.base_tag, w['nS'], *w['a'], *w['b'], w['wr'])
        return obj
        
🧾 def wire_Z(self, **dimensions):

docstring
        Create a straight wire aligned along the Z-axis, centered at the origin.

        The wire extends from -length/2 to +length/2 on the Z-axis, with the specified diameter.

        dimensions:
            length_{units_string} (float): Length of the wire. 
            wire_diameter_{units_string} (float): Diameter of the wire.
            In each case, the unit suffix (e.g., _mm, _m) must be present in the units class dictionary '_UNIT_FACTORS' (see units.py)
        Returns:
            obj (GeometryObject): The constructed geometry object with the defined wire.
body
        obj = self._new_geometry_object()
        dimensions_m = self._units._from_suffixed_dimensions(dimensions)
        half_length_m = dimensions_m.get('length_m')/2
        wire_radius_m = dimensions_m.get('wire_diameter_m')/2
        obj._add_wire(obj.base_tag, 0, 0, 0, -half_length_m, 0, 0, half_length_m, wire_radius_m)
        return obj
    
🧾 def rect_loop_XZ(self, **dimensions):

docstring
        Create a rectangular wire loop in the XZ plane, centered at the origin, with the specified wire diameter.
        The 'side' wires extend from Z=-length/2 to Z=+length/2 at X = +/- width/2.
        The 'top/bottom' wires extend from X=-width/2 to X=+width/2 at Z = +/- length/2.
        dimensions:
            length_{units_string} (float): 'Length' (extension along Z) of the rectangle. 
            width_{units_string} (float): 'Width' (extension along X) of the rectangle. 
            wire_diameter_{units_string} (float): Diameter of the wires.
            In each case, the unit suffix (e.g., _mm, _m) must be present in the units class dictionary '_UNIT_FACTORS' (see units.py)
        Returns:
            obj (GeometryObject): The constructed geometry object with the defined wires.
body
        obj = self._new_geometry_object()
        dimensions_m = self._units._from_suffixed_dimensions(dimensions)
        half_length_m = dimensions_m.get('length_m')/2
        half_width_m = dimensions_m.get('width_m')/2
        wire_radius_m = dimensions_m.get('wire_diameter_m')/2        
        obj._add_wire(obj.base_tag, 0, -half_width_m , 0, -half_length_m, -half_width_m , 0, half_length_m, wire_radius_m)
        obj._add_wire(obj.base_tag, 0,  half_width_m , 0, -half_length_m,  half_width_m , 0, half_length_m, wire_radius_m)
        obj._add_wire(obj.base_tag, 0, -half_width_m , 0, -half_length_m,  half_width_m , 0,-half_length_m, wire_radius_m)
        obj._add_wire(obj.base_tag, 0, -half_width_m , 0,  half_length_m,  half_width_m , 0, half_length_m, wire_radius_m)
        return obj

🧾 def connector(self, from_object, from_wire_index, from_alpha_wire, to_object, to_wire_index, to_alpha_wire, wire_diameter_mm = 1.0):

docstring
        Create a single wire from a specified point on the from_object to a specified point on the to_object.
        The point on an object is specified as {ftom|to}_wire_index AND {ftom|to}_alpha_wire, which specify respectively:
              the i'th wire in the n wires in the object, and
              the distance along that wire divided by that wire's length
        Arguments:
            from_object (GeometryObject), from_wire_index (int, 0 .. n_wires_in_from_object - 1), from_alpha_wire (float, 0 .. 1)
            to_object (GeometryObject), to_wire_index (int, 0 .. n_wires_in_to_object - 1), to_alpha_wire (float, 0 .. 1)
        Returns:
            obj (GeometryObject): The constructed geometry object with the defined wire.
body
        obj = self._new_geometry_object()
        from_point = obj._point_on_object(from_object, from_wire_index, from_alpha_wire)
        to_point = obj._point_on_object(to_object, to_wire_index, to_alpha_wire)
        obj._add_wire(obj.base_tag, 0, *from_point, *to_point, wire_diameter_mm/2000) 
        return obj

🧾 def helix(self, wires_per_turn, sense, taper_factor = 1.0, **dimensions):

docstring
        Create a single helix with axis = Z axis
        Arguments_
            sense ("LH"|"RH") - the handedness of the helix          
            wires_per_turn (int) - the number of wires to use to represent the helix, per turn
            dimensions:
                radius_{units} (float) - helix radius 
                length_{units} (float) - helix length along Z 
                pitch_{units} (float)  - helix length along Z per whole turn
                wire_diameter_{units} (float) - diameter of wire making the helix
                In each case above, the units suffix (e.g., _mm, _m) must be present in the units class dictionary '_UNIT_FACTORS' (see units.py)
        Returns:
            obj (GeometryObject): The constructed geometry object representing the helix.
body
        obj = self._new_geometry_object()
        dimensions_m = self._units._from_suffixed_dimensions(dimensions)
        radius_m = dimensions_m.get('diameter_m')/2
        length_m = dimensions_m.get('length_m')
        pitch_m = dimensions_m.get('pitch_m')
        wire_radius_m = dimensions_m.get('wire_diameter_m')/2

        turns = length_m / pitch_m
        n_wires = int(turns * wires_per_turn)
        delta_phi = (2 * math.pi) / wires_per_turn  # angle per segment
        delta_z_m = pitch_m / wires_per_turn 
        phi_sign = 1 if sense.upper() == "RH" else -1

        for i in range(n_wires):
            phi1 = phi_sign * delta_phi * i
            phi2 = phi_sign * delta_phi * (i + 1)
            z1 = delta_z_m * i
            z2 = delta_z_m * (i + 1)
            alpha = 0.5*(z1+z2)/length_m
            r = radius_m * (alpha + taper_factor * (1-alpha))
            x1 = radius_m * math.cos(phi1)
            y1 = radius_m * math.sin(phi1)
            x2 = radius_m * math.cos(phi2)
            y2 = radius_m * math.sin(phi2)
            obj._add_wire(obj.base_tag, 0, x1, y1, z1, x2, y2, z2, wire_radius_m)

        return obj

🧾 def flexi_helix(self, sense, wires_per_turn, n_cos,r_cos_params,p_cos_params, **dimensions):

docstring
        Create a helix along the Z axis where radius and pitch vary as scaled sums of cosines:

            r(Z) = r0 * Σ [RA_i * cos(i * π * Z / l + RP_i)] for i=0..n-1
            p(Z) = p0 * Σ [PA_i * cos(i * π * Z / l + PP_i)] for i=0..n-1

        The geometry is generated by stepping through helical phase (φ), and computing local radius and pitch from cosine series 
        as functions of normalized φ (mapped to Z via cumulative pitch integration).

        Parameters:
            sense (str): "RH" or "LH" handedness
            wires_per_turn (int): Resolution (segments per full turn)
            n_cos (int): Number of cosine terms
            r_cos_params (list of tuples): [(RA0, RP0), ...] radius amplitudes and phases
            p_cos_params (list of tuples): [(PA0, PP0), ...] pitch amplitudes and phases
            dimensions:
                l_{units} (float): Approximate helix length along Z
                r0_{units} (float): Base radius scale factor
                p0_{units} (float): Base pitch scale factor (length per full turn)
                wire_diameter_{units} (float): Wire thickness

        Returns:
            GeometryObject: The constructed helix geometry object.
body

def _cosine_series(s, terms):
            return sum(A * math.cos(i * math.pi * s + P) for i, (A, P) in enumerate(terms))

        # === Parameter unpacking and setup ===
        obj = self._new_geometry_object()
        dimensions_m = self._units._from_suffixed_dimensions(dimensions)

        l_m = dimensions_m.get('length_m')
        r0_m = dimensions_m.get('r0_m')
        p0_m = dimensions_m.get('p0_m')
        wire_radius_m = dimensions_m.get('wire_diameter_m') / 2

        phi_sign = 1 if sense.upper() == "RH" else -1

        # Estimate number of turns from average pitch and total Z span
        est_turns = l_m / p0_m
        total_phi = est_turns * 2 * math.pi
        n_segments = int(wires_per_turn * est_turns)

        # Precompute all phi values
        phi_list = [i * total_phi / n_segments for i in range(n_segments + 1)]

        # === Generate 3D points ===
        z = -l_m / 2  # center the helix vertically
        points = []

        for i, phi in enumerate(phi_list):
            s = phi / total_phi  # Normalize φ to [0, +1]

            radius = r0_m * _cosine_series(s, r_cos_params)
            pitch = p0_m * _cosine_series(s, p_cos_params)
            delta_phi = total_phi / n_segments

            if i > 0:
                z += pitch * delta_phi / (2 * math.pi)
            x = radius * math.cos(phi_sign * phi)
            y = radius * math.sin(phi_sign * phi)
            points.append((x, y, z))

        # === Create wires ===
        for i in range(n_segments):
            x1, y1, z1 = points[i]
            x2, y2, z2 = points[i + 1]
            obj._add_wire(obj.base_tag, 0, x1, y1, z1, x2, y2, z2, wire_radius_m)

        return obj


🧾 def circular_arc(self, n_wires, arc_phi_deg, **dimensions):

docstring
        Create a single circular arc in the XY plane centred on the origin
        Arguments:
            n_wires (int) - the number of wires to use to represent the arc         
            arc_phi_deg (float) - the angle subtended at the origin by the arc in degrees. Note that a continuous circular loop can be constructed by specifying arc_phi_deg = 360.
            dimensions:
                radius_{units} (float) - helix radius 
                wire_diameter_{units} (float) - diameter of wire making the helix
                In each case above, the units suffix (e.g., _mm, _m) must be present in the units class dictionary '_UNIT_FACTORS' (see units.py)
        Returns:
            obj (GeometryObject): The constructed geometry object representing the helix.
body
        obj = self._new_geometry_object()
        dimensions_m = self._units._from_suffixed_dimensions(dimensions)
        radius_m = dimensions_m.get('diameter_m')/2
        wire_radius_m = dimensions_m.get('wire_diameter_m')/2    

        delta_phi_deg = arc_phi_deg / n_wires        
        for i in range(n_wires):
            ca, sa = obj._cos_sin(delta_phi_deg * i)
            x1 = radius_m * ca
            y1 = radius_m * sa
            ca, sa = obj._cos_sin(delta_phi_deg * (i+1))
            x2 = radius_m * ca
            y2 = radius_m * sa
            obj._add_wire(obj.base_tag, 0, x1, y1, 0, x2, y2, 0, wire_radius_m)

        return obj



🧾 def thin_sheet(self, model, epsillon_r = 1.0, conductivity = 1e12, force_odd = True, close_start = True, close_end = True, close_bottom = True, close_top = True, enforce_exact_pitch = True, **dimensions):

docstring
        Creates a grid of wires interconnected at segment level to economically model a flat sheet
        which is normal to the x axis and extends from z=-height/2 to z= height/2, and y = -length/2 to length/2
        
        Models *either* conductive or dielectric sheet, not both:
            Set epsillon_r to 1.0 for conducting sheet
            Set epsillon_r > 1.0 for dielectric sheet 

        Arguments:
            model - the object model being built
            epsillon_r - relative dielectric constant
            force_odd = true ensures wires cross at y=z=0
            The four 'close_' parameters determine whether or not the edges are 'sealed' with a final wire (if True) or
            not (if False) so that the grid can be joined to other grids without wires overlapping:
                close_end = True completes the grid with a final end z wire at y = length/2 
                close_start = True starts the grid with a z wire at y = -length/2 
                close_top = True completes the grid with a y wire at z = height/2 
                close_bottom = True starts the grid with a y wire at z = -height/2 
            enforce_exact_pitch: if True, length and height are adjusted to fit an integer number
            of grid cells of the specified pitch. If False, length and height remain as specified and
            the grid pitch in Y and Z is adjusted to fit the number of grid cells calculated from the
            grid pitch and force_odd value. Behaviour prior to V2.0.3 was enforce_exact_pitch.

        Required dimensions are:
            length_
            height_
            grid_pitch_
            dielectric_thickness_ (for dielectric sheets only)
        Optional dimensions are:
            conducting_wire_diameter_ (for conducting sheets only, default is 1mm)
body
        obj = self._new_geometry_object()
        dimensions_m = self._units._from_suffixed_dimensions(dimensions)
        length_m = dimensions_m.get('length_m')
        height_m = dimensions_m.get('height_m')
        grid_pitch_m = dimensions_m.get('grid_pitch_m')

        if (epsillon_r > 1.0):
            dielectric_thickness_m = dimensions_m.get('dielectric_thickness_m')
            wire_radius_m = 0.0005
        else:
            if('conducting_wire_diameter_m' in dimensions_m):
                wire_radius_m = dimensions_m.get('conducting_wire_diameter_m')/2
            else:
                wire_radius_m = 0.0005
            
        dG = grid_pitch_m
        nY = int(length_m / dG) + 1
        nZ = int(height_m / dG) + 1
        if (force_odd):
            nY += (nY+1) % 2
            nZ += (nZ+1) % 2
        if (enforce_exact_pitch):
            L = (nY-1)*dG
            H = (nZ-1)*dG
            dY = dG
            dZ = dG
            dS = dG
        else:
            dY = L/(nY-1)
            dZ = H/(nz-1)
            dS = 0.5*(dY+dZ)


        # Create sheet
        i0 = 0 if close_start else 1
        i1 = nY if close_end else nY-1
        j0 = 0 if close_bottom else 1
        j1 = nZ if close_top else nZ-1
        for i in range(i0, i1):     # make z wires
            x1, y1, z1, x2, y2, z2 = [0, -L/2+i*dY, -H/2, 0, -L/2+i*dY, H/2]
            nSegs = nZ-1
            obj._add_wire(obj.base_tag, nSegs, x1, y1, z1, x2, y2, z2, wire_radius_m)

        for j in range(j0, j1):     # make y wires
            x1, y1, z1, x2, y2, z2 = [0, -L/2, -H/2+j*dZ, 0, L/2, -H/2+j*dZ]
            nSegs = nY-1
            obj._add_wire(obj.base_tag, nSegs, x1, y1, z1, x2, y2, z2, wire_radius_m)

        # add distributed capacitive load to the obj.base_tag of this object if dielectric
        if(epsillon_r == 1.0):
            print(f"\nAdded thin conducting sheet with wire diameter {wire_radius_m * 2} metres and conductivity = {conductivity:8.4e} mhos/metre")
            model.LOADS.append({'iTag': obj.base_tag, 'load_type': 'conductivity', 'RoLuCp': (conductivity, 0, 0), 'alpha': None})
        else:
            E0 = 8.854188 * 1e-12
            C_pF_per_metre = 1e12 * E0 * (epsillon_r-1) * dielectric_thickness_m
            # NEC LD card specification https://www.nec2.org/part_3/cards/ld.html
            model.LOADS.append({'iTag': obj.base_tag, 'load_type': 'series_per_metre', 'RoLuCp': (0, 0, C_pF_per_metre), 'alpha': None})
            print(f"\nAdded thin dielectric sheet comrising wires with diameter {wire_radius_m * 2} metres \n and capacitance loading {C_pF_per_metre:.6f} pF / metre")
            print("NOTE: The thin dielectric sheet model has been tested functionally but not validated quantitavely")

                    
        return obj




Made with Docu-lite 1.5.1 by Alan Robinson: github.com/G1OJS/docu-lite/