diff --git a/docs/region_io.rst b/docs/region_io.rst index 3aced3cb..959cfeea 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 Extension only 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 Extension only Region File Format Limitations @@ -181,3 +184,4 @@ Region File Format Limitations ds9_io fits_io + stcs_io diff --git a/docs/stcs_io.rst b/docs/stcs_io.rst new file mode 100644 index 00000000..f766fff0 --- /dev/null +++ b/docs/stcs_io.rst @@ -0,0 +1,660 @@ +.. _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 + ... # 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 + ... """ + >>> 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 + ... # 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 + ... \"\"\" + >>> + >>> 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) + +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:: + + # Region file format: STC-S astropy/regions + # Examples created based on the IVOA STC-S standard specification + + # 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 (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 +---------- + +- `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/__init__.py b/regions/io/__init__.py index c58b0dbd..bead0d05 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/__init__.py b/regions/io/stcs/__init__.py new file mode 100644 index 00000000..b295c1cf --- /dev/null +++ b/regions/io/stcs/__init__.py @@ -0,0 +1,12 @@ +# 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. +""" + +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/connect.py b/regions/io/stcs/connect.py new file mode 100644 index 00000000..c0bf22ad --- /dev/null +++ b/regions/io/stcs/connect.py @@ -0,0 +1,35 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +__all__ = [] + + +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'} + The method name called that needs auto-identification. + + filepath : str + The path to the file. + + Returns + ------- + result : bool + 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 in ['read', 'write']: + if isinstance(filepath, str): + return filepath.lower().endswith(exten[methodname]) + + return False diff --git a/regions/io/stcs/core.py b/regions/io/stcs/core.py new file mode 100644 index 00000000..fa856c8c --- /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/read.py b/regions/io/stcs/read.py new file mode 100644 index 00000000..3beba254 --- /dev/null +++ b/regions/io/stcs/read.py @@ -0,0 +1,290 @@ +# 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 - only use valid RegionMeta keys + meta = RegionMeta() + 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 (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_title] + + # 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) + 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) + 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): + """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) + 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) + 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): + """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) + 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) + 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): + """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)]) + 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) + 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): + """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) + 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) + region = region_class(center=center) + + # Set metadata after creation + region.meta = meta + return region diff --git a/regions/io/stcs/tests/__init__.py b/regions/io/stcs/tests/__init__.py new file mode 100644 index 00000000..9dce85d0 --- /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 00000000..5d9838c2 --- /dev/null +++ b/regions/io/stcs/tests/data/stcs_basic.stcs @@ -0,0 +1,21 @@ +# 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. + +# 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 00000000..bcefc755 --- /dev/null +++ b/regions/io/stcs/tests/data/stcs_complex.stcs @@ -0,0 +1,17 @@ +# 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). + +# Example demonstrating TOPOCENTER reference position +Circle ICRS TOPOCENTER 147.6 69.9 0.4 + +# Position example (spatial coordinates only, temporal coordinates excluded) +Position ICRS BARYCENTER 147.3 69.3 + +# 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 new file mode 100644 index 00000000..13208994 --- /dev/null +++ b/regions/io/stcs/tests/data/stcs_pixel.stcs @@ -0,0 +1,11 @@ +# 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. + +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 00000000..d0735a06 --- /dev/null +++ b/regions/io/stcs/tests/test_stcs.py @@ -0,0 +1,628 @@ +# 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") + 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") + 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' + + 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' + 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") + + 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.""" + + 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] + + 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.""" + + 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 + + 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 new file mode 100644 index 00000000..2404c645 --- /dev/null +++ b/regions/io/stcs/write.py @@ -0,0 +1,303 @@ +# 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_frame_map, 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) + + # 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') +@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(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) + + # 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: + 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 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}") + + # 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: + # 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}" + + +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: + # 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) + + 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: + # 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) + + 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: + # 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) + 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: + # 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}" + + +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'