Skip to main content
Solved

ZPL macro to generate field curvature plot

  • January 6, 2026
  • 2 replies
  • 41 views

John.Hygelund
Fully Spectral
Forum|alt.badge.img+1

Hello,

I’d like to customize the standard field curvature plot to include the Petzval surface. Does anyone have a related ZPL macro they would be willing to share?

I’m familiar with ZPL, but haven’t used it for plotting yet.

I tried to get some help from copilot but didn’t have much success, even when suppling the documentation. If anyone has had luck using a LLM to help with ZPL, I’d love some direction there as well.

Thanks,

John

Best answer by MichaelH

Hey John,

The code to plot via ZPL is relatively simple:

  • Create 1D numeric arrays for the independent (x-axis) and depenedent (y-axis) variables
  • Populate the variables as needed inside a for loop
  • Use the PLOT commands to format & execute the plot

For the field curvature information, I would use FCGS and FCGT to get the sagittal and tangential field curvature plots.  I would then use PETZ to calculate the Petzval radius.  A sample code would be below:

# calculates the tan & sag field curvature for a given wavelength
# and plots these curves along with the petzval surface

# user defined variables
wave = 2
max_steps = 50

# close the output window so only the plot will appear
CLOSEWINDOW

# determine the petzval curvature
pc = OPEV(OCOD("PETZ"), 0, wave, 0, 0, 0, 0)
IF pc != 0 THEN pc = 1 / pc

# declare the arrays to plot later
DECLARE s, DOUBLE, 1, max_steps + 1
DECLARE t, DOUBLE, 1, max_steps + 1
DECLARE sag, DOUBLE, 1, max_steps + 1
DECLARE y, DOUBLE, 1, max_steps + 1

# determine the maximum FOV
mfov = OPEV(OCOD("REAB"), 0, wave, 0, 1, 0, 0) / OPEV(OCOD("REAC"), 0, wave, 0, 1, 0, 0)
mfov = ATAN(mfov) * 90 / ACOS(0)

FOR i, 0, max_steps, 1
# normalize the field coordinate
hy = i / max_steps

# get the field curvature
s(i + 1) = OPEV(OCOD("FCGS"), 0, wave, 0, hy, 0, 0)
t(i + 1) = OPEV(OCOD("FCGT"), 0, wave, 0, hy, 0, 0)

# get the y-coordinate of the ray (note this only works for rotationally symmetric systems)
h = OPEV(OCOD("REAY"), NSUR(), wave, 0, hy, 0, 0)

# get the sag of the petzval surface
sag(i + 1) = pc * h / (1 + SQRT(1 - h*h * (pc*pc)))

# store the independent variable for plotting
y(i + 1) = mfov * hy
NEXT

# plot the arrays
PLOT NEW
PLOT TITLE, "FC & Petzval Radius"
PLOT TITLEX, "Millimeters"
PLOT TITLEY, "+Y"
PLOT FORMATX, "%4.2f"
PLOT FORMATY, "%2.0f"The
PLOT DATA, s, y, max_steps + 1, 2, 1, 0
PLOT DATA, t, y, max_steps + 1, 2, 0, 0
PLOT DATA, sag, y, max_steps + 1, 0, 0, 0
PLOT GO

As for using an LLM to help with the ZPL, I don’t have experience with this.  Without learning the Zemax Programming Language and creating a schema (like MCP) to feed into your LLM, I think the best you can do is copy & paste existing ZPL files (Documents/Zemax/Macros) as additional context.

2 replies

MichaelH
Ansys Staff
Forum|alt.badge.img+2
  • Ansys Staff
  • Answer
  • January 9, 2026

Hey John,

The code to plot via ZPL is relatively simple:

  • Create 1D numeric arrays for the independent (x-axis) and depenedent (y-axis) variables
  • Populate the variables as needed inside a for loop
  • Use the PLOT commands to format & execute the plot

For the field curvature information, I would use FCGS and FCGT to get the sagittal and tangential field curvature plots.  I would then use PETZ to calculate the Petzval radius.  A sample code would be below:

# calculates the tan & sag field curvature for a given wavelength
# and plots these curves along with the petzval surface

# user defined variables
wave = 2
max_steps = 50

# close the output window so only the plot will appear
CLOSEWINDOW

# determine the petzval curvature
pc = OPEV(OCOD("PETZ"), 0, wave, 0, 0, 0, 0)
IF pc != 0 THEN pc = 1 / pc

# declare the arrays to plot later
DECLARE s, DOUBLE, 1, max_steps + 1
DECLARE t, DOUBLE, 1, max_steps + 1
DECLARE sag, DOUBLE, 1, max_steps + 1
DECLARE y, DOUBLE, 1, max_steps + 1

# determine the maximum FOV
mfov = OPEV(OCOD("REAB"), 0, wave, 0, 1, 0, 0) / OPEV(OCOD("REAC"), 0, wave, 0, 1, 0, 0)
mfov = ATAN(mfov) * 90 / ACOS(0)

FOR i, 0, max_steps, 1
# normalize the field coordinate
hy = i / max_steps

# get the field curvature
s(i + 1) = OPEV(OCOD("FCGS"), 0, wave, 0, hy, 0, 0)
t(i + 1) = OPEV(OCOD("FCGT"), 0, wave, 0, hy, 0, 0)

# get the y-coordinate of the ray (note this only works for rotationally symmetric systems)
h = OPEV(OCOD("REAY"), NSUR(), wave, 0, hy, 0, 0)

# get the sag of the petzval surface
sag(i + 1) = pc * h / (1 + SQRT(1 - h*h * (pc*pc)))

# store the independent variable for plotting
y(i + 1) = mfov * hy
NEXT

# plot the arrays
PLOT NEW
PLOT TITLE, "FC & Petzval Radius"
PLOT TITLEX, "Millimeters"
PLOT TITLEY, "+Y"
PLOT FORMATX, "%4.2f"
PLOT FORMATY, "%2.0f"The
PLOT DATA, s, y, max_steps + 1, 2, 1, 0
PLOT DATA, t, y, max_steps + 1, 2, 0, 0
PLOT DATA, sag, y, max_steps + 1, 0, 0, 0
PLOT GO

As for using an LLM to help with the ZPL, I don’t have experience with this.  Without learning the Zemax Programming Language and creating a schema (like MCP) to feed into your LLM, I think the best you can do is copy & paste existing ZPL files (Documents/Zemax/Macros) as additional context.


John.Hygelund
Fully Spectral
Forum|alt.badge.img+1
  • Author
  • Fully Spectral
  • January 10, 2026

Hello ​@MichaelH ,

Thank you so much for your help!

I managed to get this working—though I took the hard route by tracing all the parabasal rays and computing the intersections manually. The FCGS and FCGT operands are definitely a much more elegant solution!

I had to help Copilot along, but I was impressed with how well it performed once I provided some examples.

The zpl is below if anyone finds it useful
 

! Field Curvature (parabasal intersection in LOCAL frame; Petzval overlay aligned at axis)
! Tangential/Sagittal: intersect ±Py/±Px rays at PREV in local coords; reference to THIC(PREV)
! Petzval overlay: PETC -> R_Petz, sag z(h)=h^2/(2*R_Petz), baseline aligned to selected curve
! Y-axis labeled in degrees when fields are angular; internal h uses chief-ray image height
! John Hygelund, Jan 6, 2026

NSAMP = 41
SCAN = 1 # 1 = vertical scan in Y; else horizontal scan in X
EPS_PY = 0.0001 # pupil Y offset for parabasal tangential rays
EPS_PX = 0.0001 # pupil X offset for parabasel sagittal rays
WAV = 1 # wavelength index to use (ensure this matches the native plot)
WL = WAVL(WAV)*1000 # Get wavelength in microns
FILE$ = $FILENAME()

NSURF = NSUR()
IMGSURF = NSURF
PREV = NSURF - 1

DECLARE F, DOUBLE, 1, NSAMP
DECLARE YLAB, DOUBLE, 1, NSAMP
DECLARE HIMG, DOUBLE, 1, NSAMP
DECLARE ZT_CURVE, DOUBLE, 1, NSAMP
DECLARE ZS_CURVE, DOUBLE, 1, NSAMP
DECLARE ZP_CURVE, DOUBLE, 1, NSAMP
DECLARE ZPETZ_CURVE, DOUBLE, 1, NSAMP

FTYPE = FTYP() # 0=angles (deg), 1=obj ht, 2=parax img ht, 3=real img ht, 4=theodolite
MAXFIELD = MAXF() # for Y-axis labeling when angular (deg) or height (lens units)

# --- Reference: actual image plane (LOCAL) at PREV ---
ZIMG_LOCAL = THIC(PREV)

# --- Petzval curvature from Merit Function (PETC) -> Petzval radius ---
PETZ_CODE = OCOD("PETC") # operand code for Petzval curvature
PETZ_CURV = OPEV(PETZ_CODE, 0, 0, 0, 0, 0, 0) # curvature (1/mm)
IF (ABSO(PETZ_CURV) < 1.0E-20)
R_PETZ = 1.0E20 # effectively flat if curvature ~0
ELSE
R_PETZ = 1.0 / PETZ_CURV # Petzval radius (mm), signed
ENDIF
PRINT "Petzval radius (mm): ", R_PETZ

# -------- Sweep fields --------
FOR I, 1, NSAMP, 1
F(I) = (I - 1.0) / (NSAMP - 1.0)

# Field coordinates (normalized 0..1)
IF (SCAN == 1)
HX = 0.0
HY = F(I)
ELSE
HX = F(I)
HY = 0.0
ENDIF

# Chief ray to get image height h_i (mm) & label
RAYTRACE HX, HY, 0.0, 0.0, WAV
IF (SCAN == 1)
HIMG(I) = ABSO(RAGY(IMGSURF)) # image height in mm (lens units)
ELSE
HIMG(I) = ABSO(RAGX(IMGSURF))
ENDIF

IF (FTYPE == 0)
YLAB(I) = F(I) * MAXFIELD # degrees (label only)
ELSE
YLAB(I) = HIMG(I) # mm (height)
ENDIF

# --- Tangential (YZ) parabasal intersection at PREV (LOCAL) ---
RAYTRACE HX, HY, 0.0, EPS_PY, WAV
TY2 = RAYY(PREV)
TZ2 = RAYZ(PREV)
TM2 = RAYM(PREV)
TN2 = RAYN(PREV)

RAYTRACE HX, HY, 0.0, -EPS_PY, WAV
TY1 = RAYY(PREV)
TZ1 = RAYZ(PREV)
TM1 = RAYM(PREV)
TN1 = RAYN(PREV)

TDY = TY2 - TY1
TDZ = TZ2 - TZ1
TDEN = (TN1*TM2 - TM1*TN2)
IF (ABSO(TDEN) < 1.0E-20)
ZTAN_LOCAL = 0.5 * (TZ1 + TZ2)
ELSE
TPAR_T = (TM2*TDZ - TN2*TDY) / TDEN
ZTAN_LOCAL = TZ1 + TPAR_T * TN1
ENDIF

# --- Sagittal (XZ) parabasal intersection at PREV (LOCAL) ---
RAYTRACE HX, HY, EPS_PX, 0.0, WAV
SX2 = RAYX(PREV)
SZ2 = RAYZ(PREV)
SL2 = RAYL(PREV)
SN2 = RAYN(PREV)

RAYTRACE HX, HY, -EPS_PX, 0.0, WAV
SX1 = RAYX(PREV)
SZ1 = RAYZ(PREV)
SL1 = RAYL(PREV)
SN1 = RAYN(PREV)

SDX = SX2 - SX1
SDZ = SZ2 - SZ1
SDEN = (SN1*SL2 - SL1*SN2)
IF (ABSO(SDEN) < 1.0E-20)
ZSAG_LOCAL = 0.5 * (SZ1 + SZ2)
ELSE
TPAR_S = (SL2*SDZ - SN2*SDX) / SDEN
ZSAG_LOCAL = SZ1 + TPAR_S * SN1
ENDIF

# --- Longitudinal curvature relative to actual image plane ---
ZT_CURVE(I) = ZTAN_LOCAL - ZIMG_LOCAL
ZS_CURVE(I) = ZSAG_LOCAL - ZIMG_LOCAL
ZP_CURVE(I) = 0.5 * (ZT_CURVE(I) + ZS_CURVE(I))
NEXT I

# --- Align Petzval overlay start to selected curve at axis ---
P_BASE = ZP_CURVE(1) # mean(T,S) at axis


FOR I, 1, NSAMP, 1
ZPETZ_CURVE(I) = (HIMG(I) * HIMG(I)) / (2.0 * R_PETZ) + P_BASE
!ZPETZ_CURVE(I) = ABSO(R_PETZ) - SQRT(R_PETZ*R_PETZ - (HIMG(I) * HIMG(I)))
!IF R_PETZ < 0
!ZPETZ_CURVE(I) = ZPETZ_CURVE(I) * -1
!ENDIF
!ZPETZ_CURVE(I) = ZPETZ_CURVE(I) + P_BASE
NEXT I

# --- Output table (includes Petzval overlay) ---
IF (FTYPE == 0)
PRINT "Field (deg) Tangential (mm) Sagittal (mm) Mean (mm) Petzval(mm)"
ELSE
PRINT "Field Height (mm) Tangential (mm) Sagittal (mm) Mean (mm) Petzval(mm)"
ENDIF

FOR I, 1, NSAMP, 1
PRINT YLAB(I), " ", ZT_CURVE(I), " ", ZS_CURVE(I), " ", ZP_CURVE(I), " ", ZPETZ_CURVE(I)
NEXT I

# --- Plot ---
PLOT NEW

PLOT TITLEX, "Distance from Image Plane (mm)"
IF (FTYPE == 0)
PLOT TITLEY, "Field Angle (deg)"
ELSE
PLOT TITLEY, "Field Height (mm)"
ENDIF

# Robust X auto-range (avoid conflict with XMAX() function by using XSPAN_MAX)
XSPAN_MAX = 0.0
FOR II, 1, NSAMP, 1
IF (ABSO(ZT_CURVE(II)) > XSPAN_MAX)
XSPAN_MAX = ABSO(ZT_CURVE(II))
ENDIF
IF (ABSO(ZS_CURVE(II)) > XSPAN_MAX)
XSPAN_MAX = ABSO(ZS_CURVE(II))
ENDIF
IF (ABSO(ZP_CURVE(II)) > XSPAN_MAX)
XSPAN_MAX = ABSO(ZP_CURVE(II))
ENDIF
IF (ABSO(ZPETZ_CURVE(II)) > XSPAN_MAX)
XSPAN_MAX = ABSO(ZPETZ_CURVE(II))
ENDIF
NEXT II

WAVE$ = $STR(WL)
WAVE$ = WAVE$ + " WAVELENGTH (NM)"

PLOT RANGEX, -1.1 * XSPAN_MAX, 1.1 * XSPAN_MAX
!PLOT RANGEX, -5, 5
PLOT RANGEY, 0.0, YLAB(NSAMP)
PLOT DATA, ZT_CURVE, YLAB, NSAMP, 1, 0, 0 # Blue solid (Tangential)
PLOT DATA, ZS_CURVE, YLAB, NSAMP, 1, 1, 0 # Red dashed (Sagittal)
!PLOT DATA, ZP_CURVE, YLAB, NSAMP, 4, 2, 0 # Green dotted (Mean)
PLOT DATA, ZPETZ_CURVE, YLAB, NSAMP, 1, 2, 0 # Magenta dashed (Petzval overlay)
PLOT COMM1, "Tangential (solid); Sagittal (dashed); Petzval radius (dots)"
PLOT COMM2, WAVE$
PLOT BANNER, FILE$
PLOT TITLE, "FIELD CURVATURE"
PLOT FORMATX,"%1.2f"
NTICKS = 8
X_TICK = XSPAN_MAX*2 / NTICKS
PLOT TICK, X_TICK
PLOT GO