jaseg 2023-10-20 18:24:45 +02:00
rodzic dd49698df9
commit 31af2b260c
4 zmienionych plików z 388 dodań i 79 usunięć

Wyświetl plik

@ -115,8 +115,9 @@ def cli():
@cli.command()
@click.option('-d', '--sim-dir', type=click.Path(dir_okay=True, file_okay=False, path_type=Path))
@click.option('-o', '--output', type=click.Path(dir_okay=False, writable=True, path_type=Path), help='Capacitance matrix output file')
@click.argument('mesh_file', type=click.Path(dir_okay=False, path_type=Path))
def capacitance_matrix(mesh_file, sim_dir):
def capacitance_matrix(mesh_file, sim_dir, output):
physical = dict(enumerate_mesh_bodies(mesh_file))
if sim_dir is not None:
sim_dir = Path(sim_dir)
@ -179,6 +180,8 @@ def capacitance_matrix(mesh_file, sim_dir):
stderr_log=(tmpdir / 'ElmerSolver_stderr.log'))
capacitance_matrix = np.loadtxt(tmpdir / 'capacitance.txt')
np.savetxt(output, capacitance_matrix)
@cli.command()
@click.option('-d', '--sim-dir', type=click.Path(dir_okay=True, file_okay=False, path_type=Path))
@ -500,34 +503,91 @@ def self_capacitance(mesh_file, sim_dir):
stdout_log=solver_stdout,
stderr_log=solver_stderr)
P, R, U_mag = None, None, None
C, U_elec = None, None
solver_error = False
for l in res.stdout.splitlines():
if (m := re.fullmatch(r'StatCurrentSolve:\s*Total Heating Power\s*:\s*([0-9.+-Ee]+)\s*', l)):
P = float(m.group(1))
elif (m := re.fullmatch(r'StatCurrentSolve:\s*Effective Resistance\s*:\s*([0-9.+-Ee]+)\s*', l)):
R = float(m.group(1))
elif (m := re.fullmatch(r'MagnetoDynamicsCalcFields:\s*ElectroMagnetic Field Energy\s*:\s*([0-9.+-Ee]+)\s*', l)):
U_mag = float(m.group(1))
if (m := re.fullmatch(r'StatElecSolve:\s*Tot. Electric Energy\s*:\s*([0-9.+-Ee]+)\s*', l)):
U_elec = float(m.group(1))
elif (m := re.fullmatch(r'StatElecSolve:\s*Capacitance\s*:\s*([0-9.+-Ee]+)\s*', l)):
C = float(m.group(1))
elif re.fullmatch(r'IterSolve: Linear iteration did not converge to tolerance', l):
solver_error = True
if solver_error:
raise click.ClickException(f'Error: One of the solvers did not converge. See log files for details:\n{solver_stdout.absolute()}\n{solver_stderr.absolute()}')
elif P is None or R is None or U_mag is None:
elif C is None or U_elec is None:
raise click.ClickException(f'Error during solver execution. Electrical parameters could not be calculated. See log files for details:\n{solver_stdout.absolute()}\n{solver_stderr.absolute()}')
V = math.sqrt(P*R)
I = math.sqrt(P/R)
L = 2*U_mag / (I**2)
print(f'Total electric field energy: {format_si(U_elec, "J")}')
print(f'Total parasitic capacitance: {format_si(C, "F")}')
assert math.isclose(V, 1.0, abs_tol=1e-3)
@cli.command()
@click.option('-d', '--sim-dir', type=click.Path(dir_okay=True, file_okay=False, path_type=Path))
@click.option('--capacitance-matrix-file', type=click.Path(dir_okay=False, exists=True))
@click.option('--total-inductance', type=float, required=True, help='Total inductance in Henry')
@click.option('--total-resistance', type=float, required=True, help='Total resistance in Ohm')
@click.option('--plot-out', type=click.Path(dir_okay=False, writable=True), help='Optional SVG plot output file')
def resonance(sim_dir, capacitance_matrix_file, total_inductance, total_resistance, plot_out):
import PySpice.Unit
from PySpice.Spice.Library import SpiceLibrary
from PySpice.Spice.Netlist import Circuit
from PySpice.Plot.BodeDiagram import bode_diagram
import scipy.signal
from matplotlib import pyplot as plt
print(f'Total magnetic field energy: {format_si(U_mag, "J")}')
print(f'Reference coil current: {format_si(I, "Ω")}')
print(f'Coil resistance calculated by solver: {format_si(R, "Ω")}')
print(f'Inductance calucated from field: {format_si(L, "H")}')
capacitance_matrix = np.loadtxt(capacitance_matrix_file)
num_elements = capacitance_matrix.shape[0]
circ = Circuit('LC ladder parasitic sim')
inputs = 'Vplus', circ.gnd
coil_in = 'coil_in'
Rtest = circ.R('Rtest', inputs[0], coil_in, 50@PySpice.Unit.u_Ohm)
intermediate_nodes = [f'intermediate{i}' for i in range(num_elements-1)]
inductor_nodes = [(a, b) for a, b in zip([coil_in, *intermediate_nodes], [*intermediate_nodes, inputs[1]])]
inductor_midpoints = [f'midpoint{i}' for i in range(num_elements)]
circ.SinusoidalVoltageSource('input', inputs[0], inputs[1], amplitude=1@PySpice.Unit.u_V)
for i, ((a, b), m) in enumerate(zip(inductor_nodes, inductor_midpoints)):
L = total_inductance / num_elements / 2
R = total_resistance / num_elements / 2
circ.L(f'L{i}A', a, f'R{i}A1', L@PySpice.Unit.u_H)
circ.R(f'R{i}A', f'R{i}A1', m, R@PySpice.Unit.u_Ohm)
circ.R(f'R{i}B', m, f'R{i}B1', R@PySpice.Unit.u_Ohm)
circ.L(f'L{i}B', f'R{i}B1', b, L@PySpice.Unit.u_H)
for i in range(num_elements):
for j in range(i):
circ.C(f'C{i}_{j}', inductor_midpoints[i], inductor_midpoints[j], capacitance_matrix[i, j]@PySpice.Unit.u_F)
sim = circ.simulator(temperature=25, nominal_temperature=25)
ana = sim.ac(start_frequency=10@PySpice.Unit.u_kHz, stop_frequency=1000@PySpice.Unit.u_MHz, number_of_points=1000, variation='dec')
figure, axs = plt.subplots(2, figsize=(20, 10), sharex=True)
freq = ana.frequency
gain = 20*np.log10(np.absolute(ana.coil_in))
peaks, peak_props = scipy.signal.find_peaks(-gain, height=20)
for peak in peaks[:3]:
print(f'Resonance at {float(freq[peak])/1e6:.3f} MHz')
if plot_out:
plt.title("Bode Diagram of a Low-Pass RC Filter")
bode_diagram(axes=axs,
frequency=freq,
gain=gain,
phase=np.angle(ana.coil_in, deg=False),
linestyle='-',
)
for peak in peaks[:3]:
for ax in axs:
ax.axvline(float(freq[peak]), color='red', alpha=0.5)
plt.tight_layout()
plt.savefig(plot_out)
if __name__ == '__main__':

Wyświetl plik

@ -9,39 +9,21 @@ ro4003c:
Density: 1790 # 23°C
Relative Permeability: 1
Relative Permittivity: 3.55
fr4:
Density: 1850 # 23°C
Relative Permeability: 1
Relative Permittivity: 4.4
Heat Conductivity: 0.81 # in-plane
ideal:
Relative Permittivity: 1
copper_inductor:
Density: 8960.0 # 20°C
Electric Conductivity: 0.0 # necessary for 2D
Emissivity: 0.012 # 327°C
Heat Capacity: 384.4 # interpolated for 20°C
Heat Conductivity: 401.0
Relative Permeability: 1
Relative Permittivity: 1
copper:
Density: 8960.0 # 0°C
Electric Conductivity: 32300000 # 200°C
Electric Conductivity: 59600000 # 20°C
Emissivity: 0.012 # 327°C
Heat Capacity: 415.0 # 200°C
Heat Conductivity: 401.0 # 0°C
Relative Permeability: 1
Relative Permittivity: 1
graphite_CZ3-R6300: # crucible
Density: 1730.0
Electric Conductivity: 58800
Emissivity: 0.81 # 205°C
Heat Capacity: 1237.0
Heat Conductivity: 65 # 20°C
Relative Permeability: 1
Relative Permittivity: 1
graphite_FU8957: # heater
Density: 1750.0
Emissivity: 0.81 # 250°C
Heat Capacity: 1237.0
Heat Conductivity: 105 # averaged over different given values
Relative Permeability: 1
Relative Permittivity: 1
steel_1.4541:
Density: 7900.0 # 20°C
Electric Conductivity: 1370
@ -50,26 +32,6 @@ steel_1.4541:
Heat Conductivity: 15.0 # 20°C
Relative Permeability: 1
Relative Permittivity: 1
tin_liquid:
Density: 6980.0
Electric Conductivity: 2080000
Emissivity: 0.064 # set equal to solid
Heat Capacity: 252.7
Heat Conductivity: 29.0
Relative Permeability: 1
Relative Permittivity: 1
Liquid: 'Logical True'
tin_solid:
Density: 7179.0
Electric Conductivity: 4380000
Emissivity: 0.064
Heat Capacity: 244.0
Heat Conductivity: 60.0
Relative Permeability: 1
Relative Permittivity: 1
Solid: 'Logical True'
Melting Point: 505
Latent Heat: 59600
water:
Density: 1000.0
Heat Capacity: 4182.0

282
sim_runner.py 100644
Wyświetl plik

@ -0,0 +1,282 @@
#!/usr/bin/env python3
import threading
import queue
import itertools
import pathlib
import tempfile
import sys
import sqlite3
import time
import math
import json
import subprocess
import tqdm
import click
from tabulate import tabulate
def mesh_args(db, coil_id, mesh_type, mesh_file, outfile):
mesh_type = {'split': '--mesh-split-out', 'normal': '--mesh-out', 'mutual': '--mesh-mutual-out'}[mesh_type]
rows = db.execute('SELECT key, value FROM results WHERE coil_id=?', (coil_id,)).fetchall()
args = ['python', '-m', 'twisted_coil_gen_twolayer', mesh_type, mesh_file, '--pcb']
for k, v in rows:
prefix, _, k = k.partition('.')
if v != 'False' and prefix == 'gen':
args.append('--' + k.replace('_', '-'))
if v != 'True':
args.append(str(v))
args.append(outfile)
return args
def get_mesh_file(db, mesh_dir, run_id, coil_id, mesh_type):
db.execute('CREATE TABLE IF NOT EXISTS meshes(coil_id INTEGER, mesh_type TEXT, error INTEGER, filename TEXT, timestamp TEXT DEFAULT current_timestamp, FOREIGN KEY (coil_id) REFERENCES coils(coil_id))')
row = db.execute('SELECT * FROM meshes WHERE coil_id=? AND mesh_type=? ORDER BY timestamp DESC LIMIT 1', (coil_id, mesh_type)).fetchone()
if row is not None:
mesh_file = mesh_dir / row['filename']
if mesh_file.is_file():
return mesh_file
timestamp = time.strftime('%Y-%m-%d_%H-%M-%S')
return mesh_dir / f'mesh-{run_id}-{coil_id}-{mesh_type}-{timestamp}.msh'
def ensure_mesh(db, mesh_dir, log_dir, run_id, coil_id, mesh_type):
mesh_file = get_mesh_file(db, mesh_dir, run_id, coil_id, mesh_type)
if mesh_file.is_file():
return mesh_file
db.execute('INSERT INTO meshes(coil_id, mesh_type, error, filename) VALUES (?, ?, 0, ?)', (coil_id, mesh_type, mesh_file.name))
db.commit()
mesh_file.parent.mkdir(exist_ok=True)
with tempfile.NamedTemporaryFile(suffix='.kicad_pcb') as f:
args = mesh_args(db, coil_id, mesh_type, mesh_file, f.name)
tqdm.tqdm.write(' '.join(map(str, args)))
logfile = log_dir / mesh_file.with_suffix('.log').name
logfile.parent.mkdir(exist_ok=True)
try:
res = subprocess.run(args, check=True, capture_output=True, text=True)
logfile.write_text(res.stdout + res.stderr)
except subprocess.CalledProcessError as e:
print('Mesh generation failed with exit code {e.returncode}', file=sys.stderr)
logfile.write_text(e.stdout + e.stderr)
print(e.stdout + e.stderr)
raise
return mesh_file
@click.group()
@click.option('-d', '--database', default='coil_parameters.sqlite3')
@click.pass_context
def cli(ctx, database):
ctx.ensure_object(dict)
def connect():
db = sqlite3.connect(database)
db.row_factory = sqlite3.Row
return db
ctx.obj['db_connect'] = connect
@cli.command()
@click.pass_context
def list_runs(ctx):
for row in ctx.obj['db_connect']().execute('SELECT * FROM runs ORDER BY timestamp').fetchall():
print(row['run_id'], row['timestamp'], row['version'])
@cli.command()
@click.pass_context
def list_runs(ctx):
for row in ctx.obj['db_connect']().execute('SELECT * FROM runs ORDER BY timestamp').fetchall():
print(row['run_id'], row['timestamp'], row['version'])
@cli.command()
@click.option('-r', '--run-id')
@click.option('-m', '--mesh-dir', default='meshes')
@click.pass_context
def list_coils(ctx, run_id, mesh_dir):
db = ctx.obj['db_connect']()
if run_id is None:
run_id, = db.execute('SELECT run_id FROM runs ORDER BY timestamp DESC LIMIT 1').fetchone()
timestamp, = db.execute('SELECT timestamp FROM runs WHERE run_id=?', (run_id,)).fetchone()
mesh_dir = pathlib.Path(mesh_dir)
print(f'Listing meshes for run {run_id} at {timestamp}')
print()
keys = {'gen.turns': 'N',
'gen.twists': 'T',
'gen.single_layer': '1L',
'gen.inner_diameter': 'ID[mm]',
'gen.outer_diameter': 'OD[mm]',
'calculated_fill_factor': 'Fill factor',
'calculated_approximate_inductance': 'L [µH]',
'calculated_trace_length': 'track len [mm]',
'calculated_approximate_resistance': 'R [mΩ]'}
out = []
for row in db.execute('SELECT *, MAX(meshes.timestamp) FROM coils LEFT JOIN meshes ON coils.coil_id=meshes.coil_id WHERE run_id=? GROUP BY coils.coil_id, mesh_type ORDER BY meshes.timestamp', (run_id,)).fetchall():
if row['timestamp']:
if row['error']:
state = 'ERROR'
elif not (mesh_dir / row['filename']).is_file():
state = 'NOT FOUND'
else:
state = 'SUCCESS'
else:
state = 'NOT RUN'
params = dict(db.execute('SELECT key, value FROM results WHERE coil_id=?', (row['coil_id'],)).fetchall())
if 'calculated_approximate_inductance' in params:
params['calculated_approximate_inductance'] = f'{float(params["calculated_approximate_inductance"])*1e6:.02f}'
if 'calculated_trace_length' in params:
params['calculated_trace_length'] = f'{float(params["calculated_trace_length"])*1e3:.03f}'
if 'calculated_approximate_resistance' in params:
params['calculated_approximate_resistance'] = f'{float(params["calculated_approximate_resistance"])*1e3:.03f}'
if 'calculated_fill_factor' in params:
params['calculated_fill_factor'] = f'{float(params["calculated_fill_factor"]):.03f}'
out.append([row['coil_id'], row['mesh_type'], state, row['timestamp']] + [params.get(key, '-') for key in keys])
print(tabulate(out, headers=['coil', 'mesh', 'state', 'time'] + list(keys.values()), disable_numparse=True, stralign='right'))
@cli.command()
@click.argument('coil_id', type=int)
@click.argument('mesh_type', type=click.Choice(['normal', 'split', 'mutual']))
@click.option('--mesh-file', default='/tmp/test.msh')
@click.option('--pcb-file', default='/tmp/test.kicad_pcb')
@click.pass_context
def cmdline(ctx, coil_id, mesh_type, mesh_file, pcb_file):
print(' '.join(mesh_args(ctx.obj['db_connect'](), coil_id, mesh_type, mesh_file, pcb_file)))
@cli.group()
@click.option('-r', '--run-id')
@click.option('-l', '--log-dir', default='logs')
@click.option('-m', '--mesh-dir', default='meshes')
@click.pass_context
def run(ctx, run_id, log_dir, mesh_dir):
if run_id is None:
run_id, = ctx.obj['db_connect']().execute('SELECT run_id FROM runs ORDER BY timestamp DESC LIMIT 1').fetchone()
ctx.obj['run_id'] = run_id
ctx.obj['log_dir'] = pathlib.Path(log_dir)
ctx.obj['mesh_dir'] = pathlib.Path(mesh_dir)
@run.command()
@click.option('-j', '--num-jobs', type=int, default=1, help='Number of jobs to run in parallel')
@click.pass_context
def generate_meshes(ctx, num_jobs):
db = ctx.obj['db_connect']()
rows = [row['coil_id'] for row in db.execute('SELECT coil_id FROM coils WHERE run_id=?', (ctx.obj['run_id'],)).fetchall()]
mesh_types = ['split', 'normal', 'mutual']
params = list(itertools.product(rows, mesh_types))
all_files = {get_mesh_file(db, ctx.obj['mesh_dir'], ctx.obj['run_id'], coil_id, mesh_type): (coil_id, mesh_type) for coil_id, mesh_type in params}
todo = [(coil_id, mesh_type) for f, (coil_id, mesh_type) in all_files.items() if not f.is_file()]
q = queue.Queue()
for elem in todo:
q.put(elem)
tq = tqdm.tqdm(total=len(todo))
def queue_worker():
try:
while True:
coil_id, mesh_type = q.get_nowait()
try:
ensure_mesh(ctx.obj['db_connect'](), ctx.obj['mesh_dir'], ctx.obj['log_dir'], ctx.obj['run_id'], coil_id, mesh_type)
except subprocess.CalledProcessError:
tqdm.tqdm.write(f'Error generating {mesh_type} mesh for {coil_id=}')
tq.update(1)
q.task_done()
except queue.Empty:
pass
tqdm.tqdm.write(f'Found {len(params)-len(todo)} meshes out of a total of {len(params)}.')
tqdm.tqdm.write(f'Processing the remaining {len(todo)} meshes on {num_jobs} workers in parallel.')
threads = []
for i in range(num_jobs):
t = threading.Thread(target=queue_worker, daemon=True)
t.start()
threads.append(t)
q.join()
@run.command()
@click.option('-j', '--num-jobs', type=int, default=1, help='Number of jobs to run in parallel')
@click.pass_context
def self_inductance(ctx, num_jobs):
db = ctx.obj['db_connect']()
q = queue.Queue()
def queue_worker():
try:
while True:
mesh_file, logfile = q.get_nowait()
with tempfile.TemporaryDirectory() as tmpdir:
try:
tqdm.tqdm.write(f'Processing {mesh_file}')
res = subprocess.run(['python', '-m', 'coil_parasitics', 'inductance', '--sim-dir', tmpdir, mesh_file], check=True, capture_output=True)
logfile.write_text(res.stdout+res.stderr)
except subprocess.CalledProcessError as e:
print(f'Error running simulation, rc={e.returncode}')
logfile.write_text(e.stdout+e.stderr)
tq.update(1)
q.task_done()
except queue.Empty:
pass
num_meshes, num_params, num_completed = 0, 0, 0
for coil_id, in db.execute('SELECT coil_id FROM coils WHERE run_id=?', (ctx.obj['run_id'],)).fetchall():
num_params += 1
mesh_file = get_mesh_file(ctx.obj['db_connect'](), ctx.obj['mesh_dir'], ctx.obj['run_id'], coil_id, 'normal')
if mesh_file.is_file():
num_meshes += 1
logfile = ctx.obj['log_dir'] / (mesh_file.stem + '_elmer_self_inductance.log')
if logfile.is_file():
num_completed += 1
else:
q.put((mesh_file, logfile))
tqdm.tqdm.write(f'Found {num_meshes} meshes out of a total of {num_params} with {num_completed} completed simulations.')
tqdm.tqdm.write(f'Processing the remaining {num_meshes-num_completed} simulations on {num_jobs} workers in parallel.')
tq = tqdm.tqdm(total=num_meshes-num_completed)
threads = []
for i in range(num_jobs):
t = threading.Thread(target=queue_worker, daemon=True)
t.start()
threads.append(t)
q.join()
@run.command()
@click.pass_context
def self_capacitance(ctx):
db = ctx.obj['db_connect']()
for coil_id, in tqdm.tqdm(db.execute('SELECT coil_id FROM coils WHERE run_id=?', (ctx.obj['run_id'],)).fetchall()):
mesh_file = get_mesh_file(ctx.obj['db_connect'](), ctx.obj['mesh_dir'], ctx.obj['run_id'], coil_id, 'normal')
if mesh_file.is_file():
logfile = ctx.obj['log_dir'] / (mesh_file.stem + '_elmer_self_capacitance.log')
with tempfile.TemporaryDirectory() as tmpdir:
try:
res = subprocess.run(['python', '-m', 'coil_parasitics', 'self-capacitance', '--sim-dir', tmpdir, mesh_file], check=True, capture_output=True)
logfile.write_text(res.stdout+res.stderr)
except subprocess.CalledProcessError as e:
print(f'Error running simulation, rc={e.returncode}')
logfile.write_text(e.stdout+e.stderr)
if __name__ == '__main__':
cli()

Wyświetl plik

@ -138,16 +138,19 @@ def traces_to_gmsh(traces, mesh_out, bbox, model_name='gerbonara_board', log=Tru
trace_field = gmsh.model.mesh.field.add('BoundaryLayer')
gmsh.model.mesh.field.setNumbers(trace_field, 'CurvesList', getCurves(*trace_tags.values()))
gmsh.model.mesh.field.setNumber(trace_field, 'Size', 0.5)
gmsh.model.mesh.field.setNumber(trace_field, 'SizeFar', 10.0)
gmsh.model.mesh.field.setNumber(trace_field, 'SizeFar', 5.0)
#gmsh.model.mesh.field.setAsBackgroundMesh(trace_field)
substrate_field = gmsh.model.mesh.field.add('AttractorAnisoCurve')
gmsh.model.mesh.field.setNumbers(substrate_field, 'CurvesList', getCurves(substrate))
gmsh.model.mesh.field.setNumber(substrate_field, 'DistMax', 10)
gmsh.model.mesh.field.setNumber(substrate_field, 'DistMin', 0)
gmsh.model.mesh.field.setNumber(substrate_field, 'SizeMinNormal', board_thickness/3)
gmsh.model.mesh.field.setNumber(substrate_field, 'SizeMaxNormal', 10.0)
gmsh.model.mesh.field.setNumber(substrate_field, 'SizeMinTangent', 0.5)
gmsh.model.mesh.field.setNumber(substrate_field, 'SizeMaxTangent', 10.0)
substrate_field = gmsh.model.mesh.field.add('Box')
gmsh.model.mesh.field.setNumber(substrate_field, 'VIn', board_thickness)
gmsh.model.mesh.field.setNumber(substrate_field, 'VOut', 10.0)
gmsh.model.mesh.field.setNumber(substrate_field, 'XMin', x1)
gmsh.model.mesh.field.setNumber(substrate_field, 'YMin', y1)
gmsh.model.mesh.field.setNumber(substrate_field, 'ZMin', -board_thickness)
gmsh.model.mesh.field.setNumber(substrate_field, 'XMax', x2)
gmsh.model.mesh.field.setNumber(substrate_field, 'YMax', y2)
gmsh.model.mesh.field.setNumber(substrate_field, 'ZMax', 0)
gmsh.model.mesh.field.setNumber(substrate_field, 'Thickness', 2*board_thickness)
background_field = gmsh.model.mesh.field.add('MinAniso')
gmsh.model.mesh.field.setNumbers(background_field, 'FieldsList', [trace_field, substrate_field])
@ -339,17 +342,19 @@ def traces_to_gmsh_mag(traces, mesh_out, bbox, model_name='gerbonara_board', log
trace_field = gmsh.model.mesh.field.add('BoundaryLayer')
gmsh.model.mesh.field.setNumbers(trace_field, 'CurvesList', getCurves(toplevel_tag))
gmsh.model.mesh.field.setNumber(trace_field, 'Size', 0.5)
gmsh.model.mesh.field.setNumber(trace_field, 'SizeFar', 10.0)
gmsh.model.mesh.field.setNumber(trace_field, 'SizeFar', 5.0)
#gmsh.model.mesh.field.setAsBackgroundMesh(trace_field)
substrate_field = gmsh.model.mesh.field.add('AttractorAnisoCurve')
gmsh.model.mesh.field.setNumbers(substrate_field, 'CurvesList', getCurves(substrate))
gmsh.model.mesh.field.setNumber(substrate_field, 'DistMax', 10)
gmsh.model.mesh.field.setNumber(substrate_field, 'DistMin', 0)
gmsh.model.mesh.field.setNumber(substrate_field, 'SizeMinNormal', board_thickness/3)
gmsh.model.mesh.field.setNumber(substrate_field, 'SizeMaxNormal', 10.0)
gmsh.model.mesh.field.setNumber(substrate_field, 'SizeMinTangent', 0.5)
gmsh.model.mesh.field.setNumber(substrate_field, 'SizeMaxTangent', 10.0)
substrate_field = gmsh.model.mesh.field.add('Box')
gmsh.model.mesh.field.setNumber(substrate_field, 'VIn', board_thickness)
gmsh.model.mesh.field.setNumber(substrate_field, 'VOut', 10.0)
gmsh.model.mesh.field.setNumber(substrate_field, 'XMin', x1)
gmsh.model.mesh.field.setNumber(substrate_field, 'YMin', y1)
gmsh.model.mesh.field.setNumber(substrate_field, 'ZMin', -board_thickness)
gmsh.model.mesh.field.setNumber(substrate_field, 'XMax', x2)
gmsh.model.mesh.field.setNumber(substrate_field, 'YMax', y2)
gmsh.model.mesh.field.setNumber(substrate_field, 'ZMax', 0)
gmsh.model.mesh.field.setNumber(substrate_field, 'Thickness', 2*board_thickness)
background_field = gmsh.model.mesh.field.add('MinAniso')
gmsh.model.mesh.field.setNumbers(background_field, 'FieldsList', [trace_field, substrate_field])