Source code for finesse_virgo.virgo

import finesse

from finesse.knm import Map
from finesse.utilities.maps import circular_aperture
from finesse.utilities.tables import Table, NumberTable
from finesse.symbols import CONSTANTS

from finesse.analysis.actions import (
    Series,
    RunLocks,
    Change,
    SensingMatrixDC,
    Minimize,
    Maximize,
    TemporaryParameters,
    OptimiseRFReadoutPhaseDC,
    Xaxis,
    Noxaxis,
    Temporary,
    FrequencyResponse,
)

import math
import os
import glob
import importlib.resources
import numpy as np
import matplotlib.pyplot as plt
from copy import deepcopy
from itertools import zip_longest

from .utils import round_to_n

from .actions import DARM_RF_to_DC

# from finesse.exceptions import ModelAttributeError
from finesse.components import DegreeOfFreedom

# definition of thermal states
THERMAL_STATES = {
    "design-matched": {
        "PR.Rc": -1430,
        "SR.Rc": 1430,
        "f_CPN_TL.value": -338008.0,
        "f_CPW_TL.value": -353134.0,
    },
    "cold": {
        "PR.Rc": -1477,
        "SR.Rc": 1443,
        "f_CPN_TL.value": float("inf"),
        "f_CPW_TL.value": float("inf"),
    },
    "measured": {
        "PR.Rc": -1469.0,  # measured from June '22 (ref?)
        "SR.Rc": 1443.0,
        "f_CPN_TL.value": 62636.0,  # from optimizer
        "f_CPW_TL.value": 60625.0,
    },
}

# definition of aperture size for each mirror.
#   as (coating diameter, substrate diameter)
APERTURE_SIZES = {
    "NI": (0.340, 0.350),
    "NE": (0.340, 0.350),
    "WI": (0.340, 0.350),
    "WE": (0.340, 0.350),
    "PR": (0.340, 0.350),
    "SR": (0.340, 0.350),
    # "BS": (0.530, 0.550), # not implemented yet in F3
}


[docs]def make_virgo(**kwargs): """Returns a fully tuned Virgo ifo as a Finesse model. Accepts same configurations as the Virgo class. """ virgo = Virgo(**kwargs) virgo.make() return virgo.model
[docs]class Virgo: """Container for the Virgo tuning script which houses and configures the model through individual steps producing a tuned interferometer. Parameters ---------- files_to_parse : str or list of str, optional File name(s) or directory name to use when parsing. When a directory name is given, all files with ending .kat will be parsed in alphabetical order. A file name can be given as a string, several file names will be provided as a list of strings. If this variable is not empty, common file included in the package will be used. display_plots : bool, optional Automatically display plots for certain methods. thermal_state : str, optional Thermal configuration to use when creating the model. use_3f_error_signals : bool, optional Sets the control scheme to use the 3f error signals. verbose : bool, optional Class-wide option to set the verbosity. add_locks : bool, optional If unset, will skip parsing the locks. Use when the locks should not be added on initialization. parse_additional_katscript : bool, optional If set, will parse the accompanying additional katscript file. Use when parsing only a modified common file rather than a pretuned file. x_scale : float, optional * Convenience function to set the x_scale. zero_k00 : bool, optional * Convenience function to set zero_k00 phase configuration. with_apertures : bool, optional * Convenience function to automaticaly apply apertures to the mirror. maxtem : str|int|tuple, optional * Convenience function to set the modes. Valid options: 'off', number of maxtem, or tuple with modes and maxtem, e.g., ('even', 4). """
[docs] def __init__( self, files_to_parse=None, display_plots=False, thermal_state=None, use_3f_error_signals=False, with_apertures=False, maxtem=None, verbose=False, x_scale=1, zero_k00=False, add_locks=True, parse_additional_katscript=False, control_scheme=None, ): self.__sensing_matrix = None self.display_plots = display_plots self.thermal_state = thermal_state self.use_3f_error_signals = use_3f_error_signals self.with_apertures = with_apertures self.verbose = verbose self.control_scheme = control_scheme # create the model self.model = finesse.Model() # parse the katscript file, if provided if files_to_parse: # if directory is provided, parse every kat file if type(files_to_parse) is str and os.path.isdir(files_to_parse): if self.verbose: print(f"Parsing input files in '{files_to_parse}':") # TODO: need to fix, will only work with local directory for input_file in sorted(glob.glob(f"{files_to_parse}/*.kat")): self.model.parse_file(input_file) if self.verbose: print(f"- {input_file}.") elif type(files_to_parse) is list: # if a list is provided, parse each as a file for file in files_to_parse: self.model.parse_file(file) if self.verbose: print("Parsed input files", *files_to_parse, sep=", ") else: # otherwise, parse the provided file # this will typically be one of two situations # 1) output from the unparser, in which everything needed is provided # 2) a modified common file, in which case additional katscript will need to be parsed self.model.parse_file(files_to_parse) if self.verbose: print(f"Parsed input file {files_to_parse}.") else: # if no file provided, use the common file if self.verbose: print("Parsing common katfile...") self.model.parse( importlib.resources.read_text( "finesse_virgo.katscript", "00_virgo_common_file.kat" ) ) # parse additional katscript if needed # case 1) the flag is set # case 2) default (no input file/directory provided) if parse_additional_katscript or not files_to_parse: if self.verbose: print("Parsing additional katscript...") self.model.parse( importlib.resources.read_text( "finesse_virgo.katscript", "01_additional_katscript.kat" ) ) # setting phase config to not zero K00 phase, see 'phase' command in F2. self.model._settings.phase_config.zero_k00 = zero_k00 # set x_scale to (maybe) reduce numerical noise from radiation pressure. self.model._settings.x_scale = x_scale # By default, surfaces are infinite. Using apertures limit their size. if with_apertures: self.use_apertures() # Set maxtem if provided # ex: 'off', 2, ('even', 10) if maxtem == "off": self.model.modes("off") elif isinstance(maxtem, int): self.model.modes(maxtem=maxtem) elif isinstance(maxtem, tuple) and len(maxtem) == 2: self.model.modes(maxtem[0], maxtem=maxtem[1]) # Setting to adjust the RoCs and focal points as defined in the thermal state. if thermal_state: self.set_thermal_state(thermal_state) # If using 3f error signals, we need to increase the order of modulation in addition to the control scheme for the central interferometer. if use_3f_error_signals: self.model.eom6.order = 3 self.model.eom8.order = 3 self.model.eom56.order = 3 if control_scheme is None: self.control_scheme = { "PRCL": ("B2_6_3f", "I", None, 1e-12), "MICH": ("B2_56_3f", "Q", None, 1e-11), "CARM": ("B2_6", "I", None, 1e-14), "DARM": ("B1p_56", "I", None, 1e-14), "SRCL": ("B2_56_3f", "I", None, 50e-11), } # TODO: should create and use ControlScheme and Lock objects instead of a tuple # Define a control scheme to link DoFs to readouts # Should be a dictionary of tuples as dof: (readout, port, accuracy, rms) # Note: accuracy will be calculated using RMS and optical gain if left as None if self.control_scheme is None: self.control_scheme = { "PRCL": ("B2_8", "I", None, 1e-12), "MICH": ("B2_56", "Q", None, 1e-11), "CARM": ("B2_6", "I", None, 1e-14), "DARM": ("B1p_56", "I", None, 1e-14), "SRCL": ("B2_56", "I", None, 50e-11), } self.init_control_scheme() # parse the locks using the control scheme # but provide ability to skip in case they already exist if add_locks: self.add_locks()
@property def sensing_matrix(self): """Return the sensing matrix if it exists, otherwise calculate it. Returns ------- SensingMatrixSolution """ if self.__sensing_matrix is None: self.__sensing_matrix = self.get_sensing_matrix() return self.__sensing_matrix
[docs] def init_control_scheme(self): # extract individual dof/readout arrays for later use self.dofs = [dof for dof in self.control_scheme.keys()] self.readouts = [lock[0] for lock in self.control_scheme.values()] self.unique_readouts = list(dict.fromkeys(self.readouts)) self.dof_readouts = [ f"{lock[0]}_{lock[1]}" for lock in self.control_scheme.values() ]
[docs] def deepcopy(self): return deepcopy(self)
[docs] def make(self, verbose=False, dc_lock=True): """Performs full make process. Parameters ---------- dc_lock : bool, optional Set to false to skip the final step switching DARM to the DC lock. verbose : bool, optional If set, displays additional information. """ # step 1: adjust the cavity lengths print("Adjusting recycling cavity lengths...") self.adjust_recycling_cavity_length("PRC", "lPRC", "lPOP_BS", verbose=verbose) self.adjust_recycling_cavity_length("SRC", "lSRC", "lsr", verbose=verbose) # step 2: pretune print("Pretuning...") self.pretune(verbose=verbose) # step 3: optimize demodulation phases print("Optimizing demodulation phases...") self.optimize_demodulation_phase(verbose=verbose) # step 4: optimize lock gains print("Optimizing lock gains...") self.optimize_lock_gains(verbose=verbose) # step 5: run RF locks print("Running RF locks...") if self.verbose: self.print_dofs("before locking") self.model.run(RunLocks(method="newton")) if self.verbose: self.print_dofs("after locking") # step 6: optionally switch to DC locks if dc_lock: print("Switching to DARM DC lock...") if self.verbose or verbose: self.print_dofs("before locking") self.model.run(DARM_RF_to_DC()) if self.verbose or verbose: self.print_dofs("after locking") print("Done.")
[docs] def print_info(self): self.print_lengths() self.print_thermal_values() self.print_tunings() self.print_powers()
[docs] def get_settings(self): """Returns a curated list of important settings from the model.""" return { "modes": self.model.modes_setting["modes"], "maxtem": self.model.modes_setting["maxtem"], "zero_k00": self.model._settings.phase_config.zero_k00, "x_scale": self.model._settings.x_scale, }
[docs] def print_settings(self): settings = self.get_settings() table = Table( [["Setting", "Value"], *settings.items()], headerrow=True, headercolumn=True, alignment=["left", "right"], compact=True, ) print(table)
[docs] def get_dofs_dc(self): return [self.model.get(f"{dof}.DC") for dof in self.dofs]
# can be repeated
[docs] def set_thermal_state(self, state: str) -> None: """Sets thermal parameter values for the provided state. Parameters ---------- state : str Key for desired thermal state as defined in THERMAL_STATES. Raises ------ Exception Raised when the key does not exist in THERMAL_STATES. """ # make sure the state exists if state not in THERMAL_STATES.keys(): raise Exception( f"Invalid thermal state `{state}`. Accepted thermal states: [{', '.join([f'`{key}`' for key in THERMAL_STATES.keys()])}]" ) # set the state for key, value in THERMAL_STATES[state].items(): self.model.set(key, value)
[docs] def use_apertures(self, use_substrate: bool = True) -> None: """Convenience function to use apertures. Creates surface maps for each major surface in Virgo. See TDR table 5.2. Parameters ---------- model : Model Finesse Virgo model containing all surfaces. substrate : bool Option to use the coating diameter (False) or the substrate diameter (True). """ # apply the appropriate aperture size to each mirror for mirror, diameters in APERTURE_SIZES.items(): self.apply_aperture(mirror, diameters[int(use_substrate)])
[docs] def apply_aperture(self, mirror, diameter=None): """Applies a circular aperture surface map to the mirror. Parameters ---------- mirror : str Name of the mirror to which to apply the aperture. diameter : float, optional Diameter, in meters, of the circular aperture. Defaults to the substrate diameter in the aperture table. """ # use substrate diameter by default if diameter is None: diameter = APERTURE_SIZES[mirror][1] # create the aperture map radius = diameter / 2 x = y = np.linspace(-radius, radius, 100) smap = Map( x, y, amplitude=circular_aperture(x, y, radius, x_offset=0.0, y_offset=0.0), ) # apply to the mirror self.model.get(mirror).surface_map = smap
[docs] def adjust_PRC_length(self): self.adjust_recycling_cavity_length("PRC", "lPRC", "lPOP_BS")
[docs] def adjust_SRC_length(self): self.adjust_recycling_cavity_length("SRC", "lSRC", "lsr")
# can be repeated # TODO: should the length in the common file use the variable? # Could otherwise be done with just the cavity space: self.adjust_cavity_length("lsr")
[docs] def adjust_recycling_cavity_length( self, cavity: str, L_in: str, S_out: str, verbose=False ): """Adjust cavity length so that it fulfils the requirement: L = 0.5 * c / (2 * f6), see TDR 2.3 (VIR–0128A–12). Parameters ---------- cavity : str Name of the cavity being adjusted. L_in : str Variable used to define the length of the cavity. Needed because the common file does not use a variable. S_out : str Name of the space component used to adjust the cavity. """ # works also for legacy f6 = self.model.get("eom6.f").value if self.verbose or verbose: print(f"-- adjusting {cavity} length") # calculate the required adjustment tmp = 0.5 * CONSTANTS["c0"] / (2 * f6) delta_l = tmp.eval() - self.model.get(L_in).value.eval() if self.verbose or verbose: print(f" adjusting {S_out}.L by {delta_l:.4g} m") # apply the adjustment self.model.get(S_out).L += delta_l
# can be repeated
[docs] def pretune(self, verbose=None): # store the modulation index for use later midx = self.model.eom56.midx.value if verbose is None: verbose = self.verbose # do the pretuning self.model.run( TemporaryParameters( Series( # Switch off the modulators and remove SR and PR by misaligning them. This ensures only the carrier is present and the arms are isolated. Change( { "eom6.midx": 0, "eom8.midx": 0, "eom56.midx": 0, "SR.misaligned": True, "PR.misaligned": True, "SRAR.misaligned": True, "PRAR.misaligned": True, } ), # Maximise arm power Maximize("B7_DC", "NE_z.DC", bounds=[-180, 180], tol=1e-14), Maximize("B8_DC", "WE_z.DC", bounds=[-180, 180], tol=1e-14), # Minimise dark fringe power Minimize("B1_DC", "MICH.DC", bounds=[-180, 180], tol=1e-14), # Bring back PR Change({"PR.misaligned": False}), # Maximise PRC power Maximize("CAR_AMP_BS", "PRCL.DC", bounds=[-180, 180], tol=1e-14), # Bring in SR Change({"SR.misaligned": False}), # Maximise SRC power # B4_112 requires 56MHz Change({"SRCL.DC": 0, "eom56.midx": midx}), Maximize("B4_112_mag", "SRCL.DC", bounds=[-180, 180], tol=1e-14), ), exclude=( "PR.phi", "NI.phi", "NE.phi", "WI.phi", "WE.phi", "SR.phi", "NE_z.DC", "WE_z.DC", "MICH.DC", "PRCL.DC", "SRCL.DC", ), ) ) # round off dofs to a reasonable level of precision self.model.NE_z.DC = round(self.model.NE_z.DC.value, 4) self.model.WE_z.DC = round(self.model.WE_z.DC.value, 4) self.model.MICH.DC = round(self.model.MICH.DC.value, 4) self.model.PRCL.DC = round(self.model.PRCL.DC.value, 4) self.model.SRCL.DC = round(self.model.SRCL.DC.value, 3) if verbose: self.print_tunings() self.print_powers()
[docs] def print_dofs(self, msg=None): print(f"-- DOFs {msg if msg else ''}:") for dof in self.dofs: print(f' {dof}: {self.model.get(dof+".DC").value}')
# can be repeated
[docs] def apply_dc_offset(self, verbose=False): """_summary_""" self.model.run( Series( # Switch off the modulators for pretuning TemporaryParameters( Series( Change({"eom6.midx": 0, "eom8.midx": 0, "eom56.midx": 0}), # Find the exact dark fringe, then search only for the negative solution Minimize("B1_DC", "DARM.DC", tol=1e-10), Minimize( "B1_DC", "DARM.DC", method=None, bounds=[self.model.DARM.DC - 90, self.model.DARM.DC], offset=4e-3, tol=1e-14, ), ), exclude=("NE_z.DC", "WE_z.DC", "DARM.DC"), ) ) ) if self.display_plots: self.plot_QNLS(axis=[5, 500, 100]) if self.verbose or verbose: self.print_powers() if self.display_plots: self.plot_powers()
[docs] def plot_powers(self, xscale=None, figsize=(8, 6)): """Plot grid of dof plots of interest when pretuning.""" # prepare some lists powers_dofs = [ ("CAR_AMP_W", "WE_z", 1), ("CAR_AMP_N", "NE_z", 1), ("CAR_AMP_AS", "MICH", 6), ("CAR_AMP_BS", "PRCL", 50), ("CAR_AMP_AS", "SRCL", 40), ("CAR_AMP_AS", "DARM", 0.001), ] # create the subplot axies fig, axs = plt.subplots(3, 2, figsize=figsize) axs = axs.flatten() # create one plot per dof for i, (detector, dof, _xscale) in enumerate(powers_dofs): out = self.dof_plot( dof, detector, xscale=xscale or _xscale, show=False, ) # TODO: use detector port rather than name axs[i].semilogy(out.x[0], np.abs(out[detector]) ** 2, label=detector) axs[i].set( xlabel=f"{dof} [deg]", ylabel=f"{detector} [W]", ) plt.tight_layout(pad=1.2) return fig, axs
[docs] def dof_plot( self, dof, detector, axis=[-1, 1, 200], xscale=1, logy=True, show=True ): """Sweep across a DoF, reading out at the provided detector.""" axis = np.array(axis, dtype=np.float64) axis[:2] *= xscale out = self.model.run( Xaxis(f"{dof}.DC", "lin", axis[0], axis[1], axis[2], relative=True) ) if show: try: out.plot([detector], logy=logy, degrees=False) except AttributeError: # Workaround for `out.plot()` not currently working for readouts plt.figure() if logy: plt.semilogy(out.x[0], np.abs(out[detector]), label=detector) else: plt.plot(out.x[0], np.abs(out[detector]), label=detector) plt.xlabel(dof.name + " DC") plt.ylabel("A.U.") plt.show() return out
# TODO: add ability to provide list of detectors (handle ad or pd)?
[docs] def get_powers(self): """Return a dictionary of carrier powers keyed by detector.""" # run the model without modulation to get the carrier powers out = self.model.run( Series( Temporary( Change( { "eom8.midx": 0, "eom6.midx": 0, "eom56.midx": 0, } ), Noxaxis(), ) ) ) powers = {} for detector in self.model.detectors: # only grab output for carrier detectors if "CAR_AMP" in detector.name: power = np.abs(out[detector]) ** 2 ratio = power / self.model.i1.P powers[detector.name] = [power, ratio] return powers
# TODO: add ability to provide list of detectors (handle ad or pd)?
[docs] def print_powers(self): """Display a table listing the carrier powers and power ratios.""" powers = self.get_powers() table = NumberTable( list(powers.values()), colnames=["Detector", "Power [W]", "Pow. ratio"], rownames=list(powers.keys()), numfmt=["{:9.4g}", "{:9.4g}"], compact=True, ) print(table)
# TODO: can probably do this better # TODO: ensuring unique readouts should be handled by OptimizeRFReadoutPhaseDC in Finesse?
[docs] def optimize_demodulation_phase( self, dofs=None, readouts=None, d_dof=1e-7, verbose=False ): """Optimize the demodulation phases.""" if dofs is None: dofs = self.dofs if readouts is None: readouts = self.readouts # Ignore any readouts which can be inferred from the others # e.g., B2_56_I and B2_56_Q are always 90 degrees from each other # so only collect and optimize the B2_56_I. pairs = list(zip(dofs, readouts)) unique_readouts = list(dict.fromkeys(readouts)) ignore = [] # loop dof readout pairs and try to remove them from unique readouts for i, (dof, readout) in enumerate(pairs): try: unique_readouts.remove(readout) except Exception: # if the readout cannot be removed, that means we have both I and Q # for this readout in the control scheme and we only want to use the I. for _dof, (_readout, port, _, _) in self.control_scheme.items(): if readout == _readout and port == "Q": # ignore the dof where the readout port is I and exit ignore.append(_dof) break self.model.run( OptimiseRFReadoutPhaseDC( *[i for s in zip(dofs, readouts) for i in s if s[0] not in ignore], d_dof=d_dof, ) ) # update the sensing matrix self.update_sensing_matrix() if self.verbose or verbose: print("-- Optimized demodulation phases:") for dof, lock in self.control_scheme.items(): readout, port, _, _ = lock # only display for provided dofs/readouts if dof in dofs and readout in readouts: phase = self.model.get(f"{readout}.phase").value + ( 0 if port == "I" else 90 ) print( f" {dof:8} {'_'.join([readout, port]):10}: phase={phase:8.4f}" ) print("-- Suggested lock gains:") for dof, lock in self.control_scheme.items(): readout, port, _, _ = lock # only display for provided dofs/readouts if dof in dofs and readout in readouts: optical_gain = self.sensing_matrix.out[ dofs.index(dof), readouts.index(readout) ] lock_gain = -1 / ( optical_gain.real if port == "I" else optical_gain.imag ) print( f" {dof:8s} {'_'.join([readout, port]):10s}: {-1/lock_gain:10.5g}" )
[docs] def get_sensing_matrix(self, dofs=None, readouts=None, d_dof=1e-6): """Calculate and return a new sensing matrix. This will not automatically update the state of the local sensing matrix. Use `update_sensing_matrix()` to do this. Parameters ---------- dofs : list of str, optional DOFs to include in the sensing matrix. Default to includes all DOFs from control scheme. readouts : list of str, optional Readouts to include in the sensing matrix. Default to include unique readouts from the control scheme. Returns ------- SensingMatrixSolution The sensing matrix. """ if dofs is None: dofs = self.dofs if readouts is None: readouts = self.unique_readouts return self.model.run(SensingMatrixDC(dofs, readouts, d_dof=d_dof))
[docs] def update_sensing_matrix(self): """Update the local sensing matrix. This is to avoid having to re-create the sensing matrix each time it is called and will always run the full sensing matrix. Returns ------- SensingMatrixSolution """ self.__sensing_matrix = self.get_sensing_matrix() return self.sensing_matrix
# TODO: ensure only one plot is shown when only one readout is selected
[docs] def plot_sensing_matrix( self, dofs=None, readouts=None, sensing_matrix=None, figsize=(8, 8) ): """Plots the sensing matrix as a grid of radar plots. Parameters ---------- dofs : list of str, optional DOFs to include. Defaults to all. readouts : list of str, optional Readouts to include. Defaults to all. sensing_matrix : SensingMatrixDC, optional Sensing matrix to use for plots if provided, otherwise attempts to use the existing sensing matrix and calculates a new one if needed. """ # use stored sensing matrix if one is not provided if sensing_matrix is None: sensing_matrix = self.sensing_matrix # prepare some lists dofs = np.atleast_1d(dofs or self.dofs) readouts = np.atleast_1d(readouts or self.unique_readouts) # create the subplot axies Nrows = int(np.ceil(len(readouts) / 2)) Ncols = 2 fig, axs = plt.subplots( Nrows, Ncols, subplot_kw={"projection": "polar"}, squeeze=False, figsize=figsize, ) axs = axs.flatten() # create one plot per readout for i in range(len(readouts)): self.plot_radar( readouts[i], dofs=dofs, sensing_matrix=sensing_matrix, ax=axs[i] ) fig.legend(dofs, loc="center", bbox_to_anchor=(0.5, 1), fontsize=8) plt.tight_layout(pad=1.2) return fig, axs
[docs] def print_sensing_matrix(self): """Convenience function for get/plot/print nomenclature consistency.""" # print it out print(self.sensing_matrix)
[docs] def plot_radar(self, readout, dofs=None, sensing_matrix=None, ax=None): """Plots a radar plot for a readout according to the sensing matrix. Parameters ---------- readout : str Readout to use for sensing dofs. dofs : [str], optional Degrees of freedom to use when sensing. sensing_matrix : SensingMatrixDC, optional Sensing matrix to use for the plot, otherwise a new one will be created. ax : AxesSubplot Subplot axes to use when plotting. This is useful if the plot will be added to a grid of several plots. See `plot_sensing_matrix()`. """ if dofs is None: dofs = self.dofs if sensing_matrix is None: sensing_matrix = self.sensing_matrix if ax is None: _, axs = plt.subplots( 1, 1, subplot_kw={"projection": "polar"}, squeeze=False, ) ax = axs[0][0] # get the data from the sensing matrix data = sensing_matrix.out[ tuple(sensing_matrix.dofs.index(dof) for dof in dofs), sensing_matrix.readouts.index(readout), ] # determine the radius length r_lim = (np.log10(np.abs(data)).min() - 1, np.log10(np.abs(data)).max()) theta = np.angle(data) r = np.log10(np.abs(data)) ax.plot( (theta, theta), (r_lim[0] * np.ones_like(r), r), marker="D", markersize=4, ) ax.set( title=f"{readout}, phase = {float(self.model.get(readout).phase):2.4g}°", ylim=[r_lim[0], r_lim[1] + 1], theta_zero_location="E", yticklabels=[], ) return ax
[docs] def update_locks(self): # adf test, does not work, as locks are not updating in the model for dof, lock in self.control_scheme.items(): readout, port, accuracy, _ = lock if self.verbose: print(f"Updating {dof} lock") # Handle DARM separately for now since we'll lock on both RF and DC. if dof != "DARM": self.model.get(f"{dof}_lock").readout = readout self.model.get(f"{dof}_lock").port = port self.model.get(f"{dof}_lock").accuracy = accuracy else: self.model.get(f"{dof}_rf_lock").readout = readout self.model.get(f"{dof}_rf_lock").port = port self.model.get(f"{dof}_rf_lock").accuracy = accuracy
[docs] def add_locks(self): """Adds the locks contained within the control scheme. Assumes the locks have not already been parsed. If any lock already exists, then this will do nothing. Parameters ---------- rms : dict, optional Loop accuracies in meters (manually tuned for the loops to work with the default file). To compute accuracies from rms, we convert rms to radians as rms_rad = rms * 2 pi/lambda and then multiply by the optical gain. Returns True when parsing occurs, False when it is skipped. """ # check if any of the locks already exist lock_names = [lock.name for lock in self.model.locks] new_lock_names = [lock + "_lock" for lock in self.control_scheme.keys()] lock_exists = any(lock in lock_names for lock in new_lock_names) # if any of the locks already exist, do nothing if lock_exists: if self.verbose: print("Cannot create new locks, other locks already exist.") return False # to make sure any changes are used, let's run init on control schemes again self.init_control_scheme() if self.verbose: print(f"Adding locks for {new_lock_names}.") # We can generate the locks from the control scheme for dof, (readout, port, accuracy, rms) in self.control_scheme.items(): # compute the lock accuracy using the rms and optical gain # optical gain is W/deg if not accuracy: factor = 360 / self.model.lambda0 optical_gain = self.get_optical_gain(dof, dof) accuracy = round_to_n(np.abs(factor * rms * optical_gain), 2) # Handle DARM separately for now since we'll lock on both RF and DC. if dof != "DARM": self.model.parse( f"lock {dof}_lock {readout}.outputs.{port} {dof}.DC 1 {accuracy}" ) else: self.model.parse( f"lock {dof}_rf_lock {readout}.outputs.{port} {dof}.DC 1 {accuracy}" ) # lock DARM to 4mW # TODO: incorporate this into the control scheme somehow self.model.parse( f"lock {dof}_dc_lock B1.outputs.DC {dof}.DC 1 {accuracy} offset=4m disabled=true" )
[docs] def get_optical_gain(self, dof_in, dof_out, sensing_matrix=None): # if no sensing matrix is provided, use the existing one, or get a new one if not sensing_matrix: sensing_matrix = self.sensing_matrix in_idx = self.dofs.index(dof_in) out_idx = self.readouts.index(self.control_scheme[dof_out][0]) value = sensing_matrix.out[in_idx][out_idx] return value.real if self.control_scheme[dof_out][1] == "I" else value.imag
[docs] def optimize_lock_gains(self, sensing_matrix=None, verbose=False): """""" if sensing_matrix is None: sensing_matrix = self.sensing_matrix # for each dof for dof, lock in self.control_scheme.items(): readout, port, _, _ = lock # get the optical gain from the sensing matrix and calculate the lock gain optical_gain = sensing_matrix.out[ self.dofs.index(dof), self.readouts.index(readout) ] lock_gain = -1 / (optical_gain.real if port == "I" else optical_gain.imag) # set the lock gain if dof != "DARM": self.model.get(f"{dof}_lock").gain = lock_gain else: self.model.get(f"{dof}_rf_lock").gain = lock_gain self.model.get(f"{dof}_dc_lock").gain = lock_gain if self.verbose or verbose: print("-- Optimized lock gains:") for dof, lock in self.control_scheme.items(): readout, port, _, _ = lock if dof != "DARM": print( f" {dof:8s} {'_'.join([readout, port]):10s}: {self.model.get(f'{dof}_lock').gain:10.5g}" ) else: # lock gain for DARM RF and DC will be the same print( f" {dof:8s} {'_'.join([readout, port]):10s}: {self.model.get(f'{dof}_rf_lock').gain:10.5g}" )
[docs] def optimize_TL(self, accuracy=1, verbose=False): """Optimizes the focal point of the thermal lenses by minimizing a figure of merit as defined by the `opt_tl` detector. Parameters ---------- accuracy : float, optional Accuracy to which to tune the focal length, in meters. """ cp_old = accuracy + 1 cp_old_w = accuracy + 1 cp_new = np.abs(self.model.CPN_TL.f.eval()) cp_new_w = np.abs(self.model.CPW_TL.f.eval()) while np.abs(cp_new - cp_old) > accuracy: if verbose or self.verbose: print("ΔCPN_TL.f = ", np.abs(cp_new - cp_old)) print("ΔCPW_TL.f = ", np.abs(cp_new_w - cp_old_w)) # keep the old value cp_old = np.abs(self.model.CPN_TL.f.eval()) cp_old_w = np.abs(self.model.CPW_TL.f.eval()) # optimize and run the locks self.model.run(Minimize("opt_tl", ["f_CPN_TL", "f_CPW_TL"])) self.optimize_demodulation_phase() self.optimize_lock_gains() self.model.run(RunLocks(method="newton")) # keep the new value cp_new = np.abs(self.model.CPN_TL.f.eval()) cp_new_w = np.abs(self.model.CPW_TL.f.eval())
[docs] def get_DARM( self, dof="DARM_Fz", readout_port="B1p_56.I", rf_sidebands=True, axis=[0.5, 1000, 200], ): """Return the DARM transfer function. Parameters ---------- dof : str, optional The DOF to inject the signal into. This is typically either DARM or DARM_Fz. readout_port : str, optional The readout port out of which to read the signal. This is typically 'B1p_56.I'. rf_sidebands : boolean, optional If false, will turn off the modulators producing the RF sidebands. axis : [start, stop, points], optional Start, stop, and number of points to use on the xaxis. """ model = self.model.deepcopy() # optionally turn off the RF sidebands if not rf_sidebands: model.eom6.order = 0 model.eom8.order = 0 # set to signal simulation model.fsig.f = 1 # do frequency response return model.run(FrequencyResponse(np.geomspace(*axis), [dof], [readout_port]))
[docs] def plot_DARM( self, dof="DARM_Fz", readout_port="B1p_56.I", axis=[0.5, 1000, 200], ax=None, **kwargs, ): """Plots the DARM TF. Parameters ---------- dof : str, optional Degree of freedom to use for measuring DARM. readout_port : str, optional Detector port to read the output. axis : [start, stop, points], optional Start, stop, and number of points to use on the xaxis. ax : matplotlib.axes.Axes, optional Matplotlib axes to use for the plot. label : str, optional Label to use for the plot. """ # get points for DARM out = self.get_DARM(dof=dof, readout_port=readout_port, axis=axis) # create a new axis if none exists if ax is None: _, ax = plt.subplots(2) ax[0].set( title="DARM TF", ylabel=r"Amplitude [$\sqrt{W}$]", ) ax[1].set( xlabel="f [Hz]", ylabel="Phase [deg]", ) # prepare the default label if "label" not in kwargs: kwargs["label"] = f"{dof}->{readout_port}" ax[0].loglog(out.f, np.abs(out["darm"]), **kwargs) ax[1].semilogx(out.f, np.angle(out["darm"], deg=True), **kwargs) ax[0].legend() return ax
# can be repeated # TODO: convert to utility?
[docs] def get_QNLS(self, axis=[5, 5000, 100]): # allows for repetition kat = self.model.deepcopy() kat.parse( """#kat # Differentially modulate the arm lengths fsig(1) sgen darmx LN.h sgen darmy LW.h phase=180 # Output the full quantum noise limited sensitivity qnoised NSR_with_RP B1.p1.i nsr=True # Output just the shot noise limited sensitivity qshot NSR_without_RP B1.p1.i nsr=True """ ) return kat.run(f'xaxis(darmx.f, "log", {axis[0]}, {axis[1]}, {axis[2]})')
[docs] def plot_QNLS( self, axis=[5, 5000, 400], ax=None, shot_noise_only=False, **kwargs, ): """Plots the quantum noise limited sensitivity. Parameters ---------- axis : [start, stop, points], optional Start, stop, and number of points to use on the xaxis. ax : matplotlib.axes.Axes, optional Matplotlib axes to use for the plot. shot_noise_only : boolean, optional Plot only the shot noise. """ # grab QNLS points out = self.get_QNLS(axis) # prepare default label if "label" not in kwargs: kwargs["label"] = "NSR" + (" (shot noise only)" if shot_noise_only else "") # prepare the plot if making a new one if ax is None: _, ax = plt.subplots() ax.set( title="Quantum Noise Limited Sensitivity", xlabel="fsig.f [Hz]", ylabel=r"ASD [1/$\sqrt{Hz}$]", ) # plot NSR ys = abs(out["NSR_without_RP" if shot_noise_only else "NSR_with_RP"]) ax.loglog(out.x1, ys, **kwargs) ax.legend() return ax
# TODO: generate from THERMAL_STATES
[docs] def print_thermal_values(self): table = NumberTable( [ [self.model.PR.Rc[0]], [self.model.PR.Rc[1]], [self.model.SR.Rc[0]], [self.model.SR.Rc[1]], [self.model.f_CPN_TL.value.value], [self.model.f_CPW_TL.value.value], ], colnames=["Thermal Parameter", "Value"], rownames=["PR.Rcx", "PR.Rcy", "SR.Rcx", "SR.Rcy", "f_CPN_TL", "f_CPW_TL"], numfmt="{:11.2f}", compact=True, ) print(table)
[docs] def plot_error_signals(self, xscale=None, range=None, figsize=(8, 6)): """Plot grid of error signals.""" # create the subplot axies fig, axs = plt.subplots(3, 2, figsize=figsize) axs = axs.flatten() _range = { "CARM": [-0.01, 0.01, 200], "DARM": [-0.1, 0.1, 200], "MICH": [-1.0, 1.0, 200], "PRCL": [-1.0, 1.0, 200], "SRCL": [-1.0, 1.0, 200], } if type(range) is dict: for _ in range.keys(): _range[_] = range[_] else: if range is not None: print("range must be a dictionary") # create one plot per dof for i, lock in enumerate(self.model.locks): dof = lock.feedback.name.split(".")[0] detector = lock.error_signal.name if dof in _range.keys(): out = self.dof_plot(dof, detector, axis=_range[dof], show=False) else: out = self.dof_plot(dof, detector, show=False) # TODO: use detector port rather than name axs[i].plot(out.x[0], out[detector], label=detector) axs[i].set( xlabel=f"{dof} [deg]", ylabel=f"{detector} [W]", ) # axs[i].legend() plt.tight_layout(pad=1.2) return fig, axs
[docs] def dof_from_lock(self, lock): """Extracts the name of the dof controlled by the lock.""" dof = lock.split("_lock")[0] if dof == "DARM_rf" or dof == "DARM_dc": return "DARM" return dof
[docs] def get_error_signals(self): """Returns a list of error signals, keyed by dof.""" # run the model to get the output out = self.model.run(Noxaxis()) # build the dictionary from the locks that ran error_signals = {} for lock in self.model.locks: dof = self.dof_from_lock(lock.name) error_signals[dof] = out[lock.error_signal.name] return error_signals
[docs] def get_dof_lock(self, dof): """Returns the lock object currently enabled for the DOF. Needed to differentiate between DARM DC and RF lock. """ if dof not in self.control_scheme.keys(): raise Exception(f"DOF {dof} must be defined in the control scheme.") # if DARM, return the enabled lock if dof == "DARM": dc_lock = self.model.DARM_dc_lock rf_lock = self.model.DARM_rf_lock lock = rf_lock if dc_lock.disabled else dc_lock else: lock = self.model.get(f"{dof}_lock") return lock
[docs] def print_error_signals(self): """Display a table of DOFs, the readout error signals, and their current values.""" data = [["DOF", "signal", "error [W]"]] for dof, error_signal in self.get_error_signals().items(): data.append( [ f"{dof:6}", f"{self.get_dof_lock(dof).error_signal.name:9}", f"{error_signal:9.4g}", ] ) table = Table(data, alignment=["left", "left", "right"], compact=True) print(table)
# TODO: find better name since this is not just "lengths"
[docs] def print_lengths(self): f6 = float(self.model.eom6.f.value) f8 = float(self.model.eom8.f.value) f56 = float(self.model.eom56.f.value) # TODO: use table generator print( f"""┌─────────────────────────────────────────────────┐ │- Arm lengths [m]: │ │ LN = {self.model.elements["LN"].L.value:<11.4f} LW = {self.model.elements["LW"].L.value:<11.4f} ├─────────────────────────────────────────────────┤ │- Michelson and recycling lengths [m]: │ │ ln = {float(self.model.ln.value):<11.4f} lw = {float(self.model.lw.value):<11.4f} │ lpr = {float(self.model.lpr.value):<11.4f} lsr = {float(self.model.lsrbs.value):<11.4f} │ lMI = {float(self.model.lMI.value):<11.4f} lSchnupp = {float(self.model.lSchnupp.value):<11.4f} │ lPRC = {float(self.model.lPRC.value):<11.4f} lSRC = {float(self.model.lSRC.value):<11.4f} ├─────────────────────────────────────────────────┤ │- Associated cavity frequencies [Hz]: │ │ fsrN = {float(self.model.fsrN.value):<11.2f} fsrW = {float(self.model.fsrW.value):<11.2f} │ fsrPRC = {float(self.model.fsrPRC.value):<11.2f} fsrSRC = {float(self.model.fsrSRC.value):<11.2f} │ │ │- Modulation sideband frequencies [MHz]: │ │ f6 = {f6/1e6:<12.6f} f8 = {f8/1e6:<12.6f} │ f56 = {f56/1e6:<12.6f} ├─────────────────────────────────────────────────┤ │- Check frequency match [MHz]: │ │ 125.5*fsrN-300 = {(125.5*float(self.model.fsrN.value)-300)/1e6:<8.6f} │ 0.5*fsrPRC = {0.5*float(self.model.fsrPRC.value)/1e6:<8.6f} │ 0.5*fsrSRC = {0.5*float(self.model.fsrSRC.value)/1e6:<8.6f} │ 9*f6 = {9*f6/1e6:<8.6f} └─────────────────────────────────────────────────┘""" )
[docs] def zero_dof_tunings(self, dofs=None): """This function will move the current DoF DC values into the phi parameter of the driven component before resetting the DoF DC value to zero. Parameters ---------- dofs : list or str, optional List of dofs to zero. By default, will zero all dofs found in the model. """ # allow a list of dofs to be passed if dofs is None: dofs = self.get_dofs() # make sure it is if type(dofs) is not list: dofs = [dofs] for dof in dofs: # if it isn't a component, get it from the model if type(dof) is str: dof = self.model.get(dof) # move each dof value into phi with the appropriate sign for drive, amp in zip(dof.drives, dof.amplitudes): component = drive.name.split(".")[0] self.model.get(component).phi += dof.DC * amp # zero the dof value dof.DC = 0
# TODO: move to Finesse 3 model?
[docs] def get_dofs(self): return list(filter(lambda c: type(c) is DegreeOfFreedom, self.model.components))
# TODO: could be moved the Finesse 3? # TODO: rename to get_dofs_by_optic()?
[docs] def get_dofs_by_component(self): """Returns a dictionary, keyed by component name, with a list of dof/amp pairs driving each component.""" dofs_by_component = {} dofs = self.get_dofs() for dof in dofs: for drive, amp in zip(dof.drives, dof.amplitudes): component = drive.name.split(".")[0] if component not in dofs_by_component.keys(): dofs_by_component[component] = [] dofs_by_component[component].append((dof.name, amp)) return dofs_by_component
[docs] def deg2m(self, deg, inverse=False): conversion = self.model.lambda0 / 360 return deg * conversion ** (-1 if inverse else 1)
[docs] def get_tuning(self, name): """Return the full phi + DC tuning for the optic/dof. For optics, it is sum of the phi of the mirror and DC value of each dof contribution # For dofs, it is the sum of the DC value and phi contribution of each mirror. Parameters ---------- name : str Name of the component of which to get the tuning. """ component = self.model.get(name) if isinstance(component, DegreeOfFreedom): # does not seem to work # # dof tuning is DC + all optic # tuning = component.DC # # sum up the contribution from each relevant optic # for drive, amp in zip(component.drives, component.amplitudes): # mir = drive.name.split(".")[0] # tuning += amp * self.model.get(mir).phi # return tuning pass else: # handle optic pairs = self.get_dofs_by_component()[name] tuning = component.phi + sum( [self.model.get(dof).DC.value * amp for dof, amp in pairs] ) return tuning
# TODO: move to Finesse 3?
[docs] def get_tunings( self, include=[ "NE", "WE", "NI", "WI", "PR", "SR", ], meters=False, ): """Sums together current phi position and all dof contributions. Returns dict with deg and meters, combining both mirrors and dofs. """ tunings = {} include_all = include is None # mirror tunings for component, pairs in self.get_dofs_by_component().items(): # only include provided tunings if not include_all and component not in include: continue phi = self.model.get(f"{component}.phi") tuning = phi + sum( [self.model.get(dof).DC.value * amp for dof, amp in pairs] ) tunings[component] = tuning if meters is False else self.deg2m(tuning) # dof tunings for dof in self.get_dofs(): # only include provided tunings if not include_all and dof.name not in include: continue # tuning = self.get_tuning(dof.name) # tunings[dof.name] = tuning if meters is False else self.deg2m(tuning) tunings[dof.name] = None return tunings
[docs] def get_phi_tunings(self): """Returns a dictionary of phi tunings, keyed by optic.""" phi_tunings = {} for optic in self.get_dofs_by_component().keys(): phi_tunings[optic] = self.model.get(optic).phi.eval() return phi_tunings
[docs] def get_dof_tunings(self): """Returns a dictionary of dof tunings, keyed by dof.""" dof_tunings = {} for dof in self.control_scheme.keys(): dof_tunings[dof] = self.model.get(dof).DC.eval() return dof_tunings
[docs] def set_phi_tunings(self, phi_tunings): """Sets phi parameter for provided dictionary keyed by mirror.""" for mirror, phi_tuning in phi_tunings.items(): self.model.get(mirror).phi.value = phi_tuning
[docs] def set_dof_tunings(self, dof_tunings): for dof, dof_tuning in dof_tunings.items(): self.model.get(dof).DC.value = dof_tuning
# TODO: get optic tunings by dof
[docs] def print_tunings(self): data = [ ["Optic/DOF", "phi [deg]", "dof.DC [deg]", "Tuning [deg]", "Tuning [pm]"] ] # build phi and dc columns for tuning, degs in self.get_tunings().items(): if degs is not None: pmeters = self.deg2m(degs) / 1e-12 else: pmeters = None # handle dof and optic differently if tuning in self.control_scheme.keys(): dc = self.model.get(tuning).DC.eval() phi = None else: dc = None phi = self.model.get(tuning).phi.eval() data.append( [ tuning, "" if phi is None else f"{phi:10.4g}", "" if dc is None else f"{dc:10.4g}", "" if degs is None else f"{degs:12.6g}", "" if pmeters is None else f"{pmeters:12.6g}", ] ) for tuning, dc in self.get_dof_tunings().items(): data.append( [ tuning, "", f"{dc:10.4g}", "", "", ] ) table = Table( data, alignment=["left", "right", "right", "right", "right"], compact=True ) print(table)
# TODO: check that all mirrors are present
[docs] def set_tunings(self, tunings): """Sets the phi parameters on the mirrors and zeroes all dofs so that all tunings are contained within the mirrors (rather than a combination of phi+dof.DC). Parameters ---------- tunings : {} Dictionary of tunings, indexed by mirror name. Must contain all relevant mirrors. """ # set the tunings on the mirrors for mirror in self.get_dofs_by_component().keys(): self.model.get(mirror).phi = tunings[mirror] # zero out the dofs for dof in self.dofs: self.model.get(dof).DC = 0
# TODO: needs testing
[docs] def sensing_W_to_m( self, watts, dof, readout_port=None, sensing_matrix=None, inverse=False ): """Uses the sensing matrix to convert the dof readout power from Watts to meters and vice versa. Parameters ---------- value : float Value to convert from W to m (or m to W) dof : str The DOF to use for the conversion. readout_port : str, optional Name of readout port to use for the conversion. Defaults to the one assigned to the DOF according to the control scheme. sensing_matrix : SensingMatrix, optional Sensing matrix to use, if provided. If not provided, then a new sensing matrix will be computed. """ # compute the sensing matrix if needed if sensing_matrix is None: sensing_matrix = self.sensing_matrix # lookup the readout from the control scheme if readout_port is None: readout = self.control_scheme[dof][0] port = self.control_scheme[dof][1] else: readout = "_".join(readout_port.split("_")[0:-1]) port = readout_port.split("_")[-1] # get the dof/readout entry from the sensing matrix sm_element = sensing_matrix.out[ self.dofs.index(dof), self.readouts.index(readout) ] # determine conversion to meters # sensing matrix in Finesse is W/deg, so also convert to W/m conversion = ( (sm_element.real if port == "I" else sm_element.imag) * 360 / self.model.lambda0 ) return watts * conversion ** (-1 if not inverse else 1)
[docs] def print_pretune_status(self): settings = self.get_settings() powers = self.get_powers() tunings = self.get_tunings() print( f""" ╔══════════════════════════════════════════════════════════════════════════╗ ║ Pretuned for maxtem = {(str(settings['maxtem']) + ', zero_k00 = ' + str(settings['zero_k00'])):<34} {' '*15} ║ Detector | Power [W] : P. ratio ║ | Tuning [deg] : Tuning [m] ║ ╟──────────────┼───────────────────────╫───────┼───────────────────────────╢""" ) for power, tuning in zip_longest(powers.items(), tunings.items()): if power: detector, (watts, ratio) = power col1 = f"{str(detector):12} | {float(watts):9.4g} : {float(ratio):9.4g}" else: col1 = f"{' '*12} | {' '*9} {' '*9}" if tuning: optic, degs = tuning meters = self.deg2m(degs) col2 = f"{str(optic):5} | {float(degs):12.4f} : {float(meters):10.3g}" else: col2 = f"{' '*5} | {' '*12} {' '*10}" print(f" ║ {col1}{col2} ║") print( " ╚══════════════╧═══════════════════════╩═══════╧═══════════════════════════╝" )
# TODO: add a __str__ desc to a lock? # something like `PRCL_lock B2_8_I PRCL.DC gain=1 disabled=False accuracy=5.3e-06`
[docs] def print_locks( self, gain_adjustments={ "DARM": 1.0, "CARM": 1.0, "PRCL": 1.0, "MICH": 1.0, "SRCL": 1.0, }, ): """""" has_adjustment = any(adj != 1.0 for adj in gain_adjustments.values()) factor1 = 180 / math.pi factor2 = 360.0 / self.model.lambda0 print(" ╔═══════════════════════════════════════════════════════╗") print(" ║ Parameters for locks: ║") print(" ╠═══════════════════════════════════════════════════════╣") print( f" ║ {'Lock name':<14} {'port':<8} {'DOF':<8} {'lock gain':<9} {'disabled':>10} ║" ) # locks for lock in self.model.locks: print( f" ║ {lock.name:<14} {lock.error_signal.name:<8} {lock.feedback.name:<8} {float(lock.gain):>9.2} {str(lock.disabled):>10} ║" ) print(" ╟───────────────────────────────────────────────────────╢") print(f" ║ {'Accuracies':<9} {'[deg]':>12} {'[m]':>12} {'[W]':>12} ║") # prepare lock accuracies lock_accs = {} for lock in self.model.locks: dof = lock.name.split("_")[0] lock_accs[dof] = lock.accuracy # loop accuracies for dof, (_, _, _, rms) in self.control_scheme.items(): og_w_deg = self.get_optical_gain(dof, dof) acc_deg = factor2 * rms acc_m = rms acc_w = lock_accs[dof] print(f" ║ {dof:<9}: {acc_deg:12.6} {acc_m:12.6} {acc_w:12.6} ║") print(" ╟───────────────────────────────────────────────────────╢") print(f" ║ {'Optical gains [W/deg]'} {'[W/rad]':>12} {'[W/m]':>12} ║") # optical gains for dof, _ in self.control_scheme.items(): og_w_deg = self.get_optical_gain(dof, dof) og_w_rad = og_w_deg * factor1 og_w_m = og_w_deg * factor2 print(f" ║ {dof:<9}: {og_w_deg:12.5} {og_w_rad:12.5} {og_w_m:12.5} ║") if has_adjustment: print(" ╟───────────────────────────────────────────────────────╢") print( f" ║ {'adjustment':>21} {'-1/opt_gain':>12} {'adj. lock gain':>14} ║" ) # gain factors for dof, _ in self.control_scheme.items(): og_w_deg = self.get_optical_gain(dof, dof) adjustment = gain_adjustments[dof] gain_recip = -1 / og_w_deg adj_gain = adjustment * gain_recip print( f" ║ {dof:<9}: {adjustment:>9.4} * {gain_recip:12.6} = {adj_gain:14.6} ║" ) print(" ╚═══════════════════════════════════════════════════════╝")