"""
Importing a circuit board
=========================

Tutorial 09 built a microstrip line out of three bricks.  That works
for a line, and stops working the moment the board has more than one
trace on it: a real layout is drawn in a layout tool, and redrawing it
with primitives is both work and a fresh source of discrepancies
between what was simulated and what was manufactured.

A board leaves its layout tool as *fabrication data* — the set of files
a board house is sent.  :func:`~magnelio.io.import_pcb` reads that set,
so the model comes from the same files the board is built from.  This
tutorial imports a 50 Ω through line and runs it, and along the way
shows the one setting a board simulation needs and nothing else does.

The fabrication set (``microstrip.gbrjob`` and the Gerber files it
names) sits next to this script.
"""

# sphinx_gallery_thumbnail_number = 1

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

import magnelio as mio
from magnelio import geo, ports
from magnelio.constants import *
from magnelio.io import import_pcb

# The fabrication set sits next to this script, or in the working
# directory.
HERE = Path(__file__).parent if "__file__" in globals() else Path.cwd()
FAB = HERE / "microstrip.gbrjob"

L = 20.0e-3  # board length, and the length of the line
H_AIR = 4.0e-3  # air above the board
F_MAX = 15.0e9

# %%
# What is in a fabrication set
# ----------------------------
#
# The set is a Gerber file per copper layer, one for the board outline,
# drill files for the holes, and a **job file** that records the
# stackup.  The job file is the one that matters here: Gerber files are
# flat drawings, and nothing in them says how thick a copper layer is
# or what the dielectric between two layers is made of.  Without it
# there is no third dimension to build, and the import says so rather
# than inventing one.
#
# Import first, ask questions later — called without materials,
# :func:`~magnelio.io.import_pcb` returns everything the set describes.

board = import_pcb(FAB)

for solid in board.members():
    low, high = solid.bounding_box()
    span = ", ".join(f"{lo * 1e3:6.3f}…{hi * 1e3:6.3f}" for lo, hi in zip(low, high))
    print(f"{solid.name:<14} {span}  mm   {solid.material.name}")

# %%
# Three things in that listing are worth reading carefully.
#
# The **names** are the layer names of the stackup.  They are the
# handle materials are assigned against, and they survive a re-export
# of the board, so a mapping written once keeps working after the
# layout changes.
#
# The **z ranges** show the stackup taken literally: the top face of
# the substrate is the origin, the top copper occupies 0 to 35 µm, and
# the stack grows downwards.  Nothing was fitted or snapped — the
# layers meet on coincident faces.
#
# And the **materials** came from the job file: copper is a perfect
# conductor by default, and the substrate carries the permittivity the
# stackup states.  What the job file's loss tangent does *not* do is
# turn into a lossy material, and the import says so with a warning:
# a loss tangent is one number at a frequency the job file does not
# record, and a constant loss tangent is not causal.  Modelling it
# needs that frequency, which only you can supply:
#
# .. code-block:: python
#
#     from magnelio.materials.dispersion import DispersionModel
#
#     fr4 = mio.Material.dispersive(
#         name="FR4",
#         model=DispersionModel.djordjevic_sarkar(
#             eps_r=4.3, tan_delta=0.02, f_ref=10e9
#         ),
#     )
#     board = import_pcb(FAB, {"dielectric_1": fr4})
#
# This tutorial keeps the line lossless, so the numbers below are about
# the geometry and nothing else.
#
# The model around the board
# --------------------------
#
# A board is not a model on its own: a microstrip lives as much in the
# air above the trace as in the substrate under it, so the air has to
# be part of the domain.  It is added as an ordinary brick sitting on
# top of the board — the cells *beside* the trace fall to the model
# background, which is air as well.

model = mio.GeometryModel()
model.add(board)
model.add(geo.Brick(origin=(0.0, 0.0, 35e-6), size=(L, 8.0e-3, H_AIR), material="air"))

model.add_port(ports.PortWaveguide(name="port1", plane="xmin", n_modes=1))
model.add_port(ports.PortWaveguide(name="port2", plane="xmax", n_modes=1))

# %%
# Meshing a board: the one setting that matters
# ---------------------------------------------
#
# Copper on a board is 35 µm thick.  That is two decades below any cell
# size a board simulation can afford, and a grid that resolved it would
# be unusable — which is why tutorial 09 drew its trace 0.2 mm thick,
# thicker than any real copper, so that the grid could carry it.
#
# It does not have to.  A perfectly conducting layer thinner than the
# **cell-size floor** gets a single grid plane on its substrate side,
# and its thickness enters through the sub-cell material fractions of
# the neighbouring cells instead of through a layer of cells of its
# own.  The floor is what "thin" is measured against, so it has to be
# set — without ``min_cell_size`` the mechanism does not run at all and
# the mesher tries to resolve the metal.
#
# Pick it from the smallest feature the *fields* need resolved, well
# above the copper thickness:

mesh = mio.Mesh.from_geometry(
    model,
    mio.MeshControl(min_nodes_per_wavelength=25, min_cell_size=150e-6),
    f_max=F_MAX,
)
print(f"grid: {mesh.Nx} x {mesh.Ny} x {mesh.Nz} cells")
print(f"smallest cell: {min(np.diff(mesh.grid.z)) * 1e6:.0f} um, copper: 35 um")

fig, ax = model.plot_cross_section("x", L / 2, mesh=mesh, title="board cross-section")
ax.set_xlim(2.6, 5.4)  # mm — the trace, not the whole 8 mm width
ax.set_ylim(-1.05, 0.95)

# %%
# The cross-section shows the payoff — zoomed onto the trace, with
# three more millimetres of air above the frame.  The substrate
# boundaries anchor grid planes and the cells are finest inside the
# substrate and coarsen into the air, exactly as in tutorial 09, but
# the smallest cell printed above is hundreds of micrometres, many
# times the copper it carries, and the whole board fits in a grid of
# some twenty thousand cells.  Trace and ground plane are drawn at
# their true 35 µm, and neither costs a layer of cells: the metal is
# there in the physics without being there in the grid.
#
# The quasi-TEM mode, from a drawn trace
# --------------------------------------
#
# From here on nothing is specific to an imported board.  The port
# solves the 2D cross-section problem numerically, because a microstrip
# has no exact TEM mode — the field would have to travel at two speeds
# at once, in the substrate and in the air — and no closed formula for
# the compromise it settles on.

analysis = mio.AnalysisScatteringTD(mesh=mesh, verbose=False)

report = analysis.solve_ports()["port1"]
print(report)

qtem = report.modes[0]
eps_eff_static = (C0 * qtem.gamma(10e9).imag / (2 * np.pi * 10e9)) ** 2
print(f"eps_eff (quasi-static): {eps_eff_static:.3f}")

# %%
# Running the line
# ----------------
#
# A matched straight line is the simplest S-parameter test there is:
# everything should go through, nothing should come back.

result = analysis.run(excited=["port1"])

fig, ax = result.plot_s(("port2", "port1"), ("port1", "port1"))
ax.set_title("50 Ω microstrip, imported from fabrication data")

s11 = result.S("port1", "port1")
s21 = result.S("port2", "port1")
print(f"|S21|: min {20 * np.log10(np.abs(s21).min()):.2f} dB")
print(f"|S11|: max {20 * np.log10(np.abs(s11).max()):.1f} dB")

# %%
# Transmission hugs 0 dB and the reflection sits in the −30 dB class —
# the honest broadband floor of a quasi-TEM port termination, which
# tutorial 09 discusses at length.  The line behaves; the point of this
# tutorial is that nobody drew it here.
#
# Dispersion, read off the phase
# ------------------------------
#
# The port's ε_eff was the quasi-static limit.  A microstrip is
# dispersive — as frequency rises the field retreats into the substrate
# and ε_eff creeps toward εᵣ — and the phase of S21 measures it, since
# the mode accumulates φ = −βL over the line.

f_axis = result.f_axis
phase = np.unwrap(np.angle(s21))
eps_eff = (C0 * (-phase) / (2 * np.pi * f_axis * L)) ** 2

fig, ax = plt.subplots(figsize=(6.0, 3.6))
ax.plot(f_axis * 1e-9, eps_eff, label="from S21 phase")
ax.axhline(eps_eff_static, ls="--", c="grey", label="port, quasi-static")
ax.axhline(4.3, ls=":", c="black", label="substrate εᵣ")
ax.set_xlabel("frequency (GHz)")
ax.set_ylabel(r"$\varepsilon_\mathrm{eff}$")
ax.set_title("microstrip dispersion")
ax.legend()
fig.tight_layout()

# %%
# What to take away
# -----------------
#
# * The **job file is required**.  Fill in the physical stackup in the
#   layout tool before exporting; a stackup without thicknesses is
#   refused, because a layout without them has no shape.
# * **Names are the interface.**  Copper layers keep their stackup
#   names, dielectrics are numbered (``dielectric_1``, …) because
#   layout tools name every core after its material, and plated
#   barrels are ``via_1``, ``via_2``, … in coordinate order.
# * **Set ``min_cell_size``.**  It is what lets the mesher carry 35 µm
#   of copper below the cell size, and it only works for a perfect
#   conductor — the default for imported copper.
# * What the fabrication data cannot say, the import does not invent:
#   a substrate with no stated permittivity arrives without a material
#   rather than silently as vacuum, and a loss tangent is reported
#   rather than modelled.
#
# See :doc:`/methods/pcb-import` for the full treatment — multilayer
# stackups, blind and buried vias, and what is deliberately left out.
