"""Plot routines"""
import copy
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import matplotlib.cm
import matplotlib.figure
import matplotlib.pyplot as plt
import matplotlib.tri as tri
import numpy as np
import pandas as pd
import plotly.graph_objs as go
from IPython.display import Markdown, display
from matplotlib import ticker
from matplotlib.collections import PolyCollection
from matplotlib.colors import ListedColormap
from mpl_toolkits.axes_grid1 import make_axes_locatable
from roxieapi.commons import roxie_style
from roxieapi.commons.roxie_constants import PlotLabels
from roxieapi.commons.types import (
Base3DGeometry,
CoilGeometry,
DesignVariableResult,
Geometry,
GraphPlot,
HarmonicCoil,
ObjectiveResult,
Plot2D,
Plot3D,
PlotInfo,
PlotLegend,
)
from roxieapi.input.builder import RoxieInputBuilder
from roxieapi.output.parser import RoxieOutputParser
try:
import pyvista as pv
except ImportError:
_has_pyvista = False
else:
_has_pyvista = True
[docs]
def requires_pv(func):
def inner(*arg):
if _has_pyvista:
return func(*arg)
else:
print("Missing dependency to pyvista")
return inner
[docs]
class RoxieGraphsPlotly:
"""Roxie Graph output
A class for plotting Standard graph plots from a roxie output
"""
def __init__(self, output_parser: RoxieOutputParser):
"""Initialize RoxieGraphs with an output parser
:param output_parser: the parser to use for all graphs
"""
self.output_parser = output_parser
[docs]
def plot_device_graph(
self,
graphPlot: GraphPlot,
opt_step: int = 1,
trans_step: int = 1,
fig_size: Optional[Tuple[float, float]] = None,
) -> Optional[go.Figure]:
"""Plot a device graph
:param graphPlot: The GraphPlot object from the RoxieOutputParser
:param opt_step: Optimization step
:param trans_step: Transient step
:param fig_size: Size of the figure (in inch), defaults to None
:return: a plotly graph_objs.Figure object, or None if Plot data is missing
"""
if step := self.output_parser.find_transstep(opt_step, trans_step):
return self.plot_graph(graphPlot, step.deviceGraphs, fig_size)
return None
[docs]
def plot_transient_graph(
self,
graphPlot: GraphPlot,
opt_step: int = 1,
fig_size: Optional[Tuple[float, float]] = None,
) -> Optional[go.Figure]:
"""Plot a device graph
:param graphPlot: The GraphPlot object from the RoxieOutputParser
:param opt_step: Optimization step
:param fig_size: Size of the figure (in inch), defaults to None
:return: a plotly graph_objs.Figure object, or None if Plot data is missing
"""
if opt := self.output_parser.find_optstep(opt_step):
return self.plot_graph(graphPlot, opt.transientGraphs, fig_size)
return None
[docs]
def plot_optimization_graph(
self,
graphPlot: GraphPlot,
fig_size: Optional[Tuple[float, float]] = None,
) -> Optional[go.Figure]:
"""Plot a device graph
:param graphPlot: The GraphPlot object from the RoxieOutputParser
:param fig_size: Size of the figure (in inch), defaults to None
:return: a plotly graph_objs.Figure object, or None if Plot data is missing
"""
return self.plot_graph(
graphPlot, self.output_parser.optimizationGraphs, fig_size
)
[docs]
@staticmethod
def plot_graph(
graphPlot: GraphPlot,
data: Dict[int, pd.DataFrame],
fig_size: Optional[Tuple[float, float]] = None,
) -> Optional[go.Figure]:
"""Plot a graph
:param graphPlot: The GraphPlot object from the RoxieOutputParser
:param data: Corresponding data object from the ROxieOutputParser
:param fig_size: Size of the figure (in inch), defaults to None
:return: a plotly graph_objs.Figure object, or None if Plot data is missing
"""
title = graphPlot.title
axisX = graphPlot.axes["X"]
axisY = graphPlot.axes["Y"]
if graphPlot.graphs[0].id not in data:
return None
p = graphPlot.graphs[0]
fig = go.Figure()
if fig_size:
px_size = plt.rcParams["figure.dpi"] # pixel in inches
fig.update_layout(
autosize=False,
width=fig_size[0] * px_size,
height=fig_size[1] * px_size,
)
fig.update_layout(title=title, showlegend=True)
fig.update_xaxes(title=axisX.label, range=axisX.bounds)
fig.update_yaxes(title=axisY.label, range=axisY.bounds)
if axisX.log:
fig.update_xaxes(type="log")
if axisY.log:
fig.update_yaxes(type="log")
for p in graphPlot.graphs:
lbl = p.label if p.label else f"Graph {p.id}"
fig.add_trace(go.Scatter(x=data[p.id]["x"], y=data[p.id]["y"], name=lbl))
return fig
[docs]
@staticmethod
def plot_forces(
data: pd.DataFrame,
fig_size: Optional[Tuple[float, float]] = None,
) -> go.Figure:
"""Plot The Forces on conductor (plot similar to roxie plot in pdf)
:param data: The dataframe with the forces defined (from RoxieOutputParser)
:param fig_size: Size of the figure in inches, defaults to None
:return: the figure
"""
fig = go.Figure()
fig.update_layout(
title="Lorenz Forces in Conductor",
xaxis_title="Conductor Nr",
yaxis_title="Force in N/m",
)
if fig_size:
px_size = plt.rcParams["figure.dpi"] # pixel in inches
fig.update_layout(
autosize=False,
width=fig_size[0] * px_size,
height=fig_size[1] * px_size,
)
cols = [
("fx", "X component"),
("fy", "Y component"),
("ftr", "Perpendicular"),
("fpa", "Parallel"),
("fra", "Radial"),
("faz", "Azimuthal"),
]
for c, n in cols:
fig.add_trace(go.Scatter(x=data["cond"], y=data[c], mode="markers", name=n))
return fig
[docs]
@staticmethod
def plot_harmonics(
coil: HarmonicCoil,
fig_size: Optional[Tuple[float, float]] = None,
) -> go.Figure:
"""Plot the Harmonics as a bar graph
:param coil: The coil to plot the harmonics for
:param fig_size: Size of figure, in inches, defaults to None
:return: The figure
"""
bnvals = list(coil.bn.values())
anvals = list(coil.an.values())
if not coil.absolute:
if coil.skew:
anvals[coil.order - 1] = 0
else:
bnvals[coil.order - 1] = 0
fig = go.Figure(
data=[
go.Bar(
name="Bn" if coil.absolute else "bn",
x=list(coil.bn.keys()),
y=bnvals,
),
go.Bar(
name="An" if coil.absolute else "an",
x=list(coil.an.keys()),
y=anvals,
),
]
)
fig.update_layout(
title=f"Harmonic coil {coil.id}",
xaxis_title="Order",
yaxis_title=f"Multipole in {'T' if coil.absolute else 'units'}",
barmode="group",
)
if fig_size:
px_size = plt.rcParams["figure.dpi"] # pixel in inches
fig.update_layout(
autosize=False,
width=fig_size[0] * px_size,
height=fig_size[1] * px_size,
)
return fig
[docs]
class RoxiePlots2D:
"""2D Plots for roxie (geometry + data)"""
[docs]
@dataclass
class CBarInfo:
pos: str = "w"
lbls: List[str] = field(default_factory=list)
vmin: Optional[float] = None
vmax: Optional[float] = None
objs: List[matplotlib.cm.ScalarMappable] = field(default_factory=list)
def __init__(self, output_parser: RoxieOutputParser) -> None:
"""Create a new RoxiePlots2D object. Stores internally color maps, figures and axis"""
self.roxie_pv = np.array(roxie_style.roxie_color_palette) / 256
self.roxie_cm = ListedColormap(self.roxie_pv) # type: ignore
self.logger = logging.getLogger("RoxiePlots2D")
self.mirrorX = False
self.mirrorY = False
self.output_parser = output_parser
@staticmethod
def _quads_to_tris(roxie_elements: List[List[int]]) -> list[list[int]]:
"""convert quads to tris
:param quads: Quad input
:return: tri output
"""
# Check if there are already tris in the input
nr_quads = sum(1 for x in roxie_elements if len(x) == 8)
nr_tris = sum(1 for x in roxie_elements if len(x) == 6)
if nr_quads + nr_tris < len(roxie_elements):
raise Exception("Encountered Elements in input which are neither T6 nor Q8")
tris = [[-1 for j in range(3)] for i in range(6 * nr_quads + 4 * nr_tris)]
j = 0
for i in range(len(roxie_elements)):
if len(roxie_elements[i]) == 8:
n = [roxie_elements[i][k] for k in range(0, 8)]
tris[j + 0] = [n[0], n[1], n[7]]
tris[j + 1] = [n[2], n[3], n[1]]
tris[j + 2] = [n[4], n[5], n[3]]
tris[j + 3] = [n[6], n[7], n[5]]
tris[j + 4] = [n[1], n[3], n[7]]
tris[j + 5] = [n[5], n[7], n[3]]
j += 6
else:
n = [roxie_elements[i][k] for k in range(0, 6)]
tris[j + 0] = [n[0], n[1], n[5]]
tris[j + 1] = [n[1], n[2], n[3]]
tris[j + 2] = [n[3], n[4], n[5]]
tris[j + 3] = [n[5], n[1], n[3]]
j += 4
return tris
@staticmethod
def _mirrorX(geom: Geometry) -> Geometry:
"""generate a mirror of geometry, mirrored over x axis
:param geom: the geometry
:return: mirrored geometry
"""
geom_m = copy.deepcopy(geom)
geom_m.nodes[:, 0] = -geom_m.nodes[:, 0]
if geom.boundaries:
for bound in geom.boundaries:
assert geom_m.boundaries is not None
geom_m.boundaries[bound][:, 0] = -geom.boundaries[bound][:, 0]
return geom_m
@staticmethod
def _mirrorY(geom: Geometry) -> Geometry:
"""generate a mirror of geometry, mirrored over y axis
:param geom: the geometry
:return: mirrored geometry
"""
geom_m = copy.deepcopy(geom)
geom_m.nodes[:, 1] = -geom_m.nodes[:, 1]
if geom.boundaries:
for bound in geom.boundaries:
assert geom_m.boundaries is not None
geom_m.boundaries[bound][:, 1] = -geom.boundaries[bound][:, 1]
return geom_m
def _create_plot(self, plot: Plot2D, figsize: Tuple[int, int] = (15, 15)) -> None:
"""Create a plot and set defaults for axis, size and aspect ratio
:param plot: the Plot2D description from roxie output
:param figsize: size of the figure in inches, defaults to (15, 15)
"""
self.fig = plt.figure(figsize=figsize)
self.ax = self.fig.add_subplot(111, aspect="equal")
self.cbars: Dict[str, RoxiePlots2D.CBarInfo] = {
"w": RoxiePlots2D.CBarInfo(pos="w")
}
self.ax.set_title(plot.title)
if plot.axes["X"].bounds:
self.ax.set_xlim(
left=plot.axes["X"].bounds[0], right=plot.axes["X"].bounds[1]
)
if plot.axes["Y"].bounds:
self.ax.set_ylim(
bottom=plot.axes["Y"].bounds[0], top=plot.axes["Y"].bounds[1]
)
self.ax.set_xlabel(plot.axes["X"].label)
self.ax.set_ylabel(plot.axes["Y"].label)
def _update_cb(
self,
obj: Optional[matplotlib.cm.ScalarMappable],
label: Optional[str],
min_val: float,
max_val: float,
legend: Optional[PlotLegend],
replace: bool = False,
) -> CBarInfo:
"""Update the range of the Colorbar
:param label: Label of data to plot
:param min_val: min
:param max_val: max
:param replace: replace current values instead of updating, defaults to False
"""
cbar_name = legend.pos if legend and legend.pos else "w"
if cbar_name not in self.cbars:
self.cbars[cbar_name] = RoxiePlots2D.CBarInfo(pos=cbar_name[0:1])
cbar = self.cbars[cbar_name]
if legend and legend.min_val is not None:
cbar.vmin = legend.min_val
elif replace or not cbar.vmin:
cbar.vmin = min_val
else:
cbar.vmin = min(cbar.vmin, min_val)
if legend and legend.max_val is not None:
cbar.vmax = legend.max_val
elif replace or not cbar.vmax:
cbar.vmax = max_val
else:
cbar.vmax = max(cbar.vmax, max_val)
if label:
cbar.lbls.append(label)
if obj:
cbar.objs.append(obj)
return cbar
def _create_colorbars(self) -> None:
"""Create a colorbar"""
divider = make_axes_locatable(self.ax)
for name, cbarInfo in self.cbars.items():
if not cbarInfo.objs:
continue
pos = cbarInfo.pos
div_pos = (
"right"
if pos == "e"
else "left"
if pos == "w"
else "top"
if pos == "n"
else "bottom"
)
pad = 0.05
if div_pos in ["left", "bottom"]:
pad = 0.5
cax = divider.append_axes(div_pos, size="5%", pad=pad)
sm = plt.cm.ScalarMappable( # type: ignore
cmap=self.roxie_cm,
norm=plt.Normalize(vmin=cbarInfo.vmin, vmax=cbarInfo.vmax), # type: ignore
)
lbl_txt = "\n".join(cbarInfo.lbls)
cbar = self.fig.colorbar(
sm, cax, label=lbl_txt, ticklocation="auto", location=div_pos
)
tick_locator = ticker.LinearLocator(numticks=len(self.roxie_pv) + 1)
cbar.locator = tick_locator
if div_pos in ["top", "bottom"]:
cbar.ax.tick_params(rotation=90)
cbar.update_ticks()
for obj in cbarInfo.objs:
obj.set_clim(vmin=cbarInfo.vmin, vmax=cbarInfo.vmax)
def _create_cable_collection(
self, coilGeom: Dict[int, CoilGeometry]
) -> PolyCollection:
patches = [copy.copy(cable.geometry) for cable in coilGeom.values()]
return PolyCollection(patches)
def _plot_cables(self, coilGeom: Dict[int, CoilGeometry]) -> None:
"""Plot the cables of the geometry
:param coilGeom: The coil geometry from the parser
:raises Exception: if no plot was created
"""
if not self.ax:
raise Exception(
"No plot created yet, cannot add cables. Call create_plot before"
)
pc = self._create_cable_collection(coilGeom)
pc.set_facecolor("none")
pc.set_edgecolor("blue")
self.ax.add_collection(pc) # type:ignore
def _plot_strands(
self,
plotInfo: PlotInfo,
opt_step: int = 1,
trans_step: int = 1,
) -> None:
"""Plot The strands of the geometry
:param opt_step: Optimization step
:param trans_step: Transient step
:param plotInfo: the PlotInfo object
"""
coilGeom = None
coilData = None
cLegend = None
dataLabel = None
coilVector = None
if opt := self.output_parser.opt.get(opt_step, None):
coilGeom = opt.coilGeometries
if step := self.output_parser.find_transstep(opt_step, trans_step):
if plotInfo.dataType == "scalar":
if plotInfo.id in step.coilData.columns:
coilData = step.coilData[plotInfo.id]
elif plotInfo.harmCoil in step.harmonicCoils:
hc = step.harmonicCoils[plotInfo.harmCoil]
if plotInfo.id in hc.strandData.columns:
coilData = hc.strandData[plotInfo.id]
if coilData is not None:
dataLabel = PlotLabels.plot2D_desc.get(
plotInfo.id, f"Plot id {plotInfo.id}"
)
cLegend = plotInfo.plotLegend
elif plotInfo.dataType == "vector" and plotInfo.vector_mappings is not None:
df = step.coilData
id_x = plotInfo.vector_mappings.get("x", -1)
id_y = plotInfo.vector_mappings.get("y", -1)
if id_x in df.columns and id_y in df.columns:
vx = df[id_x]
vy = df[id_y]
coilVector = [vx, vy]
if coilGeom is None:
return
patches = []
centers_x = []
centers_y = []
for cable in coilGeom.values():
for (
strand_idx,
strand,
) in cable.strands.items():
patches.append(strand)
centers_x.append(np.mean(strand[:, 0]))
centers_y.append(np.mean(strand[:, 1]))
p_strands = PolyCollection(patches)
if coilData is not None:
p_strands.set_array(coilData)
p_strands.set_cmap(self.roxie_cm)
self._update_cb(
p_strands, dataLabel, np.min(coilData), np.max(coilData), cLegend
)
else:
p_strands.set_edgecolor("black")
if coilVector is not None:
self.ax.quiver(centers_x, centers_y, coilVector[0], coilVector[1])
# self._update_cb_range(dataLabel,np.min(meshData), np.max(meshData))
self.ax.add_collection(p_strands) # type: ignore
@staticmethod
def _mirror_iron(
meshGeom: Geometry, mirror_x: bool = False, mirror_y: bool = False
) -> List[Geometry]:
"""Mirror the iron of meshGeometry in x and y
:param meshGeom: The base geometry
:param mirror_x: Mirror in x, defaults to False
:param mirror_y: Mirror in y, defaults to False
:return: List of Geometries (original + all mirrors)
"""
geoms = []
geoms.append(meshGeom)
if mirror_x:
geoms_mx = []
for geom in geoms:
geoms_mx.append(RoxiePlots2D._mirrorX(geom))
geoms.extend(geoms_mx)
if mirror_y:
geoms_my = []
for geom in geoms:
geoms_my.append(RoxiePlots2D._mirrorY(geom))
geoms.extend(geoms_my)
return geoms
def _plot_iron_boundary(self, geoms: List[Geometry]) -> None:
"""Plot the Boundary of the mesh geometries
:param geoms: The list of geometries
"""
boundaries = []
for meshGeom in geoms:
if meshGeom.boundaries is None:
continue
for f, bound in meshGeom.boundaries.items():
boundaries.append(bound)
p = PolyCollection(
boundaries, facecolors="none", edgecolors="black", linewidth=0.5
)
self.ax.add_collection(p) # type: ignore
def _plot_iron_mesh(self, geoms: List[Geometry]) -> None:
"""Plot the iron mesh
:param geoms: List of geometries
"""
mesh = []
for meshGeom in geoms:
elems = meshGeom.elements
if elems is None:
continue
for idx, elem in enumerate(elems):
nodes = meshGeom.nodes[elem[:]]
mesh.append(copy.copy(nodes))
p = PolyCollection(mesh, facecolors="none", edgecolors="black", linewidth=0.5)
self.ax.add_collection(p) # type: ignore
def _plot_matrix(
self,
plotInfo: PlotInfo,
opt_step: int = 1,
trans_step: int = 1,
) -> None:
"""Plot Vector Matrix over 2d plot
:param output_parser: The output parser
:param plotInfo: Plot info object
:param opt_step: Optimization step, defaults to 1
:param trans_step: Transient step, defaults to 1
"""
matrixData = None
step = self.output_parser.find_transstep(opt_step, trans_step)
if step:
matrixData = step.matrixData
if matrixData is None:
return
df = matrixData.copy()
if plotInfo.plotLegend:
uniform_length = not plotInfo.plotLegend.greyScale
plot_colors = uniform_length
else:
plot_colors = False
uniform_length = True
if plot_colors or uniform_length:
df["vc"] = np.sqrt(df["vx"] ** 2 + df["vy"] ** 2)
if uniform_length:
df["vx"] = df["vx"] / df["vc"]
df["vy"] = df["vy"] / df["vc"]
if plot_colors:
quiver = self.ax.quiver(
df["x"], df["y"], df["vx"], df["vy"], df["vc"], cmap=self.roxie_cm
)
self._update_cb(
quiver,
"2D Vector Matrix",
min(df["vc"]),
max(df["vc"]),
plotInfo.plotLegend,
)
else:
self.ax.quiver(df["x"], df["y"], df["vx"], df["vy"])
def _plot_iron(
self,
meshPlots: List[PlotInfo],
opt_step: int = 1,
trans_step: int = 1,
) -> None:
"""Plot all iron related plots (mesh, boundaries, scalar values)
:param plotInfo: The plot info object
:param opt_step: Optimization step, defaults to 1
:param trans_step: Transient step, defaults to 1
:raises Exception: for mesh types which are not implemented yet
"""
meshGeometries: List[Geometry] = []
if opt_step not in self.output_parser.opt or meshPlots is None:
return
opt = self.output_parser.opt[opt_step]
val = opt.meshGeometries
if val is None:
return
if self.mirrorX or self.mirrorY:
meshGeometries = self._mirror_iron(val, self.mirrorX, self.mirrorY)
else:
meshGeometries = [val]
for plotInfo in meshPlots:
if plotInfo.dataType == "mesh":
self._plot_iron_mesh(meshGeometries)
elif plotInfo.dataType == "surface":
self._plot_iron_boundary(meshGeometries)
elif plotInfo.dataType == "scalar":
if (
trans_step in opt.step
and plotInfo.id in opt.step[trans_step].meshData.columns
):
data = opt.step[trans_step].meshData[plotInfo.id]
dataLabel = PlotLabels.plotMesh2D_desc.get(
plotInfo.id, f"Meshplot id {plotInfo.id}"
)
cbar = self._update_cb(
None, dataLabel, np.min(data), np.max(data), plotInfo.plotLegend
)
for meshGeom in meshGeometries:
nodes_x = meshGeom.nodes[:, 0]
nodes_y = meshGeom.nodes[:, 1]
if meshGeom.elements is None:
continue
elems_tris = RoxiePlots2D._quads_to_tris(meshGeom.elements)
triangulation = tri.Triangulation(nodes_x, nodes_y, elems_tris)
tricont = self.ax.tricontourf(
triangulation, data, levels=19, cmap=self.roxie_cm
)
cbar.objs.append(tricont)
elif plotInfo.dataType == "vector":
if trans_step in opt.step and plotInfo.vector_mappings is not None:
df = opt.step[trans_step].meshData
id_x = plotInfo.vector_mappings.get("x", -1)
id_y = plotInfo.vector_mappings.get("y", -1)
if id_x in df.columns and id_y in df.columns:
nodes = np.concatenate(
[meshGeom.nodes for meshGeom in meshGeometries]
)
nodes_x = nodes[:, 1]
nodes_y = nodes[:, 2]
vx = df[id_x]
vy = df[id_y]
self.ax.quiver(nodes_x, nodes_y, vx, vy)
else:
raise Exception(
"Mesh datatype {0} not yet implemented".format(plotInfo.dataType)
)
[docs]
def plot_xs(
self,
pl: Plot2D,
opt_step: int = 1,
trans_step: int = 1,
figsize=(8, 8),
) -> matplotlib.figure.Figure:
"""Plots a crosssection plot from Roxie
:param pl: the Plot2D object
:param opt_step: Optimization step
:param trans_step: Transient step
:param figsize: size of the figure
:return: the matplotlib figure object
"""
self._create_plot(pl, figsize=figsize)
opt = self.output_parser.find_optstep(opt_step)
if opt is None:
return self.fig
self._plot_cables(opt.coilGeometries)
for pp in pl.pointPlots:
# do nothing for now
pass
for cp in pl.coilPlots:
self._plot_strands(
cp,
opt_step,
trans_step,
)
self._plot_iron(
pl.meshPlots,
opt_step,
trans_step,
)
for mp in pl.matrixPlots:
self._plot_matrix(
mp,
opt_step,
trans_step,
)
for ip in pl.irisPlots:
pass
self._create_colorbars()
self.fig.axes[0].autoscale_view()
return self.fig
[docs]
def plot_conductor_forces(
self,
opt_step: int = 1,
trans_step: int = 1,
fig_size: Tuple[int, int] = (12, 8),
) -> Optional[matplotlib.figure.Figure]:
"""Generates a set of 2D crosssection plots with Forces overlay (one plot per force)
:param output_parser: The output parser
:param opt_step: optimization step, defaults to 1
:param trans_step: transient step, defaults to 1
:param fig_size: figure size in inches, defaults to (12, 8)
:return: the matplotlib figure object, or None if no conductor forces are available
"""
if opt := self.output_parser.find_optstep(opt_step):
if trans := self.output_parser.find_transstep(opt_step, trans_step):
pc_glob = self._create_cable_collection(opt.coilGeometries)
cond_forces = trans.conductorForces
if cond_forces is not None:
max_force = cond_forces[cond_forces.columns[1:]].abs().max().max()
cols = [
("fx", "X component"),
("ftr", "Perpendicular"),
("fra", "Radial"),
("fy", "Y component"),
("fpa", "Parallel"),
("faz", "Azimuthal"),
]
self.fig, axs = plt.subplots(
2,
3,
sharex=True,
sharey=True,
squeeze=True,
subplot_kw={"aspect": "equal"},
figsize=fig_size,
)
self.fig.suptitle("Lorenz forces in conductors")
ax = None
pc = None
for i, ax in enumerate(axs.flat):
ax.set_title(cols[i][1]) # type: ignore
pc = copy.copy(pc_glob)
pc.set_array(cond_forces[cols[i][0]])
pc.set_clim(-max_force, max_force)
pc.set_cmap("RdBu")
ax.add_collection(pc, autolim=True) # type: ignore
ax.autoscale_view() # type: ignore
assert pc is not None and ax is not None
self.fig.subplots_adjust(right=0.8)
cbar_ax = self.fig.add_axes((0.82, 0.15, 0.02, 0.68))
cbar = self.fig.colorbar(pc, ax=ax, cax=cbar_ax)
cbar.set_label("Force in N/m")
return self.fig
return None
[docs]
class RoxiePlots3D:
@requires_pv
def __init__(self, output_parser: RoxieOutputParser):
self.roxie_pv = np.array(roxie_style.roxie_color_palette) / 256
self.roxie_cm = ListedColormap(self.roxie_pv, name="roxie_colors") # type: ignore
pv.set_plot_theme("document") # type: ignore
pv.global_theme.cmap = self.roxie_cm
self.output_parser = output_parser
@requires_pv
def _set_perspective(self, pl: "pv.Plotter") -> None:
pl.camera_position = "xy"
pl.camera.azimuth += 45
pl.camera.elevation += 35.264
@requires_pv
def _create_plot(self, fig_size: Tuple[float, float] = (10.0, 8.0)) -> "pv.Plotter":
px_size = plt.rcParams["figure.dpi"] # pixel in inches
window_size_px = [int(fig_size[0] * px_size), int(fig_size[1] * px_size)]
return pv.Plotter(window_size=window_size_px, lighting="three lights")
@requires_pv
def _create_structured_grid(self, geom: Base3DGeometry) -> "pv.StructuredGrid":
nodes = geom.geometry.nodes.copy()
L = len(nodes) // 4
for i in range(L):
sw = (2, 3)
tmp = nodes[i * 4 + sw[0], :].copy()
nodes[i * 4 + sw[0], :] = nodes[i * 4 + sw[1], :]
nodes[i * 4 + sw[1], :] = tmp
pass
X = np.reshape(nodes[:, 0], (L, 2, 2))
Y = np.reshape(nodes[:, 1], (L, 2, 2))
Z = np.reshape(nodes[:, 2], (L, 2, 2))
# Create and plot structured grid
block3d_data = pv.StructuredGrid(X, Y, Z)
return block3d_data
[docs]
@requires_pv
def create_coil_geometries(self, opt_step: int = 1) -> "pv.MultiBlock":
coils = pv.MultiBlock()
if opt := self.output_parser.find_optstep(opt_step):
for id, geom in opt.coilGeometries3D.items():
coils[str(id)] = self._create_structured_grid(geom)
return coils
[docs]
@requires_pv
def create_brick_geometries(self, opt_step: int = 1) -> "pv.MultiBlock":
bricks = pv.MultiBlock()
if opt := self.output_parser.find_optstep(opt_step):
for id, geom in opt.brickGeometries3D.items():
bricks[str(id)] = self._create_structured_grid(geom)
return bricks
[docs]
@requires_pv
def create_endspacer_geometries(self, opt_step: int = 1) -> "pv.MultiBlock":
wedges = pv.MultiBlock()
if opt := self.output_parser.find_optstep(opt_step):
if wedgeGeom := opt.wedgeGeometries3D:
for id, wedge in wedgeGeom.items():
edges = []
if wedge.inner_surface is None:
if wedge.outer_surface is not None:
edge_low = wedge.outer_surface.lower_edge.copy()
edge_up = wedge.outer_surface.upper_edge.copy()
edge_low[:, 2] = 0.0
edge_up[:, 2] = 0.0
edges.append(edge_low)
edges.append(edge_up)
else:
edges.append(wedge.inner_surface.lower_edge)
edges.append(wedge.inner_surface.upper_edge)
if wedge.outer_surface is None:
if wedge.inner_surface is not None:
edge_low = wedge.inner_surface.lower_edge.copy()
edge_up = wedge.inner_surface.upper_edge.copy()
max_z = max(edge_low[-1, 2], edge_up[-1, 2])
max_z = np.sign(max_z) * (abs(max_z) + 20)
edge_low[:, 2] = max_z
edge_up[:, 2] = max_z
edges.append(edge_low)
edges.append(edge_up)
else:
edges.append(wedge.outer_surface.lower_edge)
edges.append(wedge.outer_surface.upper_edge)
nodes = np.vstack([x for t in zip(*edges) for x in t])
L = len(nodes) // 4
X = np.reshape(nodes[:, 0], (L, 2, 2))
Y = np.reshape(nodes[:, 1], (L, 2, 2))
Z = np.reshape(nodes[:, 2], (L, 2, 2))
# Create and plot structured grid
mesh = pv.StructuredGrid(X, Y, Z)
wedges[f"l_{wedge.layer}_w{wedge.nr:02d}"] = mesh
return wedges
[docs]
@requires_pv
def create_coilblock_geometry(self, opt_step: int = 1) -> "pv.MultiBlock":
wedges = pv.MultiBlock()
if opt := self.output_parser.find_optstep(opt_step):
if blockGeom := opt.blockGeometries3D:
for id, block in blockGeom.items():
edges = []
edges.append(block.inner_surface.lower_edge)
edges.append(block.inner_surface.upper_edge)
edges.append(block.outer_surface.upper_edge)
edges.append(block.outer_surface.lower_edge)
nodes = np.vstack([x for t in zip(*edges) for x in t])
L = len(nodes) // 4
X = np.reshape(nodes[:, 0], (L, 2, 2))
Y = np.reshape(nodes[:, 1], (L, 2, 2))
Z = np.reshape(nodes[:, 2], (L, 2, 2))
# Create and plot structured grid
mesh = pv.StructuredGrid(X, Y, Z)
wedges[f"l_{id}_w{block.nr:02d}"] = mesh
return wedges
[docs]
@requires_pv
def get_endspacer_geometry(
self,
pl: Plot3D,
opt_step: int = 1,
trans_step: int = 1,
) -> "pv.MultiBlock":
opt = self.output_parser.find_optstep(opt_step)
if not opt or not opt.wedgeGeometries3D:
return pv.MultiBlock()
return self.create_endspacer_geometries(opt_step)
[docs]
@requires_pv
def get_coilblock_geometry(
self,
pl: Plot3D,
opt_step: int = 1,
trans_step: int = 1,
) -> "pv.MultiBlock":
opt = self.output_parser.find_optstep(opt_step)
if not opt or not opt.blockGeometries3D:
return pv.MultiBlock()
return self.create_coilblock_geometry(opt_step)
[docs]
@requires_pv
def get_coil_geometry(
self,
pl: Plot3D,
opt_step: int = 1,
trans_step: int = 1,
) -> "pv.MultiBlock":
opt = self.output_parser.find_optstep(opt_step)
trans = self.output_parser.find_transstep(opt_step, trans_step)
if not opt or not opt.coilGeometries3D:
return pv.MultiBlock()
combined = self.create_coil_geometries(opt_step)
for pi in pl.coilPlots:
if pi.dataType == "scalar":
if trans and pi.id in trans.coilData3D.columns:
for idx in combined.keys():
if idx is None:
continue
if (sg := combined[idx]) is not None:
data = trans.coilData3D[
trans.coilData3D["conductor"] == int(idx)
][pi.id]
sg.cell_data[pi.label] = data.to_numpy()
elif pi.dataType == "vector":
pass
if pl.active is not None:
combined.set_active_scalars(pl.active.label, allow_missing=True)
return combined
[docs]
@requires_pv
def get_brick_geometry(
self,
pl: Plot3D,
opt_step: int = 1,
trans_step: int = 1,
) -> "pv.MultiBlock":
opt = self.output_parser.find_optstep(opt_step)
trans = self.output_parser.find_transstep(opt_step, trans_step)
if not opt or not opt.brickGeometries3D:
return pv.MultiBlock()
combined = self.create_brick_geometries(opt_step)
for pi in pl.coilPlots:
if pi.dataType == "scalar":
if trans and pi.id in trans.brickData3D.columns:
for idx in combined.keys():
if idx is None:
continue
if (sg := combined[idx]) is not None:
data = trans.brickData3D[
trans.coilData3D["conductor"] == int(idx)
][pi.id]
sg.cell_data[pi.label] = data.to_numpy()
elif pi.dataType == "vector":
pass
if pl.active is not None:
combined.set_active_scalars(pl.active.label, allow_missing=True)
return combined
[docs]
@requires_pv
def create_iron_geometry(self, opt_step: int = 1) -> "pv.MultiBlock":
opt = self.output_parser.find_optstep(opt_step)
if opt is None or opt.meshGeometries3D is None:
return pv.MultiBlock()
geom_iron = opt.meshGeometries3D
nodes = geom_iron.nodes * 1000
face_array = []
if geom_iron.elements:
for face in geom_iron.elements:
face_array.append(len(face))
face_array.extend(face[1:])
geom = pv.PolyData(nodes, face_array) # faces
return pv.MultiBlock([geom])
[docs]
@requires_pv
def get_iron_geometry(
self,
pl: Plot3D,
opt_step: int = 1,
trans_step: int = 1,
) -> "pv.MultiBlock":
trans = self.output_parser.find_transstep(opt_step, trans_step)
geoms = self.create_iron_geometry(opt_step)
if not geoms:
return geoms
if trans:
for geom in geoms:
if not geom:
continue
for pi in pl.meshPlots:
if pi.dataType == "scalar":
if pi.id in trans.meshData3D.columns:
geom.point_data[pi.label] = trans.meshData3D[
pi.id
].to_numpy()
elif pi.dataType == "vector":
pass # TODO
if pl.active is not None:
geoms.set_active_scalars(pl.active.label)
return geoms
[docs]
@requires_pv
def plot_3d(
self,
pl: Plot3D,
opt_step: int = 1,
trans_step: int = 1,
fig_size=(10, 8),
) -> "pv.Plotter":
plotter = self._create_plot(fig_size)
if coil_geom := self.get_coil_geometry(pl, opt_step, trans_step):
plotter.add_mesh(coil_geom, color=None)
if iron_geom := self.get_iron_geometry(pl, opt_step, trans_step):
plotter.add_mesh(iron_geom)
if brick_geom := self.get_brick_geometry(pl, opt_step, trans_step):
plotter.add_mesh(brick_geom)
if pl.showSpacers:
if wedge_geom := self.get_endspacer_geometry(pl, opt_step, trans_step):
plotter.add_mesh(wedge_geom)
self._set_perspective(plotter)
return plotter
[docs]
class RoxiePrintTables:
"""Roxie table output for tabular data"""
def __init__(self, ib: RoxieInputBuilder, op: RoxieOutputParser) -> None:
self.input = ib
self.output = op
[docs]
def get_harmonic_coil(
self, opt_step, trans_step, coil_nr
) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
"""Return the harmonic coil table
:param opt_step: The Optimization step number
:param trans_step: The transient step number
:param coil_nr: Number of the harmonic coil
:return: A set of 3Pandas dataframes to plot: Coil information, Field information, Multipoles
"""
if step := self.output.find_transstep(opt_step, trans_step):
if coil := step.harmonicCoils.get(coil_nr, None):
return self.print_harmonic_coil(coil)
return (pd.DataFrame(), pd.DataFrame(), pd.DataFrame())
[docs]
def get_design_variables(self, opt_step) -> pd.DataFrame:
"""Return the design variables for opt step
:param opt_step: The Optimization step number
:return A DafaFrame containing design variable name and values
"""
if step := self.output.find_optstep(opt_step):
return self.print_design_variables(step.designVariables, self.input.design)
return pd.DataFrame()
[docs]
def get_objective_variables(self, opt_step) -> pd.DataFrame:
"""Return the Objectives variables for opt step
:param opt_step: The Optimization step number
:return A DafaFrame containing objective variable name and values
"""
if step := self.output.find_optstep(opt_step):
return self.print_objective_results(
step.objectiveResults, self.input.objective
)
return pd.DataFrame()
[docs]
def print_harmonic_coil(
self, coil: HarmonicCoil
) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
"""Prints a harmonic coil output as Markdown
:param coil: The Harmonic coil to print
:return: A set of 3Pandas dataframes to plot: Coil information, Field information, Multipoles
"""
return (
pd.DataFrame.from_dict(coil.get_coil_info(), orient="index").T,
pd.DataFrame.from_dict(coil.get_field_info(), orient="index").T,
coil.get_table(),
)
[docs]
def print_design_variables(
self,
designVariables: Dict[int, DesignVariableResult],
input_design: Optional[pd.DataFrame],
) -> pd.DataFrame:
dvs = []
for i, dv in designVariables.items():
if input_design is not None and not input_design.empty:
row = input_design[input_design["no"] == i].iloc[0]
dv_cp = copy.copy(dv)
dv_cp.name = row["string"]
dv_cp.act_on = row["act"]
dv_cp.blocks = row["bcs"]
dvs.append(dv_cp)
else:
dvs.append(dv)
return pd.DataFrame(dvs)
[docs]
def print_objective_results(
self,
objectives: Dict[int, ObjectiveResult],
input_objectives: Optional[pd.DataFrame],
) -> pd.DataFrame:
objs = []
for i, obj in objectives.items():
if input_objectives is not None and not input_objectives.empty:
row = input_objectives[input_objectives["no"] == i].iloc[0]
obj_cp = copy.copy(obj)
obj_cp.obj_name = row["string"]
obj_cp.obj_p1 = row["s1"]
obj_cp.obj_p2 = row["s2"]
objs.append(obj_cp)
else:
objs.append(obj)
return pd.DataFrame(objs)
[docs]
class RoxiePlotOutputs:
"""
Convenience class for plotting roxie outputs.
Bundles all Required classes for plotting in one class:
Example usage:
```python
output_file = "/path/to/roxie/output.xml"
input_file = "/path/to/roxie/input.xml"
plot_outputs = RoxiePlotOutputs(output_file, input_file)
```
Access to the individual objects:
- `plot_outputs.output`: `RoxieOutputParser` object
- `plot_outputs.input`: `RoxieInputBuilder` object
- `plot_outputs.graphs`: `RoxieGraphsPlotly` object
- `plot_outputs.plots2d`: `RoxiePlots2D` object
- `plot_outputs.plots3d`: `RoxiePlots3D` object
- `plot_outputs.tables`: `RoxiePrintTables` object
Content is output using Ipython display functions,
including Headers, plots and tables.
"""
def __init__(self, output_file: str, input_file: Optional[str]) -> None:
"""Create a new RoxiePlotOutputs object based on the roxie xml output and data file.
Content is output using Ipython display functions,
including Headers, plots and tables
:param output_file: The roxie xml output
:param input_file: The roxie data file (optional)
"""
self.output = RoxieOutputParser(xml_file=output_file)
if input_file:
self.input = RoxieInputBuilder.from_datafile(input_file)
else:
self.input = RoxieInputBuilder()
self.graphs = RoxieGraphsPlotly(self.output)
self.plots2d = RoxiePlots2D(self.output)
self.plots3d = RoxiePlots3D(self.output)
self.tables = RoxiePrintTables(self.input, self.output)
self._setup_plots()
def _setup_plots(self) -> None:
"""Setup plot settings based on input and output files"""
self.plots2d.mirrorX = self.input.flags["LIMAGX"]
self.plots2d.mirrorY = self.input.flags["LIMAGY"]
@staticmethod
def _print_header(level: int, txt: str) -> None:
"""Print a heading at level
:param level: The heading level
:param txt: The heading text
"""
display(Markdown("#" * level + " " + txt))
[docs]
def output_run_summary(self) -> None:
"""Generate a summary of the run"""
txt = f"""- Run date: {self.output.run_date}
- Roxie version: {self.output.roxie_version} ({self.output.roxie_githash})
- Comment: {self.output.comment}
- {len(self.output.opt)} optimization step(s) with {len(self.output.opt[1].step)} transient step(s)
"""
display(Markdown(txt))
[docs]
def output_design_variables(self, opt_step: int = 1) -> None:
"""Print design variables
:param opt_step: The Optimization step number
"""
df = self.tables.get_design_variables(opt_step)
if not df.empty:
display(df)
[docs]
def output_objective_results(self, opt_step: int = 1) -> None:
"""Print objective results
:param opt_step: The Optimization step number
"""
df = self.tables.get_objective_variables(opt_step)
if not df.empty:
display(df)
[docs]
def output_harmonic_coils(
self, opt_step: int = 1, trans_step: int = 1, header_level: int = 1
) -> None:
"""Print Harmonic coil results
:param opt_step: The Optimization step number
:param trans_step: The transient step number
:param header_level: Header level for each harmonic coil
"""
trans = self.output.find_transstep(opt_step, trans_step)
if trans is None:
return
for hc, coil in trans.harmonicCoils.items():
self._print_header(header_level, f"Harmonic coil {hc}")
dfs = self.tables.print_harmonic_coil(coil)
for df in dfs:
display(df.style.hide())
fig = self.graphs.plot_harmonics(coil, fig_size=(8, 5))
fig.show()
[docs]
def output_conductor_forces(
self, opt_step: int = 1, trans_step: int = 1, fig_size=(12, 8)
) -> None:
"""Print conductor forces for Time step
:param opt_step: The Optimization step number
:param trans_step: The transient step number
:param fig_size: Size of the output figures in inches
"""
trans = self.output.find_transstep(opt_step, trans_step)
if trans is None:
return
cond_df = trans.conductorForces
if cond_df is not None:
p = self.graphs.plot_forces(cond_df, fig_size)
p.show()
fig = self.plots2d.plot_conductor_forces(opt_step, trans_step, fig_size)
if fig:
plt.show()
[docs]
def output_optimization_graphs(
self, fig_size=(12, 8), plots: Optional[List[int]] = None
) -> None:
"""Print all optimization graphs
:param fig_size: Figure size in inches, defaults to (12,8)
:param plots: List of plots to print, or None for all
"""
for plot in self.output.graphs_optimization:
if plots is None or plot.id in plots:
p = self.graphs.plot_optimization_graph(plot, fig_size)
if p:
p.show()
[docs]
def output_transient_graphs(
self, opt_step: int = 1, fig_size=(12, 8), plots: Optional[List[int]] = None
) -> None:
"""Print all transient graphs
:param opt_step: Optimization step number, defaults to 1
:param fig_size: Figure size in inches, defaults to (12,8)
:param plots: List of plots to print, or None for all
"""
for plot in self.output.graphs_transient:
if plots is None or plot.id in plots:
p = self.graphs.plot_transient_graph(plot, opt_step, fig_size)
if p:
p.show()
[docs]
def output_device_graphs(
self,
opt_step: int = 1,
trans_step: int = 1,
fig_size=(12, 8),
plots: Optional[List[int]] = None,
) -> None:
"""Print all device graphs
:param opt_step: Optimization step number, defaults to 1
:param trans_step: Transient step number, defaults to 1
:param fig_size: Figure size in inches, defaults to (12,8)
:param plots: List of plots to print, or None for all
"""
for plot in self.output.graphs_device:
if isinstance(plot, GraphPlot):
if plots is None or plot.id in plots:
p = self.graphs.plot_device_graph(
plot, opt_step, trans_step, fig_size
)
if p:
p.show()
[docs]
def output_2dplots(
self,
opt_step: int = 1,
trans_step: int = 1,
fig_size=(8, 8),
plots: Optional[List[int]] = None,
) -> None:
"""Print all 2d plots
:param opt_step: Optimization step number, defaults to 1
:param trans_step: Transient step number, defaults to 1
:param fig_size: Figure size in inches, defaults to (12,8)
:param plots: List of plots to print, or None for all
"""
for plot in self.output.plots2D:
if plots is None or plot.id in plots:
_ = self.plots2d.plot_xs(plot, opt_step, trans_step, fig_size)
plt.show()
[docs]
def output_3dplots(
self,
opt_step: int = 1,
trans_step: int = 1,
fig_size=(10, 8),
plots: Optional[List[int]] = None,
) -> None:
"""Print all 3d plots
:param opt_step: Optimization step number, defaults to 1
:param trans_step: Transient step number, defaults to 1
:param fig_size: Figure size in inches, defaults to (12,8)
:param plots: List of plots to print, or None for all
"""
for plot in self.output.plots3D:
if plots is None or plot.id in plots:
pl = self.plots3d.plot_3d(plot, opt_step, trans_step, fig_size)
if pl:
pl.show()
[docs]
def output_report(self, header_level: int = 1) -> None:
"""Output a full run report
:param header_level: The level of header to start with (default: 1)
"""
self._print_header(header_level, "Roxie results")
self.output_run_summary()
for os, opt in self.output.opt.items():
if len(self.output.opt) > 1:
header_level = header_level + 1
self._print_header(header_level, f"Optimization step {os}: {opt.name}")
self.output_design_variables(os)
self.output_objective_results(os)
for ts, trans in opt.step.items():
if len(opt.step) > 1:
header_level = header_level + 1
self._print_header(
header_level, f"Transient step {ts}: {trans.name}"
)
self.output_harmonic_coils(os, ts, header_level + 1)
self._print_header(header_level + 1, "Plots")
self.output_conductor_forces(os, ts)
self.output_device_graphs(os, ts)
self.output_2dplots(os, ts)
self.output_3dplots(os, ts)
if len(opt.step) > 1:
header_level = header_level - 1
if len(opt.transientGraphs) > 0:
self._print_header(header_level + 1, "Transient graphs")
self.output_transient_graphs(os)
if len(self.output.opt) > 1:
header_level = header_level - 1
if len(self.output.optimizationGraphs) > 0:
self._print_header(header_level + 1, "Optimization graphs")
self.output_optimization_graphs()