splusdata 5.1__tar.gz → 5.2__tar.gz

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.
Files changed (37) hide show
  1. {splusdata-5.1/splusdata.egg-info → splusdata-5.2}/PKG-INFO +2 -1
  2. {splusdata-5.1 → splusdata-5.2}/pyproject.toml +4 -2
  3. {splusdata-5.1 → splusdata-5.2}/splusdata/__init__.py +1 -1
  4. splusdata-5.2/splusdata/core.py +657 -0
  5. splusdata-5.2/splusdata/features/io.py +100 -0
  6. splusdata-5.2/splusdata/features/zeropoints/zp_image.py +131 -0
  7. splusdata-5.2/splusdata/features/zeropoints/zp_map.py +145 -0
  8. splusdata-5.2/splusdata/scripts/args.py +77 -0
  9. splusdata-5.2/splusdata/scubes/__init__.py +1 -0
  10. splusdata-5.2/splusdata/scubes/constants.py +8 -0
  11. splusdata-5.2/splusdata/scubes/core.py +327 -0
  12. splusdata-5.2/splusdata/variability/__init__.py +0 -0
  13. splusdata-5.2/splusdata/vars.py +47 -0
  14. {splusdata-5.1 → splusdata-5.2/splusdata.egg-info}/PKG-INFO +2 -1
  15. {splusdata-5.1 → splusdata-5.2}/splusdata.egg-info/SOURCES.txt +9 -1
  16. {splusdata-5.1 → splusdata-5.2}/splusdata.egg-info/entry_points.txt +1 -0
  17. {splusdata-5.1 → splusdata-5.2}/splusdata.egg-info/requires.txt +1 -0
  18. splusdata-5.1/splusdata/core.py +0 -225
  19. splusdata-5.1/splusdata/vars.py +0 -2
  20. {splusdata-5.1 → splusdata-5.2}/LICENSE +0 -0
  21. {splusdata-5.1 → splusdata-5.2}/README.md +0 -0
  22. {splusdata-5.1 → splusdata-5.2}/setup.cfg +0 -0
  23. {splusdata-5.1 → splusdata-5.2}/splusdata/connect.py +0 -0
  24. {splusdata-5.1 → splusdata-5.2}/splusdata/features/__init__.py +0 -0
  25. {splusdata-5.1 → splusdata-5.2}/splusdata/features/extinction.py +0 -0
  26. {splusdata-5.1 → splusdata-5.2}/splusdata/features/filterbw.py +0 -0
  27. {splusdata-5.1 → splusdata-5.2}/splusdata/features/hipscat.py +0 -0
  28. {splusdata-5.1/splusdata/models → splusdata-5.2/splusdata/features/zeropoints}/__init__.py +0 -0
  29. /splusdata-5.1/splusdata/features/zeropoints.py → /splusdata-5.2/splusdata/features/zeropointsdr4.py +0 -0
  30. {splusdata-5.1/splusdata/variability → splusdata-5.2/splusdata/models}/__init__.py +0 -0
  31. {splusdata-5.1 → splusdata-5.2}/splusdata/models/star_gal_quasar.py +0 -0
  32. {splusdata-5.1 → splusdata-5.2}/splusdata/readconf.py +0 -0
  33. {splusdata-5.1 → splusdata-5.2}/splusdata/vacs/__init__.py +0 -0
  34. {splusdata-5.1 → splusdata-5.2}/splusdata/vacs/pdfs.py +0 -0
  35. {splusdata-5.1 → splusdata-5.2}/splusdata/vacs/sqg.py +0 -0
  36. {splusdata-5.1 → splusdata-5.2}/splusdata.egg-info/dependency_links.txt +0 -0
  37. {splusdata-5.1 → splusdata-5.2}/splusdata.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splusdata
3
- Version: 5.1
3
+ Version: 5.2
4
4
  Summary: Download SPLUS catalogs, FITS and more
5
5
  Author-email: Gustavo Schwarz <gustavo.b.schwarz@gmail.com>
6
6
  License: Apache-2.0
@@ -19,6 +19,7 @@ Requires-Dist: pandas
19
19
  Requires-Dist: scipy
20
20
  Requires-Dist: pillow
21
21
  Requires-Dist: pyyaml
22
+ Requires-Dist: tqdm
22
23
  Dynamic: license-file
23
24
 
24
25
  ## Source for pip package splusdata
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "splusdata"
7
- version = "5.01"
7
+ version = "5.2"
8
8
  description = "Download SPLUS catalogs, FITS and more"
9
9
  authors = [
10
10
  { name = "Gustavo Schwarz", email = "gustavo.b.schwarz@gmail.com" }
@@ -26,11 +26,13 @@ dependencies = [
26
26
  "pandas",
27
27
  "scipy",
28
28
  "pillow",
29
- "pyyaml"
29
+ "pyyaml",
30
+ "tqdm",
30
31
  ]
31
32
 
32
33
  [project.scripts]
33
34
  splusdata = "splusdata.readconf:main"
35
+ scubes_dr6 = "splusdata.scubes.core:scubes"
34
36
 
35
37
  [tool.setuptools.packages.find]
36
38
  where = ["."]
@@ -4,7 +4,7 @@ from splusdata.features import filterbw
4
4
  from splusdata.features.hipscat import get_hipscats
5
5
 
6
6
  from splusdata.features.extinction import SplusExtinction
7
- from splusdata.features.zeropoints import get_zeropoint
7
+ from splusdata.features.zeropointsdr4 import get_zeropoint
8
8
 
9
9
  import splusdata.vacs
10
10
 
@@ -0,0 +1,657 @@
1
+ import adss
2
+ import getpass
3
+
4
+ from PIL import Image
5
+ from astropy.io import fits
6
+ import io
7
+
8
+ from splusdata.features.io import print_level
9
+
10
+ class SplusdataError(Exception):
11
+ """Custom exception type for S-PLUS data errors raised by this helper module.
12
+
13
+ Use this to catch and handle issues such as:
14
+ - Missing collections or files on the server.
15
+ - Invalid filter/field combinations.
16
+ - Empty results (e.g., zero candidates for a filename pattern).
17
+ """
18
+
19
+
20
+ def open_image(image_bytes):
21
+ """Open an image from raw bytes and return a PIL Image.
22
+
23
+ Parameters
24
+ ----------
25
+ image_bytes : bytes
26
+ Raw image bytes (e.g., returned by ADSS endpoints).
27
+
28
+ Returns
29
+ -------
30
+ PIL.Image.Image
31
+ A Pillow image instance.
32
+
33
+ Raises
34
+ ------
35
+ OSError
36
+ If Pillow cannot identify or open the image.
37
+ """
38
+ from PIL import Image
39
+ im = Image.open(io.BytesIO(image_bytes))
40
+ return im
41
+
42
+
43
+ def save_image(image_bytes, filename):
44
+ """Save image bytes to a file on disk.
45
+
46
+ Parameters
47
+ ----------
48
+ image_bytes : bytes
49
+ Raw image bytes (e.g., returned by ADSS endpoints).
50
+ filename : str or pathlib.Path
51
+ Output file path, including the desired extension.
52
+
53
+ Returns
54
+ -------
55
+ None
56
+
57
+ Raises
58
+ ------
59
+ OSError
60
+ If the image cannot be opened or saved.
61
+ """
62
+ im = open_image(image_bytes)
63
+ im.save(filename)
64
+
65
+
66
+ # field frame
67
+ class Core:
68
+ """Convenience interface around `adss.ADSSClient` for S-PLUS images and queries.
69
+
70
+ This wrapper streamlines common tasks:
71
+ - Listing available image collections.
72
+ - Fetching full field FITS frames or small cutouts (stamps).
73
+ - Generating Lupton or Trilogy RGB composites.
74
+ - Submitting SQL/ADQL queries (with optional table upload).
75
+ - Retrieving and applying per-field zero points (DR6).
76
+
77
+ Notes
78
+ -----
79
+ * Authentication: If `username`/`password` are not provided, the constructor
80
+ will prompt interactively (stdin).
81
+ * All methods pass through to a single `adss.ADSSClient` instance.
82
+ """
83
+
84
+ def __init__(self, username=None, password=None, SERVER_IP=f"https://splus.cloud", auto_renew=False, verbose=0):
85
+ """Initialize a Core client.
86
+
87
+ Parameters
88
+ ----------
89
+ username : str, optional
90
+ S-PLUS account username. If None, asked interactively.
91
+ password : str, optional
92
+ S-PLUS account password. If None, prompted via getpass.
93
+ SERVER_IP : str, optional
94
+ Base URL of the S-PLUS service (default: "https://splus.cloud").
95
+ auto_renew : bool, optional
96
+ Placeholder for future token auto-renew behavior (unused here).
97
+ verbose : int, optional
98
+ Verbosity level. Defaults to 0.
99
+
100
+ Attributes
101
+ ----------
102
+ client : adss.ADSSClient
103
+ Underlying authenticated ADSS client.
104
+ collections : list[dict]
105
+ Cached list of collections after `_load_collections()`.
106
+
107
+ Raises
108
+ ------
109
+ Exception
110
+ Propagates any authentication/connection exceptions raised by ADSSClient.
111
+ """
112
+ if not username:
113
+ username = input("splus.cloud username: ")
114
+ if not password:
115
+ password = getpass.getpass("splus.cloud password: ")
116
+
117
+ self.client = adss.ADSSClient(
118
+ SERVER_IP,
119
+ username=username,
120
+ password=password
121
+ )
122
+ self.collections = []
123
+ self.verbose = verbose
124
+
125
+ def _load_collections(self):
126
+ """Fetch and cache image collections from the server.
127
+
128
+ Returns
129
+ -------
130
+ None
131
+
132
+ Side Effects
133
+ ------------
134
+ Populates `self.collections` with a list of collection dicts, as returned by
135
+ `ADSSClient.get_image_collections()`.
136
+ """
137
+ collections = self.client.get_image_collections()
138
+ self.collections = collections
139
+
140
+ def check_available_images_releases(self):
141
+ """List available image collection names (data releases).
142
+
143
+ Returns
144
+ -------
145
+ list[str]
146
+ Collection names, e.g., ["dr4", "dr5", "dr6", ...].
147
+ """
148
+ collections = self.client.get_image_collections()
149
+ names = [col['name'] for col in collections]
150
+ return names
151
+
152
+ def get_collection_id_by_pattern(self, pattern):
153
+ """Return the first collection whose `name` contains `pattern`.
154
+
155
+ Parameters
156
+ ----------
157
+ pattern : str
158
+ Substring to search for inside the collection name.
159
+
160
+ Returns
161
+ -------
162
+ dict
163
+ The first matching collection dictionary.
164
+
165
+ Raises
166
+ ------
167
+ SplusdataError
168
+ If no collection name contains the given pattern.
169
+ """
170
+ self._load_collections()
171
+ for col in self.collections:
172
+ if pattern in col['name']:
173
+ return col
174
+ raise SplusdataError("Collection not found")
175
+
176
+ def get_file_metadata(self, field, band, pattern = "", data_release = "dr4"):
177
+ """Resolve a single file metadata entry matching field/band/pattern.
178
+
179
+ Parameters
180
+ ----------
181
+ field : str
182
+ Field identifier (e.g., "SPLUS-n01s10"). If not found, tries swapping
183
+ '-' and '_' once to be tolerant to naming variants.
184
+ band : str
185
+ Filter/band name (e.g., "R", "I", "F660", "U", ...).
186
+ pattern : str, optional
187
+ Key into the collection's `patterns` dict. Empty string selects the
188
+ default pattern list for full science images. "weight" commonly selects
189
+ weight maps.
190
+ data_release : str, optional
191
+ Collection pattern (substring) to select the DR (defaults to "dr4").
192
+
193
+ Returns
194
+ -------
195
+ dict
196
+ A file entry suitable for `download_image()`, containing at least
197
+ `id`, `filename`, and `file_type`.
198
+
199
+ Selection Logic
200
+ ---------------
201
+ 1. Lists candidates via `list_image_files(collection_id, filter_str=field, filter_name=band)`.
202
+ 2. If none, swaps '-' and '_' in `field` and retries once.
203
+ 3. Reads the collection's `patterns[pattern]`, splits by commas, and filters:
204
+ - Tokens starting with '!' mean "exclude those containing token".
205
+ - Otherwise, "include if contains token".
206
+ 4. If multiple remain, prefer those with `file_type == "fz"`.
207
+
208
+ Raises
209
+ ------
210
+ SplusdataError
211
+ If no candidate files are found for the given (field, band).
212
+ KeyError
213
+ If `pattern` is not a key in the collection's `patterns` dict.
214
+ """
215
+ collection = self.get_collection_id_by_pattern(data_release)
216
+ collection_id = collection['id']
217
+
218
+ candidates = self.client.list_image_files(collection_id, filter_str=field, filter_name=band)
219
+
220
+ if len(candidates) == 0 and ("-" in field or "_" in field):
221
+ field = field.replace("-", "_") if "-" in field else field.replace("_", "-")
222
+ candidates = self.client.list_image_files(collection_id, filter_str=field, filter_name=band)
223
+ if len(candidates) == 0:
224
+ raise SplusdataError(f"Field {field} not found in band {band}")
225
+
226
+ patterns = collection['patterns']
227
+
228
+ final_candidate = None
229
+ f_candidates = []
230
+
231
+ pattern = patterns[pattern]
232
+
233
+ pattern = pattern.split(",")
234
+ for c in candidates:
235
+ for p in pattern:
236
+ if p.startswith("!"):
237
+ if p not in c['filename']:
238
+ f_candidates.append(c)
239
+ else:
240
+ if p in c['filename']:
241
+ f_candidates.append(c)
242
+
243
+ if len(f_candidates) == 0:
244
+ final_candidate = candidates[0]
245
+ elif len(f_candidates) == 1:
246
+ final_candidate = f_candidates[0]
247
+ else:
248
+ fz_candidates = [c for c in f_candidates if c['file_type'] == "fz"]
249
+ final_candidate = fz_candidates[0] if fz_candidates else f_candidates
250
+
251
+ return final_candidate
252
+
253
+ def field_frame(self, field, band, weight=False, outfile=None, data_release="dr4"):
254
+ """Download and open a full field FITS image.
255
+
256
+ Parameters
257
+ ----------
258
+ field : str
259
+ Field identifier, e.g., "SPLUS-n01s10".
260
+ band : str
261
+ Filter name, e.g., "R", "I", "F660", "U".
262
+ weight : bool, optional
263
+ If True, selects the "weight" pattern (commonly a weight map).
264
+ outfile : str or pathlib.Path, optional
265
+ If provided, ADSS will also write the downloaded file to this path.
266
+ data_release : str, optional
267
+ Target data release (pattern matched in collection name). Default "dr4".
268
+
269
+ Returns
270
+ -------
271
+ astropy.io.fits.HDUList
272
+ Opened FITS file as an HDUList.
273
+
274
+ Raises
275
+ ------
276
+ SplusdataError
277
+ If the file cannot be resolved.
278
+ """
279
+ if weight:
280
+ pattern = "weight"
281
+ else:
282
+ pattern = ""
283
+
284
+ final_candidate = self.get_file_metadata(field, band, pattern, data_release)
285
+ image_bytes = self.client.download_image(
286
+ final_candidate['id'],
287
+ output_path=outfile
288
+ )
289
+
290
+ return fits.open(io.BytesIO(image_bytes))
291
+
292
+ def stamp(self, ra, dec, size, band, weight=False, field_name=None, size_unit="pixels", outfile=None, data_release="dr4"):
293
+ """Create and open a FITS stamp (cutout) by coordinates or by object name.
294
+
295
+ Parameters
296
+ ----------
297
+ ra : float
298
+ Right ascension in degrees.
299
+ dec : float
300
+ Declination in degrees.
301
+ size : int or float
302
+ Stamp size in `size_unit`.
303
+ band : str
304
+ Filter name (e.g., "R", "I", "F660").
305
+ weight : bool, optional
306
+ If True, selects weight images (pattern "weight").
307
+ field_name : str, optional
308
+ If provided, creates a stamp using object/field name context instead
309
+ of pure coordinates (server may use FIELD metadata).
310
+ size_unit : {"pixels", "arcsec"}, optional
311
+ Unit for the size argument. Default "pixels".
312
+ outfile : str or pathlib.Path, optional
313
+ If provided, ADSS may also write the cutout to disk.
314
+ data_release : str, optional
315
+ Collection selector (substring). Default "dr4".
316
+
317
+ Returns
318
+ -------
319
+ astropy.io.fits.HDUList
320
+ Opened FITS cutout.
321
+
322
+ Raises
323
+ ------
324
+ SplusdataError
325
+ If the collection cannot be resolved.
326
+ """
327
+ collection = self.get_collection_id_by_pattern(data_release)
328
+ collection_id = collection['id']
329
+
330
+ if weight:
331
+ weight = "weight"
332
+ if not field_name:
333
+ stamp_bytes = self.client.create_stamp_by_coordinates(
334
+ collection_id=collection_id,
335
+ filter=band,
336
+ ra=ra,
337
+ dec=dec,
338
+ size=size,
339
+ size_unit=size_unit,
340
+ pattern=weight if weight else "",
341
+ output_path=outfile
342
+ )
343
+ else:
344
+ stamp_bytes = self.client.stamp_images.create_stamp_by_object(
345
+ collection_id=collection_id,
346
+ object_name=field_name,
347
+ filter_name=band,
348
+ ra=ra,
349
+ dec=dec,
350
+ size=size,
351
+ size_unit=size_unit,
352
+ pattern=weight if weight else "",
353
+ output_path=outfile
354
+ )
355
+
356
+ return fits.open(io.BytesIO(stamp_bytes))
357
+
358
+ def lupton_rgb(self, ra, dec, size, R="I", G="R", B="G", Q=8, stretch=3, field_name=None, size_unit="pixels", outfile=None, data_release="dr4"):
359
+ """Create a Lupton RGB composite and return a PIL image.
360
+
361
+ Parameters
362
+ ----------
363
+ ra, dec : float
364
+ Coordinates in degrees.
365
+ size : int or float
366
+ Output image size in `size_unit`.
367
+ R, G, B : str, optional
368
+ Filter names for the RGB channels (defaults: I/R/G).
369
+ Q : float, optional
370
+ Lupton Q parameter (contrast). Default 8.
371
+ stretch : float, optional
372
+ Lupton stretch parameter. Default 3.
373
+ field_name : str, optional
374
+ If provided, generate by object/field context.
375
+ size_unit : {"pixels", "arcsec"}, optional
376
+ Unit for `size`. Default "pixels".
377
+ outfile : str or pathlib.Path, optional
378
+ If provided, ADSS may also write PNG/JPEG to disk.
379
+ data_release : str, optional
380
+ Collection selector (substring). Default "dr4".
381
+
382
+ Returns
383
+ -------
384
+ PIL.Image.Image
385
+ Composite RGB image.
386
+ """
387
+ collection = self.get_collection_id_by_pattern(data_release)
388
+ collection_id = collection['id']
389
+
390
+ if not field_name:
391
+ stamp_bytes = self.client.create_rgb_image_by_coordinates(
392
+ collection_id=collection_id,
393
+ ra=ra,
394
+ dec=dec,
395
+ size=size,
396
+ r_filter=R,
397
+ g_filter=G,
398
+ b_filter=B,
399
+ Q=Q,
400
+ size_unit=size_unit,
401
+ stretch=stretch,
402
+ output_path=outfile
403
+ )
404
+ else:
405
+ stamp_bytes = self.client.lupton_images.create_rgb_by_object(
406
+ collection_id=collection_id,
407
+ object_name=field_name,
408
+ ra=ra,
409
+ dec=dec,
410
+ size=size,
411
+ r_filter=R,
412
+ g_filter=G,
413
+ b_filter=B,
414
+ Q=Q,
415
+ size_unit=size_unit,
416
+ stretch=stretch,
417
+ output_path=outfile
418
+ )
419
+
420
+ return Image.open(io.BytesIO(stamp_bytes))
421
+
422
+ def trilogy_image(self, ra, dec, size, R=["R", "I", "F861", "Z"], G=["G", "F515", "F660"], B=["U", "F378", "F395", "F410", "F430"], noiselum=0.15, satpercent=0.15, colorsatfac=2, size_unit="pixels", field_name=None, outfile=None, data_release="dr4"):
423
+ """Create a Trilogy RGB composite (multi-filter blend) and return a PIL image.
424
+
425
+ Parameters
426
+ ----------
427
+ ra, dec : float
428
+ Coordinates in degrees.
429
+ size : int or float
430
+ Output size in `size_unit`.
431
+ R, G, B : list[str], optional
432
+ Lists of filters contributing to each RGB channel.
433
+ noiselum : float, optional
434
+ Controls noise luminance suppression.
435
+ satpercent : float, optional
436
+ Percentile value for saturation clipping.
437
+ colorsatfac : float, optional
438
+ Factor for color saturation.
439
+ size_unit : {"pixels", "arcsec"}, optional
440
+ Size unit. Default "pixels".
441
+ field_name : str, optional
442
+ If provided, generate by object/field context.
443
+ outfile : str or pathlib.Path, optional
444
+ If provided, ADSS may also write the composite to disk.
445
+ data_release : str, optional
446
+ Collection selector (substring). Default "dr4".
447
+
448
+ Returns
449
+ -------
450
+ PIL.Image.Image
451
+ Composite RGB image (Trilogy method).
452
+ """
453
+ collection = self.get_collection_id_by_pattern(data_release)
454
+ collection_id = collection['id']
455
+
456
+ if not field_name:
457
+ stamp_bytes = self.client.trilogy_images.create_trilogy_rgb_by_coordinates(
458
+ collection_id=collection_id,
459
+ ra=ra,
460
+ dec=dec,
461
+ size=size,
462
+ r_filters=R,
463
+ g_filters=G,
464
+ b_filters=B,
465
+ size_unit=size_unit,
466
+ noiselum=noiselum,
467
+ satpercent=satpercent,
468
+ colorsatfac=colorsatfac,
469
+ output_path=outfile
470
+ )
471
+ else:
472
+ stamp_bytes = self.client.trilogy_images.create_trilogy_rgb_by_object(
473
+ collection_id=collection_id,
474
+ object_name=field_name,
475
+ ra=ra,
476
+ dec=dec,
477
+ size=size,
478
+ r_filters=R,
479
+ g_filters=G,
480
+ b_filters=B,
481
+ noiselum=noiselum,
482
+ size_unit=size_unit,
483
+ satpercent=satpercent,
484
+ colorsatfac=colorsatfac,
485
+ output_path=outfile
486
+ )
487
+
488
+ return Image.open(io.BytesIO(stamp_bytes))
489
+
490
+ def query(self, query, table_upload=None, table_name=None):
491
+ """Execute a server-side query; optionally upload a small table first.
492
+
493
+ Parameters
494
+ ----------
495
+ query : str
496
+ SQL/ADQL text to execute on the server.
497
+ table_upload : pandas.DataFrame or astropy.table.Table, optional
498
+ In-memory table to upload as a temporary (CSV) file for the query.
499
+ table_name : str, optional
500
+ Name to assign to the uploaded table on the server.
501
+
502
+ Returns
503
+ -------
504
+ Any
505
+ The `response.data` returned by `ADSSClient.query_and_wait`. Depends
506
+ on the query and server configuration (often JSON-like dict/list).
507
+
508
+ Raises
509
+ ------
510
+ ValueError
511
+ If `table_upload` is provided but is neither a DataFrame nor an
512
+ Astropy Table.
513
+ Exception
514
+ Propagates server or network errors from the ADSS client.
515
+ """
516
+ table_upload_bytes = None
517
+ if table_upload is not None and table_name is not None:
518
+ import pandas as pd
519
+ from astropy.table import Table
520
+
521
+ table_upload_bytes = None
522
+ if isinstance(table_upload, pd.DataFrame):
523
+ table_upload_bytes = table_upload.to_csv(index=False).encode()
524
+ elif isinstance(table_upload, Table):
525
+ table_upload_bytes = table_upload.to_pandas().to_csv(index=False).encode()
526
+ else:
527
+ raise ValueError("table_upload must be a pandas DataFrame or an astropy Table")
528
+
529
+ response = self.client.query_and_wait(
530
+ query_text=query,
531
+ table_name=table_name,
532
+ file=table_upload_bytes
533
+ )
534
+ return response.data
535
+
536
+ def get_zp_file(self, field, band, data_release = "dr6"):
537
+ """Download and parse the per-field zero-point model (DR6).
538
+
539
+ Parameters
540
+ ----------
541
+ field : str
542
+ Field name used in the DR6 collection.
543
+ band : str
544
+ Filter/band name.
545
+ data_release : str, optional
546
+ Collection selector, defaults to "dr6" (where zp models are expected).
547
+
548
+ Returns
549
+ -------
550
+ dict
551
+ Parsed JSON zero-point model.
552
+
553
+ Raises
554
+ ------
555
+ SplusdataError
556
+ If no zero-point model file is found for the field/band.
557
+ JSONDecodeError
558
+ If the downloaded bytes are not valid JSON.
559
+ """
560
+ import json
561
+ collection = self.get_collection_id_by_pattern(data_release)
562
+ collection_id = collection['id']
563
+
564
+ files = self.client.list_image_files(
565
+ collection_id,
566
+ filter_str=f"{field}_{band}_zp",
567
+ )
568
+ if len(files) == 0:
569
+ raise SplusdataError(f"No zp model found for field {field} in band {band} in {data_release}")
570
+ file = files[0]
571
+
572
+ print_level(f"Downloading zp_model {file['filename']}", 1, self.verbose)
573
+ json_bytes = self.client.download_image(file["id"])
574
+ json_data = json.loads(json_bytes)
575
+ return json_data
576
+
577
+ def get_zp(self, field, band, ra, dec):
578
+ """Evaluate the local zero point at a sky position using the field model.
579
+
580
+ Parameters
581
+ ----------
582
+ field : str
583
+ Field identifier for the zero-point model to use.
584
+ band : str
585
+ Filter name matching the zp model.
586
+ ra, dec : float
587
+ Coordinates (deg) where the zp should be evaluated.
588
+
589
+ Returns
590
+ -------
591
+ float
592
+ Zero point value at (ra, dec), in magnitudes.
593
+
594
+ Raises
595
+ ------
596
+ SplusdataError
597
+ If the model file cannot be found/downloaded.
598
+ Exception
599
+ Any error propagated from `zp_at_coord` evaluation.
600
+ """
601
+ model = self.get_zp_file(field, band)
602
+
603
+ from splusdata.features.zeropoints.zp_map import zp_at_coord
604
+ return zp_at_coord(model, ra, dec)
605
+
606
+ def calibrated_stamp(self, ra, dec, size, band, weight=False, field_name=None, size_unit="pixels", outfile=None, data_release="dr6"):
607
+ """Create a stamp and return a photometrically calibrated PrimaryHDU.
608
+
609
+ This computes a cutout via `stamp(...)`, then loads the appropriate DR6+
610
+ per-field zero-point model and applies spatially varying calibration.
611
+
612
+ Parameters
613
+ ----------
614
+ ra, dec : float
615
+ Coordinates in degrees.
616
+ size : int or float
617
+ Cutout size in `size_unit`.
618
+ band : str
619
+ Filter name.
620
+ weight : bool, optional
621
+ If True, returns weight cutouts (note: calibration typically applies
622
+ to science images, not weights).
623
+ field_name : str, optional
624
+ Use object/field context for the stamp creation.
625
+ size_unit : {"pixels", "arcsec"}, optional
626
+ Size unit (default "pixels").
627
+ outfile : str or pathlib.Path, optional
628
+ If provided, writes the calibrated HDU to disk (FITS).
629
+ data_release : str, optional
630
+ DR to use for both the stamp and the zp model (default "dr6").
631
+
632
+ Returns
633
+ -------
634
+ astropy.io.fits.PrimaryHDU
635
+ The calibrated science HDU (new object unless `in_place=True` were used).
636
+
637
+ Raises
638
+ ------
639
+ SplusdataError
640
+ If the zp model cannot be found.
641
+ KeyError
642
+ If expected header keys (e.g., FIELD, FILTER) are missing in the stamp.
643
+ Exception
644
+ Propagates any calibration errors from `calibrate_hdu_with_zpmodel`.
645
+ """
646
+ stamp = self.stamp(ra, dec, size, band, weight=weight, field_name=field_name, size_unit=size_unit, data_release=data_release)
647
+
648
+ from splusdata.features.zeropoints.zp_image import calibrate_hdu_with_zpmodel
649
+ zp_model = self.get_zp_file(stamp[1].header["FIELD"], stamp[1].header["FILTER"], data_release=data_release)
650
+
651
+ calibrated_hdu, factor_map = calibrate_hdu_with_zpmodel(
652
+ stamp[1], zp_model, in_place=False, return_factor=True
653
+ )
654
+
655
+ if outfile:
656
+ calibrated_hdu.writeto(outfile, overwrite=True)
657
+ return calibrated_hdu