setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -310,18 +310,133 @@ class ImageDocument(QObject):
310
310
 
311
311
  def close(self):
312
312
  """
313
- Explicit cleanup of swap files.
313
+ Free all resources held by this document:
314
+ - delete swap states for undo/redo
315
+ - clear undo/redo stacks
316
+ - drop in-memory image array and any in-memory history
317
+ - clear heavy metadata (headers/WCS)
318
+ - clear any cached previews/pixmaps
319
+ - disconnect signals to help break reference cycles
314
320
  """
315
- sm = get_swap_manager()
316
- # Clean up undo stack
317
- for swap_id, _, _ in self._undo:
318
- sm.delete_state(swap_id)
319
- self._undo.clear()
320
-
321
- # Clean up redo stack
322
- for swap_id, _, _ in self._redo:
323
- sm.delete_state(swap_id)
324
- self._redo.clear()
321
+ # --- 0) Stop emitting while we tear down (best-effort) --------------
322
+ try:
323
+ self.blockSignals(True)
324
+ except Exception:
325
+ pass
326
+
327
+ # --- 1) Swap cleanup (your existing logic) --------------------------
328
+ try:
329
+ sm = get_swap_manager()
330
+ except Exception:
331
+ sm = None
332
+
333
+ # Undo stack
334
+ try:
335
+ for item in list(getattr(self, "_undo", [])):
336
+ try:
337
+ swap_id = item[0] # (swap_id, ..., ...)
338
+ except Exception:
339
+ swap_id = None
340
+ if sm is not None and swap_id is not None:
341
+ try:
342
+ sm.delete_state(swap_id)
343
+ except Exception:
344
+ pass
345
+ getattr(self, "_undo", []).clear()
346
+ except Exception:
347
+ pass
348
+
349
+ # Redo stack
350
+ try:
351
+ for item in list(getattr(self, "_redo", [])):
352
+ try:
353
+ swap_id = item[0]
354
+ except Exception:
355
+ swap_id = None
356
+ if sm is not None and swap_id is not None:
357
+ try:
358
+ sm.delete_state(swap_id)
359
+ except Exception:
360
+ pass
361
+ getattr(self, "_redo", []).clear()
362
+ except Exception:
363
+ pass
364
+
365
+ # ROI preview stacks if you have them (your code uses _pundo/_predo on ROI docs)
366
+ for attr in ("_pundo", "_predo"):
367
+ try:
368
+ lst = getattr(self, attr, None)
369
+ if isinstance(lst, list):
370
+ # If these also store swap states, delete them too (safe even if not)
371
+ if sm is not None:
372
+ for item in list(lst):
373
+ try:
374
+ swap_id = item[0]
375
+ except Exception:
376
+ swap_id = None
377
+ if swap_id is not None:
378
+ try:
379
+ sm.delete_state(swap_id)
380
+ except Exception:
381
+ pass
382
+ lst.clear()
383
+ except Exception:
384
+ pass
385
+
386
+ # --- 2) Drop the big in-memory image --------------------------------
387
+ # This is what actually frees the 2–10GB allocations (assuming no other refs).
388
+ try:
389
+ self.image = None
390
+ except Exception:
391
+ pass
392
+
393
+ # --- 3) Clear metadata that can keep large objects alive -------------
394
+ # fits.Header/WCS objects aren't huge like the image, but they can keep references
395
+ # and add up; also helps break cycles.
396
+ try:
397
+ md = getattr(self, "metadata", None)
398
+ if isinstance(md, dict):
399
+ for k in ("wcs", "original_header", "fits_header", "wcs_header", "header"):
400
+ md.pop(k, None)
401
+ # If you keep derived/cached headers anywhere:
402
+ for k in ("_header_snapshot", "_wcs_snapshot", "roi_wcs_header"):
403
+ md.pop(k, None)
404
+ except Exception:
405
+ pass
406
+
407
+ # --- 4) Clear any preview/pixmap/qimage caches -----------------------
408
+ # Adjust these attr names to match what your view/doc uses.
409
+ for attr in ("_qimage_cache", "_pixmap_cache", "_preview_cache", "_render_cache"):
410
+ try:
411
+ v = getattr(self, attr, None)
412
+ if isinstance(v, dict):
413
+ v.clear()
414
+ setattr(self, attr, None)
415
+ except Exception:
416
+ pass
417
+
418
+ # --- 5) Disconnect signals (helps Qt reference cycles) ---------------
419
+ # If you connect doc.changed to closures (like ROI docs do), this helps.
420
+ try:
421
+ self.changed.disconnect()
422
+ except Exception:
423
+ pass
424
+
425
+ # If you have other signals, disconnect them similarly:
426
+ for sig_name in ("imageChanged", "metadataChanged"):
427
+ try:
428
+ sig = getattr(self, sig_name, None)
429
+ if sig is not None:
430
+ sig.disconnect()
431
+ except Exception:
432
+ pass
433
+
434
+ # --- 6) Allow signals again (optional) -------------------------------
435
+ try:
436
+ self.blockSignals(False)
437
+ except Exception:
438
+ pass
439
+
325
440
 
326
441
  def __del__(self):
327
442
  # Fallback cleanup if close() wasn't called (though explicit close is better)
@@ -1555,6 +1670,9 @@ def debug_dump_metadata_print(meta: dict, context: str = ""):
1555
1670
 
1556
1671
  print("===== END METADATA DUMP ({}) =====".format(context))
1557
1672
 
1673
+ import time
1674
+ _DEBUG_DND_DUP = False
1675
+
1558
1676
  class DocManager(QObject):
1559
1677
  documentAdded = pyqtSignal(object) # ImageDocument
1560
1678
  documentRemoved = pyqtSignal(object) # ImageDocument
@@ -2266,6 +2384,46 @@ class DocManager(QObject):
2266
2384
  if hasattr(doc, "changed"):
2267
2385
  doc.changed.emit()
2268
2386
 
2387
+ def _current_view_title_for_doc(self, source_doc: ImageDocument) -> str | None:
2388
+ """
2389
+ If the active MDI subwindow is showing 'source_doc' (or its parent/base),
2390
+ return the current view's title (windowTitle), otherwise None.
2391
+ """
2392
+ sw = self._active_subwindow()
2393
+ if sw is None:
2394
+ return None
2395
+
2396
+ try:
2397
+ w = sw.widget()
2398
+ except Exception:
2399
+ w = None
2400
+
2401
+ # Resolve what doc the active view corresponds to (base doc)
2402
+ try:
2403
+ base = (
2404
+ getattr(w, "base_document", None)
2405
+ or getattr(w, "_base_document", None)
2406
+ or getattr(w, "document", None)
2407
+ or getattr(sw, "document", None)
2408
+ )
2409
+ parent = getattr(base, "_parent_doc", None)
2410
+ if isinstance(parent, ImageDocument):
2411
+ base = parent
2412
+ except Exception:
2413
+ base = None
2414
+
2415
+ if base is not source_doc:
2416
+ return None
2417
+
2418
+ # Prefer the actual subwindow title (includes [View N], etc.)
2419
+ try:
2420
+ title = sw.windowTitle()
2421
+ title = title.strip() if isinstance(title, str) else ""
2422
+ return title or None
2423
+ except Exception:
2424
+ return None
2425
+
2426
+
2269
2427
  def duplicate_document(self, source_doc: ImageDocument, new_name: str | None = None) -> ImageDocument:
2270
2428
  # DEBUG: log the source doc WCS before we touch anything
2271
2429
  if _DEBUG_WCS:
@@ -2275,19 +2433,33 @@ class DocManager(QObject):
2275
2433
  name = "<src>"
2276
2434
 
2277
2435
  _debug_log_wcs_context(" source.metadata", getattr(source_doc, "metadata", {}))
2278
-
2436
+ if _DEBUG_DND_DUP:
2437
+ try:
2438
+ src_dn = source_doc.display_name() if hasattr(source_doc, "display_name") else None
2439
+ except Exception:
2440
+ src_dn = None
2441
+ print("\n[DNDDBG:DUPLICATE_DOCUMENT]")
2442
+ print(" source_doc:", source_doc, "id:", id(source_doc), "uid:", getattr(source_doc,"uid",None))
2443
+ print(" source_doc.display_name():", src_dn)
2444
+ print(" new_name arg:", new_name)
2279
2445
  # COPY-ON-WRITE: Share the source image instead of copying immediately.
2280
2446
  # The duplicate's apply_edit will copy when it first modifies the image.
2281
2447
  # This saves memory when duplicates are created but not modified.
2282
2448
  img_ref = source_doc.image # Shared reference, no copy
2283
2449
 
2284
2450
  meta = dict(source_doc.metadata or {})
2285
- base = source_doc.display_name()
2451
+
2452
+ # ✅ Use CURRENT VIEW NAME if this doc is what's active; else fall back to doc display_name()
2453
+ base = self._current_view_title_for_doc(source_doc) or source_doc.display_name()
2454
+
2286
2455
  dup_title = (new_name or f"{base}_duplicate")
2456
+
2287
2457
  # 🚫 strip any lingering emojis / link markers
2288
2458
  dup_title = dup_title.replace("🔗", "").strip()
2289
- meta["display_name"] = dup_title
2290
2459
 
2460
+ meta["display_name"] = dup_title
2461
+ if _DEBUG_DND_DUP:
2462
+ print(" dup_title computed:", dup_title)
2291
2463
  # Remove anything that makes the view look "linked/preview"
2292
2464
  imi = dict(meta.get("image_meta") or {})
2293
2465
  for k in ("readonly", "view_kind", "derived_from", "layer", "layer_index", "linked"):
@@ -2311,7 +2483,13 @@ class DocManager(QObject):
2311
2483
  # Mark this duplicate as sharing image data with source
2312
2484
  dup._cow_source = source_doc
2313
2485
  self._register_doc(dup)
2314
-
2486
+ if _DEBUG_DND_DUP:
2487
+ try:
2488
+ dn = dup.display_name() if hasattr(dup, "display_name") else None
2489
+ except Exception:
2490
+ dn = None
2491
+ print(" dup.metadata.display_name:", (dup.metadata or {}).get("display_name"))
2492
+ print(" dup.display_name():", dn)
2315
2493
  # DEBUG: log the duplicate doc WCS
2316
2494
  if _DEBUG_WCS:
2317
2495
  try:
@@ -2370,7 +2548,58 @@ class DocManager(QObject):
2370
2548
  def create_document(self, image, metadata: dict | None = None, name: str | None = None) -> ImageDocument:
2371
2549
  return self.open_array(image, metadata=metadata, title=name)
2372
2550
 
2551
+ def _drop_all_roi_for_parent(self, parent_doc):
2552
+ dead = [k for k in list(self._roi_doc_cache.keys()) if k[0] == id(parent_doc)]
2553
+ for k in dead:
2554
+ roi_doc = self._roi_doc_cache.pop(k, None)
2555
+ if roi_doc is not None:
2556
+ try:
2557
+ roi_doc.close() # you’ll implement doc.close() to release arrays
2558
+ except Exception:
2559
+ pass
2560
+
2561
+ def _hard_memory_cleanup(self):
2562
+ # 1) Drop Qt pixmap cache (can hold big chunks)
2563
+ try:
2564
+ from PyQt6.QtGui import QPixmapCache
2565
+ QPixmapCache.clear()
2566
+ except Exception:
2567
+ pass
2568
+
2569
+ # 2) Let pending deleteLater() actually execute
2570
+ try:
2571
+ from PyQt6.QtWidgets import QApplication
2572
+ QApplication.processEvents()
2573
+ except Exception:
2574
+ pass
2575
+
2576
+ # 3) Force Python GC to collect cycles (common with Qt signal/closure cycles)
2577
+ try:
2578
+ import gc
2579
+ gc.collect()
2580
+ except Exception:
2581
+ pass
2582
+
2583
+ # 4) Optional: Linux heap trim (only helps on Linux/glibc)
2584
+ try:
2585
+ import sys
2586
+ if sys.platform.startswith("linux"):
2587
+ import ctypes
2588
+ libc = ctypes.CDLL("libc.so.6")
2589
+ libc.malloc_trim(0)
2590
+ except Exception:
2591
+ pass
2592
+
2373
2593
  def close_document(self, doc):
2594
+ # If ROI wrapper, close parent; if parent, purge ROI cache
2595
+ try:
2596
+ parent = getattr(doc, "_parent_doc", None)
2597
+ if parent is not None:
2598
+ doc = parent
2599
+ except Exception:
2600
+ pass
2601
+
2602
+ self._drop_all_roi_for_parent(doc)
2374
2603
  if doc in self._docs:
2375
2604
  self._docs.remove(doc)
2376
2605
  try:
@@ -2387,6 +2616,7 @@ class DocManager(QObject):
2387
2616
  print(f"[DocManager] Failed to close document {doc}: {e}")
2388
2617
 
2389
2618
  self.documentRemoved.emit(doc)
2619
+ self._hard_memory_cleanup()
2390
2620
 
2391
2621
  # --- Active-document helpers (NEW) ---------------------------------
2392
2622
  def all_documents(self):
@@ -27,6 +27,7 @@ from astropy.coordinates import SkyCoord
27
27
  import astropy.units as u
28
28
  from astropy.wcs import WCS
29
29
  from astropy.timeseries import LombScargle, BoxLeastSquares
30
+ import re
30
31
 
31
32
  from astroquery.simbad import Simbad
32
33
  from astroquery.vizier import Vizier
@@ -152,6 +153,41 @@ def _estimate_scale_arcsec_per_pix(h: fits.Header):
152
153
 
153
154
  return None
154
155
 
156
+ _TZ_RE = re.compile(r'([+-])(\d{2})(\d{2})$') # -0700 -> -07:00
157
+
158
+ def _fix_iso_tz(s: str) -> str:
159
+ s = s.strip()
160
+ m = _TZ_RE.search(s)
161
+ if m:
162
+ s = s[:m.start()] + f"{m.group(1)}{m.group(2)}:{m.group(3)}"
163
+ return s
164
+
165
+ def _parse_obs_time_from_header(hdr) -> Time | None:
166
+ # hdr can be fits.Header or your dict-ish header
167
+ def _get(key):
168
+ try:
169
+ return hdr.get(key)
170
+ except Exception:
171
+ return None
172
+
173
+ # 1) Prefer UT-OBS if present (already “UTC-ish”)
174
+ for key in ("UT-OBS", "DATE-OBS", "DATE-END"):
175
+ v = _get(key)
176
+ if isinstance(v, str) and v.strip():
177
+ try:
178
+ return Time(_fix_iso_tz(v), format="isot", scale="utc")
179
+ except Exception:
180
+ pass
181
+
182
+ # 2) MJD-OBS is super reliable
183
+ v = _get("MJD-OBS")
184
+ if v is not None:
185
+ try:
186
+ return Time(float(v), format="mjd", scale="utc")
187
+ except Exception:
188
+ pass
189
+
190
+ return None
155
191
 
156
192
  def _estimate_fov_deg(img_shape, scale_arcsec):
157
193
  """Rough FOV (deg) from image size and scale (max of X/Y)."""
@@ -646,9 +682,12 @@ class ExoPlanetWindow(QDialog):
646
682
  self.measure_btn.setVisible(is_raw)
647
683
 
648
684
  def load_and_measure_subs(self):
685
+ before = len(getattr(self, "image_paths", []))
649
686
  self.load_aligned_subs()
687
+ after = len(getattr(self, "image_paths", []))
688
+ if after == 0 or after == before and not self._cached_images:
689
+ return
650
690
  self.detect_stars()
651
-
652
691
  # --------------- I/O + Calibration ----------------
653
692
 
654
693
  def load_raw_subs(self):
@@ -682,13 +721,38 @@ class ExoPlanetWindow(QDialog):
682
721
  ds = hdr0.get('DATE-OBS')
683
722
  except:
684
723
  ds = None
724
+
725
+ # Use robust header time parsing (UT-OBS -> DATE-OBS -> MJD-OBS fallback)
685
726
  t = None
686
- if isinstance(ds, str):
687
- try:
688
- t = Time(ds, format='isot', scale='utc')
689
- except Exception as e:
690
- print(f"[DEBUG] Failed to parse DATE-OBS for {p}: {e}")
727
+ try:
728
+ if ext == ".xisf":
729
+ # Build a tiny dict-like header for the helper
730
+ hdr_like = {}
731
+ try:
732
+ xisf = XISF(p)
733
+ img_meta = xisf.get_images_metadata()[0]
734
+ kw = img_meta.get("FITSKeywords", {}) or {}
735
+ # XISF FITSKeywords layout: key -> [ {value: ...}, ... ]
736
+ for k in ("UT-OBS", "DATE-OBS", "DATE-END", "MJD-OBS"):
737
+ if k in kw and kw[k]:
738
+ hdr_like[k] = kw[k][0].get("value")
739
+ except Exception:
740
+ hdr_like = {}
741
+ t = _parse_obs_time_from_header(hdr_like)
742
+
743
+ elif ext in (".fit", ".fits", ".fz"):
744
+ hdr0, _ = get_valid_header(p)
745
+ t = _parse_obs_time_from_header(hdr0)
746
+
747
+ else:
748
+ # TIFF etc. may not have FITS-like time headers; leave None
749
+ t = None
750
+
751
+ except Exception as e:
752
+ print(f"[DEBUG] Failed to parse obs time for {p}: {e}")
753
+
691
754
  datelist.append((p, t))
755
+
692
756
  self.progress_bar.setValue(i)
693
757
  QApplication.processEvents()
694
758
 
@@ -768,24 +832,28 @@ class ExoPlanetWindow(QDialog):
768
832
  self.progress_bar.setValue(i)
769
833
  QApplication.processEvents()
770
834
 
771
- iso_strs, mask_arr = [], []
772
- for _, t in datelist:
773
- if t is not None:
774
- iso_strs.append(t.isot); mask_arr.append(False)
775
- else:
776
- iso_strs.append(''); mask_arr.append(True)
777
- ma_strs = np.ma.MaskedArray(iso_strs, mask=mask_arr)
778
- self.times = Time(ma_strs, format='isot', scale='utc', out_subfmt='date')
835
+ # Keep full timestamps (DO NOT truncate to date-only)
836
+ tlist = [t for _, t in datelist if t is not None]
837
+ if tlist:
838
+ self.times = Time(tlist) # already utc scale from helper
839
+ else:
840
+ self.times = None
779
841
 
780
842
  self.progress_bar.setVisible(False)
781
843
  loaded = sum(1 for im in self._cached_images if im is not None)
782
844
  self.status_label.setText(f"Loaded {loaded}/{len(sorted_paths)} raw frames")
783
845
 
784
- def load_aligned_subs(self):
846
+ def load_aligned_subs(self) -> bool:
785
847
  settings = QSettings()
786
848
  start_dir = settings.value("ExoPlanet/lastAlignedFolder", os.path.expanduser("~"), type=str)
787
- paths, _ = QFileDialog.getOpenFileNames(self, "Select Aligned Frames", start_dir, "FITS or TIFF (*.fit *.fits *.tif *.tiff *.xisf)")
788
- if not paths: return
849
+ paths, _ = QFileDialog.getOpenFileNames(
850
+ self, "Select Aligned Frames", start_dir,
851
+ "FITS or TIFF (*.fit *.fits *.tif *.tiff *.xisf)"
852
+ )
853
+ if not paths:
854
+ self.status_label.setText("Load canceled.")
855
+ return False
856
+
789
857
  settings.setValue("ExoPlanet/lastAlignedFolder", os.path.dirname(paths[0]))
790
858
 
791
859
  self.status_label.setText("Reading metadata from aligned frames…")
@@ -809,10 +877,35 @@ class ExoPlanetWindow(QDialog):
809
877
  hdr0, _ = get_valid_header(p)
810
878
  ds = hdr0.get('DATE-OBS')
811
879
  except: ds = None
880
+ # Use robust header time parsing (UT-OBS -> DATE-OBS -> MJD-OBS fallback)
812
881
  t = None
813
- if isinstance(ds, str):
814
- try: t = Time(ds, format='isot', scale='utc')
815
- except Exception as e: print(f"[DEBUG] Failed to parse DATE-OBS for {p}: {e}")
882
+ try:
883
+ if ext == ".xisf":
884
+ # Build a tiny dict-like header for the helper
885
+ hdr_like = {}
886
+ try:
887
+ xisf = XISF(p)
888
+ img_meta = xisf.get_images_metadata()[0]
889
+ kw = img_meta.get("FITSKeywords", {}) or {}
890
+ # XISF FITSKeywords layout: key -> [ {value: ...}, ... ]
891
+ for k in ("UT-OBS", "DATE-OBS", "DATE-END", "MJD-OBS"):
892
+ if k in kw and kw[k]:
893
+ hdr_like[k] = kw[k][0].get("value")
894
+ except Exception:
895
+ hdr_like = {}
896
+ t = _parse_obs_time_from_header(hdr_like)
897
+
898
+ elif ext in (".fit", ".fits", ".fz"):
899
+ hdr0, _ = get_valid_header(p)
900
+ t = _parse_obs_time_from_header(hdr0)
901
+
902
+ else:
903
+ # TIFF etc. may not have FITS-like time headers; leave None
904
+ t = None
905
+
906
+ except Exception as e:
907
+ print(f"[DEBUG] Failed to parse obs time for {p}: {e}")
908
+
816
909
  datelist.append((p, t))
817
910
  self.progress_bar.setValue(i)
818
911
  QApplication.processEvents()
@@ -889,18 +982,17 @@ class ExoPlanetWindow(QDialog):
889
982
  self.progress_bar.setValue(i)
890
983
  QApplication.processEvents()
891
984
 
892
- iso_strs, mask_arr = [], []
893
- for _, t in datelist:
894
- if t is not None:
895
- iso_strs.append(t.isot); mask_arr.append(False)
896
- else:
897
- iso_strs.append(''); mask_arr.append(True)
898
- ma_strs = np.ma.MaskedArray(iso_strs, mask=mask_arr)
899
- self.times = Time(ma_strs, format='isot', scale='utc', out_subfmt='date')
985
+ # Keep full timestamps (DO NOT truncate to date-only)
986
+ tlist = [t for _, t in datelist if t is not None]
987
+ if tlist:
988
+ self.times = Time(tlist) # already utc scale from helper
989
+ else:
990
+ self.times = None
900
991
 
901
992
  self.progress_bar.setVisible(False)
902
993
  loaded = sum(1 for im in self._cached_images if im is not None)
903
994
  self.status_label.setText(f"Loaded {loaded}/{len(sorted_paths)} aligned frames")
995
+ return loaded > 0
904
996
 
905
997
  def load_masters(self):
906
998
  settings = QSettings()