__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/