Skip to content

Commit 8d7f5cf

Browse files
authored
Merge pull request #6019 from danieldouglas92/add_script_for_regional_global_coupling
Add Python Script for Coupling Global Models to Regional Models
2 parents f537114 + 325b86c commit 8d7f5cf

File tree

2 files changed

+339
-0
lines changed

2 files changed

+339
-0
lines changed
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
# This script uses Paraview's pvpython to take output from a global mantle
2+
# convection model and extract velocities along user-specified boundaries.
3+
# Next, the script uses the extracted velocities to create ASCII files which
4+
# can be applied as boundary conditions in regional models to account for
5+
# far-field effects outside of the regional model boundaries. This is achieved
6+
# by specifying the bounds of the regional model, slicing through the
7+
# global model along planes which coincide with the location of the
8+
# regional model boundaries, and saving the velocities at each point
9+
# into an ASCII file. The regional model is expected to be a 3D spherical
10+
# chunk, and the global model is expected to be a 3D spherical shell.
11+
12+
# The user must specify the following parameters in the script:
13+
# 1. The location of the .pvd file for a global convection model
14+
# 2. The output directory where the ASCII files will be saved
15+
# 3. The refinement level of the global model
16+
# 4. The radial resolution of the regional model
17+
# 5. The lateral resolution of the regional model
18+
# 6. The maximum and minimum radius, latitude, and longitude for the regional model.
19+
20+
# The default settings inside this script are for an example applying this script to
21+
# the global model presented in the S2ORTS cookbook, and applying the extracted
22+
# velocities to a regional model that spans from 20 degrees latitude to 50 degrees
23+
# latitude, 190 degrees longitude to 230 degrees longitude, and from a
24+
# radius of 5070 km to a radius of 6370 km. Before running this script,
25+
# make sure that you run the S2ORTS cookbook and generate the solution.pvd file.
26+
27+
# This script defines 3 functions
28+
# 1. spherical_to_cartesian, which converts from spherical coordinates to Cartesian
29+
# coordinates
30+
# 2. cartesian_to_spherical, which converts from Cartesian coordinates to spherical
31+
# coordinates
32+
# 3. slice_plane_calculator, which determines the normal to the plane that defines
33+
# the east and west model boundaries in the regional chunk.
34+
35+
# Import packages
36+
import numpy as np
37+
import pandas as pd
38+
import os
39+
from paraview import simple
40+
from paraview import servermanager
41+
42+
def spherical_to_cartesian(radii, latitudes, longitudes, for_slice):
43+
"""
44+
Converts from spherical coordinates to Cartesian coordinates.
45+
radii: the radius, in m
46+
latitudes: the latitude, in degrees ranging from -90 to 90
47+
longitudes: the longitude, in degrees ranging from 0 to 360
48+
for_slice: boolean, if false the provided points are directly
49+
converted into Cartesian coordinates. If true, radii, latitudes,
50+
and longitudes must be arrays, and the function returns a uniform
51+
structured grid defining the slice.
52+
Returns x, y, z, in m
53+
"""
54+
55+
if for_slice:
56+
cartesian_coordinates = []
57+
for lat in latitudes:
58+
for lon in longitudes:
59+
for r in radii:
60+
x = r * np.sin(np.deg2rad(90 - lat)) * np.cos(np.deg2rad(lon))
61+
y = r * np.sin(np.deg2rad(90 - lat)) * np.sin(np.deg2rad(lon))
62+
z = r * np.cos(np.deg2rad(90 - lat))
63+
64+
if lon == 0:
65+
y = 0
66+
elif lon == np.pi/2:
67+
x = 0
68+
if lat == np.pi/2:
69+
z = 0
70+
cartesian_coordinates.append([x, y, z])
71+
72+
return np.array(cartesian_coordinates)
73+
74+
else:
75+
x = radii * np.sin(np.deg2rad(90 - latitudes)) * np.cos(np.deg2rad(longitudes))
76+
y = radii * np.sin(np.deg2rad(90 - latitudes)) * np.sin(np.deg2rad(longitudes))
77+
z = radii * np.cos(np.deg2rad(90 - latitudes))
78+
79+
return np.array([x, y, z])
80+
81+
def cartesian_to_spherical(x, y, z):
82+
"""
83+
Takes an x, y, z point and converts it to spherical coordinates.
84+
Returns r in m, latitude in degrees, and longitude, ranging from 0 to 360, in degrees
85+
"""
86+
r = np.sqrt(x**2 + y**2 + z**2)
87+
latitude = 90 - np.rad2deg( np.arccos( z / (np.sqrt(x**2 + y**2 + z**2)) ) )
88+
longitude = np.sign(y) * np.rad2deg(np.arccos( x / np.sqrt(x**2 + y**2) ))
89+
longitude[np.where(longitude < 0)] = longitude[np.where(longitude < 0)] + 360
90+
longitude[np.where(longitude == 0)] = 180
91+
92+
return r, latitude, longitude
93+
94+
def slice_plane_calculator(boundary_name, radius_bounds, latitude_bounds, longitude_bounds):
95+
"""
96+
Calculates the normal of a plane which is used for slicing the global models. This is
97+
achieved by defining three points on either the east or west model boundary using the
98+
values provided by radius_bounds, latitude_bounds, and longitude_bounds.
99+
boundary_name: the name of the model boundary
100+
radius_bounds: the maximum and minimum radius of the regional models
101+
latitude_bounds: the maximum and minimum latitude of the regional models
102+
longitude_bounds: the maximum and minimum longitude of the regional models
103+
"""
104+
# Define the 3 points on the west or east boundary. If west, we are on the
105+
# minimum longitude, and if east we are on the maximum longitude.
106+
if boundary_name == "west":
107+
spherical_point_1 = np.array([np.max(radius_bounds), \
108+
np.max(latitude_bounds), \
109+
np.min(longitude_bounds)])
110+
spherical_point_2 = np.array([np.max(radius_bounds), \
111+
np.min(latitude_bounds), \
112+
np.min(longitude_bounds)])
113+
spherical_point_3 = np.array([np.min(radius_bounds), \
114+
np.max(latitude_bounds), \
115+
np.min(longitude_bounds)])
116+
117+
elif boundary_name == "east":
118+
spherical_point_1 = np.array([np.max(radius_bounds), \
119+
np.max(latitude_bounds), \
120+
np.max(longitude_bounds)])
121+
spherical_point_2 = np.array([np.max(radius_bounds), \
122+
np.min(latitude_bounds), \
123+
np.max(longitude_bounds)])
124+
spherical_point_3 = np.array([np.min(radius_bounds), \
125+
np.max(latitude_bounds), \
126+
np.max(longitude_bounds)])
127+
128+
else:
129+
raise Exception("Unknown boundary name: " + boundary_name)
130+
# Convert spherical points to Cartesian
131+
cartesian_point_1 = spherical_to_cartesian(spherical_point_1[0], \
132+
spherical_point_1[1], \
133+
spherical_point_1[2], \
134+
for_slice=False)
135+
cartesian_point_2 = spherical_to_cartesian(spherical_point_2[0], \
136+
spherical_point_2[1], \
137+
spherical_point_2[2], \
138+
for_slice=False)
139+
cartesian_point_3 = spherical_to_cartesian(spherical_point_3[0], \
140+
spherical_point_3[1], \
141+
spherical_point_3[2], \
142+
for_slice=False)
143+
# Calculate 2 in-plane orthogonal vectors using the 3 Cartesian points
144+
vector_1_2 = cartesian_point_2 - cartesian_point_1
145+
vector_1_3 = cartesian_point_3 - cartesian_point_1
146+
# Taking the cross product yields a vector normal to the model boundary
147+
normal_vector_to_plane = np.cross(vector_1_2, vector_1_3)
148+
# Normalize
149+
unit_normal = normal_vector_to_plane / np.linalg.norm(normal_vector_to_plane)
150+
151+
return unit_normal
152+
153+
####################################################################################################################################
154+
155+
""" Usage: This script requires 8 input arguments:
156+
input_data: solution file for the global model (*.pvd)
157+
output_directory: Where the .txt files for each boundary are saved
158+
refinement_level: The number of mesh refinements in the global model
159+
output_radius_resolution: The radial resolution of the regional slice (in meters)
160+
output_lateral_resolution: The lateral resolution of the regional slice (in degrees)
161+
radius_bounds: Array with the minimum and maximum radius (meters) of the regional model
162+
latitude_bounds: Array with the minimum and maximum latitude (degrees) of the regional model
163+
longitude_bounds: Array with the minimum and maximum longitude (degrees) of the regional model
164+
"""
165+
166+
# Define the input arguments for the S2ORTS cookbook
167+
input_data = "../../../cookbooks/initial-condition-S20RTS/output-S20RTS/solution.pvd"
168+
output_directory = "./regional_velocity_files/"
169+
refinement_level = 2
170+
output_radius_resolution = 10e3 # 10 km radial resolution
171+
output_lateral_resolution = 0.25 # 0.25 degree lateral resolution
172+
radius_bounds = np.array([4770e3, 6360e3]) # Radius bounds of the regional model
173+
latitude_bounds = np.array([-20, -55]) # Latitude bounds of the regional model
174+
longitude_bounds = np.array([152, 210]) # Longitude bounds of the regional model
175+
176+
# Load in the global model
177+
model = simple.OpenDataFile(input_data)
178+
179+
# Only load in the mesh-points and the velocity fields for computational efficiency, to load
180+
# more fields, add entries to this array or just comment the line to load all solution fields.
181+
model.PointArrays = ['Points', 'velocity']
182+
model.UpdatePipeline() # Apply the filter
183+
184+
# Loop over the 4 lateral boundaries: west, east, north, south.
185+
for boundary_name in np.array(["west", "east", "north", "south"]):
186+
# To prescribe the velocity on the boundaries of a 3D spherical chunk in ASPECT, the ASCII files must be
187+
# named following this convention: chunk_3d_%s.%d.txt, where %s is the name of the model boundary, and
188+
# %d is the timestep that the ASCII file will be applied. This python script is intended to be used for
189+
# instantaneous models, and so %d is hardcoded to 0 when setting the variable output file name.
190+
output_datafile = output_directory + "chunk_3d_" + boundary_name + ".0.txt"
191+
192+
# pvpython will save the data in a .csv file, which will not be formatted correctly for use in ASPECT.
193+
# This script creates a temporary file to store the pvpython output, then deletes the file later.
194+
temp_datafile = output_directory + boundary_name + "_boundary_paraview_slice_temp.csv"
195+
196+
# For the east and west boundary, use the slice filter to extract velocities since these boundaries
197+
# lie on great circles.
198+
if boundary_name == "west" or boundary_name == "east":
199+
cut_slice = simple.Slice(Input=model)
200+
cut_slice.SliceType.Normal = slice_plane_calculator(boundary_name, \
201+
radius_bounds, \
202+
latitude_bounds, \
203+
longitude_bounds)
204+
cut_slice.UpdatePipeline()
205+
206+
# For the north and south boundary, use the threshold filter at a constant latitude, since these
207+
# boundaries will not always lie on great circles.
208+
elif boundary_name == "north" or boundary_name == "south":
209+
# Create a calculator filter to determine the latitude in the global models
210+
pythonCalculator = simple.PythonCalculator(registrationName="pythonCalculator", Input=model)
211+
pythonCalculator.ArrayAssociation = "Point Data" # "Point Data" since the velocity is output on points
212+
213+
# Copy all of the other variables into the calculator filter. If this is set to False, then the
214+
# velocity field will not be passed through once the pythonCalculator is applied
215+
pythonCalculator.CopyArrays = True
216+
# Calculate the latitude
217+
pythonCalculator.Expression = "90 - np.arccos( points[:, 2] / (sqrt(points[:, 0]**2 + points[:, 1]**2 + points[:, 2]**2)) ) * 180 / np.pi"
218+
pythonCalculator.ArrayName = "latitude"
219+
pythonCalculator.UpdatePipeline() # Apply the filter
220+
221+
# Create a threshold filter
222+
cut_slice = simple.Threshold(Input=pythonCalculator)
223+
cut_slice.Scalars = ("POINTS", "latitude") # Threshold the latitude variable
224+
cut_slice.ThresholdMethod = "Between" # Specify the 'between' method for the threshold filter
225+
226+
# Threshold on either side of the maximum lat_bound (north) or the minimum lat_bound (south)
227+
# based on the refinement_level of the global models.
228+
if boundary_name == "north":
229+
cut_slice.LowerThreshold = np.max(latitude_bounds) - (180 / 2**(refinement_level + 1))
230+
cut_slice.UpperThreshold = np.max(latitude_bounds) + (180 / 2**(refinement_level + 1))
231+
232+
if boundary_name == "south":
233+
cut_slice.LowerThreshold = np.min(latitude_bounds) - (180 / 2**(refinement_level + 1))
234+
cut_slice.UpperThreshold = np.min(latitude_bounds) + (180 / 2**(refinement_level + 1))
235+
236+
cut_slice.UpdatePipeline()
237+
238+
else:
239+
raise Exception("Unknown boundary name: " + boundary_name)
240+
241+
# Create an array for the radius of the regional slice
242+
radius_array = np.arange(min(radius_bounds), max(radius_bounds) + output_radius_resolution, output_radius_resolution)
243+
244+
# Create arrays for the latitude and longitude for the regional slices depending on which boundary is
245+
# currently within the loop
246+
if boundary_name == "west":
247+
latitude_array = np.arange(min(latitude_bounds), max(latitude_bounds) + output_lateral_resolution, output_lateral_resolution)
248+
longitude_array = np.array([np.min(longitude_bounds)])
249+
250+
elif boundary_name == "east":
251+
latitude_array = np.arange(min(latitude_bounds), max(latitude_bounds) + output_lateral_resolution, output_lateral_resolution)
252+
longitude_array = np.array([np.max(longitude_bounds)])
253+
254+
elif boundary_name == "north":
255+
latitude_array = np.array([np.max(latitude_bounds)])
256+
longitude_array = np.arange(min(longitude_bounds), max(longitude_bounds) + output_lateral_resolution, output_lateral_resolution)
257+
258+
elif boundary_name == "south":
259+
latitude_array = np.array([np.min(latitude_bounds)])
260+
longitude_array = np.arange(min(longitude_bounds), max(longitude_bounds) + output_lateral_resolution, output_lateral_resolution)
261+
262+
# Calculate the cartesian coordinates on the regional slice
263+
cartesian_slice_coords = spherical_to_cartesian(radius_array, np.flip(latitude_array), longitude_array, for_slice=True)
264+
265+
# Save Cartesian coordinates to .csv file for use later
266+
np.savetxt(temp_datafile, X=cartesian_slice_coords, delimiter=',', header="x,y,z", comments='')
267+
268+
# This opens the .csv as a paraview object
269+
reader = simple.OpenDataFile(temp_datafile)
270+
reader.UpdatePipeline()
271+
272+
# Create a TableToPoints filter that will be used later for storing interpolated data from the slices
273+
tabletopoints = servermanager.filters.TableToPoints()
274+
275+
# Assign the TableToPoints object the points of the .csv file
276+
tabletopoints.XColumn = 'x'
277+
tabletopoints.YColumn = 'y'
278+
tabletopoints.ZColumn = 'z'
279+
tabletopoints.Input = reader
280+
tabletopoints.UpdatePipeline()
281+
282+
# Take the cut_slice object and interpolate the fields onto the TableToPoints filter above.
283+
resample = simple.ResampleWithDataset(SourceDataArrays=cut_slice, DestinationMesh=tabletopoints)
284+
# Allow pvpython to compute the tolerance for resampling. If set to False, the user can provide
285+
# a tolerance with the line `resample.Tolerance`
286+
resample.ComputeTolerance = True
287+
resample.UpdatePipeline()
288+
289+
# Take the points defined for the interpolated slice, and resave it in the .csv file.
290+
writer = simple.DataSetCSVWriter()
291+
writer.Input = resample
292+
writer.WriteTimeSteps = 1
293+
writer.FileName = temp_datafile
294+
writer.UpdatePipeline()
295+
296+
# Load the .csv file with the interpolated slice velocity data and extract both
297+
# the x, y, z coordinates and the x, y, and z components of the velocity.
298+
dataframe = pd.read_csv(temp_datafile)
299+
x_slice = dataframe["Points:0"].to_numpy()
300+
y_slice = dataframe["Points:1"].to_numpy()
301+
z_slice = dataframe["Points:2"].to_numpy()
302+
303+
vx_slice = dataframe["velocity:0"].to_numpy()
304+
vy_slice = dataframe["velocity:1"].to_numpy()
305+
vz_slice = dataframe["velocity:2"].to_numpy()
306+
307+
# Convert the cartesian points to spherical points
308+
r_slice, theta_slice, phi_slice = cartesian_to_spherical(x_slice, y_slice, z_slice)
309+
310+
# Create the header and the arrays for saving into an ASCII file. Round to ensure that
311+
# each entry for the radius and latitude/longitude is consistent. The file will be named
312+
# and formatted in such a way that it can be directly applied to an ASPECT model.
313+
if boundary_name == "west" or boundary_name == "east":
314+
# ASPECT expects colatitude, so convert theta_slice to colatitude
315+
into_ASCII = np.array([np.round(r_slice, -3), \
316+
np.deg2rad(np.round(90 - theta_slice, 2)), \
317+
vx_slice, \
318+
vy_slice, \
319+
vz_slice]).T
320+
header = "POINTS: " + str(len(radius_array)) + " " + str(len(latitude_array))
321+
322+
elif boundary_name == "north" or boundary_name == "south":
323+
into_ASCII = np.array([np.round(r_slice, -3), \
324+
np.deg2rad(np.round(phi_slice, 2)), \
325+
vx_slice, \
326+
vy_slice, \
327+
vz_slice]).T
328+
header = "POINTS: " + str(len(radius_array)) + " " + str(len(longitude_array))
329+
330+
# Save into an ASCII file and remove the temporary .csv files
331+
os.remove(temp_datafile)
332+
np.savetxt(fname=output_datafile, X=into_ASCII, fmt='%.5e', header=header)
333+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Added: A python script which uses Paraview's pvpython to extract velocities
2+
from a global model along user-specified boundaries and saves them to ASCII
3+
files which can then be applied as velocity boundary conditions in regional
4+
chunk models.
5+
<br>
6+
(Daniel Douglas, 2024/09/05)

0 commit comments

Comments
 (0)