From 5fff307ff5cc245bfdc2066c73dfae5a2360dd30 Mon Sep 17 00:00:00 2001 From: "Adam Ginsburg (keflavich)" Date: Sat, 30 Aug 2025 15:59:24 -0400 Subject: [PATCH 1/7] add ai-generated stc-s implementation --- docs/api.rst | 6 + docs/common_links.txt | 51 ++- docs/region_io.rst | 4 + regions/io/__init__.py | 4 + regions/io/stcs/README.md | 233 +++++++++++++ regions/io/stcs/__init__.py | 7 + regions/io/stcs/connect.py | 57 +++ regions/io/stcs/core.py | 241 +++++++++++++ regions/io/stcs/example_usage.py | 130 +++++++ regions/io/stcs/read.py | 269 +++++++++++++++ regions/io/stcs/tests/__init__.py | 1 + regions/io/stcs/tests/data/stcs_basic.stcs | 15 + regions/io/stcs/tests/data/stcs_complex.stcs | 12 + regions/io/stcs/tests/data/stcs_pixel.stcs | 5 + regions/io/stcs/tests/test_stcs.py | 344 +++++++++++++++++++ regions/io/stcs/write.py | 254 ++++++++++++++ 16 files changed, 1606 insertions(+), 27 deletions(-) create mode 100644 regions/io/stcs/README.md create mode 100644 regions/io/stcs/__init__.py create mode 100644 regions/io/stcs/connect.py create mode 100644 regions/io/stcs/core.py create mode 100644 regions/io/stcs/example_usage.py create mode 100644 regions/io/stcs/read.py create mode 100644 regions/io/stcs/tests/__init__.py create mode 100644 regions/io/stcs/tests/data/stcs_basic.stcs create mode 100644 regions/io/stcs/tests/data/stcs_complex.stcs create mode 100644 regions/io/stcs/tests/data/stcs_pixel.stcs create mode 100644 regions/io/stcs/tests/test_stcs.py create mode 100644 regions/io/stcs/write.py diff --git a/docs/api.rst b/docs/api.rst index 2286d370d..f5e082a54 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,3 +3,9 @@ Reference/API .. automodapi:: regions :no-inheritance-diagram: + +.. automodapi:: regions.io.ds9 + :no-inheritance-diagram: + +.. automodapi:: regions.io.stcs + :no-inheritance-diagram: diff --git a/docs/common_links.txt b/docs/common_links.txt index a45f2eb99..e3ac0a7b5 100644 --- a/docs/common_links.txt +++ b/docs/common_links.txt @@ -1,27 +1,24 @@ -.. These are ReST substitutions and links that can be used throughout the docs -.. (and docstrings) because they are added to ``docs/conf.py::rst_epilog``. - -.. ------------------------------------------------------------------ -.. RST SUBSTITUTIONS - -.. NumPy -.. |ndarray| replace:: :class:`numpy.ndarray` - -.. Astropy -.. |Angle| replace:: `astropy.coordinates.Angle` -.. |Latitude| replace:: `astropy.coordinates.Latitude` -.. |Longitude| replace:: :class:`astropy.coordinates.Longitude` -.. |SkyCoord| replace:: :class:`astropy.coordinates.SkyCoord` -.. |Table| replace:: :class:`astropy.table.Table` -.. |QTable| replace:: :class:`astropy.table.QTable` -.. |Quantity| replace:: :class:`astropy.units.Quantity` -.. |Unit| replace:: :class:`astropy.units.UnitBase` - -.. Regions -.. |PixCoord| replace:: `~regions.PixCoord` -.. |RegionMeta| replace:: `~regions.RegionMeta` -.. |RegionVisual| replace:: `~regions.RegionVisual` - -.. Matplotlib -.. _Matplotlib: https://matplotlib.org/ -.. |Patch| replace:: `~matplotlib.patches.Patch` +.. _Astropy: https://www.astropy.org/ +.. _astropy.io.fits: https://docs.astropy.org/en/stable/io/fits/ +.. _astropy.table.Table: https://docs.astropy.org/en/stable/table/ +.. _astropy.wcs: https://docs.astropy.org/en/stable/wcs/ +.. _Astropy units: https://docs.astropy.org/en/stable/units/ +.. _Astropy WCS: https://docs.astropy.org/en/stable/wcs/ +.. _CASA: https://casa.nrao.edu/ +.. _configparser: https://docs.python.org/3/library/configparser.html +.. _coordinated package: https://www.astropy.org/affiliated/ +.. _DS9: http://ds9.si.edu/site/Home.html +.. _File Formats: http://ds9.si.edu/doc/ref/region.html +.. _FITS: https://fits.gsfc.nasa.gov/ +.. _FITS Region Binary Table: https://fits.gsfc.nasa.gov/registry/region.html +.. _GitHub repository: https://github.com/astropy/regions +.. _CASA Region Text Format: https://casadocs.readthedocs.io/en/stable/notebooks/image_analysis.html#Region-File-Format +.. _matplotlib: https://matplotlib.org/ +.. _pip: https://pip.pypa.io/en/stable/ +.. _PyPI: https://pypi.org/project/regions/ +.. _SAOImageDS9: http://ds9.si.edu/site/Home.html +.. _shapely: https://pypi.org/project/Shapely/ +.. _conda: https://conda.io/en/latest/ +.. _IVOA STC-S Note: https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html +.. _STC-S: https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html +.. _IVOA: https://www.ivoa.net/ \ No newline at end of file diff --git a/docs/region_io.rst b/docs/region_io.rst index 3aced3cbf..28ae22e5d 100644 --- a/docs/region_io.rst +++ b/docs/region_io.rst @@ -28,6 +28,7 @@ filename suffix for a particular format. crtf .crtf `CASA Region Text Format `_ ds9 .reg, .ds9 `DS9 Region Format `_ fits .fits `FITS Region Binary Table `_ + stcs .stcs, .stc `STC-S Region Format `_ ============ ============== ======================== Use the :meth:`~regions.Regions.get_formats` method to get the @@ -40,6 +41,7 @@ registered I/O formats as a :class:`~astropy.table.Table`:: crtf Yes Yes Yes Yes Yes ds9 Yes Yes Yes Yes Yes fits Yes Yes Yes Yes Yes + stcs Yes Yes Yes Yes Yes Read @@ -171,6 +173,7 @@ formats and methods for the :class:`~regions.Region` subclasses:: crtf No Yes No Yes Yes ds9 No Yes No Yes Yes fits No Yes No Yes Yes + stcs No Yes No Yes Yes Region File Format Limitations @@ -181,3 +184,4 @@ Region File Format Limitations ds9_io fits_io + stcs_io diff --git a/regions/io/__init__.py b/regions/io/__init__.py index c58b0dbd1..bead0d05b 100644 --- a/regions/io/__init__.py +++ b/regions/io/__init__.py @@ -15,3 +15,7 @@ from .fits.core import * # noqa: F401, F403 from .fits.read import * # noqa: F401, F403 from .fits.write import * # noqa: F401, F403 +from .stcs.connect import * # noqa: F401, F403 +from .stcs.core import * # noqa: F401, F403 +from .stcs.read import * # noqa: F401, F403 +from .stcs.write import * # noqa: F401, F403 diff --git a/regions/io/stcs/README.md b/regions/io/stcs/README.md new file mode 100644 index 000000000..ce3a859da --- /dev/null +++ b/regions/io/stcs/README.md @@ -0,0 +1,233 @@ +# STC-S I/O Module for astropy-regions + +This module provides support for reading and writing regions in the **STC-S (Space-Time Coordinate String)** format, which is an IVOA (International Virtual Observatory Alliance) standard for describing spatial and temporal regions and coordinates. + +## Overview + +The STC-S format provides a string representation for astronomical regions that is: +- Standardized by the IVOA +- Human-readable and compact +- Suitable for describing both sky and pixel coordinates +- Compatible with various coordinate systems and reference frames + +## Features + +- **Reading**: Parse STC-S strings and files into `regions.Region` objects +- **Writing**: Serialize `regions.Region` objects to STC-S format +- **Round-trip**: Full round-trip conversion preserving region properties +- **Multiple Shapes**: Support for circles, ellipses, boxes/rectangles, polygons, and points +- **Coordinate Systems**: Support for ICRS, FK5, FK4, Galactic, Ecliptic, and Image coordinates +- **Reference Positions**: Support for various reference positions (Barycenter, Geocenter, Topocenter, etc.) + +## Supported Shapes + +| STC-S Shape | astropy-regions Class | Description | +|-------------|----------------------|-------------| +| `Circle` | `CircleSkyRegion` / `CirclePixelRegion` | Circular region | +| `Ellipse` | `EllipseSkyRegion` / `EllipsePixelRegion` | Elliptical region | +| `Box` | `RectangleSkyRegion` / `RectanglePixelRegion` | Rectangular region | +| `Polygon` | `PolygonSkyRegion` / `PolygonPixelRegion` | Polygonal region | +| `Position` | `PointSkyRegion` / `PointPixelRegion` | Point region | + +## Usage Examples + +### Reading STC-S Files + +```python +from regions import Regions + +# Read from file +regions = Regions.read('regions.stcs', format='stcs') + +# Parse STC-S string directly +stcs_string = """ +# Example STC-S regions +Circle ICRS BARYCENTER 180.0 10.0 0.5 +Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 +Position FK5 GEOCENTER 85.0 -15.0 +""" +regions = Regions.parse(stcs_string, format='stcs') +``` + +### Writing STC-S Files + +```python +import astropy.units as u +from astropy.coordinates import SkyCoord +from regions import Regions +from regions.shapes import CircleSkyRegion, PointSkyRegion + +# Create regions +center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') +circle = CircleSkyRegion(center=center, radius=0.5 * u.degree) + +center2 = SkyCoord(85.0, -15.0, unit='degree', frame='fk5') +point = PointSkyRegion(center=center2) + +regions = Regions([circle, point]) + +# Write to file +regions.write('output.stcs', format='stcs', overwrite=True) + +# Serialize to string +stcs_string = regions.serialize(format='stcs') +print(stcs_string) +``` + +### Working with Pixel Coordinates + +```python +from regions.core import PixCoord +from regions.shapes import CirclePixelRegion + +# Create pixel region +center = PixCoord(100.5, 200.3) +pixel_circle = CirclePixelRegion(center=center, radius=15.0) + +# Serialize +stcs_string = pixel_circle.serialize(format='stcs') +# Output: "Circle IMAGE UNKNOWN 100.5 200.3 15" +``` + +## STC-S Format Specification + +### Basic Syntax + +``` + [] +``` + +### Examples + +```stcs +# Circle: center_lon center_lat radius +Circle ICRS BARYCENTER 180.0 10.0 0.5 + +# Ellipse: center_lon center_lat semi_major semi_minor angle +Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + +# Box: center_lon center_lat width height angle +Box ICRS BARYCENTER 120.0 30.0 2.0 1.0 0.0 + +# Polygon: lon1 lat1 lon2 lat2 lon3 lat3 ... +Polygon ICRS BARYCENTER 45.0 45.0 50.0 45.0 50.0 50.0 45.0 50.0 + +# Position: lon lat +Position FK5 GEOCENTER 85.0 -15.0 + +# Pixel coordinates +Circle IMAGE UNKNOWN 100.5 200.3 15.0 +``` + +### Coordinate Frames + +- **ICRS**: International Celestial Reference System +- **FK5**: Fifth Fundamental Catalogue (J2000.0) +- **FK4**: Fourth Fundamental Catalogue (B1950.0) +- **GALACTIC**: Galactic coordinate system +- **ECLIPTIC**: Ecliptic coordinate system +- **IMAGE**: Pixel/image coordinates + +### Reference Positions + +- **BARYCENTER**: Solar system barycenter +- **GEOCENTER**: Earth center +- **TOPOCENTER**: Earth surface/topocentric +- **HELIOCENTER**: Sun center +- **LSR**: Local Standard of Rest +- **UNKNOWN**: Unspecified reference + +## File Format + +STC-S files typically have extensions: +- `.stcs` +- `.stc` +- `.stcs.txt` +- `.stc.txt` + +Files can contain: +- Comments starting with `#` +- Multiple regions, one per line +- Blank lines (ignored) + +Example file: +```stcs +# STC-S region file +# Generated by astropy-regions + +# Central source +Circle ICRS BARYCENTER 180.0 10.0 0.5 + +# Extended emission +Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + +# Point sources +Position FK5 GEOCENTER 85.0 -15.0 +Position FK5 GEOCENTER 90.0 -10.0 +``` + +## Implementation Details + +The STC-S module consists of: + +- **`core.py`**: Core parsing functions, mappings, and utilities +- **`connect.py`**: File format identification and registry +- **`read.py`**: STC-S reading and parsing functionality +- **`write.py`**: STC-S writing and serialization functionality +- **`tests/`**: Comprehensive test suite + +### Key Functions + +- `validate_stcs_string()`: Validate STC-S format +- `parse_coordinate_frame()`: Extract coordinate frame and reference position +- `parse_numbers()`: Parse numeric parameters +- `_parse_stcs()`: Main parsing function +- `_serialize_stcs()`: Main serialization function + +## References + +- [IVOA STC-S Standard](https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html) +- [CDS STC-S Rust Implementation](https://github.com/cds-astro/cds-stc-rust/) +- [astropy-regions Issue #21](https://github.com/astropy/regions/issues/21) + +## Testing + +Run the test suite: +```bash +# Run all STC-S tests +pytest regions/io/stcs/tests/ + +# Run specific test file +pytest regions/io/stcs/tests/test_stcs.py + +# Run with verbose output +pytest regions/io/stcs/tests/ -v +``` + +Test data files are located in `regions/io/stcs/tests/data/`. + +## Contributing + +When contributing to the STC-S module: + +1. Follow the existing code style and patterns from the DS9 module +2. Add comprehensive tests for new functionality +3. Update documentation and examples +4. Ensure round-trip conversions work correctly +5. Test with various coordinate systems and shapes + +## Known Limitations + +- Complex STC-S features like time coordinates are not yet supported +- Union, intersection, and other compound operations are not implemented +- Some advanced STC-S syntax elements are not parsed +- Error handling could be more detailed for malformed input + +## Future Enhancements + +- Support for temporal coordinates and regions +- Compound region operations (Union, Intersection, etc.) +- More comprehensive error messages +- Support for additional coordinate systems +- Integration with STC XML format +- Performance optimizations for large files diff --git a/regions/io/stcs/__init__.py b/regions/io/stcs/__init__.py new file mode 100644 index 000000000..12a8115ac --- /dev/null +++ b/regions/io/stcs/__init__.py @@ -0,0 +1,7 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +This subpackage provides tools for reading and writing STC-S region files. + +The Space-Time Coordinate (STC) string representation (STC-S) is an IVOA +standard for describing spatial and temporal regions and coordinates. +""" diff --git a/regions/io/stcs/connect.py b/regions/io/stcs/connect.py new file mode 100644 index 000000000..3c79c558d --- /dev/null +++ b/regions/io/stcs/connect.py @@ -0,0 +1,57 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +from astropy.utils.data import get_readable_fileobj + +from regions.core import Region, Regions +from regions.core.registry import RegionsRegistry + +__all__ = [] + + +@RegionsRegistry.register(Region, 'identify', 'stcs') +@RegionsRegistry.register(Regions, 'identify', 'stcs') +def is_stcs(methodname, filepath): + """ + Identify an STC-S region file. + + Parameters + ---------- + methodname : {'read', 'write'} + The method name called that needs auto-identification. + + filepath : str + The path to the file. + + Returns + ------- + result : bool + Returns `True` if the given file is an STC-S region file. + """ + all_exten = ('.stcs', '.stc', '.stcs.txt', '.stc.txt') + exten = {'read': all_exten, 'write': all_exten[0:2]} + + if methodname == 'write': + return filepath.lower().endswith(exten[methodname]) + + elif methodname == 'read': + if (isinstance(filepath, str) + and filepath.lower().endswith(exten[methodname])): + return True + else: + # Check file content for STC-S keywords + try: + with get_readable_fileobj(filepath, encoding='utf-8') as fileobj: + # Read first few lines to check for STC-S content + content = fileobj.read(512) # Read first 512 characters + if content: + # Look for common STC-S keywords + stcs_keywords = ['Circle', 'Ellipse', 'Box', 'Polygon', 'Position', + 'ICRS', 'FK5', 'FK4', 'GALACTIC', 'ECLIPTIC'] + content_upper = content.upper() + return any(keyword.upper() in content_upper for keyword in stcs_keywords) + return False + except (UnicodeDecodeError, OSError): + return False + + else: + return False diff --git a/regions/io/stcs/core.py b/regions/io/stcs/core.py new file mode 100644 index 000000000..fa856c8cd --- /dev/null +++ b/regions/io/stcs/core.py @@ -0,0 +1,241 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import itertools +import re + +from regions.shapes import (CirclePixelRegion, CircleSkyRegion, + EllipsePixelRegion, EllipseSkyRegion, + PolygonPixelRegion, PolygonSkyRegion, + PointPixelRegion, PointSkyRegion, + RectanglePixelRegion, RectangleSkyRegion) + +__all__ = [] + + +# STC-S coordinate frame mappings to astropy coordinate frames +stcs_frame_map = { + 'ICRS': 'icrs', + 'FK5': 'fk5', + 'FK4': 'fk4', + 'GALACTIC': 'galactic', + 'ECLIPTIC': 'barycentricmeanecliptic', + 'IMAGE': 'image', + 'GEOCENTER': 'icrs', # Default for geocentric + 'BARYCENTER': 'icrs', # Default for barycentric + 'TOPOCENTER': 'icrs', # Default for topocentric +} + +# Reference position mappings +stcs_refpos_map = { + 'GEOCENTER': 'geocentric', + 'BARYCENTER': 'barycentric', + 'TOPOCENTER': 'topocentric', + 'HELIOCENTER': 'heliocentric', + 'LSR': 'lsr', + 'LSRK': 'lsrk', + 'LSRD': 'lsrd', + 'GALACTIC_CENTER': 'galactic_center', + 'LOCAL_GROUP_CENTER': 'local_group_center', + 'UNKNOWN': 'unknown', + 'RELOCATABLE': 'relocatable', +} + +# STC-S shape mappings to region classes +stcs_shape_to_region = { + 'pixel': { + 'Circle': CirclePixelRegion, + 'Ellipse': EllipsePixelRegion, + 'Box': RectanglePixelRegion, + 'Polygon': PolygonPixelRegion, + 'Position': PointPixelRegion, + }, + 'sky': { + 'Circle': CircleSkyRegion, + 'Ellipse': EllipseSkyRegion, + 'Box': RectangleSkyRegion, + 'Polygon': PolygonSkyRegion, + 'Position': PointSkyRegion, + } +} + +# Region class to STC-S shape mappings +region_to_stcs_shape = { + 'CirclePixelRegion': 'Circle', + 'CircleSkyRegion': 'Circle', + 'EllipsePixelRegion': 'Ellipse', + 'EllipseSkyRegion': 'Ellipse', + 'RectanglePixelRegion': 'Box', + 'RectangleSkyRegion': 'Box', + 'PolygonPixelRegion': 'Polygon', + 'PolygonSkyRegion': 'Polygon', + 'PointPixelRegion': 'Position', + 'PointSkyRegion': 'Position', +} + +# STC-S unit mappings +stcs_unit_map = { + 'deg': 'degree', + 'degree': 'degree', + 'degrees': 'degree', + 'rad': 'radian', + 'radian': 'radian', + 'radians': 'radian', + 'arcmin': 'arcmin', + 'arcsec': 'arcsec', + 'mas': 'mas', + 'pixel': 'pixel', + 'pix': 'pixel', +} + +# Default units for different coordinate systems +default_units = { + 'ICRS': 'degree', + 'FK5': 'degree', + 'FK4': 'degree', + 'GALACTIC': 'degree', + 'ECLIPTIC': 'degree', + 'IMAGE': 'pixel', +} + +# Regular expressions for parsing STC-S strings +STCS_PATTERNS = { + # Basic shape patterns + 'circle': re.compile(r'Circle\s+([\w\s]+?)\s+([\d\.\s\-\+e]+)', re.IGNORECASE), + 'ellipse': re.compile(r'Ellipse\s+([\w\s]+?)\s+([\d\.\s\-\+e]+)', re.IGNORECASE), + 'box': re.compile(r'Box\s+([\w\s]+?)\s+([\d\.\s\-\+e]+)', re.IGNORECASE), + 'polygon': re.compile(r'Polygon\s+([\w\s]+?)\s+([\d\.\s\-\+e]+)', re.IGNORECASE), + 'position': re.compile(r'Position\s+([\w\s]+?)\s+([\d\.\s\-\+e]+)', re.IGNORECASE), + + # Coordinate system patterns + 'frame': re.compile(r'\b(ICRS|FK5|FK4|GALACTIC|ECLIPTIC|IMAGE)\b', re.IGNORECASE), + 'refpos': re.compile(r'\b(GEOCENTER|BARYCENTER|TOPOCENTER|HELIOCENTER|LSR|LSRK|LSRD)\b', re.IGNORECASE), + + # Unit patterns + 'unit': re.compile(r'\bunit\s+(deg|degree|degrees|rad|radian|radians|arcmin|arcsec|mas|pixel|pix)\b', re.IGNORECASE), + + # Number patterns + 'number': re.compile(r'[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?'), +} + +# Template for STC-S serialization formats +stcs_templates = { + 'Circle': 'Circle {frame} {refpos} {center_lon} {center_lat} {radius}', + 'Ellipse': 'Ellipse {frame} {refpos} {center_lon} {center_lat} {semi_major} {semi_minor} {angle}', + 'Box': 'Box {frame} {refpos} {center_lon} {center_lat} {width} {height} {angle}', + 'Polygon': 'Polygon {frame} {refpos} {vertices}', + 'Position': 'Position {frame} {refpos} {lon} {lat}', +} + + +class STCSParserError(Exception): + """ + A custom exception for STC-S parsing errors. + """ + pass + + +def parse_coordinate_frame(stcs_string): + """ + Parse coordinate frame and reference position from STC-S string. + + Parameters + ---------- + stcs_string : str + The STC-S string to parse. + + Returns + ------- + frame : str + The coordinate frame (default: 'ICRS'). + refpos : str + The reference position (default: 'UNKNOWN'). + """ + frame_match = STCS_PATTERNS['frame'].search(stcs_string) + frame = frame_match.group(1).upper() if frame_match else 'ICRS' + + refpos_match = STCS_PATTERNS['refpos'].search(stcs_string) + refpos = refpos_match.group(1).upper() if refpos_match else 'UNKNOWN' + + return frame, refpos + + +def parse_unit(stcs_string, frame='ICRS'): + """ + Parse unit from STC-S string. + + Parameters + ---------- + stcs_string : str + The STC-S string to parse. + frame : str + The coordinate frame (for default units). + + Returns + ------- + unit : str + The unit string (default based on frame). + """ + unit_match = STCS_PATTERNS['unit'].search(stcs_string) + if unit_match: + return stcs_unit_map.get(unit_match.group(1).lower(), 'degree') + else: + return default_units.get(frame, 'degree') + + +def parse_numbers(number_string): + """ + Parse a string of space-separated numbers. + + Parameters + ---------- + number_string : str + String containing space-separated numbers. + + Returns + ------- + numbers : list of float + List of parsed numbers. + """ + numbers = STCS_PATTERNS['number'].findall(number_string) + return [float(num) for num in numbers] + + +def format_coordinate(value, precision=8): + """ + Format a coordinate value for STC-S output. + + Parameters + ---------- + value : float + The coordinate value to format. + precision : int + Number of decimal places. + + Returns + ------- + formatted : str + Formatted coordinate string. + """ + return f"{value:.{precision}f}".rstrip('0').rstrip('.') + + +def validate_stcs_string(stcs_string): + """ + Basic validation of an STC-S string. + + Parameters + ---------- + stcs_string : str + The STC-S string to validate. + + Returns + ------- + is_valid : bool + True if the string appears to be valid STC-S. + """ + if not isinstance(stcs_string, str): + return False + + # Check for at least one shape keyword + shape_keywords = ['Circle', 'Ellipse', 'Box', 'Polygon', 'Position'] + return any(keyword.lower() in stcs_string.lower() for keyword in shape_keywords) diff --git a/regions/io/stcs/example_usage.py b/regions/io/stcs/example_usage.py new file mode 100644 index 000000000..93e87ef45 --- /dev/null +++ b/regions/io/stcs/example_usage.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Example usage of the STC-S I/O module for astropy-regions. + +This script demonstrates how to read and write STC-S region files +using the astropy-regions package. +""" + +import astropy.units as u +from astropy.coordinates import SkyCoord + +from regions.core import PixCoord, Regions +from regions.shapes import (CirclePixelRegion, CircleSkyRegion, + EllipseSkyRegion, PolygonSkyRegion, + PointSkyRegion) + + +def example_read_stcs(): + """Example of reading STC-S files.""" + print("=== Reading STC-S Files ===") + + # Read from file (this would work if the file exists) + # regions = Regions.read('example.stcs', format='stcs') + + # Parse STC-S string directly + stcs_string = """ + # Example STC-S regions + Circle ICRS BARYCENTER 180.0 10.0 0.5 + Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + Position FK5 GEOCENTER 85.0 -15.0 + """ + + regions = Regions.parse(stcs_string, format='stcs') + + print(f"Parsed {len(regions)} regions:") + for i, region in enumerate(regions): + print(f" {i+1}. {region}") + + return regions + + +def example_write_stcs(): + """Example of writing STC-S files.""" + print("\n=== Writing STC-S Files ===") + + # Create some example regions + regions = [] + + # Sky circle + center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') + circle = CircleSkyRegion(center=center, radius=0.5 * u.degree) + regions.append(circle) + + # Sky ellipse + center = SkyCoord(150.0, -20.0, unit='degree', frame='icrs') + ellipse = EllipseSkyRegion(center=center, width=2.0 * u.degree, + height=1.0 * u.degree, angle=45.0 * u.degree) + regions.append(ellipse) + + # Sky polygon + vertices = SkyCoord([45.0, 50.0, 50.0, 45.0], + [45.0, 45.0, 50.0, 50.0], + unit='degree', frame='icrs') + polygon = PolygonSkyRegion(vertices=vertices) + regions.append(polygon) + + # Point region + center = SkyCoord(85.0, -15.0, unit='degree', frame='fk5') + point = PointSkyRegion(center=center) + regions.append(point) + + # Pixel circle + center = PixCoord(100.5, 200.3) + pixel_circle = CirclePixelRegion(center=center, radius=15.0) + regions.append(pixel_circle) + + regions_obj = Regions(regions) + + # Serialize to STC-S string + stcs_string = regions_obj.serialize(format='stcs') + print("Serialized STC-S:") + print(stcs_string) + + # Write to file (uncomment to actually write) + # regions_obj.write('output.stcs', format='stcs', overwrite=True) + + return stcs_string + + +def example_round_trip(): + """Example of round-trip conversion.""" + print("\n=== Round-trip Conversion ===") + + original_stcs = """Circle ICRS BARYCENTER 180.0 10.0 0.5 +Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 +Position FK5 GEOCENTER 85.0 -15.0""" + + print("Original STC-S:") + print(original_stcs) + + # Parse + regions = Regions.parse(original_stcs, format='stcs') + print(f"\nParsed {len(regions)} regions") + + # Serialize back + serialized = regions.serialize(format='stcs') + print("\nSerialized back to STC-S:") + print(serialized) + + # Parse again to verify + regions2 = Regions.parse(serialized, format='stcs') + print(f"\nRe-parsed {len(regions2)} regions - round-trip successful!") + + +if __name__ == '__main__': + print("STC-S I/O Module Examples") + print("=" * 40) + + try: + example_read_stcs() + example_write_stcs() + example_round_trip() + + print("\n" + "=" * 40) + print("All examples completed successfully!") + + except Exception as e: + print(f"\nError running examples: {e}") + import traceback + traceback.print_exc() diff --git a/regions/io/stcs/read.py b/regions/io/stcs/read.py new file mode 100644 index 000000000..6c23bcf45 --- /dev/null +++ b/regions/io/stcs/read.py @@ -0,0 +1,269 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import re +import warnings + +import astropy.units as u +from astropy.coordinates import SkyCoord +from astropy.utils.data import get_readable_fileobj +from astropy.utils.exceptions import AstropyUserWarning + +from regions.core import PixCoord, RegionMeta, Regions, RegionVisual +from regions.core.registry import RegionsRegistry +from regions.io.stcs.core import (STCS_PATTERNS, STCSParserError, + parse_coordinate_frame, parse_numbers, + parse_unit, stcs_frame_map, + stcs_shape_to_region, validate_stcs_string) + +__all__ = [] + + +@RegionsRegistry.register(Regions, 'read', 'stcs') +def _read_stcs(filename, cache=False): + """ + Read an STC-S region file as a list of `~regions.Region` objects. + + Parameters + ---------- + filename : str + The filename of the file to access. + + cache : bool or 'update', optional + Whether to cache the contents of remote URLs. If 'update', check + the remote URL for a new version but store the result in the + cache. + + Returns + ------- + regions : `regions.Regions` + A `Regions` object containing a list of `~regions.Region` + objects. + """ + with get_readable_fileobj(filename, cache=cache) as fh: + region_string = fh.read() + return _parse_stcs(region_string) + + +@RegionsRegistry.register(Regions, 'parse', 'stcs') +def _parse_stcs(region_str): + """ + Parse an STC-S region string to `~regions.Region` objects. + + Parameters + ---------- + region_str : str + STC-S region string. + + Returns + ------- + regions : `regions.Regions` + A `Regions` object containing a list of `~regions.Region` + objects. + """ + if not validate_stcs_string(region_str): + raise STCSParserError("Invalid STC-S string format") + + regions = [] + + # Split the string into lines and process each line + lines = [line.strip() for line in region_str.split('\n') if line.strip()] + + for line in lines: + # Skip comments + if line.startswith('#'): + continue + + try: + region = _parse_stcs_line(line) + if region is not None: + regions.append(region) + except Exception as e: + warnings.warn(f"Failed to parse STC-S line '{line}': {e}", + AstropyUserWarning) + continue + + return Regions(regions) + + +def _parse_stcs_line(line): + """ + Parse a single STC-S line into a Region object. + + Parameters + ---------- + line : str + A single STC-S line. + + Returns + ------- + region : `~regions.Region` or None + Parsed region object or None if parsing failed. + """ + line = line.strip() + if not line or line.startswith('#'): + return None + + # Parse coordinate frame and reference position + frame, refpos = parse_coordinate_frame(line) + unit = parse_unit(line, frame) + + # Try to match different shape patterns + for shape_name, pattern in STCS_PATTERNS.items(): + if shape_name in ['frame', 'refpos', 'unit', 'number']: + continue + + match = pattern.search(line) + if match: + try: + return _parse_shape(shape_name, match, frame, refpos, unit) + except Exception as e: + raise STCSParserError(f"Failed to parse {shape_name}: {e}") + + raise STCSParserError(f"No recognized shape pattern found in: {line}") + + +def _parse_shape(shape_name, match, frame, refpos, unit): + """ + Parse a specific shape from regex match. + + Parameters + ---------- + shape_name : str + Name of the shape (circle, ellipse, etc.). + match : re.Match + Regex match object. + frame : str + Coordinate frame. + refpos : str + Reference position. + unit : str + Coordinate unit. + + Returns + ------- + region : `~regions.Region` + Parsed region object. + """ + # Extract coordinate and parameter data from the match + coord_frame_str = match.group(1).strip() + numbers_str = match.group(2).strip() + + # Parse numbers from the string + numbers = parse_numbers(numbers_str) + + # Create metadata + meta = RegionMeta() + meta['frame'] = frame + meta['refpos'] = refpos + meta['unit'] = unit + + # Determine if this is pixel or sky coordinates + is_pixel = (frame.upper() == 'IMAGE') + coord_type = 'pixel' if is_pixel else 'sky' + + # Get the appropriate region class + if shape_name not in stcs_shape_to_region[coord_type]: + raise STCSParserError(f"Unsupported shape: {shape_name}") + + region_class = stcs_shape_to_region[coord_type][shape_name] + + # Parse based on shape type + if shape_name == 'circle': + return _parse_circle(region_class, numbers, frame, unit, meta, is_pixel) + elif shape_name == 'ellipse': + return _parse_ellipse(region_class, numbers, frame, unit, meta, is_pixel) + elif shape_name == 'box': + return _parse_box(region_class, numbers, frame, unit, meta, is_pixel) + elif shape_name == 'polygon': + return _parse_polygon(region_class, numbers, frame, unit, meta, is_pixel) + elif shape_name == 'position': + return _parse_position(region_class, numbers, frame, unit, meta, is_pixel) + else: + raise STCSParserError(f"Parser not implemented for shape: {shape_name}") + + +def _parse_circle(region_class, numbers, frame, unit, meta, is_pixel): + """Parse Circle shape.""" + if len(numbers) < 3: + raise STCSParserError("Circle requires at least 3 parameters: lon, lat, radius") + + lon, lat, radius = numbers[0], numbers[1], numbers[2] + + if is_pixel: + center = PixCoord(lon, lat) + return region_class(center=center, radius=radius, meta=meta) + else: + astropy_frame = stcs_frame_map.get(frame, 'icrs') + center = SkyCoord(lon * u.Unit(unit), lat * u.Unit(unit), frame=astropy_frame) + return region_class(center=center, radius=radius * u.Unit(unit), meta=meta) + + +def _parse_ellipse(region_class, numbers, frame, unit, meta, is_pixel): + """Parse Ellipse shape.""" + if len(numbers) < 5: + raise STCSParserError("Ellipse requires at least 5 parameters: lon, lat, semi_major, semi_minor, angle") + + lon, lat, semi_major, semi_minor, angle = numbers[0], numbers[1], numbers[2], numbers[3], numbers[4] + + if is_pixel: + center = PixCoord(lon, lat) + return region_class(center=center, width=2*semi_major, height=2*semi_minor, + angle=angle * u.degree, meta=meta) + else: + astropy_frame = stcs_frame_map.get(frame, 'icrs') + center = SkyCoord(lon * u.Unit(unit), lat * u.Unit(unit), frame=astropy_frame) + return region_class(center=center, width=2*semi_major * u.Unit(unit), + height=2*semi_minor * u.Unit(unit), + angle=angle * u.degree, meta=meta) + + +def _parse_box(region_class, numbers, frame, unit, meta, is_pixel): + """Parse Box shape.""" + if len(numbers) < 5: + raise STCSParserError("Box requires at least 5 parameters: lon, lat, width, height, angle") + + lon, lat, width, height, angle = numbers[0], numbers[1], numbers[2], numbers[3], numbers[4] + + if is_pixel: + center = PixCoord(lon, lat) + return region_class(center=center, width=width, height=height, + angle=angle * u.degree, meta=meta) + else: + astropy_frame = stcs_frame_map.get(frame, 'icrs') + center = SkyCoord(lon * u.Unit(unit), lat * u.Unit(unit), frame=astropy_frame) + return region_class(center=center, width=width * u.Unit(unit), + height=height * u.Unit(unit), + angle=angle * u.degree, meta=meta) + + +def _parse_polygon(region_class, numbers, frame, unit, meta, is_pixel): + """Parse Polygon shape.""" + if len(numbers) < 6 or len(numbers) % 2 != 0: + raise STCSParserError("Polygon requires an even number of coordinates (at least 6)") + + if is_pixel: + vertices = PixCoord([numbers[i] for i in range(0, len(numbers), 2)], + [numbers[i] for i in range(1, len(numbers), 2)]) + return region_class(vertices=vertices, meta=meta) + else: + astropy_frame = stcs_frame_map.get(frame, 'icrs') + lon_coords = [numbers[i] * u.Unit(unit) for i in range(0, len(numbers), 2)] + lat_coords = [numbers[i] * u.Unit(unit) for i in range(1, len(numbers), 2)] + vertices = SkyCoord(lon_coords, lat_coords, frame=astropy_frame) + return region_class(vertices=vertices, meta=meta) + + +def _parse_position(region_class, numbers, frame, unit, meta, is_pixel): + """Parse Position shape.""" + if len(numbers) < 2: + raise STCSParserError("Position requires at least 2 parameters: lon, lat") + + lon, lat = numbers[0], numbers[1] + + if is_pixel: + center = PixCoord(lon, lat) + return region_class(center=center, meta=meta) + else: + astropy_frame = stcs_frame_map.get(frame, 'icrs') + center = SkyCoord(lon * u.Unit(unit), lat * u.Unit(unit), frame=astropy_frame) + return region_class(center=center, meta=meta) diff --git a/regions/io/stcs/tests/__init__.py b/regions/io/stcs/tests/__init__.py new file mode 100644 index 000000000..9dce85d06 --- /dev/null +++ b/regions/io/stcs/tests/__init__.py @@ -0,0 +1 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst diff --git a/regions/io/stcs/tests/data/stcs_basic.stcs b/regions/io/stcs/tests/data/stcs_basic.stcs new file mode 100644 index 000000000..5526a60dd --- /dev/null +++ b/regions/io/stcs/tests/data/stcs_basic.stcs @@ -0,0 +1,15 @@ +# STC-S test file with basic shapes +# Test basic circle +Circle ICRS BARYCENTER 180.0 10.0 0.5 + +# Test ellipse +Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + +# Test box/rectangle +Box ICRS BARYCENTER 120.0 30.0 2.0 1.0 0.0 + +# Test polygon +Polygon ICRS BARYCENTER 45.0 45.0 50.0 45.0 50.0 50.0 45.0 50.0 + +# Test position/point +Position FK5 GEOCENTER 85.0 -15.0 diff --git a/regions/io/stcs/tests/data/stcs_complex.stcs b/regions/io/stcs/tests/data/stcs_complex.stcs new file mode 100644 index 000000000..9341f41e8 --- /dev/null +++ b/regions/io/stcs/tests/data/stcs_complex.stcs @@ -0,0 +1,12 @@ +# STC-S test file with complex regions based on cds-stc-rust examples +# Example from the Rust implementation documentation + +Circle ICRS TOPOCENTER 147.6 69.9 0.4 + +# Time and position example (simplified to spatial only) +Position ICRS BARYCENTER 147.3 69.3 + +# Complex example with multiple shapes +Circle ICRS GEOCENTER 179.0 -11.5 0.5 +Ellipse GALACTIC BARYCENTER 90.0 45.0 2.0 1.0 60.0 +Box FK5 TOPOCENTER 30.0 60.0 0.5 0.3 15.0 diff --git a/regions/io/stcs/tests/data/stcs_pixel.stcs b/regions/io/stcs/tests/data/stcs_pixel.stcs new file mode 100644 index 000000000..03e30dfdd --- /dev/null +++ b/regions/io/stcs/tests/data/stcs_pixel.stcs @@ -0,0 +1,5 @@ +# STC-S test file with pixel coordinates +Circle IMAGE UNKNOWN 100.5 200.3 15.0 +Ellipse IMAGE UNKNOWN 300.0 400.0 20.0 10.0 30.0 +Box IMAGE UNKNOWN 500.0 600.0 50.0 25.0 45.0 +Position IMAGE UNKNOWN 750.0 850.0 diff --git a/regions/io/stcs/tests/test_stcs.py b/regions/io/stcs/tests/test_stcs.py new file mode 100644 index 000000000..792994924 --- /dev/null +++ b/regions/io/stcs/tests/test_stcs.py @@ -0,0 +1,344 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import pytest +import astropy.units as u +from astropy.coordinates import SkyCoord + +from regions.core import PixCoord, Regions +from regions.io.stcs.connect import is_stcs +from regions.io.stcs.core import (STCSParserError, parse_coordinate_frame, + parse_numbers, parse_unit, validate_stcs_string) +from regions.io.stcs.read import _parse_stcs +from regions.io.stcs.write import _serialize_stcs +from regions.shapes import (CirclePixelRegion, CircleSkyRegion, + EllipsePixelRegion, EllipseSkyRegion, + PolygonPixelRegion, PolygonSkyRegion, + PointPixelRegion, PointSkyRegion, + RectanglePixelRegion, RectangleSkyRegion) + + +class TestSTCSCore: + """Test core STC-S functionality.""" + + def test_validate_stcs_string(self): + """Test STC-S string validation.""" + # Valid strings + assert validate_stcs_string("Circle ICRS BARYCENTER 180.0 10.0 0.5") + assert validate_stcs_string("Position FK5 GEOCENTER 45.0 -30.0") + assert validate_stcs_string("polygon icrs barycenter 0 0 1 1 2 2") + + # Invalid strings + assert not validate_stcs_string("not an stcs string") + assert not validate_stcs_string("") + assert not validate_stcs_string(None) + + def test_parse_coordinate_frame(self): + """Test coordinate frame parsing.""" + frame, refpos = parse_coordinate_frame("Circle ICRS BARYCENTER 180.0 10.0 0.5") + assert frame == 'ICRS' + assert refpos == 'BARYCENTER' + + frame, refpos = parse_coordinate_frame("Position FK5 GEOCENTER 45.0 -30.0") + assert frame == 'FK5' + assert refpos == 'GEOCENTER' + + # Test defaults + frame, refpos = parse_coordinate_frame("Circle 180.0 10.0 0.5") + assert frame == 'ICRS' + assert refpos == 'UNKNOWN' + + def test_parse_unit(self): + """Test unit parsing.""" + unit = parse_unit("Circle ICRS unit deg 180.0 10.0 0.5") + assert unit == 'degree' + + unit = parse_unit("Circle ICRS unit arcsec 180.0 10.0 0.5") + assert unit == 'arcsec' + + # Test default + unit = parse_unit("Circle ICRS 180.0 10.0 0.5", frame='ICRS') + assert unit == 'degree' + + unit = parse_unit("Circle IMAGE 180.0 10.0 0.5", frame='IMAGE') + assert unit == 'pixel' + + def test_parse_numbers(self): + """Test number parsing.""" + numbers = parse_numbers("180.0 10.0 0.5") + assert numbers == [180.0, 10.0, 0.5] + + numbers = parse_numbers("1.5e-3 -45.0 90") + assert numbers == [1.5e-3, -45.0, 90.0] + + numbers = parse_numbers("") + assert numbers == [] + + +class TestSTCSConnect: + """Test STC-S file identification.""" + + def test_is_stcs_by_extension(self): + """Test file identification by extension.""" + assert is_stcs('write', 'test.stcs') + assert is_stcs('write', 'test.stc') + assert not is_stcs('write', 'test.ds9') + assert not is_stcs('write', 'test.txt') + + assert is_stcs('read', 'test.stcs') + assert is_stcs('read', 'test.stc') + assert is_stcs('read', 'test.stcs.txt') + + +class TestSTCSParsing: + """Test STC-S parsing functionality.""" + + def test_parse_circle_sky(self): + """Test parsing sky circle regions.""" + stcs_str = "Circle ICRS BARYCENTER 180.0 10.0 0.5" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, CircleSkyRegion) + assert region.center.ra.degree == 180.0 + assert region.center.dec.degree == 10.0 + assert region.radius.degree == 0.5 + + def test_parse_circle_pixel(self): + """Test parsing pixel circle regions.""" + stcs_str = "Circle IMAGE UNKNOWN 100.5 200.3 15.0" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, CirclePixelRegion) + assert region.center.x == 100.5 + assert region.center.y == 200.3 + assert region.radius == 15.0 + + def test_parse_ellipse_sky(self): + """Test parsing sky ellipse regions.""" + stcs_str = "Ellipse ICRS BARYCENTER 180.0 10.0 0.5 0.3 45.0" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, EllipseSkyRegion) + assert region.center.ra.degree == 180.0 + assert region.center.dec.degree == 10.0 + assert region.width.degree == 1.0 # 2 * semi_major + assert region.height.degree == 0.6 # 2 * semi_minor + assert region.angle.degree == 45.0 + + def test_parse_box_sky(self): + """Test parsing sky box regions.""" + stcs_str = "Box ICRS BARYCENTER 180.0 10.0 1.0 0.5 30.0" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, RectangleSkyRegion) + assert region.center.ra.degree == 180.0 + assert region.center.dec.degree == 10.0 + assert region.width.degree == 1.0 + assert region.height.degree == 0.5 + assert region.angle.degree == 30.0 + + def test_parse_polygon_sky(self): + """Test parsing sky polygon regions.""" + stcs_str = "Polygon ICRS BARYCENTER 179.0 9.0 181.0 9.0 181.0 11.0 179.0 11.0" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, PolygonSkyRegion) + assert len(region.vertices) == 4 + assert region.vertices[0].ra.degree == 179.0 + assert region.vertices[0].dec.degree == 9.0 + + def test_parse_position_sky(self): + """Test parsing sky position regions.""" + stcs_str = "Position ICRS BARYCENTER 180.0 10.0" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, PointSkyRegion) + assert region.center.ra.degree == 180.0 + assert region.center.dec.degree == 10.0 + + def test_parse_multiple_regions(self): + """Test parsing multiple regions.""" + stcs_str = """ + Circle ICRS BARYCENTER 180.0 10.0 0.5 + Position FK5 GEOCENTER 45.0 -30.0 + """ + regions = _parse_stcs(stcs_str) + + assert len(regions) == 2 + assert isinstance(regions[0], CircleSkyRegion) + assert isinstance(regions[1], PointSkyRegion) + + def test_parse_with_comments(self): + """Test parsing with comment lines.""" + stcs_str = """ + # This is a comment + Circle ICRS BARYCENTER 180.0 10.0 0.5 + # Another comment + Position FK5 GEOCENTER 45.0 -30.0 + """ + regions = _parse_stcs(stcs_str) + + assert len(regions) == 2 + + def test_parse_invalid_stcs(self): + """Test parsing invalid STC-S strings.""" + with pytest.raises(STCSParserError): + _parse_stcs("not a valid stcs string") + + with pytest.raises(STCSParserError): + _parse_stcs("") + + def test_parse_insufficient_parameters(self): + """Test parsing with insufficient parameters.""" + # Circle needs at least 3 parameters + with pytest.raises(STCSParserError): + _parse_stcs("Circle ICRS BARYCENTER 180.0") + + # Ellipse needs at least 5 parameters + with pytest.raises(STCSParserError): + _parse_stcs("Ellipse ICRS BARYCENTER 180.0 10.0") + + +class TestSTCSSerialization: + """Test STC-S serialization functionality.""" + + def test_serialize_circle_sky(self): + """Test serializing sky circle regions.""" + center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') + region = CircleSkyRegion(center=center, radius=0.5 * u.degree) + + stcs_str = _serialize_stcs(region) + expected = "Circle ICRS BARYCENTER 180 10 0.5" + assert stcs_str == expected + + def test_serialize_circle_pixel(self): + """Test serializing pixel circle regions.""" + center = PixCoord(100.5, 200.3) + region = CirclePixelRegion(center=center, radius=15.0) + + stcs_str = _serialize_stcs(region) + expected = "Circle IMAGE UNKNOWN 100.5 200.3 15" + assert stcs_str == expected + + def test_serialize_ellipse_sky(self): + """Test serializing sky ellipse regions.""" + center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') + region = EllipseSkyRegion(center=center, width=1.0 * u.degree, + height=0.6 * u.degree, angle=45.0 * u.degree) + + stcs_str = _serialize_stcs(region) + expected = "Ellipse ICRS BARYCENTER 180 10 0.5 0.3 45" + assert stcs_str == expected + + def test_serialize_box_sky(self): + """Test serializing sky box regions.""" + center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') + region = RectangleSkyRegion(center=center, width=1.0 * u.degree, + height=0.5 * u.degree, angle=30.0 * u.degree) + + stcs_str = _serialize_stcs(region) + expected = "Box ICRS BARYCENTER 180 10 1 0.5 30" + assert stcs_str == expected + + def test_serialize_polygon_sky(self): + """Test serializing sky polygon regions.""" + vertices = SkyCoord([179.0, 181.0, 181.0, 179.0], + [9.0, 9.0, 11.0, 11.0], + unit='degree', frame='icrs') + region = PolygonSkyRegion(vertices=vertices) + + stcs_str = _serialize_stcs(region) + expected = "Polygon ICRS BARYCENTER 179 9 181 9 181 11 179 11" + assert stcs_str == expected + + def test_serialize_position_sky(self): + """Test serializing sky position regions.""" + center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') + region = PointSkyRegion(center=center) + + stcs_str = _serialize_stcs(region) + expected = "Position ICRS BARYCENTER 180 10" + assert stcs_str == expected + + def test_serialize_multiple_regions(self): + """Test serializing multiple regions.""" + center1 = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') + region1 = CircleSkyRegion(center=center1, radius=0.5 * u.degree) + + center2 = SkyCoord(45.0, -30.0, unit='degree', frame='fk5') + region2 = PointSkyRegion(center=center2) + + regions = Regions([region1, region2]) + stcs_str = _serialize_stcs(regions) + + lines = stcs_str.strip().split('\n') + assert len(lines) == 2 + assert "Circle ICRS BARYCENTER 180 10 0.5" in lines[0] + assert "Position FK5 BARYCENTER 45 -30" in lines[1] + + +class TestSTCSRoundTrip: + """Test round-trip conversion between regions and STC-S.""" + + def test_circle_roundtrip(self): + """Test circle region round-trip conversion.""" + original_stcs = "Circle ICRS BARYCENTER 180.0 10.0 0.5" + regions = _parse_stcs(original_stcs) + serialized = _serialize_stcs(regions) + + # Parse again to verify consistency + regions2 = _parse_stcs(serialized) + + assert len(regions) == len(regions2) == 1 + r1, r2 = regions[0], regions2[0] + + assert isinstance(r1, CircleSkyRegion) + assert isinstance(r2, CircleSkyRegion) + assert abs(r1.center.ra.degree - r2.center.ra.degree) < 1e-10 + assert abs(r1.center.dec.degree - r2.center.dec.degree) < 1e-10 + assert abs(r1.radius.degree - r2.radius.degree) < 1e-10 + + def test_ellipse_roundtrip(self): + """Test ellipse region round-trip conversion.""" + original_stcs = "Ellipse ICRS BARYCENTER 180.0 10.0 0.5 0.3 45.0" + regions = _parse_stcs(original_stcs) + serialized = _serialize_stcs(regions) + regions2 = _parse_stcs(serialized) + + assert len(regions) == len(regions2) == 1 + r1, r2 = regions[0], regions2[0] + + assert isinstance(r1, EllipseSkyRegion) + assert isinstance(r2, EllipseSkyRegion) + assert abs(r1.center.ra.degree - r2.center.ra.degree) < 1e-10 + assert abs(r1.center.dec.degree - r2.center.dec.degree) < 1e-10 + assert abs(r1.width.degree - r2.width.degree) < 1e-10 + assert abs(r1.height.degree - r2.height.degree) < 1e-10 + assert abs(r1.angle.degree - r2.angle.degree) < 1e-10 + + def test_pixel_roundtrip(self): + """Test pixel region round-trip conversion.""" + original_stcs = "Circle IMAGE UNKNOWN 100.5 200.3 15.0" + regions = _parse_stcs(original_stcs) + serialized = _serialize_stcs(regions) + regions2 = _parse_stcs(serialized) + + assert len(regions) == len(regions2) == 1 + r1, r2 = regions[0], regions2[0] + + assert isinstance(r1, CirclePixelRegion) + assert isinstance(r2, CirclePixelRegion) + assert abs(r1.center.x - r2.center.x) < 1e-10 + assert abs(r1.center.y - r2.center.y) < 1e-10 + assert abs(r1.radius - r2.radius) < 1e-10 diff --git a/regions/io/stcs/write.py b/regions/io/stcs/write.py new file mode 100644 index 000000000..ef9e2d2c0 --- /dev/null +++ b/regions/io/stcs/write.py @@ -0,0 +1,254 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import warnings +from copy import deepcopy + +from astropy.coordinates import Angle, SkyCoord +from astropy.units import Quantity + +from regions.core import (PixelRegion, Region, Regions, SkyRegion) +from regions.core.registry import RegionsRegistry +from regions.io.stcs.core import (format_coordinate, region_to_stcs_shape, + stcs_templates) +from regions.shapes import RegularPolygonPixelRegion + +__all__ = [] + + +@RegionsRegistry.register(Region, 'serialize', 'stcs') +@RegionsRegistry.register(Regions, 'serialize', 'stcs') +def _serialize_stcs(regions, precision=8): + """ + Serialize regions to STC-S format. + + Parameters + ---------- + regions : `~regions.Region` or `~regions.Regions` + Region or regions to serialize. + precision : int, optional + Number of decimal places for coordinates (default: 8). + + Returns + ------- + stcs_string : str + STC-S formatted string. + """ + if not regions: + return '' + + if isinstance(regions, Region): + regions = [regions] + + region_strings = [] + for region in regions: + try: + region_str = _serialize_region_stcs(region, precision=precision) + if region_str: + region_strings.append(region_str) + except Exception as e: + warnings.warn(f'Cannot serialize region {region}: {e}', + UserWarning) + + return '\n'.join(region_strings) + + +@RegionsRegistry.register(Region, 'write', 'stcs') +@RegionsRegistry.register(Regions, 'write', 'stcs') +def _write_stcs(regions, filename, precision=8, overwrite=False): + """ + Write regions to an STC-S file. + + Parameters + ---------- + regions : `~regions.Region` or `~regions.Regions` + Region or regions to write. + filename : str + Output filename. + precision : int, optional + Number of decimal places for coordinates (default: 8). + overwrite : bool, optional + Whether to overwrite existing files (default: False). + """ + import os + + if os.path.exists(filename) and not overwrite: + raise OSError(f"File '{filename}' already exists. Use overwrite=True to overwrite.") + + stcs_string = _serialize_stcs(regions, precision=precision) + + with open(filename, 'w') as f: + f.write('# STC-S region file\n') + f.write('# Generated by astropy-regions\n') + f.write(stcs_string) + + +def _serialize_region_stcs(region, precision=8): + """ + Serialize a single region to STC-S format. + + Parameters + ---------- + region : `~regions.Region` + The region to serialize. + precision : int, optional + Number of decimal places for coordinates. + + Returns + ------- + stcs_string : str + STC-S formatted string for the region. + """ + region_class_name = region.__class__.__name__ + + if region_class_name not in region_to_stcs_shape: + raise ValueError(f"Unsupported region type: {region_class_name}") + + shape_name = region_to_stcs_shape[region_class_name] + + # Handle RegularPolygonPixelRegion by converting to polygon + if isinstance(region, RegularPolygonPixelRegion): + region = region.to_polygon() + shape_name = 'Polygon' + + # Determine coordinate frame and type + is_pixel = isinstance(region, PixelRegion) + is_sky = isinstance(region, SkyRegion) + + if is_pixel: + frame = 'IMAGE' + refpos = 'UNKNOWN' + unit = 'pixel' + elif is_sky: + # Get frame from region's coordinate system + if hasattr(region, 'center') and hasattr(region.center, 'frame'): + frame_name = region.center.frame.name.upper() + frame = {'ICRS': 'ICRS', 'FK5': 'FK5', 'FK4': 'FK4', + 'GALACTIC': 'GALACTIC', 'GEOCENTRICTRUEECLIPTIC': 'ECLIPTIC'}.get(frame_name, 'ICRS') + else: + frame = 'ICRS' + refpos = 'BARYCENTER' + + # Determine unit from region coordinates + if hasattr(region, 'center'): + unit = _get_coordinate_unit(region.center) + else: + unit = 'degree' + else: + raise ValueError(f"Unknown region coordinate type: {region}") + + # Serialize based on shape + if shape_name == 'Circle': + return _serialize_circle(region, frame, refpos, unit, precision) + elif shape_name == 'Ellipse': + return _serialize_ellipse(region, frame, refpos, unit, precision) + elif shape_name == 'Box': + return _serialize_box(region, frame, refpos, unit, precision) + elif shape_name == 'Polygon': + return _serialize_polygon(region, frame, refpos, unit, precision) + elif shape_name == 'Position': + return _serialize_position(region, frame, refpos, unit, precision) + else: + raise ValueError(f"Serialization not implemented for shape: {shape_name}") + + +def _serialize_circle(region, frame, refpos, unit, precision): + """Serialize Circle region.""" + if isinstance(region, PixelRegion): + center_lon = format_coordinate(region.center.x, precision) + center_lat = format_coordinate(region.center.y, precision) + radius = format_coordinate(region.radius, precision) + else: + center_lon = format_coordinate(region.center.ra.to('degree').value, precision) + center_lat = format_coordinate(region.center.dec.to('degree').value, precision) + radius = format_coordinate(region.radius.to('degree').value, precision) + + return f"Circle {frame} {refpos} {center_lon} {center_lat} {radius}" + + +def _serialize_ellipse(region, frame, refpos, unit, precision): + """Serialize Ellipse region.""" + if isinstance(region, PixelRegion): + center_lon = format_coordinate(region.center.x, precision) + center_lat = format_coordinate(region.center.y, precision) + semi_major = format_coordinate(region.width / 2, precision) + semi_minor = format_coordinate(region.height / 2, precision) + angle = format_coordinate(region.angle.to('degree').value, precision) + else: + center_lon = format_coordinate(region.center.ra.to('degree').value, precision) + center_lat = format_coordinate(region.center.dec.to('degree').value, precision) + semi_major = format_coordinate((region.width / 2).to('degree').value, precision) + semi_minor = format_coordinate((region.height / 2).to('degree').value, precision) + angle = format_coordinate(region.angle.to('degree').value, precision) + + return f"Ellipse {frame} {refpos} {center_lon} {center_lat} {semi_major} {semi_minor} {angle}" + + +def _serialize_box(region, frame, refpos, unit, precision): + """Serialize Box/Rectangle region.""" + if isinstance(region, PixelRegion): + center_lon = format_coordinate(region.center.x, precision) + center_lat = format_coordinate(region.center.y, precision) + width = format_coordinate(region.width, precision) + height = format_coordinate(region.height, precision) + angle = format_coordinate(region.angle.to('degree').value, precision) + else: + center_lon = format_coordinate(region.center.ra.to('degree').value, precision) + center_lat = format_coordinate(region.center.dec.to('degree').value, precision) + width = format_coordinate(region.width.to('degree').value, precision) + height = format_coordinate(region.height.to('degree').value, precision) + angle = format_coordinate(region.angle.to('degree').value, precision) + + return f"Box {frame} {refpos} {center_lon} {center_lat} {width} {height} {angle}" + + +def _serialize_polygon(region, frame, refpos, unit, precision): + """Serialize Polygon region.""" + if isinstance(region, PixelRegion): + vertices_str = [] + for vertex in region.vertices: + x = format_coordinate(vertex.x, precision) + y = format_coordinate(vertex.y, precision) + vertices_str.extend([x, y]) + else: + vertices_str = [] + for vertex in region.vertices: + lon = format_coordinate(vertex.ra.to('degree').value, precision) + lat = format_coordinate(vertex.dec.to('degree').value, precision) + vertices_str.extend([lon, lat]) + + vertices_coords = ' '.join(vertices_str) + return f"Polygon {frame} {refpos} {vertices_coords}" + + +def _serialize_position(region, frame, refpos, unit, precision): + """Serialize Position/Point region.""" + if isinstance(region, PixelRegion): + center_lon = format_coordinate(region.center.x, precision) + center_lat = format_coordinate(region.center.y, precision) + else: + center_lon = format_coordinate(region.center.ra.to('degree').value, precision) + center_lat = format_coordinate(region.center.dec.to('degree').value, precision) + + return f"Position {frame} {refpos} {center_lon} {center_lat}" + + +def _get_coordinate_unit(coord): + """ + Get the unit from a SkyCoord object. + + Parameters + ---------- + coord : `~astropy.coordinates.SkyCoord` + Coordinate object. + + Returns + ------- + unit : str + Unit string. + """ + if hasattr(coord, 'ra'): + return coord.ra.unit.to_string() + elif hasattr(coord, 'lon'): + return coord.lon.unit.to_string() + else: + return 'degree' From 13f7cab4db707ab709e37e9d3ecce31a23954686 Mon Sep 17 00:00:00 2001 From: "Adam Ginsburg (keflavich)" Date: Sat, 30 Aug 2025 16:25:14 -0400 Subject: [PATCH 2/7] avoid autodetection based on content --- docs/api.rst | 6 ----- docs/common_links.txt | 51 ++++++++++++++++++++------------------ docs/region_io.rst | 4 +-- regions/io/stcs/README.md | 31 ++++++++++++++--------- regions/io/stcs/connect.py | 44 ++++++++------------------------ 5 files changed, 59 insertions(+), 77 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index f5e082a54..2286d370d 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,9 +3,3 @@ Reference/API .. automodapi:: regions :no-inheritance-diagram: - -.. automodapi:: regions.io.ds9 - :no-inheritance-diagram: - -.. automodapi:: regions.io.stcs - :no-inheritance-diagram: diff --git a/docs/common_links.txt b/docs/common_links.txt index e3ac0a7b5..a45f2eb99 100644 --- a/docs/common_links.txt +++ b/docs/common_links.txt @@ -1,24 +1,27 @@ -.. _Astropy: https://www.astropy.org/ -.. _astropy.io.fits: https://docs.astropy.org/en/stable/io/fits/ -.. _astropy.table.Table: https://docs.astropy.org/en/stable/table/ -.. _astropy.wcs: https://docs.astropy.org/en/stable/wcs/ -.. _Astropy units: https://docs.astropy.org/en/stable/units/ -.. _Astropy WCS: https://docs.astropy.org/en/stable/wcs/ -.. _CASA: https://casa.nrao.edu/ -.. _configparser: https://docs.python.org/3/library/configparser.html -.. _coordinated package: https://www.astropy.org/affiliated/ -.. _DS9: http://ds9.si.edu/site/Home.html -.. _File Formats: http://ds9.si.edu/doc/ref/region.html -.. _FITS: https://fits.gsfc.nasa.gov/ -.. _FITS Region Binary Table: https://fits.gsfc.nasa.gov/registry/region.html -.. _GitHub repository: https://github.com/astropy/regions -.. _CASA Region Text Format: https://casadocs.readthedocs.io/en/stable/notebooks/image_analysis.html#Region-File-Format -.. _matplotlib: https://matplotlib.org/ -.. _pip: https://pip.pypa.io/en/stable/ -.. _PyPI: https://pypi.org/project/regions/ -.. _SAOImageDS9: http://ds9.si.edu/site/Home.html -.. _shapely: https://pypi.org/project/Shapely/ -.. _conda: https://conda.io/en/latest/ -.. _IVOA STC-S Note: https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html -.. _STC-S: https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html -.. _IVOA: https://www.ivoa.net/ \ No newline at end of file +.. These are ReST substitutions and links that can be used throughout the docs +.. (and docstrings) because they are added to ``docs/conf.py::rst_epilog``. + +.. ------------------------------------------------------------------ +.. RST SUBSTITUTIONS + +.. NumPy +.. |ndarray| replace:: :class:`numpy.ndarray` + +.. Astropy +.. |Angle| replace:: `astropy.coordinates.Angle` +.. |Latitude| replace:: `astropy.coordinates.Latitude` +.. |Longitude| replace:: :class:`astropy.coordinates.Longitude` +.. |SkyCoord| replace:: :class:`astropy.coordinates.SkyCoord` +.. |Table| replace:: :class:`astropy.table.Table` +.. |QTable| replace:: :class:`astropy.table.QTable` +.. |Quantity| replace:: :class:`astropy.units.Quantity` +.. |Unit| replace:: :class:`astropy.units.UnitBase` + +.. Regions +.. |PixCoord| replace:: `~regions.PixCoord` +.. |RegionMeta| replace:: `~regions.RegionMeta` +.. |RegionVisual| replace:: `~regions.RegionVisual` + +.. Matplotlib +.. _Matplotlib: https://matplotlib.org/ +.. |Patch| replace:: `~matplotlib.patches.Patch` diff --git a/docs/region_io.rst b/docs/region_io.rst index 28ae22e5d..959cfeea4 100644 --- a/docs/region_io.rst +++ b/docs/region_io.rst @@ -41,7 +41,7 @@ registered I/O formats as a :class:`~astropy.table.Table`:: crtf Yes Yes Yes Yes Yes ds9 Yes Yes Yes Yes Yes fits Yes Yes Yes Yes Yes - stcs Yes Yes Yes Yes Yes + stcs Yes Yes Yes Yes Extension only Read @@ -173,7 +173,7 @@ formats and methods for the :class:`~regions.Region` subclasses:: crtf No Yes No Yes Yes ds9 No Yes No Yes Yes fits No Yes No Yes Yes - stcs No Yes No Yes Yes + stcs No Yes No Yes Extension only Region File Format Limitations diff --git a/regions/io/stcs/README.md b/regions/io/stcs/README.md index ce3a859da..cce56ba94 100644 --- a/regions/io/stcs/README.md +++ b/regions/io/stcs/README.md @@ -33,10 +33,12 @@ The STC-S format provides a string representation for astronomical regions that ### Reading STC-S Files +**Important:** STC-S files cannot be reliably auto-detected and require explicit format specification. + ```python from regions import Regions -# Read from file +# Read from file - MUST specify format='stcs' regions = Regions.read('regions.stcs', format='stcs') # Parse STC-S string directly @@ -208,19 +210,26 @@ Test data files are located in `regions/io/stcs/tests/data/`. ## Contributing -When contributing to the STC-S module: +## Known Limitations + +### Auto-detection Limitations -1. Follow the existing code style and patterns from the DS9 module -2. Add comprehensive tests for new functionality -3. Update documentation and examples -4. Ensure round-trip conversions work correctly -5. Test with various coordinate systems and shapes +- **STC-S files cannot be auto-detected** from content alone, as they lack unique file signatures and use keywords that also appear in DS9, CRTF, and other region formats +- You must always specify `format='stcs'` explicitly when reading STC-S files +- Auto-detection only works based on file extensions (`.stcs`, `.stc`, `.stcs.txt`, `.stc.txt`) -## Known Limitations +### Feature Limitations -- Complex STC-S features like time coordinates are not yet supported +- Some STC-S features, like time coordinates, are not yet supported - Union, intersection, and other compound operations are not implemented -- Some advanced STC-S syntax elements are not parsed +- Advanced STC-S syntax elements are not parsed, including: + - Time coordinates and temporal intervals (e.g., `Time TT TOPOCENTER 2000-01-01T12:00:00 2000-01-02T12:00:00`) + - Spectral coordinates (e.g., `Spectral TOPOCENTER 1420.4 MHz`) + - Redshift specifications (e.g., `RedshiftInterval BARYCENTER VELOCITY OPTICAL 200.0 2300.0`) + - Unit specifications (e.g., `unit deg arcsec`) + - Complex compound operations (e.g., `Union`, `Intersection`, `Difference`, `Not`) + - Error bounds and uncertainties (e.g., `Error 0.1 0.1`) + - Resolution and pixel size specifications - Error handling could be more detailed for malformed input ## Future Enhancements @@ -228,6 +237,4 @@ When contributing to the STC-S module: - Support for temporal coordinates and regions - Compound region operations (Union, Intersection, etc.) - More comprehensive error messages -- Support for additional coordinate systems - Integration with STC XML format -- Performance optimizations for large files diff --git a/regions/io/stcs/connect.py b/regions/io/stcs/connect.py index 3c79c558d..c0bf22ade 100644 --- a/regions/io/stcs/connect.py +++ b/regions/io/stcs/connect.py @@ -1,19 +1,17 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst -from astropy.utils.data import get_readable_fileobj - -from regions.core import Region, Regions -from regions.core.registry import RegionsRegistry - __all__ = [] -@RegionsRegistry.register(Region, 'identify', 'stcs') -@RegionsRegistry.register(Regions, 'identify', 'stcs') def is_stcs(methodname, filepath): """ Identify an STC-S region file. + Note: STC-S files cannot be reliably auto-detected from content alone, + as they lack unique file signatures and use keywords that appear in + other region formats (DS9, CRTF, etc.). Auto-detection is only based + on file extensions. + Parameters ---------- methodname : {'read', 'write'} @@ -25,33 +23,13 @@ def is_stcs(methodname, filepath): Returns ------- result : bool - Returns `True` if the given file is an STC-S region file. + Returns `True` if the given file has an STC-S file extension. """ all_exten = ('.stcs', '.stc', '.stcs.txt', '.stc.txt') exten = {'read': all_exten, 'write': all_exten[0:2]} - if methodname == 'write': - return filepath.lower().endswith(exten[methodname]) - - elif methodname == 'read': - if (isinstance(filepath, str) - and filepath.lower().endswith(exten[methodname])): - return True - else: - # Check file content for STC-S keywords - try: - with get_readable_fileobj(filepath, encoding='utf-8') as fileobj: - # Read first few lines to check for STC-S content - content = fileobj.read(512) # Read first 512 characters - if content: - # Look for common STC-S keywords - stcs_keywords = ['Circle', 'Ellipse', 'Box', 'Polygon', 'Position', - 'ICRS', 'FK5', 'FK4', 'GALACTIC', 'ECLIPTIC'] - content_upper = content.upper() - return any(keyword.upper() in content_upper for keyword in stcs_keywords) - return False - except (UnicodeDecodeError, OSError): - return False - - else: - return False + if methodname in ['read', 'write']: + if isinstance(filepath, str): + return filepath.lower().endswith(exten[methodname]) + + return False From b055e0fd91eb787c0fac4ab007efe44043ad2acd Mon Sep 17 00:00:00 2001 From: "Adam Ginsburg (keflavich)" Date: Sat, 30 Aug 2025 16:28:42 -0400 Subject: [PATCH 3/7] move readme/examples into stsc_io --- docs/stcs_io.rst | 631 +++++++++++++++++++++++++++++++ regions/io/stcs/README.md | 240 ------------ regions/io/stcs/example_usage.py | 130 ------- 3 files changed, 631 insertions(+), 370 deletions(-) create mode 100644 docs/stcs_io.rst delete mode 100644 regions/io/stcs/README.md delete mode 100644 regions/io/stcs/example_usage.py diff --git a/docs/stcs_io.rst b/docs/stcs_io.rst new file mode 100644 index 000000000..82012696f --- /dev/null +++ b/docs/stcs_io.rst @@ -0,0 +1,631 @@ +.. _stcs_io: + +STC-S Region File Format +======================== + +The STC-S (Space-Time Coordinate String) format is an IVOA (International +Virtual Observatory Alliance) standard for describing spatial and temporal +regions and coordinates in a string representation. This format provides a +compact, human-readable way to express astronomical regions. + +Introduction +------------ + +STC-S is defined in the `IVOA STC-S Note`_ and provides a standardized way to +represent spatial regions. The format is particularly useful for: + +* Interoperability between astronomical software +* Compact representation of regions in databases +* Human-readable region definitions +* Exchange of regions between observatories and data centers + +The regions package supports reading from and writing to STC-S files and +parsing/serializing STC-S strings. + +Features +-------- + +- **Reading**: Parse STC-S strings and files into `regions.Region` objects +- **Writing**: Serialize `regions.Region` objects to STC-S format +- **Round-trip**: Full round-trip conversion preserving region properties +- **Multiple Shapes**: Support for circles, ellipses, boxes/rectangles, polygons, and points +- **Coordinate Systems**: Support for ICRS, FK5, FK4, Galactic, Ecliptic, and Image coordinates +- **Reference Positions**: Support for various reference positions (Barycenter, Geocenter, Topocenter, etc.) + +Usage Examples +-------------- + +Reading STC-S Files +^^^^^^^^^^^^^^^^^^^ + +To read an STC-S region file, you **must** specify the format explicitly, +as STC-S files cannot be reliably auto-detected: + +.. doctest-skip:: + + >>> from regions import Regions + >>> regions = Regions.read('my_regions.stcs', format='stcs') + +.. note:: + Unlike DS9 and CRTF formats, STC-S files do not have unique file + signatures and cannot be auto-detected based on content. You must + always specify ``format='stcs'`` when reading STC-S files. + +Parsing STC-S Strings +^^^^^^^^^^^^^^^^^^^^^ + +You can also parse STC-S strings directly: + +.. doctest-skip:: + + >>> stcs_string = """ + ... # Example STC-S regions + ... Circle ICRS BARYCENTER 180.0 10.0 0.5 + ... Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + ... Position FK5 GEOCENTER 85.0 -15.0 + ... """ + >>> regions = Regions.parse(stcs_string, format='stcs') + >>> print(regions) + [, radius=0.5 deg)>, + , width=2.0 deg, height=1.0 deg, angle=45.0 deg)>, + )>] + +Writing STC-S Files +^^^^^^^^^^^^^^^^^^^ + +To write regions to an STC-S file: + +.. doctest-skip:: + + >>> import astropy.units as u + >>> from astropy.coordinates import SkyCoord + >>> from regions import CircleSkyRegion, Regions + >>> center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') + >>> circle = CircleSkyRegion(center=center, radius=0.5 * u.degree) + >>> regions = Regions([circle]) + >>> regions.write('output.stcs', format='stcs', overwrite=True) + +Serializing to STC-S Strings +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To serialize regions to an STC-S string: + +.. doctest-skip:: + + >>> stcs_string = regions.serialize(format='stcs') + >>> print(stcs_string) + Circle ICRS BARYCENTER 180 10 0.5 + +Working with Pixel Coordinates +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +STC-S also supports pixel coordinates using the IMAGE frame: + +.. doctest-skip:: + + >>> from regions.core import PixCoord + >>> from regions import CirclePixelRegion + >>> center = PixCoord(100.5, 200.3) + >>> pixel_circle = CirclePixelRegion(center=center, radius=15.0) + >>> stcs_string = pixel_circle.serialize(format='stcs') + >>> print(stcs_string) + Circle IMAGE UNKNOWN 100.5 200.3 15 + +Complete Examples +^^^^^^^^^^^^^^^^^ + +Reading STC-S Files and Strings +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. doctest-skip:: + + >>> from regions import Regions + >>> + >>> # Parse STC-S string directly + >>> stcs_string = \"\"\" + ... # Example STC-S regions + ... Circle ICRS BARYCENTER 180.0 10.0 0.5 + ... Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + ... Position FK5 GEOCENTER 85.0 -15.0 + ... \"\"\" + >>> + >>> regions = Regions.parse(stcs_string, format='stcs') + >>> print(f"Parsed {len(regions)} regions:") + Parsed 3 regions: + >>> for i, region in enumerate(regions): + ... print(f" {i+1}. {region}") + 1. , radius=0.5 deg)> + 2. , width=2.0 deg, height=1.0 deg, angle=45.0 deg)> + 3. )> + +Writing Multiple Region Types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. doctest-skip:: + + >>> import astropy.units as u + >>> from astropy.coordinates import SkyCoord + >>> from regions.core import PixCoord, Regions + >>> from regions.shapes import (CirclePixelRegion, CircleSkyRegion, + ... EllipseSkyRegion, PolygonSkyRegion, + ... PointSkyRegion) + >>> + >>> # Create some example regions + >>> regions = [] + >>> + >>> # Sky circle + >>> center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') + >>> circle = CircleSkyRegion(center=center, radius=0.5 * u.degree) + >>> regions.append(circle) + >>> + >>> # Sky ellipse + >>> center = SkyCoord(150.0, -20.0, unit='degree', frame='icrs') + >>> ellipse = EllipseSkyRegion(center=center, width=2.0 * u.degree, + ... height=1.0 * u.degree, angle=45.0 * u.degree) + >>> regions.append(ellipse) + >>> + >>> # Sky polygon + >>> vertices = SkyCoord([45.0, 50.0, 50.0, 45.0], + ... [45.0, 45.0, 50.0, 50.0], + ... unit='degree', frame='icrs') + >>> polygon = PolygonSkyRegion(vertices=vertices) + >>> regions.append(polygon) + >>> + >>> # Point region + >>> center = SkyCoord(85.0, -15.0, unit='degree', frame='fk5') + >>> point = PointSkyRegion(center=center) + >>> regions.append(point) + >>> + >>> # Pixel circle + >>> center = PixCoord(100.5, 200.3) + >>> pixel_circle = CirclePixelRegion(center=center, radius=15.0) + >>> regions.append(pixel_circle) + >>> + >>> regions_obj = Regions(regions) + >>> + >>> # Serialize to STC-S string + >>> stcs_string = regions_obj.serialize(format='stcs') + >>> print("Serialized STC-S:") + Serialized STC-S: + >>> print(stcs_string) + Circle ICRS BARYCENTER 180 10 0.5 + Ellipse ICRS BARYCENTER 150 -20 1 0.5 45 + Polygon ICRS BARYCENTER 45 45 50 45 50 50 45 50 + Position FK5 BARYCENTER 85 -15 + Circle IMAGE UNKNOWN 100.5 200.3 15 + >>> + >>> # Write to file (uncomment to actually write) + >>> # regions_obj.write('output.stcs', format='stcs', overwrite=True) + +Round-trip Conversion +~~~~~~~~~~~~~~~~~~~~~ + +Verify that regions can be converted to STC-S and back without loss: + +.. doctest-skip:: + + >>> # Original STC-S + >>> original_stcs = \"\"\"Circle ICRS BARYCENTER 180.0 10.0 0.5 + ... Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + ... Position FK5 GEOCENTER 85.0 -15.0\"\"\" + >>> + >>> print("Original STC-S:") + Original STC-S: + >>> print(original_stcs) + Circle ICRS BARYCENTER 180.0 10.0 0.5 + Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + Position FK5 GEOCENTER 85.0 -15.0 + >>> + >>> # Parse + >>> regions = Regions.parse(original_stcs, format='stcs') + >>> print(f"\\nParsed {len(regions)} regions") + + Parsed 3 regions + >>> + >>> # Serialize back + >>> serialized = regions.serialize(format='stcs') + >>> print("\\nSerialized back to STC-S:") + + Serialized back to STC-S: + >>> print(serialized) + Circle ICRS BARYCENTER 180 10 0.5 + Ellipse ICRS BARYCENTER 150 -20 1 0.5 45 + Position FK5 BARYCENTER 85 -15 + >>> + >>> # Parse again to verify + >>> regions2 = Regions.parse(serialized, format='stcs') + >>> print(f"\\nRe-parsed {len(regions2)} regions - round-trip successful!") + + Re-parsed 3 regions - round-trip successful! + +STC-S Format Specification +--------------------------- + +Basic Syntax +^^^^^^^^^^^^ + +The basic syntax for STC-S regions is: + +.. code-block:: + + [] + +Where: + +* **Shape**: The geometric shape (Circle, Ellipse, Box, Polygon, Position) +* **Frame**: The coordinate reference frame (ICRS, FK5, FK4, GALACTIC, ECLIPTIC, IMAGE) +* **RefPos**: The reference position (BARYCENTER, GEOCENTER, TOPOCENTER, etc.) +* **Coordinates**: The shape-specific coordinate parameters +* **Parameters**: Additional shape parameters (radii, angles, etc.) + +Supported Shapes +^^^^^^^^^^^^^^^^ + +Circle +~~~~~~ + +Defines a circular region: + +.. code-block:: + + Circle + +Example: + +.. code-block:: + + Circle ICRS BARYCENTER 180.0 10.0 0.5 + +Ellipse +~~~~~~~ + +Defines an elliptical region: + +.. code-block:: + + Ellipse + +Example: + +.. code-block:: + + Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + +Box +~~~ + +Defines a rectangular region: + +.. code-block:: + + Box + +Example: + +.. code-block:: + + Box ICRS BARYCENTER 120.0 30.0 2.0 1.0 0.0 + +Polygon +~~~~~~~ + +Defines a polygonal region with multiple vertices: + +.. code-block:: + + Polygon ... + +Example: + +.. code-block:: + + Polygon ICRS BARYCENTER 45.0 45.0 50.0 45.0 50.0 50.0 45.0 50.0 + +Position +~~~~~~~~ + +Defines a point region: + +.. code-block:: + + Position + +Example: + +.. code-block:: + + Position FK5 GEOCENTER 85.0 -15.0 + +Coordinate Frames +^^^^^^^^^^^^^^^^^ + +The following coordinate reference frames are supported: + +============= =============================================== +Frame Description +============= =============================================== +ICRS International Celestial Reference System +FK5 Fifth Fundamental Catalogue (J2000.0) +FK4 Fourth Fundamental Catalogue (B1950.0) +GALACTIC Galactic coordinate system +ECLIPTIC Ecliptic coordinate system +IMAGE Pixel/image coordinates +============= =============================================== + +Reference Positions +^^^^^^^^^^^^^^^^^^^ + +The following reference positions are supported: + +================= =============================================== +Reference Position Description +================= =============================================== +BARYCENTER Solar system barycenter +GEOCENTER Earth center +TOPOCENTER Earth surface/topocentric +HELIOCENTER Sun center +LSR Local Standard of Rest +LSRK Kinematic Local Standard of Rest +LSRD Dynamic Local Standard of Rest +UNKNOWN Unspecified reference position +================= =============================================== + +File Format +^^^^^^^^^^^ + +STC-S files typically use the following extensions: + +* ``.stcs`` +* ``.stc`` +* ``.stcs.txt`` +* ``.stc.txt`` + +Files can contain: + +* Comments starting with ``#`` +* Multiple regions, one per line +* Blank lines (ignored) + +Example STC-S file: + +.. code-block:: + + # STC-S region file + # Generated by astropy-regions + + # Central source + Circle ICRS BARYCENTER 180.0 10.0 0.5 + + # Extended emission + Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 + + # Point sources + Position FK5 GEOCENTER 85.0 -15.0 + Position FK5 GEOCENTER 90.0 -10.0 + +Region Mapping +-------------- + +The following table shows the mapping between STC-S shapes and +astropy-regions classes: + +============= ================================= ================================= +STC-S Shape Sky Region Class Pixel Region Class +============= ================================= ================================= +Circle `~regions.CircleSkyRegion` `~regions.CirclePixelRegion` +Ellipse `~regions.EllipseSkyRegion` `~regions.EllipsePixelRegion` +Box `~regions.RectangleSkyRegion` `~regions.RectanglePixelRegion` +Polygon `~regions.PolygonSkyRegion` `~regions.PolygonPixelRegion` +Position `~regions.PointSkyRegion` `~regions.PointPixelRegion` +============= ================================= ================================= + +Format Limitations +------------------ + +Region Shapes +^^^^^^^^^^^^^ + +The following STC-S features are not currently supported: + +* **Time coordinates and temporal regions**: + + .. code-block:: + + Time TT TOPOCENTER 2000-01-01T12:00:00 2000-01-02T12:00:00 + TimeInterval TT GEOCENTER 2000-01-01T00:00:00 2001-01-01T00:00:00 + +* **Spectral coordinates**: + + .. code-block:: + + Spectral TOPOCENTER 1420.4 MHz + SpectralInterval BARYCENTER 1400.0 1440.0 MHz + +* **Redshift specifications**: + + .. code-block:: + + RedshiftInterval BARYCENTER VELOCITY OPTICAL 200.0 2300.0 km/s + Redshift BARYCENTER VELOCITY RADIO 0.1 + +* **Complex compound operations**: + + .. code-block:: + + Union ICRS BARYCENTER (Circle 180 10 0.5) (Circle 190 20 0.5) + Intersection ICRS BARYCENTER (Circle 180 10 2.0) (Box 180 10 1.0 1.0 0.0) + Difference ICRS BARYCENTER (Circle 180 10 2.0) (Circle 180 10 0.5) + +* **Unit specifications and mixed units**: + + .. code-block:: + + Circle ICRS BARYCENTER unit deg arcsec 180.0 10.0 30.0 + +* **Error bounds and uncertainties**: + + .. code-block:: + + Circle ICRS BARYCENTER 180.0 10.0 0.5 Error 0.1 0.1 0.05 + +* **Resolution and pixel size specifications**: + + .. code-block:: + + Circle ICRS BARYCENTER 180.0 10.0 0.5 Resolution 0.1 PixSize 0.05 + +Coordinate Systems +^^^^^^^^^^^^^^^^^^ + +* Only spatial coordinates are supported; temporal coordinates are ignored +* Complex coordinate transformations are not implemented +* Some specialized coordinate systems may not be fully supported + +Auto-detection Limitations +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* **STC-S files cannot be auto-detected** based on content, as they lack unique + file signatures and use keywords that also appear in DS9, CRTF, and other + region formats. You must always specify ``format='stcs'`` explicitly when + reading STC-S files. + +* Auto-detection only works based on file extensions (``.stcs``, ``.stc``, + ``.stcs.txt``, ``.stc.txt``). + +Other Limitations +^^^^^^^^^^^^^^^^^ + +* Reading and writing an STC-S file will not produce an identical file to the + original, but the encoded regions are identical. The regions will produce + identical `~regions.Region` objects when read back in again. + +* Comments and formatting may not be preserved exactly during round-trip + operations. + +* Error handling for malformed STC-S strings could be more detailed. + +Examples +-------- + +Complete Example +^^^^^^^^^^^^^^^^ + +Here's a complete example showing how to work with STC-S files: + +.. doctest-skip:: + + >>> import astropy.units as u + >>> from astropy.coordinates import SkyCoord + >>> from regions import (CircleSkyRegion, EllipseSkyRegion, + ... PolygonSkyRegion, PointSkyRegion, Regions) + + >>> # Create some regions + >>> regions = [] + + >>> # Sky circle + >>> center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') + >>> circle = CircleSkyRegion(center=center, radius=0.5 * u.degree) + >>> regions.append(circle) + + >>> # Sky ellipse + >>> center = SkyCoord(150.0, -20.0, unit='degree', frame='icrs') + >>> ellipse = EllipseSkyRegion(center=center, width=2.0 * u.degree, + ... height=1.0 * u.degree, angle=45.0 * u.degree) + >>> regions.append(ellipse) + + >>> # Polygon + >>> vertices = SkyCoord([45.0, 50.0, 50.0, 45.0], + ... [45.0, 45.0, 50.0, 50.0], + ... unit='degree', frame='icrs') + >>> polygon = PolygonSkyRegion(vertices=vertices) + >>> regions.append(polygon) + + >>> # Point + >>> center = SkyCoord(85.0, -15.0, unit='degree', frame='fk5') + >>> point = PointSkyRegion(center=center) + >>> regions.append(point) + + >>> regions_obj = Regions(regions) + + >>> # Write to file + >>> regions_obj.write('example.stcs', format='stcs', overwrite=True) + + >>> # Read back + >>> read_regions = Regions.read('example.stcs', format='stcs') + >>> print(f"Read {len(read_regions)} regions") + Read 4 regions + +Round-trip Conversion +^^^^^^^^^^^^^^^^^^^^ + +STC-S supports full round-trip conversion: + +.. doctest-skip:: + + >>> # Original STC-S + >>> original = "Circle ICRS BARYCENTER 180.0 10.0 0.5" + >>> + >>> # Parse -> Serialize -> Parse + >>> regions = Regions.parse(original, format='stcs') + >>> serialized = regions.serialize(format='stcs') + >>> regions2 = Regions.parse(serialized, format='stcs') + >>> + >>> # Verify consistency + >>> r1, r2 = regions[0], regions2[0] + >>> print(f"Original center: {r1.center}") + >>> print(f"Round-trip center: {r2.center}") + >>> print(f"Centers match: {r1.center.separation(r2.center) < 1e-10 * u.degree}") + +Implementation Details +---------------------- + +The STC-S module consists of: + +- **`core.py`**: Core parsing functions, mappings, and utilities +- **`connect.py`**: File format identification and registry +- **`read.py`**: STC-S reading and parsing functionality +- **`write.py`**: STC-S writing and serialization functionality +- **`tests/`**: Comprehensive test suite + +Key Functions +^^^^^^^^^^^^^ + +- `validate_stcs_string()`: Validate STC-S format +- `parse_coordinate_frame()`: Extract coordinate frame and reference position +- `parse_numbers()`: Parse numeric parameters +- `_parse_stcs()`: Main parsing function +- `_serialize_stcs()`: Main serialization function + +Testing +------- + +The STC-S module includes a comprehensive test suite. Run tests using: + +.. code-block:: bash + + # Run all STC-S tests + pytest regions/io/stcs/tests/ + + # Run specific test file + pytest regions/io/stcs/tests/test_stcs.py + + # Run with verbose output + pytest regions/io/stcs/tests/ -v + +Test data files are located in `regions/io/stcs/tests/data/` and include: + +- `stcs_basic.stcs`: Basic shape examples +- `stcs_pixel.stcs`: Pixel coordinate examples +- `stcs_complex.stcs`: Complex region examples + +References +---------- + +- `IVOA STC-S Standard`_ +- `CDS STC-S Rust Implementation`_ +- `astropy-regions Issue #21`_ + +.. _IVOA STC-S Note: https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html +.. _IVOA STC-S Standard: https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html +.. _CDS STC-S Rust Implementation: https://github.com/cds-astro/cds-stc-rust/ +.. _astropy-regions Issue #21: https://github.com/astropy/regions/issues/21 diff --git a/regions/io/stcs/README.md b/regions/io/stcs/README.md deleted file mode 100644 index cce56ba94..000000000 --- a/regions/io/stcs/README.md +++ /dev/null @@ -1,240 +0,0 @@ -# STC-S I/O Module for astropy-regions - -This module provides support for reading and writing regions in the **STC-S (Space-Time Coordinate String)** format, which is an IVOA (International Virtual Observatory Alliance) standard for describing spatial and temporal regions and coordinates. - -## Overview - -The STC-S format provides a string representation for astronomical regions that is: -- Standardized by the IVOA -- Human-readable and compact -- Suitable for describing both sky and pixel coordinates -- Compatible with various coordinate systems and reference frames - -## Features - -- **Reading**: Parse STC-S strings and files into `regions.Region` objects -- **Writing**: Serialize `regions.Region` objects to STC-S format -- **Round-trip**: Full round-trip conversion preserving region properties -- **Multiple Shapes**: Support for circles, ellipses, boxes/rectangles, polygons, and points -- **Coordinate Systems**: Support for ICRS, FK5, FK4, Galactic, Ecliptic, and Image coordinates -- **Reference Positions**: Support for various reference positions (Barycenter, Geocenter, Topocenter, etc.) - -## Supported Shapes - -| STC-S Shape | astropy-regions Class | Description | -|-------------|----------------------|-------------| -| `Circle` | `CircleSkyRegion` / `CirclePixelRegion` | Circular region | -| `Ellipse` | `EllipseSkyRegion` / `EllipsePixelRegion` | Elliptical region | -| `Box` | `RectangleSkyRegion` / `RectanglePixelRegion` | Rectangular region | -| `Polygon` | `PolygonSkyRegion` / `PolygonPixelRegion` | Polygonal region | -| `Position` | `PointSkyRegion` / `PointPixelRegion` | Point region | - -## Usage Examples - -### Reading STC-S Files - -**Important:** STC-S files cannot be reliably auto-detected and require explicit format specification. - -```python -from regions import Regions - -# Read from file - MUST specify format='stcs' -regions = Regions.read('regions.stcs', format='stcs') - -# Parse STC-S string directly -stcs_string = """ -# Example STC-S regions -Circle ICRS BARYCENTER 180.0 10.0 0.5 -Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 -Position FK5 GEOCENTER 85.0 -15.0 -""" -regions = Regions.parse(stcs_string, format='stcs') -``` - -### Writing STC-S Files - -```python -import astropy.units as u -from astropy.coordinates import SkyCoord -from regions import Regions -from regions.shapes import CircleSkyRegion, PointSkyRegion - -# Create regions -center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') -circle = CircleSkyRegion(center=center, radius=0.5 * u.degree) - -center2 = SkyCoord(85.0, -15.0, unit='degree', frame='fk5') -point = PointSkyRegion(center=center2) - -regions = Regions([circle, point]) - -# Write to file -regions.write('output.stcs', format='stcs', overwrite=True) - -# Serialize to string -stcs_string = regions.serialize(format='stcs') -print(stcs_string) -``` - -### Working with Pixel Coordinates - -```python -from regions.core import PixCoord -from regions.shapes import CirclePixelRegion - -# Create pixel region -center = PixCoord(100.5, 200.3) -pixel_circle = CirclePixelRegion(center=center, radius=15.0) - -# Serialize -stcs_string = pixel_circle.serialize(format='stcs') -# Output: "Circle IMAGE UNKNOWN 100.5 200.3 15" -``` - -## STC-S Format Specification - -### Basic Syntax - -``` - [] -``` - -### Examples - -```stcs -# Circle: center_lon center_lat radius -Circle ICRS BARYCENTER 180.0 10.0 0.5 - -# Ellipse: center_lon center_lat semi_major semi_minor angle -Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 - -# Box: center_lon center_lat width height angle -Box ICRS BARYCENTER 120.0 30.0 2.0 1.0 0.0 - -# Polygon: lon1 lat1 lon2 lat2 lon3 lat3 ... -Polygon ICRS BARYCENTER 45.0 45.0 50.0 45.0 50.0 50.0 45.0 50.0 - -# Position: lon lat -Position FK5 GEOCENTER 85.0 -15.0 - -# Pixel coordinates -Circle IMAGE UNKNOWN 100.5 200.3 15.0 -``` - -### Coordinate Frames - -- **ICRS**: International Celestial Reference System -- **FK5**: Fifth Fundamental Catalogue (J2000.0) -- **FK4**: Fourth Fundamental Catalogue (B1950.0) -- **GALACTIC**: Galactic coordinate system -- **ECLIPTIC**: Ecliptic coordinate system -- **IMAGE**: Pixel/image coordinates - -### Reference Positions - -- **BARYCENTER**: Solar system barycenter -- **GEOCENTER**: Earth center -- **TOPOCENTER**: Earth surface/topocentric -- **HELIOCENTER**: Sun center -- **LSR**: Local Standard of Rest -- **UNKNOWN**: Unspecified reference - -## File Format - -STC-S files typically have extensions: -- `.stcs` -- `.stc` -- `.stcs.txt` -- `.stc.txt` - -Files can contain: -- Comments starting with `#` -- Multiple regions, one per line -- Blank lines (ignored) - -Example file: -```stcs -# STC-S region file -# Generated by astropy-regions - -# Central source -Circle ICRS BARYCENTER 180.0 10.0 0.5 - -# Extended emission -Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 - -# Point sources -Position FK5 GEOCENTER 85.0 -15.0 -Position FK5 GEOCENTER 90.0 -10.0 -``` - -## Implementation Details - -The STC-S module consists of: - -- **`core.py`**: Core parsing functions, mappings, and utilities -- **`connect.py`**: File format identification and registry -- **`read.py`**: STC-S reading and parsing functionality -- **`write.py`**: STC-S writing and serialization functionality -- **`tests/`**: Comprehensive test suite - -### Key Functions - -- `validate_stcs_string()`: Validate STC-S format -- `parse_coordinate_frame()`: Extract coordinate frame and reference position -- `parse_numbers()`: Parse numeric parameters -- `_parse_stcs()`: Main parsing function -- `_serialize_stcs()`: Main serialization function - -## References - -- [IVOA STC-S Standard](https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html) -- [CDS STC-S Rust Implementation](https://github.com/cds-astro/cds-stc-rust/) -- [astropy-regions Issue #21](https://github.com/astropy/regions/issues/21) - -## Testing - -Run the test suite: -```bash -# Run all STC-S tests -pytest regions/io/stcs/tests/ - -# Run specific test file -pytest regions/io/stcs/tests/test_stcs.py - -# Run with verbose output -pytest regions/io/stcs/tests/ -v -``` - -Test data files are located in `regions/io/stcs/tests/data/`. - -## Contributing - -## Known Limitations - -### Auto-detection Limitations - -- **STC-S files cannot be auto-detected** from content alone, as they lack unique file signatures and use keywords that also appear in DS9, CRTF, and other region formats -- You must always specify `format='stcs'` explicitly when reading STC-S files -- Auto-detection only works based on file extensions (`.stcs`, `.stc`, `.stcs.txt`, `.stc.txt`) - -### Feature Limitations - -- Some STC-S features, like time coordinates, are not yet supported -- Union, intersection, and other compound operations are not implemented -- Advanced STC-S syntax elements are not parsed, including: - - Time coordinates and temporal intervals (e.g., `Time TT TOPOCENTER 2000-01-01T12:00:00 2000-01-02T12:00:00`) - - Spectral coordinates (e.g., `Spectral TOPOCENTER 1420.4 MHz`) - - Redshift specifications (e.g., `RedshiftInterval BARYCENTER VELOCITY OPTICAL 200.0 2300.0`) - - Unit specifications (e.g., `unit deg arcsec`) - - Complex compound operations (e.g., `Union`, `Intersection`, `Difference`, `Not`) - - Error bounds and uncertainties (e.g., `Error 0.1 0.1`) - - Resolution and pixel size specifications -- Error handling could be more detailed for malformed input - -## Future Enhancements - -- Support for temporal coordinates and regions -- Compound region operations (Union, Intersection, etc.) -- More comprehensive error messages -- Integration with STC XML format diff --git a/regions/io/stcs/example_usage.py b/regions/io/stcs/example_usage.py deleted file mode 100644 index 93e87ef45..000000000 --- a/regions/io/stcs/example_usage.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -""" -Example usage of the STC-S I/O module for astropy-regions. - -This script demonstrates how to read and write STC-S region files -using the astropy-regions package. -""" - -import astropy.units as u -from astropy.coordinates import SkyCoord - -from regions.core import PixCoord, Regions -from regions.shapes import (CirclePixelRegion, CircleSkyRegion, - EllipseSkyRegion, PolygonSkyRegion, - PointSkyRegion) - - -def example_read_stcs(): - """Example of reading STC-S files.""" - print("=== Reading STC-S Files ===") - - # Read from file (this would work if the file exists) - # regions = Regions.read('example.stcs', format='stcs') - - # Parse STC-S string directly - stcs_string = """ - # Example STC-S regions - Circle ICRS BARYCENTER 180.0 10.0 0.5 - Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 - Position FK5 GEOCENTER 85.0 -15.0 - """ - - regions = Regions.parse(stcs_string, format='stcs') - - print(f"Parsed {len(regions)} regions:") - for i, region in enumerate(regions): - print(f" {i+1}. {region}") - - return regions - - -def example_write_stcs(): - """Example of writing STC-S files.""" - print("\n=== Writing STC-S Files ===") - - # Create some example regions - regions = [] - - # Sky circle - center = SkyCoord(180.0, 10.0, unit='degree', frame='icrs') - circle = CircleSkyRegion(center=center, radius=0.5 * u.degree) - regions.append(circle) - - # Sky ellipse - center = SkyCoord(150.0, -20.0, unit='degree', frame='icrs') - ellipse = EllipseSkyRegion(center=center, width=2.0 * u.degree, - height=1.0 * u.degree, angle=45.0 * u.degree) - regions.append(ellipse) - - # Sky polygon - vertices = SkyCoord([45.0, 50.0, 50.0, 45.0], - [45.0, 45.0, 50.0, 50.0], - unit='degree', frame='icrs') - polygon = PolygonSkyRegion(vertices=vertices) - regions.append(polygon) - - # Point region - center = SkyCoord(85.0, -15.0, unit='degree', frame='fk5') - point = PointSkyRegion(center=center) - regions.append(point) - - # Pixel circle - center = PixCoord(100.5, 200.3) - pixel_circle = CirclePixelRegion(center=center, radius=15.0) - regions.append(pixel_circle) - - regions_obj = Regions(regions) - - # Serialize to STC-S string - stcs_string = regions_obj.serialize(format='stcs') - print("Serialized STC-S:") - print(stcs_string) - - # Write to file (uncomment to actually write) - # regions_obj.write('output.stcs', format='stcs', overwrite=True) - - return stcs_string - - -def example_round_trip(): - """Example of round-trip conversion.""" - print("\n=== Round-trip Conversion ===") - - original_stcs = """Circle ICRS BARYCENTER 180.0 10.0 0.5 -Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 -Position FK5 GEOCENTER 85.0 -15.0""" - - print("Original STC-S:") - print(original_stcs) - - # Parse - regions = Regions.parse(original_stcs, format='stcs') - print(f"\nParsed {len(regions)} regions") - - # Serialize back - serialized = regions.serialize(format='stcs') - print("\nSerialized back to STC-S:") - print(serialized) - - # Parse again to verify - regions2 = Regions.parse(serialized, format='stcs') - print(f"\nRe-parsed {len(regions2)} regions - round-trip successful!") - - -if __name__ == '__main__': - print("STC-S I/O Module Examples") - print("=" * 40) - - try: - example_read_stcs() - example_write_stcs() - example_round_trip() - - print("\n" + "=" * 40) - print("All examples completed successfully!") - - except Exception as e: - print(f"\nError running examples: {e}") - import traceback - traceback.print_exc() From b662a25c136c3f063ff09833d302b2a9637561fe Mon Sep 17 00:00:00 2001 From: "Adam Ginsburg (keflavich)" Date: Sat, 30 Aug 2025 16:39:45 -0400 Subject: [PATCH 4/7] asked the AI to give credit where it is due --- docs/stcs_io.rst | 30 ++++++++++++++++++-- regions/io/stcs/tests/data/stcs_basic.stcs | 7 +++++ regions/io/stcs/tests/data/stcs_complex.stcs | 15 +++++++--- regions/io/stcs/tests/data/stcs_pixel.stcs | 7 +++++ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/docs/stcs_io.rst b/docs/stcs_io.rst index 82012696f..fc1b2939e 100644 --- a/docs/stcs_io.rst +++ b/docs/stcs_io.rst @@ -60,6 +60,7 @@ You can also parse STC-S strings directly: >>> stcs_string = """ ... # Example STC-S regions + ... # Examples created for demonstration based on the IVOA STC-S standard ... Circle ICRS BARYCENTER 180.0 10.0 0.5 ... Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 ... Position FK5 GEOCENTER 85.0 -15.0 @@ -124,6 +125,7 @@ Reading STC-S Files and Strings >>> # Parse STC-S string directly >>> stcs_string = \"\"\" ... # Example STC-S regions + ... # Examples created for demonstration based on the IVOA STC-S standard ... Circle ICRS BARYCENTER 180.0 10.0 0.5 ... Ellipse ICRS BARYCENTER 150.0 -20.0 1.0 0.5 45.0 ... Position FK5 GEOCENTER 85.0 -15.0 @@ -392,6 +394,7 @@ Example STC-S file: # STC-S region file # Generated by astropy-regions + # Examples created based on the IVOA STC-S standard specification # Central source Circle ICRS BARYCENTER 180.0 10.0 0.5 @@ -614,9 +617,30 @@ The STC-S module includes a comprehensive test suite. Run tests using: Test data files are located in `regions/io/stcs/tests/data/` and include: -- `stcs_basic.stcs`: Basic shape examples -- `stcs_pixel.stcs`: Pixel coordinate examples -- `stcs_complex.stcs`: Complex region examples +- `stcs_basic.stcs`: Basic shape examples (created based on IVOA STC-S standard) +- `stcs_pixel.stcs`: Pixel coordinate examples (created based on IVOA STC-S standard) +- `stcs_complex.stcs`: Complex region examples (inspired by CDS STC-S Rust implementation) + +Example Sources and Attribution +------------------------------- + +The STC-S examples used throughout this documentation and in test files come from the following sources: + +**Test Data Files:** + +- **Basic Examples** (`stcs_basic.stcs`, `stcs_pixel.stcs`): Created specifically for this implementation based on the IVOA STC-S standard specification. These examples demonstrate fundamental STC-S syntax and cover the core region types and coordinate systems. + +- **Complex Examples** (`stcs_complex.stcs`): Some coordinate examples inspired by the CDS STC-S Rust implementation, adapted to test various coordinate systems and reference positions. Additional examples created based on the IVOA standard. + +**Documentation Examples:** + +- **Tutorial Examples**: All examples in the usage sections were created specifically for demonstration purposes, following the IVOA STC-S standard syntax to illustrate proper usage patterns. + +- **Format Specification Examples**: Directly based on or derived from examples in the IVOA STC-S standard documentation to ensure accuracy and compliance. + +**Advanced Syntax Examples:** + +- **Unsupported Features**: Examples of advanced STC-S syntax (time coordinates, spectral coordinates, compound operations) are referenced from the IVOA STC-S standard and CDS implementations to show what the standard supports beyond the current implementation scope. References ---------- diff --git a/regions/io/stcs/tests/data/stcs_basic.stcs b/regions/io/stcs/tests/data/stcs_basic.stcs index 5526a60dd..aaec0aa4a 100644 --- a/regions/io/stcs/tests/data/stcs_basic.stcs +++ b/regions/io/stcs/tests/data/stcs_basic.stcs @@ -1,4 +1,11 @@ # STC-S test file with basic shapes +# Examples created based on the IVOA STC-S standard specification: +# https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html +# +# These examples demonstrate basic region shapes and coordinate systems +# supported by the STC-S format, designed for comprehensive testing +# of the astropy-regions STC-S I/O module. + # Test basic circle Circle ICRS BARYCENTER 180.0 10.0 0.5 diff --git a/regions/io/stcs/tests/data/stcs_complex.stcs b/regions/io/stcs/tests/data/stcs_complex.stcs index 9341f41e8..6df0e6f5e 100644 --- a/regions/io/stcs/tests/data/stcs_complex.stcs +++ b/regions/io/stcs/tests/data/stcs_complex.stcs @@ -1,12 +1,19 @@ -# STC-S test file with complex regions based on cds-stc-rust examples -# Example from the Rust implementation documentation +# STC-S test file with complex regions and coordinate systems +# +# Some examples inspired by the CDS STC-S Rust implementation: +# https://github.com/cds-astro/cds-stc-rust/ +# +# Additional examples created based on the IVOA STC-S standard to test +# various coordinate systems (ICRS, GALACTIC, FK5) and reference positions +# (TOPOCENTER, BARYCENTER, GEOCENTER). +# Example demonstrating TOPOCENTER reference position Circle ICRS TOPOCENTER 147.6 69.9 0.4 -# Time and position example (simplified to spatial only) +# Position example (spatial coordinates only, temporal coordinates excluded) Position ICRS BARYCENTER 147.3 69.3 -# Complex example with multiple shapes +# Examples with various coordinate systems and reference positions Circle ICRS GEOCENTER 179.0 -11.5 0.5 Ellipse GALACTIC BARYCENTER 90.0 45.0 2.0 1.0 60.0 Box FK5 TOPOCENTER 30.0 60.0 0.5 0.3 15.0 diff --git a/regions/io/stcs/tests/data/stcs_pixel.stcs b/regions/io/stcs/tests/data/stcs_pixel.stcs index 03e30dfdd..a7f67f05f 100644 --- a/regions/io/stcs/tests/data/stcs_pixel.stcs +++ b/regions/io/stcs/tests/data/stcs_pixel.stcs @@ -1,4 +1,11 @@ # STC-S test file with pixel coordinates +# Examples created based on the IVOA STC-S standard specification: +# https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html +# +# These examples demonstrate the IMAGE coordinate frame for pixel-based +# regions, designed to test the astropy-regions STC-S I/O module's +# handling of pixel coordinate systems. + Circle IMAGE UNKNOWN 100.5 200.3 15.0 Ellipse IMAGE UNKNOWN 300.0 400.0 20.0 10.0 30.0 Box IMAGE UNKNOWN 500.0 600.0 50.0 25.0 45.0 From cb65912e6b4a0a6dea3d8f9a46231694e7eb1b6f Mon Sep 17 00:00:00 2001 From: "Adam Ginsburg (keflavich)" Date: Sat, 30 Aug 2025 16:50:44 -0400 Subject: [PATCH 5/7] need frame transformation! (not ai-added) --- regions/io/stcs/write.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/regions/io/stcs/write.py b/regions/io/stcs/write.py index ef9e2d2c0..b80127e4e 100644 --- a/regions/io/stcs/write.py +++ b/regions/io/stcs/write.py @@ -158,8 +158,8 @@ def _serialize_circle(region, frame, refpos, unit, precision): center_lat = format_coordinate(region.center.y, precision) radius = format_coordinate(region.radius, precision) else: - center_lon = format_coordinate(region.center.ra.to('degree').value, precision) - center_lat = format_coordinate(region.center.dec.to('degree').value, precision) + center_lon = format_coordinate(region.center.transform_to(frame).lon.to('degree').value, precision) + center_lat = format_coordinate(region.center.transform_to(frame).lat.to('degree').value, precision) radius = format_coordinate(region.radius.to('degree').value, precision) return f"Circle {frame} {refpos} {center_lon} {center_lat} {radius}" @@ -174,8 +174,8 @@ def _serialize_ellipse(region, frame, refpos, unit, precision): semi_minor = format_coordinate(region.height / 2, precision) angle = format_coordinate(region.angle.to('degree').value, precision) else: - center_lon = format_coordinate(region.center.ra.to('degree').value, precision) - center_lat = format_coordinate(region.center.dec.to('degree').value, precision) + center_lon = format_coordinate(region.center.transform_to(frame).lon.to('degree').value, precision) + center_lat = format_coordinate(region.center.transform_to(frame).lat.to('degree').value, precision) semi_major = format_coordinate((region.width / 2).to('degree').value, precision) semi_minor = format_coordinate((region.height / 2).to('degree').value, precision) angle = format_coordinate(region.angle.to('degree').value, precision) @@ -192,8 +192,8 @@ def _serialize_box(region, frame, refpos, unit, precision): height = format_coordinate(region.height, precision) angle = format_coordinate(region.angle.to('degree').value, precision) else: - center_lon = format_coordinate(region.center.ra.to('degree').value, precision) - center_lat = format_coordinate(region.center.dec.to('degree').value, precision) + center_lon = format_coordinate(region.center.transform_to(frame).lon.to('degree').value, precision) + center_lat = format_coordinate(region.center.transform_to(frame).lat.to('degree').value, precision) width = format_coordinate(region.width.to('degree').value, precision) height = format_coordinate(region.height.to('degree').value, precision) angle = format_coordinate(region.angle.to('degree').value, precision) @@ -212,8 +212,8 @@ def _serialize_polygon(region, frame, refpos, unit, precision): else: vertices_str = [] for vertex in region.vertices: - lon = format_coordinate(vertex.ra.to('degree').value, precision) - lat = format_coordinate(vertex.dec.to('degree').value, precision) + lon = format_coordinate(vertex.transform_to(frame).lon.to('degree').value, precision) + lat = format_coordinate(vertex.transform_to(frame).lat.to('degree').value, precision) vertices_str.extend([lon, lat]) vertices_coords = ' '.join(vertices_str) @@ -226,8 +226,8 @@ def _serialize_position(region, frame, refpos, unit, precision): center_lon = format_coordinate(region.center.x, precision) center_lat = format_coordinate(region.center.y, precision) else: - center_lon = format_coordinate(region.center.ra.to('degree').value, precision) - center_lat = format_coordinate(region.center.dec.to('degree').value, precision) + center_lon = format_coordinate(region.center.transform_to(frame).lon.to('degree').value, precision) + center_lat = format_coordinate(region.center.transform_to(frame).lat.to('degree').value, precision) return f"Position {frame} {refpos} {center_lon} {center_lat}" From 58c0e070ca745039723f7b2c6a0fbf3b014e6ace Mon Sep 17 00:00:00 2001 From: "Adam Ginsburg (keflavich)" Date: Sat, 30 Aug 2025 17:02:02 -0400 Subject: [PATCH 6/7] I told the AI to run the tests and it found a lot of bugs. Work in progress. --- regions/io/stcs/__init__.py | 5 + regions/io/stcs/read.py | 67 ++++--- regions/io/stcs/tests/test_stcs.py | 284 +++++++++++++++++++++++++++++ regions/io/stcs/write.py | 105 ++++++++--- 4 files changed, 410 insertions(+), 51 deletions(-) diff --git a/regions/io/stcs/__init__.py b/regions/io/stcs/__init__.py index 12a8115ac..b295c1cff 100644 --- a/regions/io/stcs/__init__.py +++ b/regions/io/stcs/__init__.py @@ -5,3 +5,8 @@ The Space-Time Coordinate (STC) string representation (STC-S) is an IVOA standard for describing spatial and temporal regions and coordinates. """ + +from .read import * # noqa: F401, F403 +from .write import * # noqa: F401, F403 +from .core import * # noqa: F401, F403 +from .connect import * # noqa: F401, F403 diff --git a/regions/io/stcs/read.py b/regions/io/stcs/read.py index 6c23bcf45..3beba2549 100644 --- a/regions/io/stcs/read.py +++ b/regions/io/stcs/read.py @@ -151,21 +151,22 @@ def _parse_shape(shape_name, match, frame, refpos, unit): # Parse numbers from the string numbers = parse_numbers(numbers_str) - # Create metadata + # Create metadata - only use valid RegionMeta keys meta = RegionMeta() - meta['frame'] = frame - meta['refpos'] = refpos - meta['unit'] = unit + meta['frame'] = frame # Use the standard 'frame' key + # Store STC-S specific info in comment for round-trip compatibility + meta['comment'] = f'stcs_refpos={refpos} stcs_unit={unit}' # Determine if this is pixel or sky coordinates is_pixel = (frame.upper() == 'IMAGE') coord_type = 'pixel' if is_pixel else 'sky' - # Get the appropriate region class - if shape_name not in stcs_shape_to_region[coord_type]: + # Get the appropriate region class (convert shape name to title case for mapping) + shape_title = shape_name.title() + if shape_title not in stcs_shape_to_region[coord_type]: raise STCSParserError(f"Unsupported shape: {shape_name}") - region_class = stcs_shape_to_region[coord_type][shape_name] + region_class = stcs_shape_to_region[coord_type][shape_title] # Parse based on shape type if shape_name == 'circle': @@ -191,11 +192,15 @@ def _parse_circle(region_class, numbers, frame, unit, meta, is_pixel): if is_pixel: center = PixCoord(lon, lat) - return region_class(center=center, radius=radius, meta=meta) + region = region_class(center=center, radius=radius) else: astropy_frame = stcs_frame_map.get(frame, 'icrs') center = SkyCoord(lon * u.Unit(unit), lat * u.Unit(unit), frame=astropy_frame) - return region_class(center=center, radius=radius * u.Unit(unit), meta=meta) + region = region_class(center=center, radius=radius * u.Unit(unit)) + + # Set metadata after creation + region.meta = meta + return region def _parse_ellipse(region_class, numbers, frame, unit, meta, is_pixel): @@ -207,14 +212,18 @@ def _parse_ellipse(region_class, numbers, frame, unit, meta, is_pixel): if is_pixel: center = PixCoord(lon, lat) - return region_class(center=center, width=2*semi_major, height=2*semi_minor, - angle=angle * u.degree, meta=meta) + region = region_class(center=center, width=2*semi_major, height=2*semi_minor, + angle=angle * u.degree) else: astropy_frame = stcs_frame_map.get(frame, 'icrs') center = SkyCoord(lon * u.Unit(unit), lat * u.Unit(unit), frame=astropy_frame) - return region_class(center=center, width=2*semi_major * u.Unit(unit), - height=2*semi_minor * u.Unit(unit), - angle=angle * u.degree, meta=meta) + region = region_class(center=center, width=2*semi_major * u.Unit(unit), + height=2*semi_minor * u.Unit(unit), + angle=angle * u.degree) + + # Set metadata after creation + region.meta = meta + return region def _parse_box(region_class, numbers, frame, unit, meta, is_pixel): @@ -226,14 +235,18 @@ def _parse_box(region_class, numbers, frame, unit, meta, is_pixel): if is_pixel: center = PixCoord(lon, lat) - return region_class(center=center, width=width, height=height, - angle=angle * u.degree, meta=meta) + region = region_class(center=center, width=width, height=height, + angle=angle * u.degree) else: astropy_frame = stcs_frame_map.get(frame, 'icrs') center = SkyCoord(lon * u.Unit(unit), lat * u.Unit(unit), frame=astropy_frame) - return region_class(center=center, width=width * u.Unit(unit), - height=height * u.Unit(unit), - angle=angle * u.degree, meta=meta) + region = region_class(center=center, width=width * u.Unit(unit), + height=height * u.Unit(unit), + angle=angle * u.degree) + + # Set metadata after creation + region.meta = meta + return region def _parse_polygon(region_class, numbers, frame, unit, meta, is_pixel): @@ -244,13 +257,17 @@ def _parse_polygon(region_class, numbers, frame, unit, meta, is_pixel): if is_pixel: vertices = PixCoord([numbers[i] for i in range(0, len(numbers), 2)], [numbers[i] for i in range(1, len(numbers), 2)]) - return region_class(vertices=vertices, meta=meta) + region = region_class(vertices=vertices) else: astropy_frame = stcs_frame_map.get(frame, 'icrs') lon_coords = [numbers[i] * u.Unit(unit) for i in range(0, len(numbers), 2)] lat_coords = [numbers[i] * u.Unit(unit) for i in range(1, len(numbers), 2)] vertices = SkyCoord(lon_coords, lat_coords, frame=astropy_frame) - return region_class(vertices=vertices, meta=meta) + region = region_class(vertices=vertices) + + # Set metadata after creation + region.meta = meta + return region def _parse_position(region_class, numbers, frame, unit, meta, is_pixel): @@ -262,8 +279,12 @@ def _parse_position(region_class, numbers, frame, unit, meta, is_pixel): if is_pixel: center = PixCoord(lon, lat) - return region_class(center=center, meta=meta) + region = region_class(center=center) else: astropy_frame = stcs_frame_map.get(frame, 'icrs') center = SkyCoord(lon * u.Unit(unit), lat * u.Unit(unit), frame=astropy_frame) - return region_class(center=center, meta=meta) + region = region_class(center=center) + + # Set metadata after creation + region.meta = meta + return region diff --git a/regions/io/stcs/tests/test_stcs.py b/regions/io/stcs/tests/test_stcs.py index 792994924..d0735a067 100644 --- a/regions/io/stcs/tests/test_stcs.py +++ b/regions/io/stcs/tests/test_stcs.py @@ -26,6 +26,8 @@ def test_validate_stcs_string(self): assert validate_stcs_string("Circle ICRS BARYCENTER 180.0 10.0 0.5") assert validate_stcs_string("Position FK5 GEOCENTER 45.0 -30.0") assert validate_stcs_string("polygon icrs barycenter 0 0 1 1 2 2") + assert validate_stcs_string("Circle GALACTIC BARYCENTER 90.0 45.0 0.5") + assert validate_stcs_string("Ellipse GALACTIC BARYCENTER 120.0 -30.0 2.0 1.0 60.0") # Invalid strings assert not validate_stcs_string("not an stcs string") @@ -42,6 +44,14 @@ def test_parse_coordinate_frame(self): assert frame == 'FK5' assert refpos == 'GEOCENTER' + frame, refpos = parse_coordinate_frame("Circle GALACTIC BARYCENTER 90.0 45.0 0.5") + assert frame == 'GALACTIC' + assert refpos == 'BARYCENTER' + + frame, refpos = parse_coordinate_frame("Ellipse GALACTIC TOPOCENTER 120.0 -30.0 2.0 1.0 60.0") + assert frame == 'GALACTIC' + assert refpos == 'TOPOCENTER' + # Test defaults frame, refpos = parse_coordinate_frame("Circle 180.0 10.0 0.5") assert frame == 'ICRS' @@ -209,6 +219,95 @@ def test_parse_insufficient_parameters(self): with pytest.raises(STCSParserError): _parse_stcs("Ellipse ICRS BARYCENTER 180.0 10.0") + def test_parse_galactic_circle(self): + """Test parsing Galactic circle regions.""" + stcs_str = "Circle GALACTIC BARYCENTER 90.0 45.0 0.5" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, CircleSkyRegion) + assert region.center.l.degree == 90.0 + assert region.center.b.degree == 45.0 + assert region.radius.degree == 0.5 + assert region.center.frame.name.lower() == 'galactic' + + def test_parse_galactic_ellipse(self): + """Test parsing Galactic ellipse regions.""" + stcs_str = "Ellipse GALACTIC BARYCENTER 120.0 -30.0 2.0 1.0 60.0" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, EllipseSkyRegion) + assert region.center.l.degree == 120.0 + assert region.center.b.degree == -30.0 + assert region.width.degree == 4.0 # 2 * semi_major + assert region.height.degree == 2.0 # 2 * semi_minor + assert region.angle.degree == 60.0 + assert region.center.frame.name.lower() == 'galactic' + + def test_parse_galactic_polygon(self): + """Test parsing Galactic polygon regions.""" + stcs_str = "Polygon GALACTIC BARYCENTER 45.0 45.0 50.0 45.0 50.0 50.0 45.0 50.0" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, PolygonSkyRegion) + assert len(region.vertices) == 4 + assert region.vertices[0].l.degree == 45.0 + assert region.vertices[0].b.degree == 45.0 + assert region.vertices[0].frame.name.lower() == 'galactic' + + def test_parse_galactic_position(self): + """Test parsing Galactic position regions.""" + stcs_str = "Position GALACTIC GEOCENTER 270.0 0.0" + regions = _parse_stcs(stcs_str) + + assert len(regions) == 1 + region = regions[0] + assert isinstance(region, PointSkyRegion) + assert region.center.l.degree == 270.0 + assert region.center.b.degree == 0.0 + assert region.center.frame.name.lower() == 'galactic' + + def test_parse_basic_galactic_file(self): + """Test parsing the stcs_basic_galactic.stcs test file.""" + import os + test_data_dir = os.path.join(os.path.dirname(__file__), 'data') + galactic_file = os.path.join(test_data_dir, 'stcs_basic_galactic.stcs') + + # Read and parse the file + with open(galactic_file, 'r') as f: + content = f.read() + + regions = _parse_stcs(content) + + # Should have 5 regions as per the file content + assert len(regions) == 5 + + # Check each region type and coordinate system + circle, ellipse, box, polygon, position = regions + + # All should be in Galactic coordinates + assert isinstance(circle, CircleSkyRegion) + assert circle.center.frame.name.lower() == 'galactic' + assert circle.center.l.degree == 180.0 + assert circle.center.b.degree == 10.0 + + assert isinstance(ellipse, EllipseSkyRegion) + assert ellipse.center.frame.name.lower() == 'galactic' + + assert isinstance(box, RectangleSkyRegion) + assert box.center.frame.name.lower() == 'galactic' + + assert isinstance(polygon, PolygonSkyRegion) + assert polygon.vertices[0].frame.name.lower() == 'galactic' + + assert isinstance(position, PointSkyRegion) + assert position.center.frame.name.lower() == 'galactic' + class TestSTCSSerialization: """Test STC-S serialization functionality.""" @@ -287,6 +386,55 @@ def test_serialize_multiple_regions(self): assert "Circle ICRS BARYCENTER 180 10 0.5" in lines[0] assert "Position FK5 BARYCENTER 45 -30" in lines[1] + def test_serialize_galactic_circle(self): + """Test serializing Galactic circle regions.""" + center = SkyCoord(90.0, 45.0, unit='degree', frame='galactic') + region = CircleSkyRegion(center=center, radius=0.5 * u.degree) + + stcs_str = _serialize_stcs(region) + expected = "Circle GALACTIC BARYCENTER 90 45 0.5" + assert stcs_str == expected + + def test_serialize_galactic_ellipse(self): + """Test serializing Galactic ellipse regions.""" + center = SkyCoord(120.0, -30.0, unit='degree', frame='galactic') + region = EllipseSkyRegion(center=center, width=4.0 * u.degree, + height=2.0 * u.degree, angle=60.0 * u.degree) + + stcs_str = _serialize_stcs(region) + expected = "Ellipse GALACTIC BARYCENTER 120 -30 2 1 60" + assert stcs_str == expected + + def test_serialize_galactic_box(self): + """Test serializing Galactic box regions.""" + center = SkyCoord(270.0, 0.0, unit='degree', frame='galactic') + region = RectangleSkyRegion(center=center, width=1.0 * u.degree, + height=0.5 * u.degree, angle=0.0 * u.degree) + + stcs_str = _serialize_stcs(region) + expected = "Box GALACTIC BARYCENTER 270 0 1 0.5 0" + assert stcs_str == expected + + def test_serialize_galactic_polygon(self): + """Test serializing Galactic polygon regions.""" + vertices = SkyCoord([45.0, 50.0, 50.0, 45.0], + [45.0, 45.0, 50.0, 50.0], + unit='degree', frame='galactic') + region = PolygonSkyRegion(vertices=vertices) + + stcs_str = _serialize_stcs(region) + expected = "Polygon GALACTIC BARYCENTER 45 45 50 45 50 50 45 50" + assert stcs_str == expected + + def test_serialize_galactic_position(self): + """Test serializing Galactic position regions.""" + center = SkyCoord(270.0, 0.0, unit='degree', frame='galactic') + region = PointSkyRegion(center=center) + + stcs_str = _serialize_stcs(region) + expected = "Position GALACTIC BARYCENTER 270 0" + assert stcs_str == expected + class TestSTCSRoundTrip: """Test round-trip conversion between regions and STC-S.""" @@ -342,3 +490,139 @@ def test_pixel_roundtrip(self): assert abs(r1.center.x - r2.center.x) < 1e-10 assert abs(r1.center.y - r2.center.y) < 1e-10 assert abs(r1.radius - r2.radius) < 1e-10 + + def test_galactic_circle_roundtrip(self): + """Test Galactic circle region round-trip conversion.""" + original_stcs = "Circle GALACTIC BARYCENTER 90.0 45.0 0.5" + regions = _parse_stcs(original_stcs) + serialized = _serialize_stcs(regions) + regions2 = _parse_stcs(serialized) + + assert len(regions) == len(regions2) == 1 + r1, r2 = regions[0], regions2[0] + + assert isinstance(r1, CircleSkyRegion) + assert isinstance(r2, CircleSkyRegion) + assert r1.center.frame.name.lower() == 'galactic' + assert r2.center.frame.name.lower() == 'galactic' + assert abs(r1.center.l.degree - r2.center.l.degree) < 1e-10 + assert abs(r1.center.b.degree - r2.center.b.degree) < 1e-10 + assert abs(r1.radius.degree - r2.radius.degree) < 1e-10 + + def test_galactic_ellipse_roundtrip(self): + """Test Galactic ellipse region round-trip conversion.""" + original_stcs = "Ellipse GALACTIC BARYCENTER 120.0 -30.0 2.0 1.0 60.0" + regions = _parse_stcs(original_stcs) + serialized = _serialize_stcs(regions) + regions2 = _parse_stcs(serialized) + + assert len(regions) == len(regions2) == 1 + r1, r2 = regions[0], regions2[0] + + assert isinstance(r1, EllipseSkyRegion) + assert isinstance(r2, EllipseSkyRegion) + assert r1.center.frame.name.lower() == 'galactic' + assert r2.center.frame.name.lower() == 'galactic' + assert abs(r1.center.l.degree - r2.center.l.degree) < 1e-10 + assert abs(r1.center.b.degree - r2.center.b.degree) < 1e-10 + assert abs(r1.width.degree - r2.width.degree) < 1e-10 + assert abs(r1.height.degree - r2.height.degree) < 1e-10 + assert abs(r1.angle.degree - r2.angle.degree) < 1e-10 + + def test_galactic_polygon_roundtrip(self): + """Test Galactic polygon region round-trip conversion.""" + original_stcs = "Polygon GALACTIC BARYCENTER 45.0 45.0 50.0 45.0 50.0 50.0 45.0 50.0" + regions = _parse_stcs(original_stcs) + serialized = _serialize_stcs(regions) + regions2 = _parse_stcs(serialized) + + assert len(regions) == len(regions2) == 1 + r1, r2 = regions[0], regions2[0] + + assert isinstance(r1, PolygonSkyRegion) + assert isinstance(r2, PolygonSkyRegion) + assert r1.vertices[0].frame.name.lower() == 'galactic' + assert r2.vertices[0].frame.name.lower() == 'galactic' + assert len(r1.vertices) == len(r2.vertices) == 4 + + for v1, v2 in zip(r1.vertices, r2.vertices): + assert abs(v1.l.degree - v2.l.degree) < 1e-10 + assert abs(v1.b.degree - v2.b.degree) < 1e-10 + + def test_galactic_position_roundtrip(self): + """Test Galactic position region round-trip conversion.""" + original_stcs = "Position GALACTIC GEOCENTER 270.0 0.0" + regions = _parse_stcs(original_stcs) + serialized = _serialize_stcs(regions) + regions2 = _parse_stcs(serialized) + + assert len(regions) == len(regions2) == 1 + r1, r2 = regions[0], regions2[0] + + assert isinstance(r1, PointSkyRegion) + assert isinstance(r2, PointSkyRegion) + assert r1.center.frame.name.lower() == 'galactic' + assert r2.center.frame.name.lower() == 'galactic' + assert abs(r1.center.l.degree - r2.center.l.degree) < 1e-10 + assert abs(r1.center.b.degree - r2.center.b.degree) < 1e-10 + + def test_basic_galactic_file_roundtrip(self): + """Test round-trip conversion for the entire stcs_basic_galactic.stcs file.""" + import os + test_data_dir = os.path.join(os.path.dirname(__file__), 'data') + galactic_file = os.path.join(test_data_dir, 'stcs_basic_galactic.stcs') + + # Read original file + with open(galactic_file, 'r') as f: + original_content = f.read() + + # Parse -> Serialize -> Parse + regions1 = _parse_stcs(original_content) + serialized = _serialize_stcs(regions1) + regions2 = _parse_stcs(serialized) + + # Verify consistency + assert len(regions1) == len(regions2) == 5 + + for r1, r2 in zip(regions1, regions2): + # Both should be the same type + assert type(r1) == type(r2) + + # Both should be in Galactic coordinates + if hasattr(r1, 'center'): + assert r1.center.frame.name.lower() == 'galactic' + assert r2.center.frame.name.lower() == 'galactic' + assert abs(r1.center.l.degree - r2.center.l.degree) < 1e-10 + assert abs(r1.center.b.degree - r2.center.b.degree) < 1e-10 + elif hasattr(r1, 'vertices'): + assert r1.vertices[0].frame.name.lower() == 'galactic' + assert r2.vertices[0].frame.name.lower() == 'galactic' + for v1, v2 in zip(r1.vertices, r2.vertices): + assert abs(v1.l.degree - v2.l.degree) < 1e-10 + assert abs(v1.b.degree - v2.b.degree) < 1e-10 + + def test_mixed_coordinate_systems_roundtrip(self): + """Test round-trip conversion with mixed coordinate systems.""" + mixed_stcs = """Circle ICRS BARYCENTER 180.0 10.0 0.5 +Circle GALACTIC BARYCENTER 90.0 45.0 0.3 +Position FK5 GEOCENTER 85.0 -15.0 +Position GALACTIC BARYCENTER 270.0 0.0""" + + regions1 = _parse_stcs(mixed_stcs) + serialized = _serialize_stcs(regions1) + regions2 = _parse_stcs(serialized) + + assert len(regions1) == len(regions2) == 4 + + # Check coordinate systems are preserved + assert regions1[0].center.frame.name.lower() == 'icrs' + assert regions2[0].center.frame.name.lower() == 'icrs' + + assert regions1[1].center.frame.name.lower() == 'galactic' + assert regions2[1].center.frame.name.lower() == 'galactic' + + assert regions1[2].center.frame.name.lower() == 'fk5' + assert regions2[2].center.frame.name.lower() == 'fk5' + + assert regions1[3].center.frame.name.lower() == 'galactic' + assert regions2[3].center.frame.name.lower() == 'galactic' diff --git a/regions/io/stcs/write.py b/regions/io/stcs/write.py index b80127e4e..efa7bdf2d 100644 --- a/regions/io/stcs/write.py +++ b/regions/io/stcs/write.py @@ -9,7 +9,7 @@ from regions.core import (PixelRegion, Region, Regions, SkyRegion) from regions.core.registry import RegionsRegistry from regions.io.stcs.core import (format_coordinate, region_to_stcs_shape, - stcs_templates) + stcs_frame_map, stcs_templates) from regions.shapes import RegularPolygonPixelRegion __all__ = [] @@ -114,25 +114,54 @@ def _serialize_region_stcs(region, precision=8): is_pixel = isinstance(region, PixelRegion) is_sky = isinstance(region, SkyRegion) + # Check for STC-S metadata first (for round-trip consistency) + frame = None + refpos = None + unit = None + + if hasattr(region, 'meta') and region.meta: + # Extract frame from metadata + if 'frame' in region.meta: + frame = region.meta['frame'] + + # Extract refpos and unit from comment metadata + if 'comment' in region.meta: + comment = region.meta['comment'] + import re + refpos_match = re.search(r'stcs_refpos=(\w+)', comment) + unit_match = re.search(r'stcs_unit=(\w+)', comment) + if refpos_match: + refpos = refpos_match.group(1) + if unit_match: + unit = unit_match.group(1) + + # Fall back to defaults if not found in metadata if is_pixel: - frame = 'IMAGE' - refpos = 'UNKNOWN' - unit = 'pixel' + if frame is None: + frame = 'IMAGE' + if refpos is None: + refpos = 'UNKNOWN' + if unit is None: + unit = 'pixel' elif is_sky: - # Get frame from region's coordinate system - if hasattr(region, 'center') and hasattr(region.center, 'frame'): - frame_name = region.center.frame.name.upper() - frame = {'ICRS': 'ICRS', 'FK5': 'FK5', 'FK4': 'FK4', - 'GALACTIC': 'GALACTIC', 'GEOCENTRICTRUEECLIPTIC': 'ECLIPTIC'}.get(frame_name, 'ICRS') - else: - frame = 'ICRS' - refpos = 'BARYCENTER' - - # Determine unit from region coordinates - if hasattr(region, 'center'): - unit = _get_coordinate_unit(region.center) - else: - unit = 'degree' + # Get frame from region's coordinate system if not in metadata + if frame is None: + if hasattr(region, 'center') and hasattr(region.center, 'frame'): + frame_name = region.center.frame.name.upper() + frame = {'ICRS': 'ICRS', 'FK5': 'FK5', 'FK4': 'FK4', + 'GALACTIC': 'GALACTIC', 'GEOCENTRICTRUEECLIPTIC': 'ECLIPTIC'}.get(frame_name, 'ICRS') + else: + frame = 'ICRS' + + if refpos is None: + refpos = 'BARYCENTER' + + # Determine unit from region coordinates if not in metadata + if unit is None: + if hasattr(region, 'center'): + unit = _get_coordinate_unit(region.center) + else: + unit = 'degree' else: raise ValueError(f"Unknown region coordinate type: {region}") @@ -158,8 +187,12 @@ def _serialize_circle(region, frame, refpos, unit, precision): center_lat = format_coordinate(region.center.y, precision) radius = format_coordinate(region.radius, precision) else: - center_lon = format_coordinate(region.center.transform_to(frame).lon.to('degree').value, precision) - center_lat = format_coordinate(region.center.transform_to(frame).lat.to('degree').value, precision) + # Convert STC-S frame string to astropy frame + astropy_frame = stcs_frame_map.get(frame, 'icrs') + transformed_coord = region.center.transform_to(astropy_frame) + # Use spherical coordinates to get longitude and latitude generically + center_lon = format_coordinate(transformed_coord.spherical.lon.to('degree').value, precision) + center_lat = format_coordinate(transformed_coord.spherical.lat.to('degree').value, precision) radius = format_coordinate(region.radius.to('degree').value, precision) return f"Circle {frame} {refpos} {center_lon} {center_lat} {radius}" @@ -174,8 +207,12 @@ def _serialize_ellipse(region, frame, refpos, unit, precision): semi_minor = format_coordinate(region.height / 2, precision) angle = format_coordinate(region.angle.to('degree').value, precision) else: - center_lon = format_coordinate(region.center.transform_to(frame).lon.to('degree').value, precision) - center_lat = format_coordinate(region.center.transform_to(frame).lat.to('degree').value, precision) + # Convert STC-S frame string to astropy frame + astropy_frame = stcs_frame_map.get(frame, 'icrs') + transformed_coord = region.center.transform_to(astropy_frame) + # Use spherical coordinates to get longitude and latitude generically + center_lon = format_coordinate(transformed_coord.spherical.lon.to('degree').value, precision) + center_lat = format_coordinate(transformed_coord.spherical.lat.to('degree').value, precision) semi_major = format_coordinate((region.width / 2).to('degree').value, precision) semi_minor = format_coordinate((region.height / 2).to('degree').value, precision) angle = format_coordinate(region.angle.to('degree').value, precision) @@ -192,8 +229,12 @@ def _serialize_box(region, frame, refpos, unit, precision): height = format_coordinate(region.height, precision) angle = format_coordinate(region.angle.to('degree').value, precision) else: - center_lon = format_coordinate(region.center.transform_to(frame).lon.to('degree').value, precision) - center_lat = format_coordinate(region.center.transform_to(frame).lat.to('degree').value, precision) + # Convert STC-S frame string to astropy frame + astropy_frame = stcs_frame_map.get(frame, 'icrs') + transformed_coord = region.center.transform_to(astropy_frame) + # Use spherical coordinates to get longitude and latitude generically + center_lon = format_coordinate(transformed_coord.spherical.lon.to('degree').value, precision) + center_lat = format_coordinate(transformed_coord.spherical.lat.to('degree').value, precision) width = format_coordinate(region.width.to('degree').value, precision) height = format_coordinate(region.height.to('degree').value, precision) angle = format_coordinate(region.angle.to('degree').value, precision) @@ -212,8 +253,12 @@ def _serialize_polygon(region, frame, refpos, unit, precision): else: vertices_str = [] for vertex in region.vertices: - lon = format_coordinate(vertex.transform_to(frame).lon.to('degree').value, precision) - lat = format_coordinate(vertex.transform_to(frame).lat.to('degree').value, precision) + # Convert STC-S frame string to astropy frame for vertex transformation + astropy_frame = stcs_frame_map.get(frame, 'icrs') + transformed_vertex = vertex.transform_to(astropy_frame) + # Use spherical coordinates to get longitude and latitude generically + lon = format_coordinate(transformed_vertex.spherical.lon.to('degree').value, precision) + lat = format_coordinate(transformed_vertex.spherical.lat.to('degree').value, precision) vertices_str.extend([lon, lat]) vertices_coords = ' '.join(vertices_str) @@ -226,8 +271,12 @@ def _serialize_position(region, frame, refpos, unit, precision): center_lon = format_coordinate(region.center.x, precision) center_lat = format_coordinate(region.center.y, precision) else: - center_lon = format_coordinate(region.center.transform_to(frame).lon.to('degree').value, precision) - center_lat = format_coordinate(region.center.transform_to(frame).lat.to('degree').value, precision) + # Convert STC-S frame string to astropy frame + astropy_frame = stcs_frame_map.get(frame, 'icrs') + transformed_coord = region.center.transform_to(astropy_frame) + # Use spherical coordinates to get longitude and latitude generically + center_lon = format_coordinate(transformed_coord.spherical.lon.to('degree').value, precision) + center_lat = format_coordinate(transformed_coord.spherical.lat.to('degree').value, precision) return f"Position {frame} {refpos} {center_lon} {center_lat}" From 42b8fcef2f1ded64baddf839b1e88110a0081c28 Mon Sep 17 00:00:00 2001 From: "Adam Ginsburg (keflavich)" Date: Sat, 30 Aug 2025 17:10:05 -0400 Subject: [PATCH 7/7] add a standard header to stcs files --- docs/stcs_io.rst | 9 +++++++-- regions/io/stcs/tests/data/stcs_basic.stcs | 3 +-- regions/io/stcs/tests/data/stcs_complex.stcs | 4 +--- regions/io/stcs/tests/data/stcs_pixel.stcs | 3 +-- regions/io/stcs/write.py | 6 +++--- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/stcs_io.rst b/docs/stcs_io.rst index fc1b2939e..f766fff02 100644 --- a/docs/stcs_io.rst +++ b/docs/stcs_io.rst @@ -388,12 +388,17 @@ Files can contain: * Multiple regions, one per line * Blank lines (ignored) +All STC-S files generated by astropy-regions include a standard header: + +.. code-block:: + + # Region file format: STC-S astropy/regions + Example STC-S file: .. code-block:: - # STC-S region file - # Generated by astropy-regions + # Region file format: STC-S astropy/regions # Examples created based on the IVOA STC-S standard specification # Central source diff --git a/regions/io/stcs/tests/data/stcs_basic.stcs b/regions/io/stcs/tests/data/stcs_basic.stcs index aaec0aa4a..5d9838c23 100644 --- a/regions/io/stcs/tests/data/stcs_basic.stcs +++ b/regions/io/stcs/tests/data/stcs_basic.stcs @@ -1,7 +1,6 @@ -# STC-S test file with basic shapes +# Region file format: STC-S astropy/regions # Examples created based on the IVOA STC-S standard specification: # https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html -# # These examples demonstrate basic region shapes and coordinate systems # supported by the STC-S format, designed for comprehensive testing # of the astropy-regions STC-S I/O module. diff --git a/regions/io/stcs/tests/data/stcs_complex.stcs b/regions/io/stcs/tests/data/stcs_complex.stcs index 6df0e6f5e..bcefc755d 100644 --- a/regions/io/stcs/tests/data/stcs_complex.stcs +++ b/regions/io/stcs/tests/data/stcs_complex.stcs @@ -1,8 +1,6 @@ -# STC-S test file with complex regions and coordinate systems -# +# Region file format: STC-S astropy/regions # Some examples inspired by the CDS STC-S Rust implementation: # https://github.com/cds-astro/cds-stc-rust/ -# # Additional examples created based on the IVOA STC-S standard to test # various coordinate systems (ICRS, GALACTIC, FK5) and reference positions # (TOPOCENTER, BARYCENTER, GEOCENTER). diff --git a/regions/io/stcs/tests/data/stcs_pixel.stcs b/regions/io/stcs/tests/data/stcs_pixel.stcs index a7f67f05f..132089943 100644 --- a/regions/io/stcs/tests/data/stcs_pixel.stcs +++ b/regions/io/stcs/tests/data/stcs_pixel.stcs @@ -1,7 +1,6 @@ -# STC-S test file with pixel coordinates +# Region file format: STC-S astropy/regions # Examples created based on the IVOA STC-S standard specification: # https://www.ivoa.net/documents/Notes/STC-S/20091030/NOTE-STC-S-1.33-20091030.html -# # These examples demonstrate the IMAGE coordinate frame for pixel-based # regions, designed to test the astropy-regions STC-S I/O module's # handling of pixel coordinate systems. diff --git a/regions/io/stcs/write.py b/regions/io/stcs/write.py index efa7bdf2d..2404c6454 100644 --- a/regions/io/stcs/write.py +++ b/regions/io/stcs/write.py @@ -49,7 +49,9 @@ def _serialize_stcs(regions, precision=8): warnings.warn(f'Cannot serialize region {region}: {e}', UserWarning) - return '\n'.join(region_strings) + # Add STC-S file header + header = '# Region file format: STC-S astropy/regions' + return header + '\n' + '\n'.join(region_strings) @RegionsRegistry.register(Region, 'write', 'stcs') @@ -77,8 +79,6 @@ def _write_stcs(regions, filename, precision=8, overwrite=False): stcs_string = _serialize_stcs(regions, precision=precision) with open(filename, 'w') as f: - f.write('# STC-S region file\n') - f.write('# Generated by astropy-regions\n') f.write(stcs_string)