Auto-Frame: centering & orientation
This page is also an executable Jupyter notebook: open / download galaxyframe.ipynb. The notebooks run end-to-end and double as part of Mera's test suite.
"Find the centre, then rotate to face-on / edge-on" is a ritual every disk-galaxy analysis repeats by hand. center_of and face_on / edge_on do it from the data: the centre from the mass distribution, the orientation from the gas angular momentum. The result drops straight into projection.

This notebook runs on the mw_L10 disk-galaxy snapshot (output 300), an isolated spiral, so the bare face_on(gas) call is correct. Each cell prints the real frame it computed.
# Example-data root. Point this at your own simulation folder, or set the
# MERA_EXAMPLES environment variable; every path below is built from it.
MERA_EXAMPLES = get(ENV, "MERA_EXAMPLES", "/Volumes/FASTStorage/Simulations/Mera-Tests");
using Mera
info = getinfo(300, joinpath(MERA_EXAMPLES, "RAMSES/mw_L10"))
gas = gethydro(info);
println("cells loaded : ", length(gas.data))*__ __ _______ ______ _______
| |_| | | _ | | _ |
| | ___| | || | |_| |
| | |___| |_||_| |
| | ___| __ | |
| ||_|| | |___| | | | _ |
|_| |_|_______|___| |_|__| |__|
Mera v1.8.0 | Julia 1.12.7 | 8 threads
[Mera]: 2026-09-28T11:31:00.079
Code: RAMSES
output [300] summary:
mtime: 2023-04-09T05:34:09
ctime: 2025-06-21T18:31:24.020
=======================================================
simulation time: 445.89 [Myr]
boxlen: 48.0 [kpc]
ncpu: 640
ndim: 3
cosmological: false
-------------------------------------------------------
amr: true
level(s): 6 - 10 --> cellsize(s): 750.0 [pc] - 46.88 [pc]
-------------------------------------------------------
hydro: true
hydro-variables: 7 --> (:rho, :vx, :vy, :vz, :p, :scalar_00, :scalar_01)
hydro-descriptor: (:density, :velocity_x, :velocity_y, :velocity_z, :pressure, :scalar_00, :scalar_01)
γ: 1.6667
-------------------------------------------------------
gravity: true
gravity-variables: (:epot, :ax, :ay, :az)
-------------------------------------------------------
particles: true
- Nstars: 5.445150e+05
particle-variables: 7 --> (:vx, :vy, :vz, :mass, :family, :tag, :birth)
particle-descriptor: (:position_x, :position_y, :position_z, :velocity_x, :velocity_y, :velocity_z, :mass, :identity, :levelp, :family, :tag, :birth_time)
-------------------------------------------------------
rt: false
clumps: false
-------------------------------------------------------
namelist-file: ("&COOLING_PARAMS", "&SF_PARAMS", "&AMR_PARAMS", "&BOUNDARY_PARAMS", "&OUTPUT_PARAMS", "&POISSON_PARAMS", "&RUN_PARAMS", "&FEEDBACK_PARAMS", "&HYDRO_PARAMS", "&INIT_PARAMS", "&REFINE_PARAMS")
-------------------------------------------------------
boundaries: not periodic (&BOUNDARY_PARAMS closes x, y, z)
timer-file: true
compilation-file: false
makefile: true
patchfile: true
=======================================================
[Mera]: Get hydro data: 2026-09-28T11:31:01.885
Key vars=(:level, :cx, :cy, :cz)
Using var(s)=(1, 2, 3, 4, 5, 6, 7) = (:rho, :vx, :vy, :vz, :p, :scalar_00, :scalar_01)
domain:
xmin::xmax: 0.0 :: 1.0 ==> 0.0 [kpc] :: 48.0 [kpc]
ymin::ymax: 0.0 :: 1.0 ==> 0.0 [kpc] :: 48.0 [kpc]
zmin::zmax: 0.0 :: 1.0 ==> 0.0 [kpc] :: 48.0 [kpc]
📊 Processing Configuration:
Total CPU files available: 640
Files to be processed: 640
Compute threads: 8
GC threads: 8
Processing files: 100%|██████████████████████████████████████████████████| Time: 0:00:17 (27.57 ms/it)
✓ File processing complete! Combining results...
✓ Data combination complete!
Final data size: 28320979 cells, 7 variables
Creating Table from 28320979 cells with max 8 threads...
Threading: 8 threads for 11 columns
Max threads requested: 8
Available threads: 8
Using parallel processing with 8 threads
Creating IndexedTable with 11 columns...
✓ Table created in 38.498 seconds
Memory used for data table :2.321086215786636 GB
-------------------------------------------------------
cells loaded : 28320979Finding the centre
center_of returns [x, y, z]:
c_com = center_of(gas) # mass-weighted CoM (box fraction)
c_densest = center_of(gas, method=:densest) # densest hydro cell
c_kpc = center_of(gas, unit=:kpc) # CoM in physical units
println("center_of (:com, fraction) : ", round.(c_com, digits=5))
println("center_of (:densest) : ", round.(c_densest, digits=5))
println("center_of (:com, kpc) : ", round.(c_kpc, digits=4))center_of (:com, fraction) : [0.50001, 0.50046, 0.50044]
center_of (:densest) : [0.49658, 0.50244, 0.50146]
center_of (:com, kpc) : [24.0003, 24.022, 24.0212]For :standard the result is a box fraction (0 to 1), the convention that projection, subregion and getvar(…; center=…) expect, so it feeds straight back into them.
Orienting: face-on and edge-on
face_on and edge_on compute the net angular momentum L about the centre and return a GalaxyFrame:
face_on→ line of sight along the spin axis (look down on the disk).edge_on→ line of sight in the disk plane, with the spin axis pointing up.
fr = face_on(gas)
println(fr) # GalaxyFrame pretty-print
println()
println("los : ", round.(fr.los, digits=4)) # line of sight, pointing toward the camera
println("up : ", round.(fr.up, digits=4)) # camera up
println("center : ", round.(fr.center, digits=5), " [", fr.center_unit, "]")
println("angmom : ", round.(fr.angmom, sigdigits=4))GalaxyFrame:
center (standard) = [0.5, 0.5005, 0.5004]
los = [-0.0004, -0.0002, 1.0]
up = [-0.0, 1.0, 0.0002]
|angmom| = 160.2
los : [-0.0004, -0.0002, 1.0]
up : [-0.0, 1.0, 0.0002]
center : [0.50001, 0.50046, 0.50044] [standard]
angmom : [-0.06597, -0.03328, 160.2]eo = edge_on(gas)
println(eo)
println()
println("edge-on los : ", round.(eo.los, digits=4))
println("edge-on up : ", round.(eo.up, digits=4))
# face-on and edge-on lines of sight are orthogonal:
println("los_faceon · los_edgeon = ", round(sum(fr.los .* eo.los), digits=6))GalaxyFrame:
center (standard) = [0.5, 0.5005, 0.5004]
los = [0.0, -1.0, -0.0002]
up = [-0.0004, -0.0002, 1.0]
|angmom| = 160.2
edge-on los : [0.0, -1.0, -0.0002]
edge-on up : [-0.0004, -0.0002, 1.0]
los_faceon · los_edgeon = -0.0Why it works without subtracting the bulk velocity: angular momentum measured about the centre of mass cancels any net translation, because $\sum_i m_i \mathbf{r}_i = 0$ there. (The same cancellation removes the Hubble flow in cosmological runs, since $\mathbf{r} \times H\mathbf{r} = 0$.)
The vector itself: angmom
face_on and edge_on hand you a ready-made frame. When you want the angular-momentum vector on its own, to reuse across datasets or snapshots, ask for it directly.
angmom(gas) | L about the box centre, over everything loaded |
angmom(gas; radius=[10,:kpc]) | restricted to a sphere about center |
angmom(gas; mask=cold) | restricted to part of the data |
angmom([gas, stars, dm]) | one axis from several components, momenta added |
Feed the result to any view with axis=L or los=L. That is also the cheap route: L costs a full pass over the data and the :faceon/:edgeon presets recompute it on every call.
# The vector, and how much it depends on what you measure it over AND about.
using LinearAlgebra
unitv(v) = v ./ norm(v)
angle_between(a, b) = round(acosd(clamp(abs(dot(unitv(a), unitv(b))), -1, 1)), digits=4)
L_all = angmom(gas; center=[:bc], range_unit=:kpc)
L_in = angmom(gas; center=[:bc], range_unit=:kpc, radius=[10, :kpc])
println("about the box centre, everything loaded : ", round.(unitv(L_all), digits=5))
println("about the box centre, inside 10 kpc : ", round.(unitv(L_in), digits=5))
println("angle between them : ", angle_between(L_all, L_in), " deg")
# `center` is the origin of the lever arm, so it changes the answer too. `face_on` centres on
# the centre of mass by default, which is NOT the box centre, so its vector is a third one.
c_com = center_of(gas) # box fraction
L_com = angmom(gas; center=c_com, range_unit=:standard)
fr = face_on(gas)
println("\nabout the centre of mass : ", round.(unitv(L_com), digits=5))
println("angle, box centre vs centre of mass : ", angle_between(L_all, L_com), " deg")
println("face_on(gas).angmom is that same vector : ", angle_between(fr.angmom, L_com) < 1e-8)about the box centre, everything loaded : [-0.00042, -0.00022, 1.0]
about the box centre, inside 10 kpc : [-0.00061, -0.00017, 1.0]
angle between them : 0.0116 deg
about the centre of mass : [-0.00041, -0.00021, 1.0]
angle, box centre vs centre of mass : 0.0007 deg
face_on(gas).angmom is that same vector : trueTwo things move the answer, and the cell separates them. What you sum over: a whole-box L is dominated by whatever carries the most angular momentum times lever arm, usually the outskirts rather than the disc you meant. What you measure about: center is the origin of the lever arm, so the box centre and the centre of mass give different vectors. face_on defaults to the centre of mass, which is why its axis matches the third line and not the first.
The angle is the number that decides whether any of this matters for you. A whole-box L is dominated by whatever carries the most angular momentum times lever arm, which is usually the outskirts rather than the disc you meant.
The vector belongs to the object you measured. Gas and stars each have their own, so face_on(gas) and face_on(stars) are two different cameras. To compare them, compute one vector and pass it to both:
L = angmom([gas, stars]; center=[:bc], range_unit=:kpc, radius=[15, :kpc])
projection(gas, :sd; axis=L, inclination=0, center=[:bc], range_unit=:kpc)
projection(stars, :sd; axis=L, inclination=0, center=[:bc], range_unit=:kpc)Velocities are taken in the box frame, as face_on does. For an object streaming through the box, measuring about its own centre of mass largely cancels that.
Drive a projection with the frame
Splat the frame's los/up/center into projection, face-on for morphology, edge-on for the rotating disk.
using CairoMakie
p_face = projection(gas, :sd, :Msol_pc2; los=fr.los, up=fr.up,
center=fr.center, range_unit=fr.center_unit)
p_edge = projection(gas, :sd, :Msol_pc2; los=eo.los, up=eo.up,
center=eo.center, range_unit=eo.center_unit)
println("face-on Sigma extrema : ", extrema(p_face.maps[:sd]))
println("edge-on Sigma extrema : ", extrema(p_edge.maps[:sd]))
fig = Figure(size=(900, 420))
ax1 = Axis(fig[1,1]; title="face-on Sigma [Msol/pc^2]", aspect=DataAspect()); hidedecorations!(ax1)
ax2 = Axis(fig[1,2]; title="edge-on Sigma [Msol/pc^2]", aspect=DataAspect()); hidedecorations!(ax2)
# no transpose: a map's first index is image x, which is what heatmap puts on x
heatmap!(ax1, log10.(p_face.maps[:sd]); colormap=:inferno)
heatmap!(ax2, log10.(p_edge.maps[:sd]); colormap=:inferno)
fig[Mera]: 2026-09-28T11:32:31.320
center: [0.5000061, 0.5004579, 0.5004408] ==> [24.0 [kpc] :: 24.022 [kpc] :: 24.021 [kpc]]
domain:
xmin::xmax: 0.0 :: 1.0 ==> 0.0 [kpc] :: 48.0 [kpc]
ymin::ymax: 0.0 :: 1.0 ==> 0.0 [kpc] :: 48.0 [kpc]
zmin::zmax: 0.0 :: 1.0 ==> 0.0 [kpc] :: 48.0 [kpc]
Selected var(s)=(:sd,)
Weighting = :mass
Off-axis LOS = [-0.0004, -0.0002, 1.0] (binning=:overlap)
Effective resolution: 1024^2 → map size: 1038 x 1038
[Mera]: 2026-09-28T11:32:40.274
center: [0.5000061, 0.5004579, 0.5004408] ==> [24.0 [kpc] :: 24.022 [kpc] :: 24.021 [kpc]]
domain:
xmin::xmax: 0.0 :: 1.0 ==> 0.0 [kpc] :: 48.0 [kpc]
ymin::ymax: 0.0 :: 1.0 ==> 0.0 [kpc] :: 48.0 [kpc]
zmin::zmax: 0.0 :: 1.0 ==> 0.0 [kpc] :: 48.0 [kpc]
Selected var(s)=(:sd,)
Weighting = :mass
Off-axis LOS = [0.0, -1.0, -0.0002] (binning=:overlap)
Effective resolution: 1024^2 → map size: 1038 x 1038
face-on Sigma extrema : (0.0, 197.12448191215034)
edge-on Sigma extrema : (0.0, 6535.0811176608195)
Several galaxies, mergers, cosmological boxes
face_on(gas) / center_of(gas) use the global CoM and the summed angular momentum. In a box with many galaxies that is meaningless, the CoM lands between them and unrelated spins cancel. Point the tool at the object with a seed center plus an aperture; it then re-centres on the local CoM inside that sphere and measures only that object's spin:
# the densest galaxy in the box (good first guess in a cosmological run)
fr = face_on(gas; center=:densest, aperture=30, range_unit=:kpc)
# a galaxy at a known/catalogued position (e.g. from a halo or clump finder)
fr = face_on(gas; center=[x, y, z], aperture=30, range_unit=:kpc)Equivalently, cut the object out first and frame that:
gal = subregion(gas, :sphere; center=[x,y,z], radius=30, range_unit=:kpc)
fr = face_on(gal)Because the spin is then taken about the local CoM, this is also the correct recipe for a merger progenitor and for any galaxy moving through a cosmological box. Choosing the aperture to enclose the disk (but not the neighbours) is the one judgement call.
mw_L10 is isolated, so here we just demonstrate the aperture form locks onto the disk.
fr_ap = face_on(gas; center=:densest, aperture=30, range_unit=:kpc)
println(fr_ap)
println("aperture-framed center [kpc] : ", round.(fr_ap.center, digits=4))GalaxyFrame:
center (kpc) = [24.0003, 24.022, 24.0212]
los = [-0.0004, -0.0002, 1.0]
up = [-0.0, 1.0, 0.0002]
|angmom| = 160.2
aperture-framed center [kpc] : [24.0003, 24.022, 24.0212]Options
| function | keyword | default | meaning |
|---|---|---|---|
center_of | method | :com | :com (centre of mass) or :densest (densest hydro cell) |
center_of | unit | :standard | output unit; :standard → box fraction, else physical |
center_of | mask | [false] | restrict to masked cells/particles |
face_on/edge_on | center | :com | :com, :densest, or an explicit [x,y,z] |
face_on/edge_on | aperture | nothing | sphere radius (in range_unit) to isolate one object |
face_on/edge_on | range_unit | :standard | unit of center/aperture/output centre |
Works on hydro and particle data (both carry mass and velocity → angular momentum).
Method and references
Aperture. The aperture keyword is a sphere radius (in range_unit) around the seed centre, the region within which the local centre and the spin axis are measured. The name is borrowed from aperture photometry: only data inside the sphere contributes, which is what isolates one object from its neighbours. aperture=nothing (the default) uses all the data, which is correct only for an already-isolated object.
Orientation. face_on / edge_on take the net, mass-weighted angular momentum
\[\mathbf{L} = \sum_i m_i\, \mathbf{r}_i \times \mathbf{v}_i\]
of the selected region about the centre, and use $\hat{\mathbf{L}}$ as the spin axis (the face-on line of sight); edge-on looks along a direction in the disc plane. This is the standard angular-momentum recipe for orienting disc galaxies.
Centring. :com is the mass-weighted centre of mass; :densest is the density peak. With a seed centre plus an aperture, the frame re-centres on the local CoM inside the sphere, one iteration of the shrinking-sphere centre commonly used for haloes.
Why no bulk-velocity subtraction. Angular momentum about the centre of mass separates into centre-of-mass and internal parts (König's theorem), so a net translation contributes nothing about the CoM. The Hubble flow $\mathbf{v} = H\mathbf{r}$ is parallel to $\mathbf{r}$, so $\mathbf{r} \times \mathbf{v} = 0$, hence the recipe is also correct in cosmological runs.
These are standard techniques in galaxy-simulation analysis rather than any single source; the authoritative references for the ingredients:
- A. Pontzen, R. Roškar, G. Stinson, et al., pynbody: Astrophysics Simulation Analysis for Python (2013), Astrophysics Source Code Library, ascl:1305.002,
faceon/sideonorientation by angular momentum. - M. J. Turk, B. D. Smith, J. S. Oishi, et al., "yt: A Multi-code Analysis Toolkit for Astrophysical Simulation Data", ApJS 192, 9 (2011).
- C. Power, J. F. Navarro, A. Jenkins, et al., "The inner structure of ΛCDM haloes, I. A numerical convergence study", MNRAS 338, 14 (2003), iterative shrinking-sphere centre.
- V. Springel, N. Yoshida, S. D. M. White, "GADGET … and the SUBFIND algorithm", MNRAS 328, 726 (2001), density-peak substructure centres.
- J. Binney & S. Tremaine, Galactic Dynamics, 2nd ed. (Princeton University Press, 2008), angular momentum and disc dynamics.
- H. Goldstein, C. Poole, J. Safko, Classical Mechanics, 3rd ed. (Addison-Wesley, 2002), König's theorem (decomposition of angular momentum about the CoM).
See also
projection, consumeslos/up/centerfor off-axis views.subregion, isolate one object before framing it.center_of_mass,bulk_velocity, the underlying reductions.- Off-axis projection, the projection machinery the frame drives.