.. DO NOT EDIT. .. THIS FILE WAS AUTOMATICALLY GENERATED BY SPHINX-GALLERY. .. TO MAKE CHANGES, EDIT THE SOURCE PYTHON FILE: .. "tutorials/19_cassegrain_reflector.py" .. LINE NUMBERS ARE GIVEN BELOW. .. only:: html .. note:: :class: sphx-glr-download-link-note :ref:`Go to the end ` to download the full example code. .. rst-class:: sphx-glr-example-title .. _sphx_glr_tutorials_19_cassegrain_reflector.py: Offset Cassegrain reflector: parametric surfaces and a horn feed ================================================================= Every reflector so far — there was none. The geometry chapters built bodies from primitives, profiles and lofts; an antenna dish is a *surface given by a formula*, a paraboloid, and the subreflector of a Cassegrain system is another one, a hyperboloid. This tutorial builds an **offset Cassegrain antenna** from two such surfaces, feeds it with a pyramidal horn from the wall of an open box, and reads its radiation pattern off the far-field monitor. Three things are new: - :meth:`~magnelio.geo.Surface.parametric` turns any map :math:`(u, v) \mapsto (x, y, z)` into a curved sheet, which :meth:`~magnelio.geo.Shape.extruded` makes into a metal shell — the map may be a closed formula or, as for the subreflector here, a short computation; - a **waveguide port in an absorbing wall**: the horn's neck ends on the ``xmin`` face, which is CPML like the other five, and the port sits in the neck's cross-section; - the far field of a **waveguide-fed** antenna, whose feed guide crosses the Huygens box. The antenna is electrically small for a Cassegrain — an 8 λ dish and a 2 λ subreflector, where a real system has 20 λ and more — so its pattern is diffraction-dominated; the point of the page is the construction. The script runs for minutes (a 2 M-cell mesh and a GPU-sized time-domain run), so the gallery renders it without executing it; the figures and the numbers quoted below are from a measured run. .. GENERATED FROM PYTHON SOURCE LINES 34-36 .. code-block:: Python :dedent: 1 .. GENERATED FROM PYTHON SOURCE LINES 38-65 The optics ---------- A Cassegrain antenna is a paraboloid (the *main reflector*) with a hyperboloid (the *subreflector*) in front of its focus. Rays leaving the dish converge toward the paraboloid's focus :math:`F`; the convex hyperboloid intercepts them and, because :math:`F` is one of *its* two foci, sends them to the other, :math:`P`, where the feed sits. The *offset* version uses only a patch of the paraboloid that lies to one side of its axis, so that neither subreflector nor feed stands in the beam. The design is a handful of numbers in the paraboloid's own frame — axis along :math:`z`, vertex at the origin, focus at :math:`(0, 0, F)`: ============================ ========== quantity value ============================ ========== frequency 10 GHz focal length :math:`F` 180 mm aperture diameter :math:`D` 240 mm (8 λ) aperture centre offset 150 mm subreflector diameter 70 mm subreflector before focus 50 mm feed phase centre :math:`P` (−20, 0, 40) mm ============================ ========== .. GENERATED FROM PYTHON SOURCE LINES 65-86 .. code-block:: Python import numpy as np import magnelio as mio from magnelio import geo, monitors, ports f0 = 10.0e9 wavelength = 3.0e8 / f0 focal = 0.18 # paraboloid focal length diameter = 0.24 # aperture diameter offset = 0.15 # aperture centre, off the paraboloid axis d_sub = 0.07 # subreflector diameter s_from_focus = 0.05 # subreflector on the central ray, this far before the focus p_feed = np.array([-0.02, 0.0, 0.04]) # feed phase centre t_shell = 5.0e-3 # reflector shell thickness — two cells or more focus = np.array([0.0, 0.0, focal]) aperture_centre = np.array([offset, 0.0, offset**2 / (4 * focal)]) central_ray = (focus - aperture_centre) / np.linalg.norm(focus - aperture_centre) hit = focus - s_from_focus * central_ray # where the central ray meets the subreflector .. GENERATED FROM PYTHON SOURCE LINES 87-94 The hyperboloid follows from its two foci and one point on it: a point of the branch nearer :math:`F` is :math:`2a` closer to :math:`F` than to :math:`P`, with :math:`2c` the focal distance. The ratio :math:`e = c/a` is its eccentricity, and the *magnification* :math:`M = (e + 1)/(e - 1)` — how much longer the system's equivalent focal length is than the paraboloid's — is the number a reflector designer quotes. .. GENERATED FROM PYTHON SOURCE LINES 94-103 .. code-block:: Python c_h = np.linalg.norm(focus - p_feed) / 2 a_h = (np.linalg.norm(hit - p_feed) - np.linalg.norm(hit - focus)) / 2 b_h = np.sqrt(c_h**2 - a_h**2) centre_h = 0.5 * (p_feed + focus) axis_h = (focus - p_feed) / (2 * c_h) print(f"hyperboloid: a = {a_h * 1e3:.1f} mm, c = {c_h * 1e3:.1f} mm, e = {c_h / a_h:.2f}") print(f"magnification M = {(c_h / a_h + 1) / (c_h / a_h - 1):.2f}") .. GENERATED FROM PYTHON SOURCE LINES 104-113 The main reflector ------------------ The dish is the paraboloid :math:`z = (x^2 + y^2)/4F` over a circular aperture centred at ``offset``. Parametrising the map in **polar coordinates about the aperture centre** — radius and angle, not :math:`x` and :math:`y` — makes the rim of the patch an exact circle without any trimming afterwards. The sheet is sampled on a 32 × 64 grid and interpolated; the extrusion along −z gives it a thickness. .. GENERATED FROM PYTHON SOURCE LINES 113-126 .. code-block:: Python def paraboloid(r, phi): x = offset + r * np.cos(phi) y = r * np.sin(phi) return x, y, (x * x + y * y) / (4 * focal) dish_sheet = geo.Surface.parametric( paraboloid, u=(0.0, diameter / 2), v=(0.0, 2 * np.pi), samples=(32, 64) ) main_reflector = dish_sheet.extruded(vector=(0.0, 0.0, -t_shell), material="pec") .. GENERATED FROM PYTHON SOURCE LINES 127-137 The subreflector ---------------- The subreflector is a patch of the hyperboloid around the central hit. Its map is again a disc — in the plane tangent to the hyperboloid at the hit, spanned by the normal there — but the third coordinate is not a formula: each disc point is projected along the normal onto the surface by solving the hyperboloid's quadratic equation along that line. A parametric map may compute; NumPy arrays go in, three arrays come out. .. GENERATED FROM PYTHON SOURCE LINES 137-184 .. code-block:: Python def hyperboloid_normal(p): q = p - centre_h zeta = q @ axis_h grad = 2 * zeta / a_h**2 * axis_h - 2 * (q - zeta * axis_h) / b_h**2 return grad / np.linalg.norm(grad) n_hit = hyperboloid_normal(hit) e_y = np.array([0.0, 1.0, 0.0]) e_t = np.cross(e_y, n_hit) e_t /= np.linalg.norm(e_t) def hyperboloid_patch(r, phi): """Disc of radius r about the hit, projected along the normal onto the surface.""" q0 = ( hit[:, None, None] + (r * np.cos(phi))[None] * e_t[:, None, None] + (r * np.sin(phi))[None] * e_y[:, None, None] ) # Points q0 + t n on the hyperboloid zeta²/a² − rho²/b² = 1, with # zeta the coordinate along the axis and rho the distance from it. dq = q0 - centre_h[:, None, None] z0 = np.einsum("i,ijk->jk", axis_h, dq) zn = n_hit @ axis_h rho0 = dq - z0[None] * axis_h[:, None, None] rhon = n_hit - zn * axis_h A = zn**2 / a_h**2 - (rhon @ rhon) / b_h**2 B = 2 * (z0 * zn / a_h**2 - np.einsum("ijk,i->jk", rho0, rhon) / b_h**2) C = z0**2 / a_h**2 - np.einsum("ijk,ijk->jk", rho0, rho0) / b_h**2 - 1.0 root = np.sqrt(np.maximum(B * B - 4 * A * C, 0.0)) t1, t2 = (-B + root) / (2 * A), (-B - root) / (2 * A) t = np.where(np.abs(t1) < np.abs(t2), t1, t2) # the root next to the tangent plane p = q0 + t[None] * n_hit[:, None, None] return p[0], p[1], p[2] sub_sheet = geo.Surface.parametric( hyperboloid_patch, u=(0.0, d_sub / 2), v=(0.0, 2 * np.pi), samples=(16, 48) ) # The normal at the hit points toward the dish (the convex side); the # shell grows behind the surface, away from the incoming rays. behind = -n_hit if n_hit @ central_ray < 0 else n_hit subreflector = sub_sheet.extruded(vector=tuple(t_shell * behind), material="pec") .. GENERATED FROM PYTHON SOURCE LINES 185-195 Orientation ----------- The feed must enter through a box wall, so the whole assembly is turned about :math:`y` until the feed axis — from :math:`P` to the hit on the subreflector — points along +x. The beam, the paraboloid's +z, then leaves at an angle: that is the *oblique* radiation the pattern will show. (Rotating a sheet keeps it a sheet; here the solids are rotated, which is the same thing one step later.) .. GENERATED FROM PYTHON SOURCE LINES 195-205 .. code-block:: Python feed_axis = (hit - p_feed) / np.linalg.norm(hit - p_feed) turn = np.degrees(np.arctan2(feed_axis[2], feed_axis[0])) # about y, feed axis -> +x rotate = lambda shape: shape.rotated("y", turn, origin=tuple(p_feed)) # noqa: E731 main_reflector = rotate(main_reflector) subreflector = rotate(subreflector) beam = np.array([np.sin(np.radians(turn)), 0.0, np.cos(np.radians(turn))]) print(f"assembly turned by {turn:.1f} deg; beam direction {beam.round(3)}") print(f"beam is {np.degrees(np.arccos(beam[2])):.1f} deg off the box's +z axis") .. GENERATED FROM PYTHON SOURCE LINES 206-213 The feed horn ------------- A WR-90 neck runs from the ``xmin`` wall along +x to the throat of a pyramidal horn whose mouth is at :math:`P` — standard horn numbers, not optimised. Outer and inner lofts make the walls; the neck is a pair of bricks. .. GENERATED FROM PYTHON SOURCE LINES 213-262 .. code-block:: Python a_wg, b_wg = 22.86e-3, 10.16e-3 # WR-90 l_neck, l_horn = 30e-3, 60e-3 a_mouth, b_mouth = 60e-3, 45e-3 wall = 2e-3 x_mouth = p_feed[0] x_throat = x_mouth - l_horn x_wall = x_throat - l_neck yc, zc = p_feed[1], p_feed[2] def rectangle(x, a, b, grow=0.0): return geo.Face( normal="x", points=( (yc - a / 2 - grow, zc - b / 2 - grow), (yc + a / 2 + grow, zc - b / 2 - grow), (yc + a / 2 + grow, zc + b / 2 + grow), (yc - a / 2 - grow, zc + b / 2 + grow), ), position=x, ) flare_a, flare_b = (a_mouth - a_wg) / l_horn, (b_mouth - b_wg) / l_horn horn_outer = geo.Loft( rectangle(x_throat, a_wg, b_wg, wall), rectangle(x_mouth, a_mouth, b_mouth, wall), blend="ruled", material="pec", ) horn_inner = geo.Loft( rectangle(x_throat - 1e-3, a_wg - 1e-3 * flare_a, b_wg - 1e-3 * flare_b), rectangle(x_mouth + 1e-3, a_mouth + 1e-3 * flare_a, b_mouth + 1e-3 * flare_b), blend="ruled", material="air", ) neck_outer = geo.Brick( origin=(x_wall, yc - a_wg / 2 - wall, zc - b_wg / 2 - wall), size=(l_neck + 1e-3, a_wg + 2 * wall, b_wg + 2 * wall), material="pec", ) neck_inner = geo.Brick( origin=(x_wall - 1e-3, yc - a_wg / 2, zc - b_wg / 2), size=(l_neck + 2e-3, a_wg, b_wg), material="air", ) horn = geo.Difference(geo.Union(neck_outer, horn_outer), neck_inner, horn_inner) .. GENERATED FROM PYTHON SOURCE LINES 263-274 The box and the port -------------------- Half a wavelength of clearance around everything, six absorbing faces, and the neck ending exactly on ``xmin``. The port is a window in that face with the neck's inner cross-section as its corners: a port in an absorbing wall must be the end of a conductor-enclosed guide, and the window must match the guide's walls (the ports chapter states the rule). Behind the window the absorber is switched off, so the mode meets the port's own termination. .. GENERATED FROM PYTHON SOURCE LINES 274-297 .. code-block:: Python parts = [main_reflector, subreflector, horn] lo = np.min([p.bounding_box()[0] for p in parts], axis=0) - wavelength / 2 hi = np.max([p.bounding_box()[1] for p in parts], axis=0) + wavelength / 2 lo[0] = x_wall model = mio.GeometryModel( boundary_conditions={f: "CPML" for f in ("xmin", "xmax", "ymin", "ymax", "zmin", "zmax")} ) air = geo.Brick(origin=tuple(lo), size=tuple(hi - lo), material="air") model.add(geo.Difference(air, main_reflector, subreflector, horn)) model.add(main_reflector) model.add(subreflector) model.add(horn) model.add_port( ports.PortWaveguide( name="feed", plane="xmin", corners=((None, yc - a_wg / 2, zc - b_wg / 2), (None, yc + a_wg / 2, zc + b_wg / 2)), ) ) print(f"box: {np.round((hi - lo) * 1e3, 0)} mm") model.show() .. GENERATED FROM PYTHON SOURCE LINES 298-305 The assembled antenna — horn on the left, the subreflector on its axis, the dish behind and below it, the beam leaving obliquely upward: .. image:: /_static/tutorial_19_geometry.png :width: 90 % :alt: Offset Cassegrain antenna: horn, subreflector and dish in the open box .. GENERATED FROM PYTHON SOURCE LINES 307-321 Mesh ---- Free-form surfaces give the mesher nothing to hold on to — no feature planes, only their bounding boxes — so the resolution across the reflectors is the wavelength rule's: about 1.7 mm at the 12 GHz band edge. The horn is the opposite case: its walls and loft rims are edges a millimetre or two apart, and honouring every one of them refines the grid to that scale across the whole box — ten times the cells. A floor of half a cell drops those planes (with a warning naming them); the wall of a perfect conductor needs no cells of its own. 2 M cells for this box, about a minute to mesh. The 5 mm shells are three cells thick, comfortably above the two-cell rule of the geometry chapter. .. GENERATED FROM PYTHON SOURCE LINES 321-328 .. code-block:: Python f_max = 12.0e9 mesh = mio.Mesh.from_geometry( model, mio.MeshControl(max_cell_size=5e-3, min_cell_size=2.5e-3), f_max=f_max ) print(f"grid: {mesh.Nx} x {mesh.Ny} x {mesh.Nz} cells") .. GENERATED FROM PYTHON SOURCE LINES 329-332 The port report shows the neck's TE10 mode; its cut-off lands within a few percent of WR-90's 6.56 GHz on this coarse neck (13 cells across the broad wall). .. GENERATED FROM PYTHON SOURCE LINES 332-336 .. code-block:: Python analysis = mio.AnalysisScatteringTD(mesh=mesh, f_min=8.5e9, verbose=False) print(analysis.solve_ports()["feed"]) .. GENERATED FROM PYTHON SOURCE LINES 337-345 Run and far field ----------------- One far-field frequency is enough for a pattern. The monitor knows that the feed crosses its box: the ``xmin`` face is sampled at the absorber interface, the neck's interior is left out of the surface, and the normalisation refers to the incident power the TE10 port launched at each frequency. .. GENERATED FROM PYTHON SOURCE LINES 345-370 .. code-block:: Python farfield = monitors.MonitorFarFieldFrequency(freqs=[f0], name="farfield") analysis = mio.AnalysisScatteringTD(mesh=mesh, f_min=8.5e9, monitors=(farfield,), verbose=False) f_axis = np.linspace(8.5e9, 11.5e9, 31) result = analysis.run(f_axis=f_axis, excited=["feed"]) pattern = farfield.result(f0) directivity = pattern.directivity i_peak = np.unravel_index(np.argmax(directivity), directivity.shape) theta_peak, phi_peak = pattern.theta[i_peak[0]], pattern.phi[i_peak[1]] peak_dir = np.array( [ np.sin(theta_peak) * np.cos(phi_peak), np.sin(theta_peak) * np.sin(phi_peak), np.cos(theta_peak), ] ) s11 = np.abs(result.S("feed", "feed")) print(f"peak directivity: {10 * np.log10(directivity.max()):.1f} dBi") print(f"aperture bound: {10 * np.log10((np.pi * diameter / wavelength) ** 2):.1f} dBi") print(f"beam: designed {beam.round(3)}, measured {peak_dir.round(3)}, ") print(f" {np.degrees(np.arccos(np.clip(peak_dir @ beam, -1, 1))):.1f} deg apart") print(f"|S11| at f0: {20 * np.log10(np.interp(f0, f_axis, s11)):.1f} dB") print(f"radiated / accepted power: {pattern.P_rad / (1 - np.interp(f0, f_axis, s11) ** 2):.2f}") .. GENERATED FROM PYTHON SOURCE LINES 371-376 The pattern: a cut through the plane of the tilt (the :math:`xz` plane, :math:`\varphi = 0`) and the full radiation surface. The main beam sits where the paraboloid's axis points after the turn; the sidelobes and the spillover past the subreflector are what an 8 λ dish and a 2 λ subreflector cannot avoid. .. GENERATED FROM PYTHON SOURCE LINES 376-380 .. code-block:: Python fig, ax = pattern.plot_cut(plane="phi", angle=0.0, title="pattern in the plane of the tilt") fig, ax = pattern.plot_3d(title="Cassegrain radiation surface") .. GENERATED FROM PYTHON SOURCE LINES 381-388 .. image:: /_static/tutorial_19_pattern_cut.png :width: 80 % :alt: Directivity cut in the plane of the tilt .. image:: /_static/tutorial_19_pattern_3d.png :width: 80 % :alt: Three-dimensional radiation surface of the Cassegrain antenna .. GENERATED FROM PYTHON SOURCE LINES 390-421 The measured run ---------------- Numbers from the run this page was written against (131 × 122 × 130 cells; one minute to mesh, under a minute to run on a consumer GPU): ======================================== ========== quantity value ======================================== ========== peak directivity 19.2 dBi aperture bound :math:`(\pi D/\lambda)^2` 28.0 dBi beam direction, measured vs designed 2° apart |S11| over 8.5–11.5 GHz −12 … −19 dB radiated / accepted power 0.93 power balance of the far-field box 1.005 ======================================== ========== Eight decibels below the aperture bound is what an 8 λ dish with a 2 λ subreflector and an unoptimised horn does: spillover past the small subreflector and diffraction at its rim take the difference. The beam points where the construction says it should, which is the check that the two parametric surfaces, their orientation and the far-field transform agree. The 7 % of accepted power the far field does not account for is the feed-guide approximation of the far-field chapter — the currents on the neck's outer wall inside the absorber. The monitor's own check confirms that reading: the flux through the recording box (``pattern.surface_power``) is short of the accepted power by the same 7 %, and the pattern radiates that flux to 0.5 % (``pattern.power_balance``) — the box's half-wavelength clearance is enough for this radiator; the missing power never crosses it. .. GENERATED FROM PYTHON SOURCE LINES 423-436 What to take away ----------------- A reflector is a *surface with a formula*, and :meth:`~magnelio.geo.Surface.parametric` accepts the formula — closed-form or computed, in the parametrisation that makes the rim come out right. Extruded into a shell a few cells thick, it is a perfect conductor like any other body. A horn feed enters through an absorbing wall as a window port in its neck's cross-section, and the far-field monitor treats the crossing feed for you. To make the antenna behave like a Cassegrain rather than a diffraction experiment, scale the dish to 20 λ and the subreflector to 5 λ — and budget the cells accordingly. .. _sphx_glr_download_tutorials_19_cassegrain_reflector.py: .. only:: html .. container:: sphx-glr-footer sphx-glr-footer-example .. container:: sphx-glr-download sphx-glr-download-jupyter :download:`Download Jupyter notebook: 19_cassegrain_reflector.ipynb <19_cassegrain_reflector.ipynb>` .. container:: sphx-glr-download sphx-glr-download-python :download:`Download Python source code: 19_cassegrain_reflector.py <19_cassegrain_reflector.py>` .. container:: sphx-glr-download sphx-glr-download-zip :download:`Download zipped: 19_cassegrain_reflector.zip <19_cassegrain_reflector.zip>` .. only:: html .. rst-class:: sphx-glr-signature `Gallery generated by Sphinx-Gallery `_