fitsview 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fitsview/__init__.py +30 -0
- fitsview/_version.py +4 -0
- fitsview/handlers.py +248 -0
- fitsview/tests/__init__.py +1 -0
- fitsview/tests/generate_files.py +51 -0
- fitsview/tests/test_handlers.py +298 -0
- fitsview-0.1.0.data/data/etc/jupyter/jupyter_server_config.d/fitsview.json +7 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/install.json +5 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/package.json +218 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/static/308d31810c640fc2ef5b.wasm +0 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/static/55.099988c1bc884ad9eab2.js +1 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/static/728.f72867741749a85b33b3.js +1 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/static/764.af4af5395b1fc54801b8.js +1 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/static/777.b2a5d1ff502d61e4b889.js +1 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/static/remoteEntry.d410838ebf2e9fb8598c.js +1 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/static/style.js +4 -0
- fitsview-0.1.0.data/data/share/jupyter/labextensions/fitsview/static/third-party-licenses.json +22 -0
- fitsview-0.1.0.dist-info/METADATA +375 -0
- fitsview-0.1.0.dist-info/RECORD +21 -0
- fitsview-0.1.0.dist-info/WHEEL +4 -0
- fitsview-0.1.0.dist-info/licenses/LICENSE +29 -0
fitsview/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
except ImportError:
|
|
4
|
+
# Fallback when using the package in dev mode without installing
|
|
5
|
+
# in editable mode with pip. It is highly recommended to install
|
|
6
|
+
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
|
|
7
|
+
import warnings
|
|
8
|
+
warnings.warn("Importing 'fitsview' outside a proper installation.")
|
|
9
|
+
__version__ = "dev"
|
|
10
|
+
|
|
11
|
+
from .handlers import setup_handlers
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _jupyter_labextension_paths():
|
|
15
|
+
return [{
|
|
16
|
+
"src": "labextension",
|
|
17
|
+
"dest": "fitsview"
|
|
18
|
+
}]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _jupyter_server_extension_points():
|
|
22
|
+
return [{
|
|
23
|
+
"module": "fitsview"
|
|
24
|
+
}]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_jupyter_server_extension(server_app):
|
|
28
|
+
"""Register the API handlers."""
|
|
29
|
+
setup_handlers(server_app.web_app)
|
|
30
|
+
server_app.log.info("Registered fitsview server extension")
|
fitsview/_version.py
ADDED
fitsview/handlers.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API handlers for FITS file operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tornado
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from jupyter_server.base.handlers import APIHandler, JupyterHandler
|
|
9
|
+
from jupyter_server.utils import url_path_join
|
|
10
|
+
from astropy.io import fits
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ArrayType(str, Enum):
|
|
15
|
+
"""
|
|
16
|
+
Enum mapping numpy dtypes to Rust-style type specifiers.
|
|
17
|
+
|
|
18
|
+
Uses dtype.kind and dtype.itemsize to unambiguously determine the type,
|
|
19
|
+
avoiding string comparison issues with numpy's various dtype representations.
|
|
20
|
+
|
|
21
|
+
Values use Rust-style type names for consistency across the wire protocol.
|
|
22
|
+
"""
|
|
23
|
+
INT8 = "i8"
|
|
24
|
+
UINT8 = "u8"
|
|
25
|
+
INT16 = "i16"
|
|
26
|
+
UINT16 = "u16"
|
|
27
|
+
INT32 = "i32"
|
|
28
|
+
UINT32 = "u32"
|
|
29
|
+
INT64 = "i64"
|
|
30
|
+
UINT64 = "u64"
|
|
31
|
+
FLOAT32 = "f32"
|
|
32
|
+
FLOAT64 = "f64"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def numpy_dtype_to_array_type(dtype: np.dtype) -> ArrayType:
|
|
36
|
+
"""
|
|
37
|
+
Map a numpy dtype to an ArrayType based on kind and itemsize.
|
|
38
|
+
|
|
39
|
+
dtype.kind values:
|
|
40
|
+
'b' - boolean
|
|
41
|
+
'i' - signed integer
|
|
42
|
+
'u' - unsigned integer
|
|
43
|
+
'f' - floating-point
|
|
44
|
+
'c' - complex floating-point
|
|
45
|
+
'S', 'a' - byte string
|
|
46
|
+
'U' - unicode string
|
|
47
|
+
'V' - void (raw data)
|
|
48
|
+
"""
|
|
49
|
+
kind = dtype.kind
|
|
50
|
+
size = dtype.itemsize
|
|
51
|
+
|
|
52
|
+
if kind == 'i': # Signed integer
|
|
53
|
+
if size == 1:
|
|
54
|
+
return ArrayType.INT8
|
|
55
|
+
elif size == 2:
|
|
56
|
+
return ArrayType.INT16
|
|
57
|
+
elif size == 4:
|
|
58
|
+
return ArrayType.INT32
|
|
59
|
+
elif size == 8:
|
|
60
|
+
return ArrayType.INT64
|
|
61
|
+
elif kind == 'u': # Unsigned integer
|
|
62
|
+
if size == 1:
|
|
63
|
+
return ArrayType.UINT8
|
|
64
|
+
elif size == 2:
|
|
65
|
+
return ArrayType.UINT16
|
|
66
|
+
elif size == 4:
|
|
67
|
+
return ArrayType.UINT32
|
|
68
|
+
elif size == 8:
|
|
69
|
+
return ArrayType.UINT64
|
|
70
|
+
elif kind == 'f': # Floating-point
|
|
71
|
+
if size == 4:
|
|
72
|
+
return ArrayType.FLOAT32
|
|
73
|
+
elif size == 8:
|
|
74
|
+
return ArrayType.FLOAT64
|
|
75
|
+
elif kind == 'b': # Boolean - treat as uint8
|
|
76
|
+
return ArrayType.UINT8
|
|
77
|
+
|
|
78
|
+
# Default to float64 for unsupported types
|
|
79
|
+
return ArrayType.FLOAT64
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FITSMetadataHandler(APIHandler):
|
|
83
|
+
"""Handler for retrieving FITS file metadata (headers, dimensions)."""
|
|
84
|
+
|
|
85
|
+
@tornado.web.authenticated
|
|
86
|
+
async def get(self):
|
|
87
|
+
path = self.get_argument('path')
|
|
88
|
+
|
|
89
|
+
# Validate path exists via Contents API
|
|
90
|
+
cm = self.contents_manager
|
|
91
|
+
try:
|
|
92
|
+
await cm.get(path, content=False)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
self.set_status(404)
|
|
95
|
+
self.finish(json.dumps({'error': f'File not found: {path}'}))
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Get filesystem path for astropy
|
|
99
|
+
os_path = cm._get_os_path(path)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
with fits.open(os_path) as hdul:
|
|
103
|
+
hdus = []
|
|
104
|
+
for i, hdu in enumerate(hdul):
|
|
105
|
+
# Use repr() to get the raw 80-column card format with newlines
|
|
106
|
+
# str() returns an object description, repr() returns the actual content
|
|
107
|
+
header_str = repr(hdu.header)
|
|
108
|
+
|
|
109
|
+
hdu_info = {
|
|
110
|
+
'index': i,
|
|
111
|
+
'name': hdu.name,
|
|
112
|
+
'type': hdu.__class__.__name__,
|
|
113
|
+
'header': header_str, # Raw 80-column format string
|
|
114
|
+
}
|
|
115
|
+
if hdu.data is not None:
|
|
116
|
+
hdu_info['shape'] = list(hdu.data.shape)
|
|
117
|
+
# Use ArrayType enum for consistent type representation
|
|
118
|
+
hdu_info['arrayType'] = numpy_dtype_to_array_type(hdu.data.dtype).value
|
|
119
|
+
else:
|
|
120
|
+
hdu_info['shape'] = None
|
|
121
|
+
hdu_info['arrayType'] = None
|
|
122
|
+
hdus.append(hdu_info)
|
|
123
|
+
|
|
124
|
+
result = {
|
|
125
|
+
'path': path,
|
|
126
|
+
'hdus': hdus
|
|
127
|
+
}
|
|
128
|
+
self.finish(json.dumps(result))
|
|
129
|
+
except Exception as e:
|
|
130
|
+
self.set_status(500)
|
|
131
|
+
self.finish(json.dumps({'error': f'Error reading FITS file: {str(e)}'}))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class FITSSliceHandler(JupyterHandler):
|
|
135
|
+
"""Handler for retrieving data slices from FITS files.
|
|
136
|
+
|
|
137
|
+
Uses JupyterHandler instead of APIHandler to support binary responses.
|
|
138
|
+
|
|
139
|
+
The slices parameter uses NumPy/Python conventions:
|
|
140
|
+
- Zero-indexed
|
|
141
|
+
- Axis order matches NumPy (e.g., for 3D data: z,y,x or depth,row,col)
|
|
142
|
+
- Half-open intervals [start, stop) with exclusive upper bound
|
|
143
|
+
- Format: "start:stop,start:stop,..." for each axis
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
@tornado.web.authenticated
|
|
147
|
+
async def get(self):
|
|
148
|
+
path = self.get_argument('path')
|
|
149
|
+
hdu = int(self.get_argument('hdu', 0))
|
|
150
|
+
slices_str = self.get_argument('slices') # e.g., "0:10,5:15" for 2D
|
|
151
|
+
|
|
152
|
+
# Parse slices parameter
|
|
153
|
+
try:
|
|
154
|
+
slice_tuples = []
|
|
155
|
+
for s in slices_str.split(','):
|
|
156
|
+
parts = s.strip().split(':')
|
|
157
|
+
if len(parts) != 2:
|
|
158
|
+
raise ValueError(f"Invalid slice format: '{s}'. Expected 'start:stop'.")
|
|
159
|
+
start, stop = int(parts[0]), int(parts[1])
|
|
160
|
+
if start < 0 or stop < 0:
|
|
161
|
+
raise ValueError(f"Negative indices not supported: '{s}'")
|
|
162
|
+
if start >= stop:
|
|
163
|
+
raise ValueError(f"Start must be less than stop: '{s}'")
|
|
164
|
+
slice_tuples.append((start, stop))
|
|
165
|
+
except ValueError as e:
|
|
166
|
+
self.set_status(400)
|
|
167
|
+
self.finish(json.dumps({'error': str(e)}))
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Validate path exists via Contents API
|
|
171
|
+
cm = self.contents_manager
|
|
172
|
+
try:
|
|
173
|
+
await cm.get(path, content=False)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
self.set_status(404)
|
|
176
|
+
self.finish(json.dumps({'error': f'File not found: {path}'}))
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Get filesystem path for astropy
|
|
180
|
+
os_path = cm._get_os_path(path)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
with fits.open(os_path) as hdul:
|
|
184
|
+
if hdu >= len(hdul):
|
|
185
|
+
self.set_status(400)
|
|
186
|
+
self.finish(json.dumps({
|
|
187
|
+
'error': f'HDU index {hdu} out of range (file has {len(hdul)} HDUs)'
|
|
188
|
+
}))
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
data = hdul[hdu].data
|
|
192
|
+
if data is None:
|
|
193
|
+
self.set_status(400)
|
|
194
|
+
self.finish(json.dumps({
|
|
195
|
+
'error': f'HDU {hdu} has no data'
|
|
196
|
+
}))
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# Validate number of slice dimensions matches data dimensions
|
|
200
|
+
if len(slice_tuples) != len(data.shape):
|
|
201
|
+
self.set_status(400)
|
|
202
|
+
self.finish(json.dumps({
|
|
203
|
+
'error': f'Number of slice dimensions ({len(slice_tuples)}) does not match '
|
|
204
|
+
f'data dimensions ({len(data.shape)}). Data shape: {list(data.shape)}'
|
|
205
|
+
}))
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# Validate slice bounds for each axis
|
|
209
|
+
for axis, ((start, stop), size) in enumerate(zip(slice_tuples, data.shape)):
|
|
210
|
+
if stop > size:
|
|
211
|
+
self.set_status(400)
|
|
212
|
+
self.finish(json.dumps({
|
|
213
|
+
'error': f'Slice [{start}:{stop}] on axis {axis} out of bounds '
|
|
214
|
+
f'for dimension size {size}. Data shape: {list(data.shape)}'
|
|
215
|
+
}))
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# Build the slice tuple and extract data
|
|
219
|
+
numpy_slices = tuple(slice(start, stop) for start, stop in slice_tuples)
|
|
220
|
+
slice_data = data[numpy_slices]
|
|
221
|
+
|
|
222
|
+
# Convert to little-endian for JavaScript TypedArray compatibility
|
|
223
|
+
le_dtype = slice_data.dtype.newbyteorder('<')
|
|
224
|
+
slice_bytes = slice_data.astype(le_dtype).tobytes()
|
|
225
|
+
|
|
226
|
+
# Use ArrayType enum for consistent type representation
|
|
227
|
+
array_type = numpy_dtype_to_array_type(slice_data.dtype)
|
|
228
|
+
|
|
229
|
+
self.set_header('Content-Type', 'application/octet-stream')
|
|
230
|
+
self.set_header('X-FITS-Shape', json.dumps(list(slice_data.shape)))
|
|
231
|
+
self.set_header('X-FITS-Type', array_type.value)
|
|
232
|
+
self.finish(slice_bytes)
|
|
233
|
+
|
|
234
|
+
except Exception as e:
|
|
235
|
+
self.set_status(500)
|
|
236
|
+
self.finish(json.dumps({'error': f'Error reading FITS data: {str(e)}'}))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def setup_handlers(web_app):
|
|
240
|
+
"""Register FITS API handlers."""
|
|
241
|
+
host_pattern = '.*$'
|
|
242
|
+
base_url = web_app.settings['base_url']
|
|
243
|
+
|
|
244
|
+
handlers = [
|
|
245
|
+
(url_path_join(base_url, 'fitsview', 'metadata'), FITSMetadataHandler),
|
|
246
|
+
(url_path_join(base_url, 'fitsview', 'slice'), FITSSliceHandler),
|
|
247
|
+
]
|
|
248
|
+
web_app.add_handlers(host_pattern, handlers)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# fitsview tests package
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from astropy.io import fits
|
|
2
|
+
import math
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
if __name__ == "__main__":
|
|
9
|
+
dest = pathlib.Path(sys.argv[1])
|
|
10
|
+
if not dest.is_dir():
|
|
11
|
+
raise RuntimeError(f"Make sure the directory {sys.argv[1]} exists for the destination")
|
|
12
|
+
rng = np.random.default_rng(seed=0)
|
|
13
|
+
# cases to cover:
|
|
14
|
+
# - simple 2D image
|
|
15
|
+
simple_2d = rng.random((16, 16))
|
|
16
|
+
fits.PrimaryHDU(simple_2d).writeto(dest / 'simple_2d.fits', overwrite=True)
|
|
17
|
+
# - simple 2D image (integers)
|
|
18
|
+
simple_2d_uints = (1000 * rng.random((16, 16))).astype(np.uint16)
|
|
19
|
+
fits.PrimaryHDU(simple_2d_uints).writeto(dest / 'simple_2d_uints.fits', overwrite=True)
|
|
20
|
+
# - simple 2D image > 5MB
|
|
21
|
+
min_bytes = 5.5e6
|
|
22
|
+
bytes_per_double = 4
|
|
23
|
+
npix_per_side = math.ceil(math.sqrt(min_bytes / bytes_per_double))
|
|
24
|
+
big_2d = rng.random((npix_per_side, npix_per_side), dtype=np.float64)
|
|
25
|
+
assert big_2d.nbytes >= min_bytes
|
|
26
|
+
fits.PrimaryHDU(big_2d).writeto(dest / 'big_2d.fits', overwrite=True)
|
|
27
|
+
# - 3D data cube
|
|
28
|
+
simple_3d = rng.random((5, 16, 16))
|
|
29
|
+
fits.PrimaryHDU(simple_3d).writeto(dest / 'simple_3d.fits', overwrite=True)
|
|
30
|
+
# - 3D data cube where 1 plane > 5 MB
|
|
31
|
+
big_3d = rng.random((5, npix_per_side, npix_per_side), dtype=np.float64)
|
|
32
|
+
assert big_3d[0].nbytes >= min_bytes
|
|
33
|
+
fits.PrimaryHDU(big_3d).writeto(dest / 'big_3d.fits', overwrite=True)
|
|
34
|
+
# - 4D hypercube
|
|
35
|
+
simple_4d = rng.random((4, 5, 16, 16))
|
|
36
|
+
fits.PrimaryHDU(simple_4d).writeto(dest / 'simple_4d.fits', overwrite=True)
|
|
37
|
+
# - file with no image extensions
|
|
38
|
+
table_hdu = fits.TableHDU(np.ones((10,), dtype=[('x', float), ('y', float)]), name='TABLE')
|
|
39
|
+
table_hdu.writeto(dest / 'no_image.fits', overwrite=True)
|
|
40
|
+
# - file with multiple extensions
|
|
41
|
+
fits.HDUList([
|
|
42
|
+
fits.PrimaryHDU(),
|
|
43
|
+
fits.ImageHDU(simple_4d, name='FOURDEE'),
|
|
44
|
+
table_hdu,
|
|
45
|
+
]).writeto(dest / 'multi_ext.fits', overwrite=True)
|
|
46
|
+
# - file with multiple *image* extensions
|
|
47
|
+
fits.HDUList([
|
|
48
|
+
fits.PrimaryHDU(),
|
|
49
|
+
fits.ImageHDU(simple_4d, name='FOURDEE'),
|
|
50
|
+
fits.ImageHDU(simple_3d, name='THREEDEE')
|
|
51
|
+
]).writeto(dest / 'multi_image_ext.fits', overwrite=True)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Tests for FITS API handlers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
from astropy.io import fits
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def fits_file(jp_root_dir):
|
|
12
|
+
"""Create a test FITS file with multiple HDUs."""
|
|
13
|
+
# Primary HDU with float32 data
|
|
14
|
+
primary_data = np.arange(100, dtype=np.float32).reshape(10, 10)
|
|
15
|
+
primary_hdu = fits.PrimaryHDU(primary_data)
|
|
16
|
+
primary_hdu.header["OBJECT"] = "Test Object"
|
|
17
|
+
primary_hdu.header["TELESCOP"] = "Test Telescope"
|
|
18
|
+
|
|
19
|
+
# Image extension with int16 data
|
|
20
|
+
int16_data = np.arange(50, dtype=np.int16).reshape(5, 10)
|
|
21
|
+
image_hdu = fits.ImageHDU(int16_data, name="SCI")
|
|
22
|
+
image_hdu.header["EXTNAME"] = "SCI"
|
|
23
|
+
|
|
24
|
+
# Table extension
|
|
25
|
+
col1 = fits.Column(name="ID", format="J", array=np.array([1, 2, 3]))
|
|
26
|
+
col2 = fits.Column(name="NAME", format="10A", array=np.array(["a", "b", "c"]))
|
|
27
|
+
table_hdu = fits.BinTableHDU.from_columns([col1, col2], name="TABLE")
|
|
28
|
+
|
|
29
|
+
hdul = fits.HDUList([primary_hdu, image_hdu, table_hdu])
|
|
30
|
+
|
|
31
|
+
fits_path = jp_root_dir / "test_data.fits"
|
|
32
|
+
hdul.writeto(fits_path, overwrite=True)
|
|
33
|
+
hdul.close()
|
|
34
|
+
|
|
35
|
+
return "test_data.fits"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def float64_fits_file(jp_root_dir):
|
|
40
|
+
"""Create a test FITS file with float64 data."""
|
|
41
|
+
data = np.array([[1.5, 2.5], [3.5, 4.5]], dtype=np.float64)
|
|
42
|
+
hdu = fits.PrimaryHDU(data)
|
|
43
|
+
hdul = fits.HDUList([hdu])
|
|
44
|
+
|
|
45
|
+
fits_path = jp_root_dir / "float64_test.fits"
|
|
46
|
+
hdul.writeto(fits_path, overwrite=True)
|
|
47
|
+
hdul.close()
|
|
48
|
+
|
|
49
|
+
return "float64_test.fits"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def cube_fits_file(jp_root_dir):
|
|
54
|
+
"""Create a test FITS file with 3D data cube."""
|
|
55
|
+
# 3D cube: 4 planes of 5x6 data
|
|
56
|
+
data = np.arange(120, dtype=np.float32).reshape(4, 5, 6)
|
|
57
|
+
hdu = fits.PrimaryHDU(data)
|
|
58
|
+
hdul = fits.HDUList([hdu])
|
|
59
|
+
|
|
60
|
+
fits_path = jp_root_dir / "cube_test.fits"
|
|
61
|
+
hdul.writeto(fits_path, overwrite=True)
|
|
62
|
+
hdul.close()
|
|
63
|
+
|
|
64
|
+
return "cube_test.fits"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestMetadataHandler:
|
|
68
|
+
"""Tests for the FITSMetadataHandler."""
|
|
69
|
+
|
|
70
|
+
async def test_get_metadata(self, jp_fetch, fits_file):
|
|
71
|
+
"""Test retrieving metadata from a FITS file."""
|
|
72
|
+
response = await jp_fetch("fitsview", "metadata", params={"path": fits_file})
|
|
73
|
+
|
|
74
|
+
assert response.code == 200
|
|
75
|
+
data = json.loads(response.body)
|
|
76
|
+
|
|
77
|
+
assert data["path"] == fits_file
|
|
78
|
+
|
|
79
|
+
# Check primary HDU
|
|
80
|
+
hdus = data["hdus"]
|
|
81
|
+
assert len(hdus) == 3
|
|
82
|
+
|
|
83
|
+
primary = hdus[0]
|
|
84
|
+
assert primary["index"] == 0
|
|
85
|
+
assert primary["name"] == "PRIMARY"
|
|
86
|
+
assert primary["type"] == "PrimaryHDU"
|
|
87
|
+
assert primary["shape"] == [10, 10]
|
|
88
|
+
# arrayType is now a Rust-style type specifier
|
|
89
|
+
assert primary["arrayType"] == "f32"
|
|
90
|
+
# header is returned as raw 80-column FITS format string
|
|
91
|
+
assert "OBJECT" in primary["header"]
|
|
92
|
+
assert "Test Object" in primary["header"]
|
|
93
|
+
assert "TELESCOP" in primary["header"]
|
|
94
|
+
assert "Test Telescope" in primary["header"]
|
|
95
|
+
|
|
96
|
+
# Check image extension
|
|
97
|
+
sci = hdus[1]
|
|
98
|
+
assert sci["index"] == 1
|
|
99
|
+
assert sci["name"] == "SCI"
|
|
100
|
+
assert sci["type"] == "ImageHDU"
|
|
101
|
+
assert sci["shape"] == [5, 10]
|
|
102
|
+
assert sci["arrayType"] == "i16"
|
|
103
|
+
|
|
104
|
+
# Check table extension
|
|
105
|
+
table = hdus[2]
|
|
106
|
+
assert table["index"] == 2
|
|
107
|
+
assert table["name"] == "TABLE"
|
|
108
|
+
assert table["type"] == "BinTableHDU"
|
|
109
|
+
|
|
110
|
+
async def test_metadata_file_not_found(self, jp_fetch):
|
|
111
|
+
"""Test that 404 is returned for non-existent files."""
|
|
112
|
+
response = await jp_fetch(
|
|
113
|
+
"fitsview",
|
|
114
|
+
"metadata",
|
|
115
|
+
params={"path": "nonexistent.fits"},
|
|
116
|
+
raise_error=False,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert response.code == 404
|
|
120
|
+
data = json.loads(response.body)
|
|
121
|
+
assert "error" in data
|
|
122
|
+
assert "not found" in data["error"].lower()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestSliceHandler:
|
|
126
|
+
"""Tests for the FITSSliceHandler."""
|
|
127
|
+
|
|
128
|
+
async def test_get_slice_float32(self, jp_fetch, fits_file):
|
|
129
|
+
"""Test retrieving a slice of float32 data."""
|
|
130
|
+
# 2D data with shape [10, 10], slice rows 0:2, cols 0:3
|
|
131
|
+
response = await jp_fetch(
|
|
132
|
+
"fitsview",
|
|
133
|
+
"slice",
|
|
134
|
+
params={"path": fits_file, "hdu": "0", "slices": "0:2,0:3"},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
assert response.code == 200
|
|
138
|
+
assert response.headers["Content-Type"] == "application/octet-stream"
|
|
139
|
+
|
|
140
|
+
# Check shape header
|
|
141
|
+
shape = json.loads(response.headers["X-FITS-Shape"])
|
|
142
|
+
assert shape == [2, 3]
|
|
143
|
+
|
|
144
|
+
# Check type header - should be f32 (Rust-style type specifier)
|
|
145
|
+
array_type = response.headers["X-FITS-Type"]
|
|
146
|
+
assert array_type == "f32"
|
|
147
|
+
|
|
148
|
+
# Verify data content (data is sent as little-endian)
|
|
149
|
+
data = np.frombuffer(response.body, dtype="<f4").reshape(2, 3)
|
|
150
|
+
expected = np.arange(100, dtype=np.float32).reshape(10, 10)[0:2, 0:3]
|
|
151
|
+
np.testing.assert_array_almost_equal(data, expected)
|
|
152
|
+
|
|
153
|
+
async def test_get_slice_int16(self, jp_fetch, fits_file):
|
|
154
|
+
"""Test retrieving a slice of int16 data."""
|
|
155
|
+
# HDU 1 has shape [5, 10], slice rows 1:3, cols 2:6
|
|
156
|
+
response = await jp_fetch(
|
|
157
|
+
"fitsview",
|
|
158
|
+
"slice",
|
|
159
|
+
params={"path": fits_file, "hdu": "1", "slices": "1:3,2:6"},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
assert response.code == 200
|
|
163
|
+
|
|
164
|
+
# Check type header - should be i16 (Rust-style type specifier)
|
|
165
|
+
array_type = response.headers["X-FITS-Type"]
|
|
166
|
+
assert array_type == "i16"
|
|
167
|
+
|
|
168
|
+
# Verify data content (data is sent as little-endian)
|
|
169
|
+
shape = json.loads(response.headers["X-FITS-Shape"])
|
|
170
|
+
assert shape == [2, 4]
|
|
171
|
+
|
|
172
|
+
data = np.frombuffer(response.body, dtype="<i2").reshape(2, 4)
|
|
173
|
+
expected = np.arange(50, dtype=np.int16).reshape(5, 10)[1:3, 2:6]
|
|
174
|
+
np.testing.assert_array_equal(data, expected)
|
|
175
|
+
|
|
176
|
+
async def test_get_slice_float64(self, jp_fetch, float64_fits_file):
|
|
177
|
+
"""Test retrieving float64 data preserves dtype."""
|
|
178
|
+
# 2D data with shape [2, 2], slice entire array
|
|
179
|
+
response = await jp_fetch(
|
|
180
|
+
"fitsview",
|
|
181
|
+
"slice",
|
|
182
|
+
params={"path": float64_fits_file, "hdu": "0", "slices": "0:2,0:2"},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert response.code == 200
|
|
186
|
+
|
|
187
|
+
# Check type header - should be f64 (Rust-style type specifier)
|
|
188
|
+
array_type = response.headers["X-FITS-Type"]
|
|
189
|
+
assert array_type == "f64"
|
|
190
|
+
|
|
191
|
+
data = np.frombuffer(response.body, dtype="<f8").reshape(2, 2)
|
|
192
|
+
expected = np.array([[1.5, 2.5], [3.5, 4.5]], dtype=np.float64)
|
|
193
|
+
np.testing.assert_array_almost_equal(data, expected)
|
|
194
|
+
|
|
195
|
+
async def test_get_slice_3d_cube(self, jp_fetch, cube_fits_file):
|
|
196
|
+
"""Test retrieving a slice from a 3D data cube."""
|
|
197
|
+
# 3D data with shape [4, 5, 6], slice planes 1:3, rows 0:2, cols 2:5
|
|
198
|
+
response = await jp_fetch(
|
|
199
|
+
"fitsview",
|
|
200
|
+
"slice",
|
|
201
|
+
params={"path": cube_fits_file, "hdu": "0", "slices": "1:3,0:2,2:5"},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
assert response.code == 200
|
|
205
|
+
|
|
206
|
+
# Check shape header
|
|
207
|
+
shape = json.loads(response.headers["X-FITS-Shape"])
|
|
208
|
+
assert shape == [2, 2, 3]
|
|
209
|
+
|
|
210
|
+
# Verify data content (using type header for reference, but hardcode dtype for parsing)
|
|
211
|
+
array_type = response.headers["X-FITS-Type"]
|
|
212
|
+
assert array_type == "f32"
|
|
213
|
+
data = np.frombuffer(response.body, dtype="<f4").reshape(2, 2, 3)
|
|
214
|
+
expected = np.arange(120, dtype=np.float32).reshape(4, 5, 6)[1:3, 0:2, 2:5]
|
|
215
|
+
np.testing.assert_array_almost_equal(data, expected)
|
|
216
|
+
|
|
217
|
+
async def test_slice_out_of_bounds(self, jp_fetch, fits_file):
|
|
218
|
+
"""Test that out-of-bounds slices return 400."""
|
|
219
|
+
response = await jp_fetch(
|
|
220
|
+
"fitsview",
|
|
221
|
+
"slice",
|
|
222
|
+
params={"path": fits_file, "hdu": "0", "slices": "8:15,8:15"},
|
|
223
|
+
raise_error=False,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
assert response.code == 400
|
|
227
|
+
data = json.loads(response.body)
|
|
228
|
+
assert "error" in data
|
|
229
|
+
assert "out of bounds" in data["error"].lower()
|
|
230
|
+
|
|
231
|
+
async def test_slice_dimension_mismatch(self, jp_fetch, fits_file):
|
|
232
|
+
"""Test that wrong number of slice dimensions returns 400."""
|
|
233
|
+
# HDU 0 is 2D but we provide 3 slice dimensions
|
|
234
|
+
response = await jp_fetch(
|
|
235
|
+
"fitsview",
|
|
236
|
+
"slice",
|
|
237
|
+
params={"path": fits_file, "hdu": "0", "slices": "0:2,0:2,0:2"},
|
|
238
|
+
raise_error=False,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
assert response.code == 400
|
|
242
|
+
data = json.loads(response.body)
|
|
243
|
+
assert "error" in data
|
|
244
|
+
assert "dimensions" in data["error"].lower()
|
|
245
|
+
|
|
246
|
+
async def test_slice_invalid_format(self, jp_fetch, fits_file):
|
|
247
|
+
"""Test that invalid slice format returns 400."""
|
|
248
|
+
response = await jp_fetch(
|
|
249
|
+
"fitsview",
|
|
250
|
+
"slice",
|
|
251
|
+
params={"path": fits_file, "hdu": "0", "slices": "0:2:1,0:3"}, # step not supported
|
|
252
|
+
raise_error=False,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
assert response.code == 400
|
|
256
|
+
data = json.loads(response.body)
|
|
257
|
+
assert "error" in data
|
|
258
|
+
|
|
259
|
+
async def test_slice_invalid_hdu(self, jp_fetch, fits_file):
|
|
260
|
+
"""Test that invalid HDU index returns 400."""
|
|
261
|
+
response = await jp_fetch(
|
|
262
|
+
"fitsview",
|
|
263
|
+
"slice",
|
|
264
|
+
params={"path": fits_file, "hdu": "99", "slices": "0:1,0:1"},
|
|
265
|
+
raise_error=False,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
assert response.code == 400
|
|
269
|
+
data = json.loads(response.body)
|
|
270
|
+
assert "error" in data
|
|
271
|
+
assert "out of range" in data["error"].lower()
|
|
272
|
+
|
|
273
|
+
async def test_slice_table_hdu_error(self, jp_fetch, fits_file):
|
|
274
|
+
"""Test that requesting a slice from a table HDU returns an error."""
|
|
275
|
+
# HDU 2 is the table extension - tables have no standard shape
|
|
276
|
+
response = await jp_fetch(
|
|
277
|
+
"fitsview",
|
|
278
|
+
"slice",
|
|
279
|
+
params={"path": fits_file, "hdu": "2", "slices": "0:1,0:1"},
|
|
280
|
+
raise_error=False,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
assert response.code == 400
|
|
284
|
+
data = json.loads(response.body)
|
|
285
|
+
assert "error" in data
|
|
286
|
+
|
|
287
|
+
async def test_slice_file_not_found(self, jp_fetch):
|
|
288
|
+
"""Test that 404 is returned for non-existent files."""
|
|
289
|
+
response = await jp_fetch(
|
|
290
|
+
"fitsview",
|
|
291
|
+
"slice",
|
|
292
|
+
params={"path": "nonexistent.fits", "hdu": "0", "slices": "0:1,0:1"},
|
|
293
|
+
raise_error=False,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
assert response.code == 404
|
|
297
|
+
data = json.loads(response.body)
|
|
298
|
+
assert "error" in data
|