ANASEN_analysis/anasen_fem/paraview_dotproduct.py
Vignesh Sitaraman 3edbfa5bd8 modified: anasen_fem/README.md added the Shockley-Ramo dot calculation capability adapted fom Sudarsan's branch with a new visusalisation scheme to show the +ve and-ve sides on a logscale
modified:   anasen_fem/clean.sh
	new file:   anasen_fem/dotproduct.py
	new file:   anasen_fem/paraview_dotproduct.py
	modified:   anasen_fem/paraview_plotter.py
	modified:   anasen_fem/run.py
	modified:   anasen_fem/scalars.dat.names
	new file:   anasen_fem/scalars_weight.dat
	new file:   anasen_fem/scalars_weight.dat.names
	modified:   anasen_fem/wires2d.sif
	new file:   anasen_fem/wires2d_weight.sif
	modified:   anasen_fem/wires_gmsh2d_bc.py
2026-09-23 12:14:05 -04:00

280 lines
12 KiB
Python
Executable File

#!/home/vsitaraman/ParaView-6.1.0-RC1-MPI-Linux-Python3.12-x86_64/bin/pvbatch
# Point the shebang above at your own pvbatch/pvpython, as for paraview_plotter.py.
#
# Renders the E . E_w map written by dotproduct.py.
#
# By Shockley-Ramo the dot product describes the current induced on ONE
# electrode: the cathode that wires2d_weight.sif held at 1 V. Everywhere else
# the number is just the tail of that electrode's weighting field, so the view
# is centred on that wire and nothing else. Its position is read out of the
# weighting solve rather than recomputed from the geometry -- the driven
# electrode is wherever the weighting potential reaches 1.
#
# Usage (from anasen_fem/):
# ./paraview_dotproduct.py [dotproduct.vtu] [output.png] [weight.vtu]
#
# Re-plotting an archived z needs its archived weighting solve as well, since
# that is where the driven cathode's position is read from:
# ./paraview_dotproduct.py wires2d/vtu_files/dotproduct_10_0.0000.vtu \
# png/DotProduct_output_z_10_0.0000.png \
# wires2d/vtu_files/elfield_weight_10_0.0000.vtu
import sys
import numpy as np
from paraview.simple import *
INPUT_VTU = sys.argv[1] if len(sys.argv) > 1 else "wires2d/dotproduct.vtu"
OUTPUT_IMAGE = sys.argv[2] if len(sys.argv) > 2 else "DotProduct_output.png"
# Weighting solve, used only to locate the driven cathode. Must be the one that
# belongs to this dot product -- the wire moves with z.
WEIGHT_VTU = sys.argv[3] if len(sys.argv) > 3 else "wires2d/elfield_weight_t0001.vtu"
# Half-height of the view in metres. Cathodes sit every 15 degrees at a radius
# of about 36.4 mm, so the neighbours either side are 9.5 mm away; 14 mm takes
# them both in with a margin.
# Image size. The structure round the driven cathode is much wider than it is
# tall, so a square frame spends most of its area on empty gas.
VIEW_SIZE = [2000, 1200]
# CameraParallelScale, which is the half-HEIGHT of the view in metres. The
# width follows from the aspect: half-height * (VIEW_SIZE[0] / VIEW_SIZE[1]).
# So widening the frame alone does not crop anything, it just shows more
# sideways; the scale has to come down with it. 0.0084 at 2000x1200 keeps the
# same 28 mm width as the old square 0.014 and trims the height to 16.8 mm.
ZOOM_SCALE = 0.0084
# The driven cathode swings round the axis as z changes, so with a fixed "up"
# every z comes out rotated by a different amount and the shots cannot be laid
# side by side. Aligning up with the outward radial direction through the
# cathode puts the detector axis below and the barrel wall above in every
# frame, whatever z. Set False for plain screen-aligned axes.
ALIGN_RADIAL = True
# Shift of the view centre along the outward radial, in metres. Up in the frame
# is the outward radial, so a NEGATIVE value walks the camera in towards the
# detector axis and the cathode rides higher in the picture. Positive drops it.
# Purely framing; it does not move the wire.
CENTER_SHIFT = -0.0036
# Second, wider shot over the same quadrant paraview_plotter.py frames, so the
# dot product can be laid beside the field and contour views of that z. Centre
# is at x < 0, y > 0; negate the y for the lower-left quadrant instead.
# Colour range for DotProduct. Both ends negative: E and E_w point in broadly
# opposite senses over most of the drift volume.
# Most of the painted patch lies between -2e7 and -2e6. Against a range
# reaching -3.1e8 that is the top few percent of the scale, so it all came out
# at the white end of Greens. Narrowing the range spreads those values over the
# green; anything stronger simply clamps to the darkest end, which only affects
# the last fraction of a millimetre at the wire surface.
DOT_MIN, DOT_MAX = -8.0e7, -3.0e6
# Opacity of the painted map. Anything at least as strong as OPAQUE_BELOW is
# drawn solid; from there it ramps down to transparent at FADE_TO, so the grey
# shows through with a soft round edge rather than the ragged one a hard cut
# leaves.
#
# The plateau matters more than either endpoint. Ramping all the way from
# DOT_MIN (-3.1e8) meant the opacity at -5e7 was only about 0.13, so the whole
# patch read as washed out however the fade point was set.
OPAQUE_BELOW = -2.0e7
FADE_TO = -2.0e6
# Coarse cut, only to keep the far field out of the render. Must be nearer zero
# than FADE_TO so its hard edge lands where the surface is already fully
# transparent and cannot be seen.
PAINT_CUT = -5.0e5
# "signed-log" maps sign and magnitude together through a signed logarithm and
# a diverging colour map; "linear" is the earlier one-sided Greens scale, which
# the constants above still drive.
COLOR_MODE = "signed-log"
# X0 sets where the mapping turns from linear to logarithmic, and so how much
# of the weak far field survives at all: at 1e6 the data map to -2.51 .. 1.62,
# against -3.51 .. 2.61 at 1e5. Raise it to paint less.
SYMLOG_X0 = 1.0e6
SYMLOG_LIMIT = 2.5 # symmetric colour limit; match it to the mapped range
SYMLOG_OPAQUE = 0.6 # |value| at which the map is fully opaque
SYMLOG_FADE = 0.3 # |value| below which it is transparent
# Shape of the two opacity ramps, as opposed to where they sit.
#
# A piecewise function's midpoint describes the segment running RIGHTWARD from
# each node, and the two ramps run in opposite senses -- falling on the
# negative side, rising on the positive. So a single midpoint below 0.5 applied
# to both makes the negative lobe fade out sooner while the positive lobe goes
# opaque sooner, rendering the two polarities at different effective
# thresholds. Mirroring it keeps them symmetric: below 0.5 both sides go opaque
# sooner, so more is painted, equally on each.
#
# Sharpness is the independent dial for how abrupt the edge is: 0 smooth, 1 a
# hard step. 0.5 is already close to a step.
SYMLOG_MIDPOINT = 0.3
SYMLOG_SHARPNESS = 0.0
DIVERGING_PRESET = "PRGn" # purple / white / green; zero sits at white
BACKGROUND = [0.35, 0.34, 0.33] # grey
# Equipotential lines over the whole frame, painted gas or not. Off by default:
# the map is the subject here, and the lines are already in the contour plots.
SHOW_CONTOURS = False
CONTOUR_BY = "potential"
CONTOUR_LEVELS = list(np.arange(0, 660, 660 / 40.0))
def threshold_between(source, array, low, high):
"""Threshold filter, tolerating the pre-5.10 property names."""
t = Threshold(Input=source, Scalars=["POINTS", array])
try:
t.LowerThreshold = low
t.UpperThreshold = high
t.ThresholdMethod = "Between"
except Exception:
t.ThresholdRange = [low, high]
return t
# --- locate the driven cathode: the weighting potential is 1 on it ---
weight_reader = XMLUnstructuredGridReader(FileName=[WEIGHT_VTU])
driven = threshold_between(weight_reader, "potential", 0.99, 1.01)
driven.UpdatePipeline()
info = driven.GetDataInformation()
if info.GetNumberOfPoints() == 0:
sys.exit(
"no nodes with weighting potential ~1 in %s -- the weighting solve did\n"
"not drive any electrode, so there is nothing to centre on." % WEIGHT_VTU
)
b = info.GetBounds()
CATHODE = [0.5 * (b[0] + b[1]), 0.5 * (b[2] + b[3]), 0.0]
print("driven cathode at x = %.5f m, y = %.5f m" % (CATHODE[0], CATHODE[1]))
radius = (CATHODE[0] ** 2 + CATHODE[1] ** 2) ** 0.5
outward = [CATHODE[0] / radius, CATHODE[1] / radius, 0.0] if radius > 0 else [0.0, 1.0, 0.0]
# Frame centre, offset from the wire along that radial.
CENTER = [
CATHODE[0] + CENTER_SHIFT * outward[0],
CATHODE[1] + CENTER_SHIFT * outward[1],
0.0,
]
Delete(driven)
Delete(weight_reader)
# --- the dot product map ---
reader = XMLUnstructuredGridReader(FileName=[INPUT_VTU])
renderView = GetActiveViewOrCreate("RenderView")
renderView.ViewSize = VIEW_SIZE
renderView.OrientationAxesVisibility = 1
renderView.UseColorPaletteForBackground = 0
renderView.Background = BACKGROUND
renderView.MultiSamples = 8
if COLOR_MODE == "signed-log":
# E . E_w spans about 3.5 decades negative and 2.6 positive, and 38% of the
# nodes are positive -- charge drifting there induces current of the
# opposite polarity on the cathode. A linear scale over one sign shows
# neither the decades nor the sign, so map through a signed log:
#
# sign(d) * log10(1 + |d| / SYMLOG_X0)
#
# sign() is not in the calculator namespace, hence d/(|d|+1); the +1 is
# negligible beside the 1e5 scale and the expression tends to 0 anyway
# where d does.
calc = PythonCalculator(Input=reader)
calc.ArrayName = "SignedLogDot"
calc.ArrayAssociation = "Point Data"
calc.Expression = (
"(DotProduct/(abs(DotProduct)+1.0))*log10(1.0+abs(DotProduct)/%g)"
% SYMLOG_X0
)
painted = calc
array = "SignedLogDot"
colour_range = (-SYMLOG_LIMIT, SYMLOG_LIMIT) # symmetric, so zero is neutral
preset = DIVERGING_PRESET
# Transparent either side of zero, opaque past SYMLOG_OPAQUE, both signs.
_m, _s = SYMLOG_MIDPOINT, SYMLOG_SHARPNESS
opacity_points = [
-SYMLOG_LIMIT, 1.0, 0.5, _s,
-SYMLOG_OPAQUE, 1.0, 1.0 - _m, _s, # mirrored: this ramp falls
-SYMLOG_FADE, 0.0, 0.5, _s,
SYMLOG_FADE, 0.0, _m, _s, # this one rises
SYMLOG_OPAQUE, 1.0, 0.5, _s,
SYMLOG_LIMIT, 1.0, 0.5, _s,
]
bar_title = "sign(E.Ew) log10(1+|E.Ew|/%g)" % SYMLOG_X0
else:
painted = threshold_between(reader, "DotProduct", -1.0e30, PAINT_CUT)
array = "DotProduct"
colour_range = (DOT_MIN, DOT_MAX)
preset = "Greens"
opacity_points = [
DOT_MIN, 1.0, 0.5, 0.0,
OPAQUE_BELOW, 1.0, 0.5, 0.0,
FADE_TO, 0.0, 0.5, 0.0,
]
bar_title = "E . E_w (V/m^2)"
surface_display = Show(painted, renderView)
surface_display.Representation = "Surface"
ColorBy(surface_display, ("POINTS", array))
dotLUT = GetColorTransferFunction(array)
dotLUT.ApplyPreset(preset, True)
dotLUT.RescaleTransferFunction(colour_range[0], colour_range[1])
# Points go (value, opacity, midpoint, sharpness) in ascending value.
dotPWF = GetOpacityTransferFunction(array)
dotPWF.Points = opacity_points
dotLUT.ScalarOpacityFunction = dotPWF
dotLUT.EnableOpacityMapping = 1
surface_display.SetScalarBarVisibility(renderView, True)
try:
GetScalarBar(dotLUT, renderView).Title = bar_title
except Exception as exc:
print("note: could not set the scalar bar title (%s)" % exc)
# --- contour lines over the whole field, including the unpainted gas ---
if SHOW_CONTOURS:
contour_filter = Contour(Input=reader, ContourBy=["POINTS", CONTOUR_BY])
contour_filter.Isosurfaces = CONTOUR_LEVELS
contour_display = Show(contour_filter, renderView)
contour_display.LineWidth = 1.0
contour_display.DiffuseColor = [1.0, 1.0, 1.0]
# ColorBy(rep, None) is rejected by ParaView 6.1 -- it forwards the
# association as the string 'NONE' and raises -- so set the property.
contour_display.ColorArrayName = [None, ""]
contour_display.SetScalarBarVisibility(renderView, False)
# --- camera: parallel projection, centred on the driven cathode ---
# ParaView resets the camera on a view's FIRST render, throwing away anything
# set beforehand, so burn that render here and set the camera after it.
# Measured: scale 0.008 set before the first Render comes back as 0.021; set
# again afterwards it holds.
Render()
view = GetActiveView()
view.CameraParallelScale = ZOOM_SCALE
view.CenterOfRotation = CENTER
view.CameraPosition = [CENTER[0], CENTER[1], 1.0]
view.CameraFocalPoint = CENTER
if ALIGN_RADIAL:
up = outward
print("view rotated %.1f deg so the outward radial is up"
% np.degrees(np.arctan2(up[0], up[1])))
else:
up = [0.0, 1.0, 0.0]
view.CameraViewUp = up
view.CameraParallelProjection = 1
Render()
SaveScreenshot(OUTPUT_IMAGE)
print("Saved %s" % OUTPUT_IMAGE)