Skip to content

Fix #8490: FITS serialization for SunPy coordinates - #8548

Merged
ayshih merged 17 commits into
sunpy:mainfrom
Amityush-lgtm:fix-coordinate-serialization
May 8, 2026
Merged

ayshih merged 17 commits into
sunpy:mainfrom
Amityush-lgtm:fix-coordinate-serialization

Conversation

@Amityush-lgtm

@Amityush-lgtm Amityush-lgtm commented Mar 21, 2026 •

Copy link
Copy Markdown
Contributor

PR Description

Fixes #8490

This PR resolves an issue where writing or reading SunPy coordinate frames to and from FITS files using Astropy tables (QTable) was crashing due to missing serialization registrations.

What Changed:

  • Added init_subclass to SunPyBaseCoordinateFrame in sunpy/coordinates/frames.py. This ensures that any new coordinate frame subclass is automatically registered for serialization without needing manual configuration.
  • The init_subclass now configures Astropy’s QTable processing and registers the correct PyYAML handlers so frames save and load exactly as they should.
  • Added test_table_serialization.py to properly verify FITS reading and writing for both single observer and array observer.

AI Assistance Disclosure

AI tools were used for:

  • Code generation (e.g., when writing an implementation or fixing a bug)
  • Test/benchmark generation
  • Documentation (including examples)
  • Research and understanding
  • No AI tools were used

AI was used to help draft the initial version of the coordinate serialization fix and some unit tests. I have fully reviewed and manually verified the behaviour via tests

Regardless of AI use, the human contributor remains fully responsible for correctness, design choices, licensing compatibility, and long-term maintainability.

@samaloney, Let me know your thoughts on this. Happy to make changes!

Amityush-lgtm added a commit to Amityush-lgtm/sunpy that referenced this pull request Mar 21, 2026
@ayshih

ayshih commented Mar 21, 2026 •

Copy link
Copy Markdown
Member

If such registration is necessary, can we do it within __init_subclass__() for SunPyBaseCoordinateFrame? Or better yet, push it upstream to BaseCoordinateFrame?

@Amityush-lgtm

Copy link
Copy Markdown
Contributor Author

@ayshih Thank you for your guidance, Using init_subclass on SunPyBaseCoordinateFrame would be much better than my current approach it would automatically register any new frames without needing a separate module.

@Amityush-lgtm

Copy link
Copy Markdown
Contributor Author

for pushing it upstream to BaseCoordinateFrame in Astropy, will that be in the scope of this PR??

@ayshih

ayshih commented Mar 22, 2026

Copy link
Copy Markdown
Member

It'd be fine to fix it here, and then try to push it upstream to Astropy at some future date. How does Astropy register their built-in frames, and if it's not through __init_subclass__() on BaseCoordinateFrame, why not? Let's check for any gotchas before we proceed with __init_subclass__().

@Amityush-lgtm

Copy link
Copy Markdown
Contributor Author

@ayshih I looked into how Astropy handles this. It doesn't use __init_subclass__() on BaseCoordinateFrame. Instead in _construct_mixin_from_obj_attrs_and_info method serialize.py (L354-358) uses a prefix check.

@Amityush-lgtm

Copy link
Copy Markdown
Contributor Author

Astropy's own frames like ICRS, Galactic pass through because they all start with astropy.coordinates.builtin_frames, while SunPy frames fail because sunpy.coordinates.frames.* doesn't match that prefix.

@samaloney samaloney left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

Comment thread sunpy/coordinates/_table_yaml.py Outdated
if isinstance(cls, type) and issubclass(cls, BaseCoordinateFrame):
key = f"{cls.__module__}.{cls.__name__}"
frame_classes[key] = cls
return tuple(frame_classes.values())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why create the a dictionary if just returning the values tuple? I think if you did return the dictionary here you could avoid having to recreate it later in ~L43 e.g f"{cls.__module__}.{cls.__name__}"?

Comment thread sunpy/coordinates/_table_yaml.py Outdated
from astropy.io.misc.yaml import AstropyDumper, AstropyLoader
except ImportError:
# PyYAML is optional in Astropy, so skip registration if unavailable.
return

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure it's core dependency of astropy?

@samaloney

Copy link
Copy Markdown
Member

Yea the relevant code block as linked to by @Amityush-lgtm

    # We translate locally created skyoffset frames and treat all
    # built-in frames as known.
    if cls_full_name.startswith("abc.SkyOffset"):
        cls_full_name = "astropy.coordinates.SkyOffsetFrame"
    elif (
        cls_full_name not in __construct_mixin_classes
        and not cls_full_name.startswith("astropy.coordinates.builtin_frames")
    ):
        raise ValueError(f"unsupported class for construct {cls_full_name}")

and since we can't change the namespace of our coords it looks like the only way is to add it to __construct_mixin_classes which is created from a hard coded list at the top of the file https://github.com/astropy/astropy/blob/905b4262359f7b0384f36170be96de6e2558ceab/astropy/table/serialize.py#L14-L79

@Amityush-lgtm

Amityush-lgtm commented Mar 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review @samaloney I have implemented the approach as per @ayshih suggestion i replaced _table_yaml.py with init_subclass() on SunPyBaseCoordinateFrame so any new frame subclass is automatically registered also updated the test to use np.arange instead of zeros. Will be pushing soon!

@Amityush-lgtm
Amityush-lgtm force-pushed the fix-coordinate-serialization branch from 85ba7ea to ecde9cf Compare March 23, 2026 09:59
@Amityush-lgtm

Amityush-lgtm commented Mar 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Done! Let me know your thoughts on this @ayshih @samaloney
Happy to make changes

Comment thread sunpy/coordinates/frames.py Resolved

@samaloney samaloney left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this nearly good to go, just few small comments

Comment thread sunpy/coordinates/frames.py Resolved
@Amityush-lgtm

Copy link
Copy Markdown
Contributor Author

@samaloney, I have made the suggested changes.

@Amityush-lgtm
Amityush-lgtm requested a review from samaloney March 26, 2026 21:10
Comment thread changelog/8548.bugfix.rst Resolved
@Amityush-lgtm
Amityush-lgtm requested a review from samaloney April 1, 2026 17:02
Comment thread sunpy/coordinates/frames.py Outdated
Comment on lines +132 to +135
if hasattr(table_serialize, "__construct_mixin_classes"):
existing_classes = getattr(table_serialize, "__construct_mixin_classes")
if cls_name not in existing_classes:
setattr(table_serialize, "__construct_mixin_classes", existing_classes + (cls_name,))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's safe to assume that __construct_mixin_classes exists. I also think that using getattr()/setattr() is only useful if we are anticipating __construct_mixin_classes not being a variable, but it's difficult to guess the future. Can we keep things simple:

Suggested change
if hasattr(table_serialize, "__construct_mixin_classes"):
existing_classes = getattr(table_serialize, "__construct_mixin_classes")
if cls_name not in existing_classes:
setattr(table_serialize, "__construct_mixin_classes", existing_classes + (cls_name,))
if cls_name not in table_serialize.__construct_mixin_classes:
table_serialize.__construct_mixin_classes += (cls_name,)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On writing table_serialize.__construct_mixin_classes directly inside throws an AttributeError.
image

I guess using getattr() might solve this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know your thoughts @ayshih

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argh, the problem is that the variable name has two leading underscores, which unfortunately triggers Python's private-name-mangling logic when accessed in a class definition. I suggest adding a comment for future maintenance.

You can still remove the hasattr() check.

Suggested change
if hasattr(table_serialize, "__construct_mixin_classes"):
existing_classes = getattr(table_serialize, "__construct_mixin_classes")
if cls_name not in existing_classes:
setattr(table_serialize, "__construct_mixin_classes", existing_classes + (cls_name,))
# We have to use getattr()/setattr() to avoid triggering Python name mangling
existing_classes = getattr(table_serialize, "__construct_mixin_classes")
if cls_name not in existing_classes:
setattr(table_serialize, "__construct_mixin_classes", existing_classes + (cls_name,))

@Amityush-lgtm Amityush-lgtm Apr 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it! I've updated the PR to use getattr() and added the comment.

@samaloney samaloney left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is good to go

@Amityush-lgtm

Copy link
Copy Markdown
Contributor Author

Thanks for taking the time to review this

@samaloney
samaloney force-pushed the fix-coordinate-serialization branch from 7d1ca71 to 5cc57d4 Compare April 18, 2026 14:53
@Cadair Cadair added this to the 8.0.0 milestone Apr 22, 2026
@Cadair Cadair added coordinates Affects the coordinates submodule No Backport A PR that isn't to be backported to any release branch. (To be used as a flag to other maintainers) labels Apr 22, 2026
@Cadair

Cadair commented Apr 22, 2026

Copy link
Copy Markdown
Member

I'm going to have to vote -0 on this, the setting of a variable which someone decided to prefix with a double underscore feels really dirty.

I feel like this is something we should really work with astropy to fix properly, i.e. address astropy/astropy#13689.

Comment thread sunpy/coordinates/frames.py Resolved
Co-authored-by: Albert Y. Shih <[email protected]>
Comment thread changelog/8548.bugfix.rst Resolved

@ayshih ayshih left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach is not ideal, but I feel that it is acceptable

@Amityush-lgtm

Copy link
Copy Markdown
Contributor Author

Thank you for the review and the guidance @ayshih . I agree its not a long term fix but i'm glad its acceptable for now and I'll keep an eye on that Astropy issue for a cleaner fix in the future.

@samaloney

Copy link
Copy Markdown
Member

Hum the py312-oldestdeps fail seems real and related to this PR, going to assume something different in older version of astropy==6.1.0?

@Amityush-lgtm

Copy link
Copy Markdown
Contributor Author

@samaloney I looked into the failure and found that older Astropy versions don't have the .info part that is used here.

Im thinking to update the code to handle this like manually collect all the frame's data like longitude, latitude and observation time and put them into a dictionary so it works on older versions and fix the tests to skip a few tricky cases that only work on newer Astropy.

Will this be fine? Let me know your thoughts

@samaloney

Copy link
Copy Markdown
Member

Hey @Amityush-lgtm ok that make sense, what version of astropy was .info added in? @Cadair when is our next planned bump of the astropy dependency?

@Cadair

Cadair commented Apr 23, 2026

Copy link
Copy Markdown
Member

8.0 should have a minimum of 6.1

@Amityush-lgtm

Copy link
Copy Markdown
Contributor Author

Another failure in oldestdeps was due to older Astropy using .get_name() instead of .name for the representation type class. Also the ascii.ecsv format was failing to serialize array mixins on older versions. I've tried to update the logic.

@Amityush-lgtm

Amityush-lgtm commented Apr 30, 2026 •

Copy link
Copy Markdown
Contributor Author

@samaloney The oldestdeps fix worked. The remaining failures are unrelated to coordinate serialization, let me know if any other changes are required.

@ayshih
ayshih enabled auto-merge (squash) May 8, 2026 19:00
@ayshih
ayshih merged commit 74cdad8 into sunpy:main May 8, 2026
49 of 57 checks passed
@Cadair Cadair added the Whats New? Needs a section added to the current Whats New? page. label May 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

coordinates Affects the coordinates submodule No Backport A PR that isn't to be backported to any release branch. (To be used as a flag to other maintainers) Whats New? Needs a section added to the current Whats New? page.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sunpy coordinate frame serialisation issues using astropy table to/from fits

5 participants