Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
13c2ac6
Allow new meta entries to be saved as lists
SolarDrew Jun 23, 2026
6bf538a
Merge branch 'main' of github.com:DKISTDC/dkist
SolarDrew Jun 24, 2026
6032d9d
Merge branch 'main' of github.com:DKISTDC/dkist
SolarDrew Jul 7, 2026
d2f65d3
Merge branch 'main' of github.com:DKISTDC/dkist
SolarDrew Jul 7, 2026
f754b53
Merge branch 'main' of github.com:DKISTDC/dkist
SolarDrew Jul 23, 2026
5717583
Make a new repr for inversions based on the dataset repr
SolarDrew Jul 29, 2026
377e751
Small tweak
SolarDrew Jul 31, 2026
0ce545c
Add shared dimensions sizes
SolarDrew Jul 31, 2026
88bc618
Generalise Inversion info string to work for Profiles as well
SolarDrew Aug 3, 2026
6985a03
Merge branch 'main' of github.com:DKISTDC/dkist into inv-repr
SolarDrew Aug 4, 2026
e9fbe75
Update inversion repr test
SolarDrew Aug 4, 2026
2e017f9
More updates and test profiles
SolarDrew Aug 4, 2026
369ac0a
Merge branch 'main' of github.com:DKISTDC/dkist into inv-repr
SolarDrew Aug 4, 2026
edc28b4
Gotta get that coverage up
SolarDrew Aug 4, 2026
ee18776
Block astropy 8.0.1 for now until I figure out why it breaks plots
SolarDrew Aug 11, 2026
abd7927
Changelog
SolarDrew Aug 11, 2026
4eb4206
Smallest correction ever
SolarDrew Aug 11, 2026
a842ac3
Merge branch 'main' of github.com:DKISTDC/dkist into inv-repr
SolarDrew Aug 13, 2026
47a8848
Put astropy back, it's being fixed elsewhere
SolarDrew Aug 13, 2026
d258a52
Spaces are important
SolarDrew Aug 14, 2026
a4213f6
Spaces are still important, even in other tests
SolarDrew Aug 17, 2026
dd3fb81
Merge branch 'main' of github.com:DKISTDC/dkist into inv-repr
SolarDrew Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/745.trivial.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Update representation of Inversion and Profiles classes for consistency with Dataset and TiledDataset.
29 changes: 15 additions & 14 deletions dkist/dataset/inversion.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import types
import textwrap
from textwrap import dedent
from collections.abc import Iterable

import matplotlib.figure
Expand All @@ -11,6 +11,8 @@

from dkist.utils.exceptions import DKISTUserWarning

from .utils import level2_info_str

__all__ = ["Inversion", "Profiles"]


Expand Down Expand Up @@ -43,6 +45,13 @@ class Profiles(NDCollection):
General metadata for the overall collection.
"""

def __repr__(self):
prefix = object.__repr__(self)
return dedent(f"{prefix}\n{self.__str__()}")

def __str__(self):
return level2_info_str(self)

def plot(
self,
slice_index: int | slice | Iterable[int | slice],
Expand Down Expand Up @@ -149,20 +158,12 @@ def __init__(self, *args, profiles: Profiles | None = None, **kwargs):
super().__init__(*args, **kwargs)
self.profiles = profiles

def __str__(self):
quants_repr = "\n".join(super().__str__().split("\n")[2:])
profiles_repr = "\n".join(self.profiles.__str__().split("\n")[2:])
s = """\
Inversion
~~~~~~~~~
{}

Profiles
~~~~~~~~
{}
"""
def __repr__(self):
prefix = object.__repr__(self)
return dedent(f"{prefix}\n{self.__str__()}")

return textwrap.dedent(s).format(quants_repr, profiles_repr)
def __str__(self):
return level2_info_str(self)

def __getitem__(self, item):
new_inv = super().__getitem__(item)
Expand Down
33 changes: 22 additions & 11 deletions dkist/dataset/tests/test_inversion.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import collections.abc
from itertools import product, permutations

import matplotlib.pyplot as plt
import numpy as np
Expand All @@ -19,18 +18,30 @@ def test_inversion(inversion):
assert len(inversion.profiles.items()) == 6


def test_str(inversion):
def test_repr(inversion):
r = repr(inversion)
keys = "('optical_depth', 'temperature', 'electron_pressure', 'microturbulence', 'mag_strength', 'velocity', 'mag_inclination', 'mag_azimuth', 'geo_height', 'gas_pressure', 'density')"
assert "This Level 2 product is a dictionary of 11 Datasets with 3 aligned dimensions and consists of 4664 total frames." in r
keys = "\n- ".join(["optical_depth", "temperature", "electron_pressure", "microturbulence", "mag_strength", "velocity", "mag_inclination", "mag_azimuth", "geo_height", "gas_pressure", "density"]) # noqa: FLY002
assert keys in r
# Ordering of axes appears to be random causing high chance of test failure
# Therefore we need to check every possible combination of axis keys
item0keys = ("time", "custom:pos.helioprojective.lat", "custom:pos.helioprojective.lon")
item1keys = ("custom:pos.helioprojective.lat", "phys.polarization.stokes", "custom:pos.helioprojective.lon")
item0_pmtns = list(permutations(item0keys))
item1_pmtns = list(permutations(item1keys))
allorders = [str([i0, i1, ("phys.absorption.opticalDepth",)]) for (i0, i1) in product(item0_pmtns, item1_pmtns)]
assert any([s in r for s in allorders]) # noqa:C419
profiles = "\n- ".join(["NaID", "FeI630", "CaII854"]) # noqa: FLY002
assert profiles in r
assert "These Datasets share 3 pixel and 5 world dimensions. The shared pixel axes have shape [424, 508, 81]." in r

# Test that all basepaths are represented if component datasets are saved in different locations
orig_basepath = inversion["velocity"].files.basepath
temp_basepath = orig_basepath / "temperature"
inversion["temperature"].files.basepath = temp_basepath
r = repr(inversion)
assert f"- {orig_basepath}" in r
assert f"- {temp_basepath}" in r


def test_profiles_repr(inversion):
r = repr(inversion.profiles)
assert "This Level 2 product is a dictionary of 6 Datasets with 3 aligned dimensions and consists of 2544 total frames." in r
profiles = "\n- ".join(["NaID_orig", "NaID_fit", "FeI630_orig", "FeI630_fit", "CaII854_orig", "CaII854_fit"]) # noqa: FLY002
assert profiles in r
assert "These Datasets share 3 pixel and 5 world dimensions. The shared pixel axes have shape [424, 508, 4]." in r


def test_get_item(inversion):
Expand Down
169 changes: 129 additions & 40 deletions dkist/dataset/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,48 +67,138 @@ def dataset_info_str(ds_in):

s += f"The data are represented by a {type(ds.data)} object:\n{get_array_repr(ds.data)}\n\n"

s += array_dimensions_info(wcs)
s += world_dimensions_info(wcs)

# Axis correlation matrix
pixel_dim_width = max(3, len(str(wcs.world_n_dim)))
s += "Correlation between pixel and world axes:\n\n"
s += _get_pp_matrix(ds.wcs)

# Make sure we get rid of the extra whitespace at the end of some lines
return "\n".join([line.rstrip() for line in s.splitlines()])


def level2_info_str(l2_in):
from dkist.dataset.inversion import Profiles # noqa: PLC0415

s = f"This Level 2 product is a dictionary of {len(l2_in.items())} Datasets with {len(l2_in.aligned_dimensions)} aligned dimensions "
s += f"and consists of {sum([len(ds.files._fm.filenames) for ds in l2_in.values()])} total frames.\n"
basepaths = []
for ds in l2_in.values():
basepaths.append(ds.files.basepath)
basepaths = set(basepaths)
if len(basepaths) == 1:
s += f"Files are stored in {list(basepaths)[0]}\n"
else:
s += "Files are stored in the following locations:\n"
for path in basepaths:
s += f"- {path}\n"
s += "\n"

if not isinstance(l2_in, Profiles):
s += "This Inversion has ID ...\n\n"

s += f"The Datasets in this Inversion represent the following {len(l2_in.items())} physical parameters:\n"
for param in l2_in.keys():
s += f"- {param}\n"

lines = []
for p in l2_in.profiles.keys():
line = p[:p.index("_")] if "_" in p else p
if line not in lines:
lines.append(line)
s += f"\nThese parameters were calculated using the following {len(lines)} line profiles "
s += "(see the .profiles attribute for more information) :\n"
for line in lines:
s += f"- {line}\n"
s += "\n"
else:
s += f"The Datasets in this Profiles object represent the following {len(l2_in.items())} line profiles:\n"
for param in l2_in.keys():
s += f"- {param}\n"
s += "\n"

# This section shows only info about the pixel axes shared across all inversions and the
# corresponding world axes
aligned_axes = list(l2_in.aligned_axes.values())
indices = list(set(aligned_axes[0]).intersection(*aligned_axes))
acm = list(l2_in.values())[0].wcs.axis_correlation_matrix[:, indices]
world_indices = np.where(np.any(acm, axis=1))[0]

s += f"These Datasets share {len(indices)} pixel and {len(world_indices)} world dimensions. "
s += f"The shared pixel axes have shape {[list(l2_in.values())[0].shape[i] for i in indices]}.\n\n"

# Low level Just in case the dataset has been sliced and returned the wrong kind of wcs
wcs = l2_in[list(l2_in.keys())[0]].wcs.low_level_wcs
s += array_dimensions_info(wcs, indices)
s += world_dimensions_info(wcs, indices)

# Axis correlation matrix
pixel_dim_width = max(3, len(str(wcs.world_n_dim)))
s += "Correlation between pixel and world axes:\n\n"
s += _get_pp_matrix(ds.wcs, indices)

s += "\n\n"
s += "The above WCS information relates to only the pixel axes shared by all inversions, and the\n"
s += "corresponding world axes. Invdividual inversions may include other coordinate information.\n"

# Make sure we get rid of the extra whitespace at the end of some lines
return "\n".join([line.rstrip() for line in s.splitlines()])


def array_dimensions_info(wcs, indices=None):
n_dim = len(indices) if indices else wcs.pixel_n_dim
indices = indices if indices else range(wcs.pixel_n_dim)
array_shape = wcs.array_shape or (0,)
pixel_shape = wcs.pixel_shape or (None,) * wcs.pixel_n_dim
pixel_shape = wcs.pixel_shape or (None,) * n_dim

# Find largest between header size and value length
if hasattr(wcs, "pixel_axis_names"):
pixel_axis_names = wcs.pixel_axis_names
pixel_axis_names = [wcs.pixel_axis_names[i] for i in indices]
elif isinstance(wcs, gwcs.WCS):
pixel_axis_names = wcs.input_frame.axes_names
pixel_axis_names = [wcs.input_frame.axes_names[i] for i in indices]
else:
pixel_axis_names = [""] * wcs.pixel_n_dim
pixel_axis_names = [""] * n_dim

pixel_dim_width = max(9, len(str(wcs.pixel_n_dim)))
pixel_dim_width = max(9, len(str(n_dim)))
pixel_nam_width = max(9, max(len(x) for x in pixel_axis_names))
pixel_siz_width = max(9, len(str(max(array_shape))))

s += (("{0:" + str(pixel_dim_width) + "s}").format("Array Dim") + " " +
("{0:" + str(pixel_nam_width) + "s}").format("Axis Name") + " " +
("{0:" + str(pixel_siz_width) + "s}").format("Data size") + " " +
"Bounds\n")
s = (("{0:" + str(pixel_dim_width) + "s}").format("Array Dim") + " " +
("{0:" + str(pixel_nam_width) + "s}").format("Axis Name") + " " +
("{0:" + str(pixel_siz_width) + "s}").format("Data size") + " " +
"Bounds\n")

for ipix in range(ds.wcs.pixel_n_dim):
for ipix in range(n_dim):
s += (("{0:" + str(pixel_dim_width) + "d}").format(ipix) + " " +
("{0:" + str(pixel_nam_width) + "s}").format(pixel_axis_names[::-1][ipix] or "None") + " " +
(" " * 5 + str(None) if pixel_shape[::-1][ipix] is None else
("{0:" + str(pixel_siz_width) + "d}").format(pixel_shape[::-1][ipix])) + " " +
"{:s}".format(str(None if wcs.pixel_bounds is None else wcs.pixel_bounds[::-1][ipix]) + "\n"))
s += "\n"

# World dimensions table
return s

# Find largest between header size and value length
world_dim_width = max(9, len(str(wcs.world_n_dim)))
world_nam_width = max(9, max(len(x) if x is not None else 0 for x in wcs.world_axis_names))
world_typ_width = max(13, max(len(x) if x is not None else 0 for x in wcs.world_axis_physical_types))

s += (("{0:" + str(world_dim_width) + "s}").format("World Dim") + " " +
("{0:" + str(world_nam_width) + "s}").format("Axis Name") + " " +
("{0:" + str(world_typ_width) + "s}").format("Physical Type") + " " +
"Units\n")
def world_dimensions_info(wcs, indices=None):
acm = wcs.axis_correlation_matrix
if indices:
acm = acm[:, indices]
n_dim = len(indices) if indices else wcs.pixel_n_dim
indices = indices if indices else range(wcs.pixel_n_dim)
# Find largest between header size and value length
world_dim_width = max(9, len(str(n_dim)))
world_nam_width = max(9, max(len(x) if x is not None else 0 for x in [wcs.world_axis_names[i] for i in indices]))
world_typ_width = max(13, max(len(x) if x is not None else 0 for x in [wcs.world_axis_physical_types[i] for i in indices]))

for iwrl in range(wcs.world_n_dim)[::-1]:
s = (("{0:" + str(world_dim_width) + "s}").format("World Dim") + " " +
("{0:" + str(world_nam_width) + "s}").format("Axis Name") + " " +
("{0:" + str(world_typ_width) + "s}").format("Physical Type") + " " +
"Units\n")

shared_world_axis_idxs = np.where(np.any(acm, axis=1))[0]
for iwrl in shared_world_axis_idxs[::-1]:
name = wcs.world_axis_names[iwrl] or "None"
typ = wcs.world_axis_physical_types[iwrl] or "None"
unit = wcs.world_axis_units[iwrl] or "unknown"
Expand All @@ -120,27 +210,26 @@ def dataset_info_str(ds_in):

s += "\n"

# Axis correlation matrix

pixel_dim_width = max(3, len(str(wcs.world_n_dim)))

s += "Correlation between pixel and world axes:\n\n"

s += _get_pp_matrix(ds.wcs)

# Make sure we get rid of the extra whitespace at the end of some lines
return "\n".join([line.rstrip() for line in s.splitlines()])


def _get_pp_matrix(wcs):
wcs = wcs.low_level_wcs # Just in case the dataset has been sliced and returned the wrong kind of wcs
slen = np.max([len(line) for line in list(wcs.world_axis_names) + list(wcs.pixel_axis_names)])
mstr = wcs.axis_correlation_matrix.astype("<U")
return s


def _get_pp_matrix(wcs, indices=None):
wcs = wcs.low_level_wcs
acm = wcs.axis_correlation_matrix
if indices:
acm = acm[:, indices]
indices = indices if indices else range(wcs.pixel_n_dim)
world_indices = np.where(np.any(acm, axis=1))[0]
acm = acm[world_indices]
pixel_names = [wcs.pixel_axis_names[i] for i in indices]
world_names = [wcs.world_axis_names[i] for i in world_indices]
slen = np.max([len(line) for line in list(world_names) + list(pixel_names)])
mstr = acm.astype("<U")
mstr[np.where(mstr == "True")] = "x"
mstr[np.where(mstr == "False")] = ""
mstr = mstr.astype(f"<U{slen}")

labels = wcs.pixel_axis_names
labels = pixel_names
width = max(max([len(w) for w in label.split(" ")]) for label in labels)
wrapped = [textwrap.wrap(l, width=width, break_long_words=False) for l in labels]
maxlines = max([len(l) for l in wrapped])
Expand All @@ -150,8 +239,8 @@ def _get_pp_matrix(wcs):
header = np.vstack([[s.center(width) for s in wrapped[l]] for l, _ in enumerate(labels)]).T

mstr = np.insert(mstr, 0, header, axis=0)
world = ["WORLD DIMENSIONS", *list(wcs.world_axis_names)]
nrows = maxlines + len(wcs.world_axis_names)
world = ["WORLD DIMENSIONS", *world_names]
nrows = maxlines + len(world_names)
while len(world) < nrows:
world.insert(0, "")
mstr = np.insert(mstr, 0, world, axis=1)
Expand All @@ -168,7 +257,7 @@ def _get_pp_matrix(wcs):
# Probably a nicer way to do this with regexes but this works fine
mstr = mstr.replace("[[", "").replace(" [", "").replace("]", "").replace("' '", " | ").replace("'", "")
wid = sum(widths[1:])
header = (" "*widths[0]) + " | " + "PIXEL DIMENSIONS".center(wid+(3*(len(wcs.pixel_axis_names)-1))) + "\n"
header = (" "*widths[0]) + " | " + "PIXEL DIMENSIONS".center(wid+(3*(len(pixel_names)-1))) + "\n"

return header + mstr

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ dependencies = [
"asdf-standard>=1.1.0",
"asdf-transform-schemas>=0.6.0",
"asdf-wcs-schemas>=0.4.0", # required by gwcs 0.24
"astropy>=6.1", # required by ndcube 2.4
"astropy>=6.1", # required by ndcube 2.4; 8.0.1 introduces ValueError when saving some plots
"dask[array]>=2024.4.1", # required by dask-image via reproject
"globus-sdk>=4.0",
"gwcs>=0.24.0", # Inverse transform fix
Expand Down
Loading