Spaces:
Running
Running
Commit ·
869b08d
1
Parent(s): 27762e4
feat: add lazy loading for large FITS files
Browse files- Add LazyEventLoader class for memory-efficient file loading
- Extend DataService with lazy loading and preview methods
- Add file size risk assessment and Astropy table roundtrip
- Update DataIngestion UI with lazy loading controls
- Add comprehensive unit and integration tests
- environment.yml +329 -522
- modules/DataLoading/DataIngestion.py +166 -16
- modules/QuickLook/EventList.py +356 -14
- services/base_service.py +10 -4
- services/data_service.py +474 -0
- services/lightcurve_service.py +177 -0
- test_astropy_roundtrip.py +151 -0
- tests/test_lazy_loader.py +506 -0
- tests/test_lazy_loading_integration.py +642 -0
- utils/lazy_loader.py +438 -0
environment.yml
CHANGED
|
@@ -3,526 +3,333 @@ channels:
|
|
| 3 |
- conda-forge
|
| 4 |
- defaults
|
| 5 |
dependencies:
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
-
|
| 11 |
-
-
|
| 12 |
-
-
|
| 13 |
-
-
|
| 14 |
-
-
|
| 15 |
-
-
|
| 16 |
-
-
|
| 17 |
-
-
|
| 18 |
-
-
|
| 19 |
-
-
|
| 20 |
-
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
| 26 |
-
-
|
| 27 |
-
-
|
| 28 |
-
-
|
| 29 |
-
- c-
|
| 30 |
-
-
|
| 31 |
-
-
|
| 32 |
-
-
|
| 33 |
-
-
|
| 34 |
-
-
|
| 35 |
-
-
|
| 36 |
-
-
|
| 37 |
-
-
|
| 38 |
-
-
|
| 39 |
-
-
|
| 40 |
-
-
|
| 41 |
-
-
|
| 42 |
-
-
|
| 43 |
-
-
|
| 44 |
-
-
|
| 45 |
-
-
|
| 46 |
-
-
|
| 47 |
-
-
|
| 48 |
-
-
|
| 49 |
-
-
|
| 50 |
-
-
|
| 51 |
-
-
|
| 52 |
-
-
|
| 53 |
-
-
|
| 54 |
-
-
|
| 55 |
-
-
|
| 56 |
-
-
|
| 57 |
-
-
|
| 58 |
-
-
|
| 59 |
-
-
|
| 60 |
-
-
|
| 61 |
-
-
|
| 62 |
-
-
|
| 63 |
-
-
|
| 64 |
-
-
|
| 65 |
-
-
|
| 66 |
-
-
|
| 67 |
-
-
|
| 68 |
-
-
|
| 69 |
-
-
|
| 70 |
-
-
|
| 71 |
-
-
|
| 72 |
-
-
|
| 73 |
-
-
|
| 74 |
-
-
|
| 75 |
-
-
|
| 76 |
-
-
|
| 77 |
-
-
|
| 78 |
-
-
|
| 79 |
-
-
|
| 80 |
-
-
|
| 81 |
-
-
|
| 82 |
-
-
|
| 83 |
-
-
|
| 84 |
-
-
|
| 85 |
-
-
|
| 86 |
-
-
|
| 87 |
-
-
|
| 88 |
-
-
|
| 89 |
-
-
|
| 90 |
-
-
|
| 91 |
-
-
|
| 92 |
-
-
|
| 93 |
-
-
|
| 94 |
-
-
|
| 95 |
-
-
|
| 96 |
-
-
|
| 97 |
-
-
|
| 98 |
-
-
|
| 99 |
-
-
|
| 100 |
-
-
|
| 101 |
-
-
|
| 102 |
-
-
|
| 103 |
-
-
|
| 104 |
-
-
|
| 105 |
-
-
|
| 106 |
-
-
|
| 107 |
-
-
|
| 108 |
-
-
|
| 109 |
-
-
|
| 110 |
-
-
|
| 111 |
-
-
|
| 112 |
-
-
|
| 113 |
-
-
|
| 114 |
-
-
|
| 115 |
-
-
|
| 116 |
-
-
|
| 117 |
-
-
|
| 118 |
-
-
|
| 119 |
-
-
|
| 120 |
-
-
|
| 121 |
-
-
|
| 122 |
-
-
|
| 123 |
-
-
|
| 124 |
-
-
|
| 125 |
-
-
|
| 126 |
-
-
|
| 127 |
-
-
|
| 128 |
-
-
|
| 129 |
-
-
|
| 130 |
-
-
|
| 131 |
-
-
|
| 132 |
-
-
|
| 133 |
-
-
|
| 134 |
-
-
|
| 135 |
-
-
|
| 136 |
-
-
|
| 137 |
-
-
|
| 138 |
-
-
|
| 139 |
-
-
|
| 140 |
-
-
|
| 141 |
-
-
|
| 142 |
-
-
|
| 143 |
-
-
|
| 144 |
-
-
|
| 145 |
-
-
|
| 146 |
-
-
|
| 147 |
-
-
|
| 148 |
-
-
|
| 149 |
-
-
|
| 150 |
-
-
|
| 151 |
-
-
|
| 152 |
-
-
|
| 153 |
-
-
|
| 154 |
-
-
|
| 155 |
-
-
|
| 156 |
-
-
|
| 157 |
-
-
|
| 158 |
-
-
|
| 159 |
-
-
|
| 160 |
-
-
|
| 161 |
-
-
|
| 162 |
-
-
|
| 163 |
-
-
|
| 164 |
-
-
|
| 165 |
-
-
|
| 166 |
-
-
|
| 167 |
-
-
|
| 168 |
-
-
|
| 169 |
-
-
|
| 170 |
-
-
|
| 171 |
-
-
|
| 172 |
-
-
|
| 173 |
-
-
|
| 174 |
-
-
|
| 175 |
-
-
|
| 176 |
-
-
|
| 177 |
-
-
|
| 178 |
-
-
|
| 179 |
-
-
|
| 180 |
-
-
|
| 181 |
-
-
|
| 182 |
-
-
|
| 183 |
-
-
|
| 184 |
-
-
|
| 185 |
-
-
|
| 186 |
-
-
|
| 187 |
-
-
|
| 188 |
-
-
|
| 189 |
-
-
|
| 190 |
-
-
|
| 191 |
-
-
|
| 192 |
-
-
|
| 193 |
-
-
|
| 194 |
-
-
|
| 195 |
-
-
|
| 196 |
-
-
|
| 197 |
-
-
|
| 198 |
-
-
|
| 199 |
-
-
|
| 200 |
-
-
|
| 201 |
-
-
|
| 202 |
-
-
|
| 203 |
-
-
|
| 204 |
-
-
|
| 205 |
-
-
|
| 206 |
-
-
|
| 207 |
-
-
|
| 208 |
-
-
|
| 209 |
-
-
|
| 210 |
-
-
|
| 211 |
-
-
|
| 212 |
-
-
|
| 213 |
-
-
|
| 214 |
-
-
|
| 215 |
-
-
|
| 216 |
-
-
|
| 217 |
-
-
|
| 218 |
-
-
|
| 219 |
-
-
|
| 220 |
-
-
|
| 221 |
-
-
|
| 222 |
-
-
|
| 223 |
-
-
|
| 224 |
-
-
|
| 225 |
-
-
|
| 226 |
-
-
|
| 227 |
-
-
|
| 228 |
-
-
|
| 229 |
-
-
|
| 230 |
-
-
|
| 231 |
-
-
|
| 232 |
-
-
|
| 233 |
-
-
|
| 234 |
-
-
|
| 235 |
-
-
|
| 236 |
-
-
|
| 237 |
-
-
|
| 238 |
-
-
|
| 239 |
-
-
|
| 240 |
-
-
|
| 241 |
-
-
|
| 242 |
-
-
|
| 243 |
-
-
|
| 244 |
-
-
|
| 245 |
-
-
|
| 246 |
-
-
|
| 247 |
-
-
|
| 248 |
-
-
|
| 249 |
-
-
|
| 250 |
-
-
|
| 251 |
-
-
|
| 252 |
-
-
|
| 253 |
-
-
|
| 254 |
-
-
|
| 255 |
-
-
|
| 256 |
-
-
|
| 257 |
-
-
|
| 258 |
-
-
|
| 259 |
-
-
|
| 260 |
-
-
|
| 261 |
-
-
|
| 262 |
-
-
|
| 263 |
-
-
|
| 264 |
-
-
|
| 265 |
-
-
|
| 266 |
-
-
|
| 267 |
-
-
|
| 268 |
-
-
|
| 269 |
-
-
|
| 270 |
-
-
|
| 271 |
-
-
|
| 272 |
-
-
|
| 273 |
-
-
|
| 274 |
-
-
|
| 275 |
-
-
|
| 276 |
-
-
|
| 277 |
-
-
|
| 278 |
-
-
|
| 279 |
-
-
|
| 280 |
-
-
|
| 281 |
-
-
|
| 282 |
-
-
|
| 283 |
-
-
|
| 284 |
-
-
|
| 285 |
-
-
|
| 286 |
-
-
|
| 287 |
-
-
|
| 288 |
-
-
|
| 289 |
-
-
|
| 290 |
-
-
|
| 291 |
-
-
|
| 292 |
-
-
|
| 293 |
-
-
|
| 294 |
-
-
|
| 295 |
-
-
|
| 296 |
-
-
|
| 297 |
-
-
|
| 298 |
-
-
|
| 299 |
-
-
|
| 300 |
-
-
|
| 301 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
- pip:
|
| 303 |
-
-
|
| 304 |
-
-
|
| 305 |
-
|
| 306 |
-
- autobahn==23.6.2
|
| 307 |
-
- automat==22.10.0
|
| 308 |
-
- bfg==0.1.0
|
| 309 |
-
- blinker==1.8.2
|
| 310 |
-
- bokeh==3.4.2
|
| 311 |
-
- bokeh-django==0.1.0
|
| 312 |
-
- channels==3.0.0
|
| 313 |
-
- chex==0.1.86
|
| 314 |
-
- constantly==23.10.4
|
| 315 |
-
- cryptography==42.0.8
|
| 316 |
-
- daphne==3.0.2
|
| 317 |
-
- django==5.0.6
|
| 318 |
-
- dm-haiku==0.0.12
|
| 319 |
-
- flask==3.0.3
|
| 320 |
-
- flatbuffers==24.3.25
|
| 321 |
-
- flax==0.8.4
|
| 322 |
-
- future==1.0.0
|
| 323 |
-
- gast==0.5.4
|
| 324 |
-
- git-filter-repo==2.38.0
|
| 325 |
-
- google-pasta==0.2.0
|
| 326 |
-
- gputil==1.4.0
|
| 327 |
-
- grpcio==1.64.0
|
| 328 |
-
- h2==2.6.2
|
| 329 |
-
- holoviews==1.19.1
|
| 330 |
-
- hpack==3.0.0
|
| 331 |
-
- hyper==0.7.0
|
| 332 |
-
- hyperframe==3.2.0
|
| 333 |
-
- hyperlink==21.0.0
|
| 334 |
-
- itsdangerous==2.2.0
|
| 335 |
-
- jaxns==2.5.0
|
| 336 |
-
- jaxopt==0.8.3
|
| 337 |
-
- jmp==0.0.4
|
| 338 |
-
- jplephem==2.22
|
| 339 |
-
- keras==3.3.3
|
| 340 |
-
- libclang==18.1.1
|
| 341 |
-
- loguru==0.7.2
|
| 342 |
-
- ml-dtypes==0.3.2
|
| 343 |
-
- msgpack==1.0.8
|
| 344 |
-
- namex==0.0.8
|
| 345 |
-
- nestle==0.2.0
|
| 346 |
-
- numdifftools==0.9.41
|
| 347 |
-
- optax==0.2.2
|
| 348 |
-
- optree==0.11.0
|
| 349 |
-
- orbax-checkpoint==0.5.14
|
| 350 |
-
- panel==1.4.4
|
| 351 |
-
- pint-pulsar==1.0
|
| 352 |
-
- protobuf==4.25.3
|
| 353 |
-
- pyasn1==0.6.0
|
| 354 |
-
- pyasn1-modules==0.4.0
|
| 355 |
-
- pyobjc==10.3.1
|
| 356 |
-
- pyobjc-core==10.3.1
|
| 357 |
-
- pyobjc-framework-accessibility==10.3.1
|
| 358 |
-
- pyobjc-framework-accounts==10.3.1
|
| 359 |
-
- pyobjc-framework-addressbook==10.3.1
|
| 360 |
-
- pyobjc-framework-adservices==10.3.1
|
| 361 |
-
- pyobjc-framework-adsupport==10.3.1
|
| 362 |
-
- pyobjc-framework-applescriptkit==10.3.1
|
| 363 |
-
- pyobjc-framework-applescriptobjc==10.3.1
|
| 364 |
-
- pyobjc-framework-applicationservices==10.3.1
|
| 365 |
-
- pyobjc-framework-apptrackingtransparency==10.3.1
|
| 366 |
-
- pyobjc-framework-audiovideobridging==10.3.1
|
| 367 |
-
- pyobjc-framework-authenticationservices==10.3.1
|
| 368 |
-
- pyobjc-framework-automaticassessmentconfiguration==10.3.1
|
| 369 |
-
- pyobjc-framework-automator==10.3.1
|
| 370 |
-
- pyobjc-framework-avfoundation==10.3.1
|
| 371 |
-
- pyobjc-framework-avkit==10.3.1
|
| 372 |
-
- pyobjc-framework-avrouting==10.3.1
|
| 373 |
-
- pyobjc-framework-backgroundassets==10.3.1
|
| 374 |
-
- pyobjc-framework-browserenginekit==10.3.1
|
| 375 |
-
- pyobjc-framework-businesschat==10.3.1
|
| 376 |
-
- pyobjc-framework-calendarstore==10.3.1
|
| 377 |
-
- pyobjc-framework-callkit==10.3.1
|
| 378 |
-
- pyobjc-framework-cfnetwork==10.3.1
|
| 379 |
-
- pyobjc-framework-cinematic==10.3.1
|
| 380 |
-
- pyobjc-framework-classkit==10.3.1
|
| 381 |
-
- pyobjc-framework-cloudkit==10.3.1
|
| 382 |
-
- pyobjc-framework-cocoa==10.3.1
|
| 383 |
-
- pyobjc-framework-collaboration==10.3.1
|
| 384 |
-
- pyobjc-framework-colorsync==10.3.1
|
| 385 |
-
- pyobjc-framework-contacts==10.3.1
|
| 386 |
-
- pyobjc-framework-contactsui==10.3.1
|
| 387 |
-
- pyobjc-framework-coreaudio==10.3.1
|
| 388 |
-
- pyobjc-framework-coreaudiokit==10.3.1
|
| 389 |
-
- pyobjc-framework-corebluetooth==10.3.1
|
| 390 |
-
- pyobjc-framework-coredata==10.3.1
|
| 391 |
-
- pyobjc-framework-corehaptics==10.3.1
|
| 392 |
-
- pyobjc-framework-corelocation==10.3.1
|
| 393 |
-
- pyobjc-framework-coremedia==10.3.1
|
| 394 |
-
- pyobjc-framework-coremediaio==10.3.1
|
| 395 |
-
- pyobjc-framework-coremidi==10.3.1
|
| 396 |
-
- pyobjc-framework-coreml==10.3.1
|
| 397 |
-
- pyobjc-framework-coremotion==10.3.1
|
| 398 |
-
- pyobjc-framework-coreservices==10.3.1
|
| 399 |
-
- pyobjc-framework-corespotlight==10.3.1
|
| 400 |
-
- pyobjc-framework-coretext==10.3.1
|
| 401 |
-
- pyobjc-framework-corewlan==10.3.1
|
| 402 |
-
- pyobjc-framework-cryptotokenkit==10.3.1
|
| 403 |
-
- pyobjc-framework-datadetection==10.3.1
|
| 404 |
-
- pyobjc-framework-devicecheck==10.3.1
|
| 405 |
-
- pyobjc-framework-dictionaryservices==10.3.1
|
| 406 |
-
- pyobjc-framework-discrecording==10.3.1
|
| 407 |
-
- pyobjc-framework-discrecordingui==10.3.1
|
| 408 |
-
- pyobjc-framework-diskarbitration==10.3.1
|
| 409 |
-
- pyobjc-framework-dvdplayback==10.3.1
|
| 410 |
-
- pyobjc-framework-eventkit==10.3.1
|
| 411 |
-
- pyobjc-framework-exceptionhandling==10.3.1
|
| 412 |
-
- pyobjc-framework-executionpolicy==10.3.1
|
| 413 |
-
- pyobjc-framework-extensionkit==10.3.1
|
| 414 |
-
- pyobjc-framework-externalaccessory==10.3.1
|
| 415 |
-
- pyobjc-framework-fileprovider==10.3.1
|
| 416 |
-
- pyobjc-framework-fileproviderui==10.3.1
|
| 417 |
-
- pyobjc-framework-findersync==10.3.1
|
| 418 |
-
- pyobjc-framework-fsevents==10.3.1
|
| 419 |
-
- pyobjc-framework-gamecenter==10.3.1
|
| 420 |
-
- pyobjc-framework-gamecontroller==10.3.1
|
| 421 |
-
- pyobjc-framework-gamekit==10.3.1
|
| 422 |
-
- pyobjc-framework-gameplaykit==10.3.1
|
| 423 |
-
- pyobjc-framework-healthkit==10.3.1
|
| 424 |
-
- pyobjc-framework-imagecapturecore==10.3.1
|
| 425 |
-
- pyobjc-framework-inputmethodkit==10.3.1
|
| 426 |
-
- pyobjc-framework-installerplugins==10.3.1
|
| 427 |
-
- pyobjc-framework-instantmessage==10.3.1
|
| 428 |
-
- pyobjc-framework-intents==10.3.1
|
| 429 |
-
- pyobjc-framework-intentsui==10.3.1
|
| 430 |
-
- pyobjc-framework-iobluetooth==10.3.1
|
| 431 |
-
- pyobjc-framework-iobluetoothui==10.3.1
|
| 432 |
-
- pyobjc-framework-iosurface==10.3.1
|
| 433 |
-
- pyobjc-framework-ituneslibrary==10.3.1
|
| 434 |
-
- pyobjc-framework-kernelmanagement==10.3.1
|
| 435 |
-
- pyobjc-framework-latentsemanticmapping==10.3.1
|
| 436 |
-
- pyobjc-framework-launchservices==10.3.1
|
| 437 |
-
- pyobjc-framework-libdispatch==10.3.1
|
| 438 |
-
- pyobjc-framework-libxpc==10.3.1
|
| 439 |
-
- pyobjc-framework-linkpresentation==10.3.1
|
| 440 |
-
- pyobjc-framework-localauthentication==10.3.1
|
| 441 |
-
- pyobjc-framework-localauthenticationembeddedui==10.3.1
|
| 442 |
-
- pyobjc-framework-mailkit==10.3.1
|
| 443 |
-
- pyobjc-framework-mapkit==10.3.1
|
| 444 |
-
- pyobjc-framework-mediaaccessibility==10.3.1
|
| 445 |
-
- pyobjc-framework-medialibrary==10.3.1
|
| 446 |
-
- pyobjc-framework-mediaplayer==10.3.1
|
| 447 |
-
- pyobjc-framework-mediatoolbox==10.3.1
|
| 448 |
-
- pyobjc-framework-metal==10.3.1
|
| 449 |
-
- pyobjc-framework-metalfx==10.3.1
|
| 450 |
-
- pyobjc-framework-metalkit==10.3.1
|
| 451 |
-
- pyobjc-framework-metalperformanceshaders==10.3.1
|
| 452 |
-
- pyobjc-framework-metalperformanceshadersgraph==10.3.1
|
| 453 |
-
- pyobjc-framework-metrickit==10.3.1
|
| 454 |
-
- pyobjc-framework-mlcompute==10.3.1
|
| 455 |
-
- pyobjc-framework-modelio==10.3.1
|
| 456 |
-
- pyobjc-framework-multipeerconnectivity==10.3.1
|
| 457 |
-
- pyobjc-framework-naturallanguage==10.3.1
|
| 458 |
-
- pyobjc-framework-netfs==10.3.1
|
| 459 |
-
- pyobjc-framework-network==10.3.1
|
| 460 |
-
- pyobjc-framework-networkextension==10.3.1
|
| 461 |
-
- pyobjc-framework-notificationcenter==10.3.1
|
| 462 |
-
- pyobjc-framework-opendirectory==10.3.1
|
| 463 |
-
- pyobjc-framework-osakit==10.3.1
|
| 464 |
-
- pyobjc-framework-oslog==10.3.1
|
| 465 |
-
- pyobjc-framework-passkit==10.3.1
|
| 466 |
-
- pyobjc-framework-pencilkit==10.3.1
|
| 467 |
-
- pyobjc-framework-phase==10.3.1
|
| 468 |
-
- pyobjc-framework-photos==10.3.1
|
| 469 |
-
- pyobjc-framework-photosui==10.3.1
|
| 470 |
-
- pyobjc-framework-preferencepanes==10.3.1
|
| 471 |
-
- pyobjc-framework-pushkit==10.3.1
|
| 472 |
-
- pyobjc-framework-quartz==10.3.1
|
| 473 |
-
- pyobjc-framework-quicklookthumbnailing==10.3.1
|
| 474 |
-
- pyobjc-framework-replaykit==10.3.1
|
| 475 |
-
- pyobjc-framework-safariservices==10.3.1
|
| 476 |
-
- pyobjc-framework-safetykit==10.3.1
|
| 477 |
-
- pyobjc-framework-scenekit==10.3.1
|
| 478 |
-
- pyobjc-framework-screencapturekit==10.3.1
|
| 479 |
-
- pyobjc-framework-screensaver==10.3.1
|
| 480 |
-
- pyobjc-framework-screentime==10.3.1
|
| 481 |
-
- pyobjc-framework-scriptingbridge==10.3.1
|
| 482 |
-
- pyobjc-framework-searchkit==10.3.1
|
| 483 |
-
- pyobjc-framework-security==10.3.1
|
| 484 |
-
- pyobjc-framework-securityfoundation==10.3.1
|
| 485 |
-
- pyobjc-framework-securityinterface==10.3.1
|
| 486 |
-
- pyobjc-framework-sensitivecontentanalysis==10.3.1
|
| 487 |
-
- pyobjc-framework-servicemanagement==10.3.1
|
| 488 |
-
- pyobjc-framework-sharedwithyou==10.3.1
|
| 489 |
-
- pyobjc-framework-sharedwithyoucore==10.3.1
|
| 490 |
-
- pyobjc-framework-shazamkit==10.3.1
|
| 491 |
-
- pyobjc-framework-social==10.3.1
|
| 492 |
-
- pyobjc-framework-soundanalysis==10.3.1
|
| 493 |
-
- pyobjc-framework-speech==10.3.1
|
| 494 |
-
- pyobjc-framework-spritekit==10.3.1
|
| 495 |
-
- pyobjc-framework-storekit==10.3.1
|
| 496 |
-
- pyobjc-framework-symbols==10.3.1
|
| 497 |
-
- pyobjc-framework-syncservices==10.3.1
|
| 498 |
-
- pyobjc-framework-systemconfiguration==10.3.1
|
| 499 |
-
- pyobjc-framework-systemextensions==10.3.1
|
| 500 |
-
- pyobjc-framework-threadnetwork==10.3.1
|
| 501 |
-
- pyobjc-framework-uniformtypeidentifiers==10.3.1
|
| 502 |
-
- pyobjc-framework-usernotifications==10.3.1
|
| 503 |
-
- pyobjc-framework-usernotificationsui==10.3.1
|
| 504 |
-
- pyobjc-framework-videosubscriberaccount==10.3.1
|
| 505 |
-
- pyobjc-framework-videotoolbox==10.3.1
|
| 506 |
-
- pyobjc-framework-virtualization==10.3.1
|
| 507 |
-
- pyobjc-framework-vision==10.3.1
|
| 508 |
-
- pyobjc-framework-webkit==10.3.1
|
| 509 |
-
- pyopenssl==24.1.0
|
| 510 |
-
- pytoml==0.1.21
|
| 511 |
-
- rich==13.7.1
|
| 512 |
-
- service-identity==24.1.0
|
| 513 |
-
- sqlparse==0.5.0
|
| 514 |
-
- stingray==0.1.dev3967+g822f755
|
| 515 |
-
- tensorboard==2.16.2
|
| 516 |
-
- tensorboard-data-server==0.7.2
|
| 517 |
-
- tensorflow==2.16.1
|
| 518 |
-
- tensorflow-io-gcs-filesystem==0.37.0
|
| 519 |
-
- tensorflow-probability==0.24.0
|
| 520 |
-
- tensorstore==0.1.59
|
| 521 |
-
- termcolor==2.4.0
|
| 522 |
-
- twisted==24.3.0
|
| 523 |
-
- txaio==23.1.1
|
| 524 |
-
- uncertainties==3.1.7
|
| 525 |
-
- werkzeug==3.0.3
|
| 526 |
-
- wrapt==1.16.0
|
| 527 |
-
- zope-interface==6.4.post2
|
| 528 |
-
prefix: /opt/anaconda3/envs/stingray-env
|
|
|
|
| 3 |
- conda-forge
|
| 4 |
- defaults
|
| 5 |
dependencies:
|
| 6 |
+
- _libgcc_mutex=0.1
|
| 7 |
+
- _openmp_mutex=4.5
|
| 8 |
+
- aiobotocore=2.25.0
|
| 9 |
+
- aiohappyeyeballs=2.6.1
|
| 10 |
+
- aiohttp=3.13.2
|
| 11 |
+
- aioitertools=0.12.0
|
| 12 |
+
- aiosignal=1.4.0
|
| 13 |
+
- anyio=4.11.0
|
| 14 |
+
- argon2-cffi=25.1.0
|
| 15 |
+
- argon2-cffi-bindings=25.1.0
|
| 16 |
+
- arrow=1.4.0
|
| 17 |
+
- astropy=7.1.1
|
| 18 |
+
- astropy-base=7.1.1
|
| 19 |
+
- astropy-iers-data=0.2025.11.3.0.38.37
|
| 20 |
+
- asttokens=3.0.0
|
| 21 |
+
- async-lru=2.0.5
|
| 22 |
+
- attrs=25.4.0
|
| 23 |
+
- aws-c-auth=0.9.1
|
| 24 |
+
- aws-c-cal=0.9.8
|
| 25 |
+
- aws-c-common=0.12.5
|
| 26 |
+
- aws-c-compression=0.3.1
|
| 27 |
+
- aws-c-event-stream=0.5.6
|
| 28 |
+
- aws-c-http=0.10.7
|
| 29 |
+
- aws-c-io=0.23.2
|
| 30 |
+
- aws-c-mqtt=0.13.3
|
| 31 |
+
- aws-c-s3=0.8.6
|
| 32 |
+
- aws-c-sdkutils=0.2.4
|
| 33 |
+
- aws-checksums=0.2.7
|
| 34 |
+
- aws-crt-cpp=0.35.0
|
| 35 |
+
- aws-sdk-cpp=1.11.606
|
| 36 |
+
- azure-core-cpp=1.16.1
|
| 37 |
+
- azure-identity-cpp=1.13.2
|
| 38 |
+
- azure-storage-blobs-cpp=12.15.0
|
| 39 |
+
- azure-storage-common-cpp=12.11.0
|
| 40 |
+
- azure-storage-files-datalake-cpp=12.13.0
|
| 41 |
+
- babel=2.17.0
|
| 42 |
+
- beautifulsoup4=4.14.2
|
| 43 |
+
- black=25.1.0
|
| 44 |
+
- bleach=6.2.0
|
| 45 |
+
- bleach-with-css=6.2.0
|
| 46 |
+
- bokeh=3.8.0
|
| 47 |
+
- botocore=1.40.49
|
| 48 |
+
- bottleneck=1.6.0
|
| 49 |
+
- bqplot=0.12.45
|
| 50 |
+
- brotli=1.2.0
|
| 51 |
+
- brotli-bin=1.2.0
|
| 52 |
+
- brotli-python=1.2.0
|
| 53 |
+
- bzip2=1.0.8
|
| 54 |
+
- c-ares=1.34.5
|
| 55 |
+
- ca-certificates=2025.10.5
|
| 56 |
+
- cached-property=1.5.2
|
| 57 |
+
- cached_property=1.5.2
|
| 58 |
+
- certifi=2025.10.5
|
| 59 |
+
- cffi=2.0.0
|
| 60 |
+
- charset-normalizer=3.4.4
|
| 61 |
+
- click=8.3.0
|
| 62 |
+
- cloudpickle=3.1.2
|
| 63 |
+
- colorama=0.4.6
|
| 64 |
+
- colorcet=3.1.0
|
| 65 |
+
- comm=0.2.3
|
| 66 |
+
- contourpy=1.3.3
|
| 67 |
+
- cycler=0.12.1
|
| 68 |
+
- dask-core=2025.10.0
|
| 69 |
+
- dask-expr=2.0.0
|
| 70 |
+
- datashader=0.18.2
|
| 71 |
+
- debugpy=1.8.17
|
| 72 |
+
- decorator=5.2.1
|
| 73 |
+
- defusedxml=0.7.1
|
| 74 |
+
- entrypoints=0.4
|
| 75 |
+
- exceptiongroup=1.3.0
|
| 76 |
+
- executing=2.2.1
|
| 77 |
+
- fonttools=4.60.1
|
| 78 |
+
- fqdn=1.5.1
|
| 79 |
+
- freetype=2.14.1
|
| 80 |
+
- frozenlist=1.7.0
|
| 81 |
+
- fsspec=2025.10.0
|
| 82 |
+
- gast=0.4.0
|
| 83 |
+
- gflags=2.2.2
|
| 84 |
+
- glog=0.7.1
|
| 85 |
+
- h11=0.16.0
|
| 86 |
+
- h2=4.3.0
|
| 87 |
+
- h5py=3.15.1
|
| 88 |
+
- hdf5=1.14.6
|
| 89 |
+
- holoviews=1.21.0
|
| 90 |
+
- hpack=4.1.0
|
| 91 |
+
- html5lib=1.1
|
| 92 |
+
- httpcore=1.0.9
|
| 93 |
+
- httpx=0.28.1
|
| 94 |
+
- hvplot=0.12.1
|
| 95 |
+
- hyperframe=6.1.0
|
| 96 |
+
- icu=75.1
|
| 97 |
+
- idna=3.11
|
| 98 |
+
- importlib-metadata=8.7.0
|
| 99 |
+
- importlib_metadata=8.7.0
|
| 100 |
+
- importlib_resources=6.5.2
|
| 101 |
+
- iniconfig=2.3.0
|
| 102 |
+
- ipydatagrid=1.4.0
|
| 103 |
+
- ipykernel=7.1.0
|
| 104 |
+
- ipython=9.7.0
|
| 105 |
+
- ipython_pygments_lexers=1.1.1
|
| 106 |
+
- ipywidgets=8.1.8
|
| 107 |
+
- isoduration=20.11.0
|
| 108 |
+
- jedi=0.19.2
|
| 109 |
+
- jinja2=3.1.6
|
| 110 |
+
- jmespath=1.0.1
|
| 111 |
+
- jplephem=2.23
|
| 112 |
+
- json5=0.12.1
|
| 113 |
+
- jsonpointer=3.0.0
|
| 114 |
+
- jsonschema=4.25.1
|
| 115 |
+
- jsonschema-specifications=2025.9.1
|
| 116 |
+
- jsonschema-with-format-nongpl=4.25.1
|
| 117 |
+
- jupyter-lsp=2.3.0
|
| 118 |
+
- jupyter_client=8.6.3
|
| 119 |
+
- jupyter_core=5.9.1
|
| 120 |
+
- jupyter_events=0.12.0
|
| 121 |
+
- jupyter_server=2.17.0
|
| 122 |
+
- jupyter_server_terminals=0.5.3
|
| 123 |
+
- jupyterlab=4.4.10
|
| 124 |
+
- jupyterlab_pygments=0.3.0
|
| 125 |
+
- jupyterlab_server=2.28.0
|
| 126 |
+
- jupyterlab_widgets=3.0.16
|
| 127 |
+
- keyutils=1.6.3
|
| 128 |
+
- kiwisolver=1.4.9
|
| 129 |
+
- krb5=1.21.3
|
| 130 |
+
- lark=1.3.1
|
| 131 |
+
- lcms2=2.17
|
| 132 |
+
- ld_impl_linux-64=2.44
|
| 133 |
+
- lerc=4.0.0
|
| 134 |
+
- libabseil=20250512.1
|
| 135 |
+
- libaec=1.1.4
|
| 136 |
+
- libarrow=22.0.0
|
| 137 |
+
- libarrow-acero=22.0.0
|
| 138 |
+
- libarrow-compute=22.0.0
|
| 139 |
+
- libarrow-dataset=22.0.0
|
| 140 |
+
- libarrow-substrait=22.0.0
|
| 141 |
+
- libblas=3.9.0
|
| 142 |
+
- libbrotlicommon=1.2.0
|
| 143 |
+
- libbrotlidec=1.2.0
|
| 144 |
+
- libbrotlienc=1.2.0
|
| 145 |
+
- libcblas=3.9.0
|
| 146 |
+
- libcrc32c=1.1.2
|
| 147 |
+
- libcurl=8.17.0
|
| 148 |
+
- libdeflate=1.25
|
| 149 |
+
- libedit=3.1.20250104
|
| 150 |
+
- libev=4.33
|
| 151 |
+
- libevent=2.1.12
|
| 152 |
+
- libexpat=2.7.1
|
| 153 |
+
- libffi=3.5.2
|
| 154 |
+
- libfreetype=2.14.1
|
| 155 |
+
- libfreetype6=2.14.1
|
| 156 |
+
- libgcc=15.2.0
|
| 157 |
+
- libgcc-ng=15.2.0
|
| 158 |
+
- libgfortran=15.2.0
|
| 159 |
+
- libgfortran-ng=15.2.0
|
| 160 |
+
- libgfortran5=15.2.0
|
| 161 |
+
- libgomp=15.2.0
|
| 162 |
+
- libgoogle-cloud=2.39.0
|
| 163 |
+
- libgoogle-cloud-storage=2.39.0
|
| 164 |
+
- libgrpc=1.73.1
|
| 165 |
+
- libiconv=1.18
|
| 166 |
+
- libjpeg-turbo=3.1.2
|
| 167 |
+
- liblapack=3.9.0
|
| 168 |
+
- libllvm14=14.0.6
|
| 169 |
+
- liblzma=5.8.1
|
| 170 |
+
- liblzma-devel=5.8.1
|
| 171 |
+
- libnghttp2=1.67.0
|
| 172 |
+
- libnsl=2.0.1
|
| 173 |
+
- libopenblas=0.3.30
|
| 174 |
+
- libopentelemetry-cpp=1.21.0
|
| 175 |
+
- libopentelemetry-cpp-headers=1.21.0
|
| 176 |
+
- libparquet=22.0.0
|
| 177 |
+
- libpng=1.6.50
|
| 178 |
+
- libprotobuf=6.31.1
|
| 179 |
+
- libre2-11=2025.11.05
|
| 180 |
+
- libsodium=1.0.20
|
| 181 |
+
- libsqlite=3.51.0
|
| 182 |
+
- libssh2=1.11.1
|
| 183 |
+
- libstdcxx=15.2.0
|
| 184 |
+
- libstdcxx-ng=15.2.0
|
| 185 |
+
- libthrift=0.22.0
|
| 186 |
+
- libtiff=4.7.1
|
| 187 |
+
- libutf8proc=2.11.0
|
| 188 |
+
- libuuid=2.41.2
|
| 189 |
+
- libwebp-base=1.6.0
|
| 190 |
+
- libxcb=1.17.0
|
| 191 |
+
- libxcrypt=4.4.36
|
| 192 |
+
- libxml2=2.15.1
|
| 193 |
+
- libxml2-16=2.15.1
|
| 194 |
+
- libzlib=1.3.1
|
| 195 |
+
- linkify-it-py=2.0.3
|
| 196 |
+
- llvmlite=0.45.1
|
| 197 |
+
- locket=1.0.0
|
| 198 |
+
- lz4-c=1.10.0
|
| 199 |
+
- markdown=3.10
|
| 200 |
+
- markdown-it-py=4.0.0
|
| 201 |
+
- markupsafe=3.0.3
|
| 202 |
+
- matplotlib-base=3.10.7
|
| 203 |
+
- matplotlib-inline=0.2.1
|
| 204 |
+
- mdit-py-plugins=0.5.0
|
| 205 |
+
- mdurl=0.1.2
|
| 206 |
+
- mistune=3.1.4
|
| 207 |
+
- mpmath=1.3.0
|
| 208 |
+
- multidict=6.6.3
|
| 209 |
+
- multipledispatch=0.6.0
|
| 210 |
+
- munkres=1.1.4
|
| 211 |
+
- mypy_extensions=1.1.0
|
| 212 |
+
- narwhals=2.10.2
|
| 213 |
+
- nbclient=0.10.2
|
| 214 |
+
- nbconvert-core=7.16.6
|
| 215 |
+
- nbformat=5.10.4
|
| 216 |
+
- ncurses=6.5
|
| 217 |
+
- nest-asyncio=1.6.0
|
| 218 |
+
- nlohmann_json=3.12.0
|
| 219 |
+
- notebook-shim=0.2.4
|
| 220 |
+
- numba=0.62.1
|
| 221 |
+
- numpy=2.3.4
|
| 222 |
+
- openjpeg=2.5.4
|
| 223 |
+
- openssl=3.5.4
|
| 224 |
+
- orc=2.2.1
|
| 225 |
+
- overrides=7.7.0
|
| 226 |
+
- packaging=25.0
|
| 227 |
+
- pandas=2.3.3
|
| 228 |
+
- pandocfilters=1.5.0
|
| 229 |
+
- panel=1.8.2
|
| 230 |
+
- param=2.2.1
|
| 231 |
+
- parso=0.8.5
|
| 232 |
+
- partd=1.4.2
|
| 233 |
+
- pathspec=0.12.1
|
| 234 |
+
- pexpect=4.9.0
|
| 235 |
+
- pickleshare=0.7.5
|
| 236 |
+
- pillow=12.0.0
|
| 237 |
+
- pip=25.2
|
| 238 |
+
- pkgutil-resolve-name=1.3.10
|
| 239 |
+
- platformdirs=4.5.0
|
| 240 |
+
- pluggy=1.6.0
|
| 241 |
+
- prometheus-cpp=1.3.0
|
| 242 |
+
- prometheus_client=0.23.1
|
| 243 |
+
- prompt-toolkit=3.0.52
|
| 244 |
+
- propcache=0.3.1
|
| 245 |
+
- psutil=7.1.3
|
| 246 |
+
- pthread-stubs=0.4
|
| 247 |
+
- ptyprocess=0.7.0
|
| 248 |
+
- pure_eval=0.2.3
|
| 249 |
+
- py2vega=0.6.1
|
| 250 |
+
- pyarrow=22.0.0
|
| 251 |
+
- pyarrow-core=22.0.0
|
| 252 |
+
- pycparser=2.22
|
| 253 |
+
- pyct=0.6.0
|
| 254 |
+
- pyerfa=2.0.1.5
|
| 255 |
+
- pygments=2.19.2
|
| 256 |
+
- pyparsing=3.2.5
|
| 257 |
+
- pysocks=1.7.1
|
| 258 |
+
- pytest=8.4.2
|
| 259 |
+
- python=3.11.9
|
| 260 |
+
- python-dateutil=2.9.0.post0
|
| 261 |
+
- python-fastjsonschema=2.21.2
|
| 262 |
+
- python-json-logger=2.0.7
|
| 263 |
+
- python-tzdata=2025.2
|
| 264 |
+
- python_abi=3.11
|
| 265 |
+
- pytz=2025.2
|
| 266 |
+
- pyviz_comms=3.0.6
|
| 267 |
+
- pyyaml=6.0.3
|
| 268 |
+
- pyzmq=27.1.0
|
| 269 |
+
- qhull=2020.2
|
| 270 |
+
- re2=2025.11.05
|
| 271 |
+
- readline=8.2
|
| 272 |
+
- referencing=0.37.0
|
| 273 |
+
- requests=2.32.5
|
| 274 |
+
- rfc3339-validator=0.1.4
|
| 275 |
+
- rfc3986-validator=0.1.1
|
| 276 |
+
- rfc3987-syntax=1.1.0
|
| 277 |
+
- rpds-py=0.28.0
|
| 278 |
+
- s2n=1.6.0
|
| 279 |
+
- s3fs=2025.10.0
|
| 280 |
+
- scipy=1.16.3
|
| 281 |
+
- send2trash=1.8.3
|
| 282 |
+
- setuptools=80.9.0
|
| 283 |
+
- six=1.17.0
|
| 284 |
+
- snappy=1.2.2
|
| 285 |
+
- sniffio=1.3.1
|
| 286 |
+
- sortedcontainers=2.4.0
|
| 287 |
+
- soupsieve=2.8
|
| 288 |
+
- stack_data=0.6.3
|
| 289 |
+
- stingray=2.2.10
|
| 290 |
+
- terminado=0.18.1
|
| 291 |
+
- tinycss2=1.4.0
|
| 292 |
+
- tk=8.6.13
|
| 293 |
+
- tomli=2.3.0
|
| 294 |
+
- toolz=1.1.0
|
| 295 |
+
- tornado=6.5.2
|
| 296 |
+
- tqdm=4.67.1
|
| 297 |
+
- traitlets=5.14.3
|
| 298 |
+
- traittypes=0.2.3
|
| 299 |
+
- types-python-dateutil=2.9.0.20251008
|
| 300 |
+
- typing-extensions=4.15.0
|
| 301 |
+
- typing_extensions=4.15.0
|
| 302 |
+
- typing_utils=0.1.0
|
| 303 |
+
- tzdata=2025b
|
| 304 |
+
- uc-micro-py=1.0.3
|
| 305 |
+
- uncompresspy=0.4.1
|
| 306 |
+
- unicodedata2=17.0.0
|
| 307 |
+
- uri-template=1.3.0
|
| 308 |
+
- urllib3=2.5.0
|
| 309 |
+
- watchfiles=1.1.1
|
| 310 |
+
- wcwidth=0.2.14
|
| 311 |
+
- webcolors=25.10.0
|
| 312 |
+
- webencodings=0.5.1
|
| 313 |
+
- websocket-client=1.9.0
|
| 314 |
+
- wheel=0.45.1
|
| 315 |
+
- widgetsnbextension=4.0.15
|
| 316 |
+
- wrapt=1.17.3
|
| 317 |
+
- xarray=2025.10.1
|
| 318 |
+
- xorg-libxau=1.0.12
|
| 319 |
+
- xorg-libxdmcp=1.1.5
|
| 320 |
+
- xyzservices=2025.10.0
|
| 321 |
+
- xz=5.8.1
|
| 322 |
+
- xz-gpl-tools=5.8.1
|
| 323 |
+
- xz-tools=5.8.1
|
| 324 |
+
- yaml=0.2.5
|
| 325 |
+
- yarl=1.22.0
|
| 326 |
+
- zeromq=4.3.5
|
| 327 |
+
- zipp=3.23.0
|
| 328 |
+
- zlib=1.3.1
|
| 329 |
+
- zlib-ng=2.2.5
|
| 330 |
+
- zstandard=0.25.0
|
| 331 |
+
- zstd=1.5.7
|
| 332 |
- pip:
|
| 333 |
+
- docutils==0.22.3
|
| 334 |
+
- git-filter-repo==2.47.0
|
| 335 |
+
prefix: /home/kartikmandar/anaconda3/envs/stingray-env
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
modules/DataLoading/DataIngestion.py
CHANGED
|
@@ -134,6 +134,9 @@ def read_event_data(
|
|
| 134 |
format_checkbox,
|
| 135 |
rmf_file_dropper,
|
| 136 |
additional_columns_input,
|
|
|
|
|
|
|
|
|
|
| 137 |
context: AppContext,
|
| 138 |
warning_handler,
|
| 139 |
):
|
|
@@ -310,24 +313,49 @@ def read_event_data(
|
|
| 310 |
# Use data service to load files
|
| 311 |
loaded_files = []
|
| 312 |
for file_path, file_name, file_format in zip(file_paths, filenames, formats):
|
| 313 |
-
#
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
if result["success"]:
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
else:
|
| 325 |
# If loading failed, show error panel with retry
|
| 326 |
def retry_load():
|
| 327 |
-
|
| 328 |
event, file_selector, filename_input, format_input,
|
| 329 |
format_checkbox, rmf_file_dropper, additional_columns_input,
|
| 330 |
-
context, warning_handler
|
| 331 |
)
|
| 332 |
|
| 333 |
error_panel = ErrorRecoveryPanel.create_error_panel(
|
|
@@ -888,6 +916,108 @@ def create_loading_tab(context: AppContext, warning_handler):
|
|
| 888 |
name="Additional Columns (optional)", placeholder="Comma-separated column names"
|
| 889 |
)
|
| 890 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 891 |
def on_load_click(event):
|
| 892 |
# Clear previous outputs and warnings
|
| 893 |
context.update_container('output_box', create_loadingdata_output_box("N.A."))
|
|
@@ -903,6 +1033,9 @@ def create_loading_tab(context: AppContext, warning_handler):
|
|
| 903 |
format_checkbox,
|
| 904 |
rmf_file_dropper,
|
| 905 |
additional_columns_input,
|
|
|
|
|
|
|
|
|
|
| 906 |
context,
|
| 907 |
warning_handler,
|
| 908 |
)
|
|
@@ -962,7 +1095,8 @@ def create_loading_tab(context: AppContext, warning_handler):
|
|
| 962 |
preview_button.on_click(on_preview_click)
|
| 963 |
clear_button.on_click(on_clear_click)
|
| 964 |
|
| 965 |
-
|
|
|
|
| 966 |
pn.Row(
|
| 967 |
pn.pane.Markdown("<h2> Read an EventList object from File</h2>"),
|
| 968 |
pn.widgets.TooltipIcon(
|
|
@@ -973,20 +1107,36 @@ def create_loading_tab(context: AppContext, warning_handler):
|
|
| 973 |
),
|
| 974 |
),
|
| 975 |
file_selector,
|
|
|
|
|
|
|
| 976 |
pn.Row(filename_input, tooltip_file),
|
| 977 |
pn.Row(format_input, tooltip_format),
|
| 978 |
format_checkbox,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
pn.Row(rmf_file_dropper, tooltip_rmf),
|
| 980 |
pn.Row(additional_columns_input, tooltip_additional_columns),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 981 |
pn.Row(load_button, save_button, delete_button),
|
| 982 |
pn.Row(preview_button, clear_button),
|
| 983 |
-
pn.pane.Markdown("<br/>"),
|
| 984 |
width_policy="min",
|
| 985 |
)
|
| 986 |
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
|
|
|
|
|
|
| 990 |
)
|
| 991 |
|
| 992 |
return tab_content
|
|
|
|
| 134 |
format_checkbox,
|
| 135 |
rmf_file_dropper,
|
| 136 |
additional_columns_input,
|
| 137 |
+
use_lazy_loading,
|
| 138 |
+
use_preview_mode,
|
| 139 |
+
preview_duration_input,
|
| 140 |
context: AppContext,
|
| 141 |
warning_handler,
|
| 142 |
):
|
|
|
|
| 313 |
# Use data service to load files
|
| 314 |
loaded_files = []
|
| 315 |
for file_path, file_name, file_format in zip(file_paths, filenames, formats):
|
| 316 |
+
# Choose loading method based on mode selection
|
| 317 |
+
if use_preview_mode.value:
|
| 318 |
+
# Use preview mode for extremely large files
|
| 319 |
+
result = context.services.data.load_event_list_preview(
|
| 320 |
+
file_path=file_path,
|
| 321 |
+
name=file_name,
|
| 322 |
+
preview_duration=preview_duration_input.value,
|
| 323 |
+
rmf_file=tmp_file_path if rmf_file_dropper.value else None,
|
| 324 |
+
additional_columns=additional_columns
|
| 325 |
+
)
|
| 326 |
+
elif use_lazy_loading.value:
|
| 327 |
+
# Use lazy loading method (now supports RMF and additional columns!)
|
| 328 |
+
result = context.services.data.load_event_list_lazy(
|
| 329 |
+
file_path=file_path,
|
| 330 |
+
name=file_name,
|
| 331 |
+
safety_margin=0.5,
|
| 332 |
+
rmf_file=tmp_file_path if rmf_file_dropper.value else None,
|
| 333 |
+
additional_columns=additional_columns
|
| 334 |
+
)
|
| 335 |
+
else:
|
| 336 |
+
# Use standard loading method
|
| 337 |
+
result = context.services.data.load_event_list(
|
| 338 |
+
file_path=file_path,
|
| 339 |
+
name=file_name,
|
| 340 |
+
fmt=file_format,
|
| 341 |
+
rmf_file=tmp_file_path if rmf_file_dropper.value else None,
|
| 342 |
+
additional_columns=additional_columns
|
| 343 |
+
)
|
| 344 |
|
| 345 |
if result["success"]:
|
| 346 |
+
# Add loading method info to message
|
| 347 |
+
method_info = result.get("metadata", {}).get("method", "standard")
|
| 348 |
+
message = result["message"]
|
| 349 |
+
if method_info == "standard_risky":
|
| 350 |
+
message += " ⚠️ (Loaded despite memory risk)"
|
| 351 |
+
loaded_files.append(message)
|
| 352 |
else:
|
| 353 |
# If loading failed, show error panel with retry
|
| 354 |
def retry_load():
|
| 355 |
+
read_event_data(
|
| 356 |
event, file_selector, filename_input, format_input,
|
| 357 |
format_checkbox, rmf_file_dropper, additional_columns_input,
|
| 358 |
+
use_lazy_loading, context, warning_handler
|
| 359 |
)
|
| 360 |
|
| 361 |
error_panel = ErrorRecoveryPanel.create_error_panel(
|
|
|
|
| 916 |
name="Additional Columns (optional)", placeholder="Comma-separated column names"
|
| 917 |
)
|
| 918 |
|
| 919 |
+
# Lazy loading controls
|
| 920 |
+
use_lazy_loading = pn.widgets.Checkbox(
|
| 921 |
+
name="Use lazy loading (recommended for files >1GB)",
|
| 922 |
+
value=False,
|
| 923 |
+
)
|
| 924 |
+
|
| 925 |
+
tooltip_lazy = pn.widgets.TooltipIcon(
|
| 926 |
+
value=Tooltip(
|
| 927 |
+
content="""Lazy loading reads large files in chunks without loading everything into memory.
|
| 928 |
+
Recommended for files >1GB. Prevents memory crashes but some operations may be slower.""",
|
| 929 |
+
position="bottom",
|
| 930 |
+
)
|
| 931 |
+
)
|
| 932 |
+
|
| 933 |
+
# Preview mode controls (for extremely large files)
|
| 934 |
+
use_preview_mode = pn.widgets.Checkbox(
|
| 935 |
+
name="Preview mode (load only first segment)",
|
| 936 |
+
value=False,
|
| 937 |
+
)
|
| 938 |
+
|
| 939 |
+
preview_duration_input = pn.widgets.FloatInput(
|
| 940 |
+
name="Preview duration (seconds)",
|
| 941 |
+
value=100.0,
|
| 942 |
+
start=10.0,
|
| 943 |
+
end=1000.0,
|
| 944 |
+
step=10.0,
|
| 945 |
+
)
|
| 946 |
+
|
| 947 |
+
tooltip_preview = pn.widgets.TooltipIcon(
|
| 948 |
+
value=Tooltip(
|
| 949 |
+
content="""Preview mode loads only the first segment of data for extremely large files.
|
| 950 |
+
Useful when file is too large to fit in memory even with lazy loading.
|
| 951 |
+
You can analyze the preview and decide on next steps.""",
|
| 952 |
+
position="bottom",
|
| 953 |
+
),
|
| 954 |
+
)
|
| 955 |
+
|
| 956 |
+
# File size info pane (updated dynamically)
|
| 957 |
+
file_size_info = pn.pane.Markdown("", sizing_mode="stretch_width")
|
| 958 |
+
|
| 959 |
+
def update_file_size_info(event=None):
|
| 960 |
+
"""Update file size info when file selection changes."""
|
| 961 |
+
if not file_selector.value:
|
| 962 |
+
file_size_info.object = ""
|
| 963 |
+
use_lazy_loading.value = False
|
| 964 |
+
return
|
| 965 |
+
|
| 966 |
+
try:
|
| 967 |
+
file_path = file_selector.value[0] if isinstance(file_selector.value, list) else file_selector.value
|
| 968 |
+
|
| 969 |
+
# Check file size using data service
|
| 970 |
+
result = context.services.data.check_file_size(file_path)
|
| 971 |
+
|
| 972 |
+
if result["success"]:
|
| 973 |
+
data = result["data"]
|
| 974 |
+
risk_level = data["risk_level"]
|
| 975 |
+
file_size_mb = data["file_size_mb"]
|
| 976 |
+
file_size_gb = data["file_size_gb"]
|
| 977 |
+
estimated_mem_mb = data["estimated_memory_mb"]
|
| 978 |
+
memory_info = data["memory_info"]
|
| 979 |
+
recommend_lazy = data["recommend_lazy"]
|
| 980 |
+
|
| 981 |
+
# Color code based on risk
|
| 982 |
+
color_map = {
|
| 983 |
+
'safe': 'green',
|
| 984 |
+
'caution': 'orange',
|
| 985 |
+
'risky': 'darkorange',
|
| 986 |
+
'critical': 'red'
|
| 987 |
+
}
|
| 988 |
+
color = color_map.get(risk_level, 'black')
|
| 989 |
+
|
| 990 |
+
# Auto-enable lazy loading for large/risky files
|
| 991 |
+
if recommend_lazy and not use_lazy_loading.value:
|
| 992 |
+
use_lazy_loading.value = True
|
| 993 |
+
|
| 994 |
+
# Create info message
|
| 995 |
+
recommendation_text = "Use lazy loading" if recommend_lazy else "Standard loading OK"
|
| 996 |
+
|
| 997 |
+
# Add preview mode suggestion for critical/extremely large files
|
| 998 |
+
show_preview_warning = (risk_level == 'critical') or (file_size_gb > 5.0)
|
| 999 |
+
|
| 1000 |
+
info_md = f"""
|
| 1001 |
+
**File Size Info:**
|
| 1002 |
+
- **File Size**: {file_size_gb:.2f} GB ({file_size_mb:.1f} MB)
|
| 1003 |
+
- **Estimated Memory**: ~{estimated_mem_mb:.1f} MB
|
| 1004 |
+
- **Risk Level**: <span style="color:{color}; font-weight:bold">{risk_level.upper()}</span>
|
| 1005 |
+
- **Available RAM**: {memory_info['available_mb']:.0f} MB ({100-memory_info['percent']:.1f}% free)
|
| 1006 |
+
- **Recommendation**: {recommendation_text}
|
| 1007 |
+
"""
|
| 1008 |
+
if show_preview_warning:
|
| 1009 |
+
info_md += "\n- **CRITICAL**: File may be too large for full load. Consider using Preview Mode!"
|
| 1010 |
+
|
| 1011 |
+
file_size_info.object = info_md
|
| 1012 |
+
else:
|
| 1013 |
+
file_size_info.object = f"**Error checking file size:** {result['message']}"
|
| 1014 |
+
|
| 1015 |
+
except Exception as e:
|
| 1016 |
+
file_size_info.object = f"**Error:** {str(e)}"
|
| 1017 |
+
|
| 1018 |
+
# Update file size info when file selection changes
|
| 1019 |
+
file_selector.param.watch(update_file_size_info, 'value')
|
| 1020 |
+
|
| 1021 |
def on_load_click(event):
|
| 1022 |
# Clear previous outputs and warnings
|
| 1023 |
context.update_container('output_box', create_loadingdata_output_box("N.A."))
|
|
|
|
| 1033 |
format_checkbox,
|
| 1034 |
rmf_file_dropper,
|
| 1035 |
additional_columns_input,
|
| 1036 |
+
use_lazy_loading,
|
| 1037 |
+
use_preview_mode,
|
| 1038 |
+
preview_duration_input,
|
| 1039 |
context,
|
| 1040 |
warning_handler,
|
| 1041 |
)
|
|
|
|
| 1095 |
preview_button.on_click(on_preview_click)
|
| 1096 |
clear_button.on_click(on_clear_click)
|
| 1097 |
|
| 1098 |
+
# Left column: Basic file selection and configuration
|
| 1099 |
+
left_column = pn.Column(
|
| 1100 |
pn.Row(
|
| 1101 |
pn.pane.Markdown("<h2> Read an EventList object from File</h2>"),
|
| 1102 |
pn.widgets.TooltipIcon(
|
|
|
|
| 1107 |
),
|
| 1108 |
),
|
| 1109 |
file_selector,
|
| 1110 |
+
file_size_info, # Show file size and memory info
|
| 1111 |
+
pn.pane.Markdown("---"), # Separator
|
| 1112 |
pn.Row(filename_input, tooltip_file),
|
| 1113 |
pn.Row(format_input, tooltip_format),
|
| 1114 |
format_checkbox,
|
| 1115 |
+
width_policy="min",
|
| 1116 |
+
)
|
| 1117 |
+
|
| 1118 |
+
# Right column: Advanced options and actions
|
| 1119 |
+
right_column = pn.Column(
|
| 1120 |
+
pn.pane.Markdown("<h3>Advanced Options</h3>"),
|
| 1121 |
pn.Row(rmf_file_dropper, tooltip_rmf),
|
| 1122 |
pn.Row(additional_columns_input, tooltip_additional_columns),
|
| 1123 |
+
pn.pane.Markdown("---"), # Separator
|
| 1124 |
+
pn.pane.Markdown("<h3>Loading Options</h3>"),
|
| 1125 |
+
pn.Row(use_lazy_loading, tooltip_lazy),
|
| 1126 |
+
pn.Row(use_preview_mode, tooltip_preview),
|
| 1127 |
+
preview_duration_input,
|
| 1128 |
+
pn.pane.Markdown("---"), # Separator
|
| 1129 |
+
pn.pane.Markdown("<h3>Actions</h3>"),
|
| 1130 |
pn.Row(load_button, save_button, delete_button),
|
| 1131 |
pn.Row(preview_button, clear_button),
|
|
|
|
| 1132 |
width_policy="min",
|
| 1133 |
)
|
| 1134 |
|
| 1135 |
+
# Two-column layout
|
| 1136 |
+
tab_content = pn.Row(
|
| 1137 |
+
left_column,
|
| 1138 |
+
right_column,
|
| 1139 |
+
width_policy="max",
|
| 1140 |
)
|
| 1141 |
|
| 1142 |
return tab_content
|
modules/QuickLook/EventList.py
CHANGED
|
@@ -286,6 +286,11 @@ def simulate_event_list(
|
|
| 286 |
max_counts_input,
|
| 287 |
dt_input,
|
| 288 |
name_input,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
context: AppContext,
|
| 290 |
warning_handler,
|
| 291 |
):
|
|
@@ -294,14 +299,17 @@ def simulate_event_list(
|
|
| 294 |
|
| 295 |
Args:
|
| 296 |
event: The event object triggering the function.
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
dt_input
|
| 300 |
-
name_input
|
| 301 |
-
method_selector
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
Side effects:
|
| 307 |
- Creates a simulated EventList object and adds it to `loaded_event_data`.
|
|
@@ -314,7 +322,7 @@ def simulate_event_list(
|
|
| 314 |
- Requires a unique name for the simulated event list.
|
| 315 |
|
| 316 |
Example:
|
| 317 |
-
>>> simulate_event_list(event,
|
| 318 |
"Event List simulated successfully!"
|
| 319 |
"""
|
| 320 |
# Clear previous warnings
|
|
@@ -361,8 +369,22 @@ def simulate_event_list(
|
|
| 361 |
|
| 362 |
lc = lc_result["data"]
|
| 363 |
|
| 364 |
-
#
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
|
| 367 |
if not event_list_result["success"]:
|
| 368 |
context.update_container('output_box',
|
|
@@ -371,13 +393,80 @@ def simulate_event_list(
|
|
| 371 |
return
|
| 372 |
|
| 373 |
event_list = event_list_result["data"]
|
|
|
|
| 374 |
name = name_input.value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
context.state.add_event_data(name, event_list)
|
| 376 |
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
)
|
|
|
|
|
|
|
|
|
|
| 381 |
)
|
| 382 |
|
| 383 |
except Exception as e:
|
|
@@ -568,6 +657,69 @@ def create_simulate_event_list_tab(context: AppContext, warning_handler):
|
|
| 568 |
sim_name_input = pn.widgets.TextInput(
|
| 569 |
name="Simulated Event List Name", placeholder="e.g., my_sim_event_list"
|
| 570 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
simulate_button = pn.widgets.Button(
|
| 572 |
name="Simulate Event List", button_type="primary"
|
| 573 |
)
|
|
@@ -592,6 +744,11 @@ def create_simulate_event_list_tab(context: AppContext, warning_handler):
|
|
| 592 |
max_counts_input,
|
| 593 |
dt_input,
|
| 594 |
sim_name_input,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
context,
|
| 596 |
warning_handler,
|
| 597 |
)
|
|
@@ -604,6 +761,14 @@ def create_simulate_event_list_tab(context: AppContext, warning_handler):
|
|
| 604 |
max_counts_input,
|
| 605 |
dt_input,
|
| 606 |
sim_name_input,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
simulate_button,
|
| 608 |
)
|
| 609 |
return tab_content
|
|
@@ -736,6 +901,40 @@ def create_eventlist_operations_tab(context: AppContext, warning_handler):
|
|
| 736 |
sort_inplace_checkbox = pn.widgets.Checkbox(name="Sort in place", value=False)
|
| 737 |
sort_button = pn.widgets.Button(name="Sort EventLists", button_type="primary")
|
| 738 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
# Callback to update the properties box
|
| 740 |
def update_event_list_properties(event):
|
| 741 |
selected_indices = multi_event_list_select.value
|
|
@@ -1350,6 +1549,130 @@ def create_eventlist_operations_tab(context: AppContext, warning_handler):
|
|
| 1350 |
print(error_message)
|
| 1351 |
warning_handler.warn(error_message, category=RuntimeWarning)
|
| 1352 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1353 |
# Assign callbacks to buttons
|
| 1354 |
multi_event_list_select.param.watch(update_event_list_properties, "value")
|
| 1355 |
multi_light_curve_select.param.watch(update_light_curve_properties, "value")
|
|
@@ -1361,6 +1684,8 @@ def create_eventlist_operations_tab(context: AppContext, warning_handler):
|
|
| 1361 |
compute_intensity_button.on_click(compute_intensity_callback)
|
| 1362 |
join_button.on_click(join_eventlists_callback)
|
| 1363 |
sort_button.on_click(sort_eventlists_callback)
|
|
|
|
|
|
|
| 1364 |
|
| 1365 |
# Layout for the tab
|
| 1366 |
tab_content = pn.Column(
|
|
@@ -1442,6 +1767,23 @@ def create_eventlist_operations_tab(context: AppContext, warning_handler):
|
|
| 1442 |
width=400,
|
| 1443 |
height=300,
|
| 1444 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1445 |
flex_direction="row",
|
| 1446 |
flex_wrap="wrap",
|
| 1447 |
align_items="center",
|
|
|
|
| 286 |
max_counts_input,
|
| 287 |
dt_input,
|
| 288 |
name_input,
|
| 289 |
+
method_selector,
|
| 290 |
+
seed_input,
|
| 291 |
+
simulate_energies_checkbox,
|
| 292 |
+
energy_bins_input,
|
| 293 |
+
energy_counts_input,
|
| 294 |
context: AppContext,
|
| 295 |
warning_handler,
|
| 296 |
):
|
|
|
|
| 299 |
|
| 300 |
Args:
|
| 301 |
event: The event object triggering the function.
|
| 302 |
+
time_bins_input: The input for the number of time bins.
|
| 303 |
+
max_counts_input: The input for the maximum counts per bin.
|
| 304 |
+
dt_input: The input for delta time (dt).
|
| 305 |
+
name_input: The input widget for the simulated event list name.
|
| 306 |
+
method_selector: Radio button group for simulation method selection.
|
| 307 |
+
seed_input: Input for random seed (optional).
|
| 308 |
+
simulate_energies_checkbox: Checkbox to enable energy simulation.
|
| 309 |
+
energy_bins_input: Energy bins input (comma-separated keV values).
|
| 310 |
+
energy_counts_input: Counts per bin input (comma-separated values).
|
| 311 |
+
context: Application context.
|
| 312 |
+
warning_handler: The handler for warnings.
|
| 313 |
|
| 314 |
Side effects:
|
| 315 |
- Creates a simulated EventList object and adds it to `loaded_event_data`.
|
|
|
|
| 322 |
- Requires a unique name for the simulated event list.
|
| 323 |
|
| 324 |
Example:
|
| 325 |
+
>>> simulate_event_list(event, time_bins_input, max_counts_input, dt_input, name_input, method_selector, seed_input, ...)
|
| 326 |
"Event List simulated successfully!"
|
| 327 |
"""
|
| 328 |
# Clear previous warnings
|
|
|
|
| 369 |
|
| 370 |
lc = lc_result["data"]
|
| 371 |
|
| 372 |
+
# Map radio button value to method string
|
| 373 |
+
method_map = {
|
| 374 |
+
'Probabilistic (Recommended)': 'probabilistic',
|
| 375 |
+
'Deterministic (Legacy)': 'deterministic'
|
| 376 |
+
}
|
| 377 |
+
method = method_map.get(method_selector.value, 'probabilistic')
|
| 378 |
+
|
| 379 |
+
# Get seed value (None if empty)
|
| 380 |
+
seed = seed_input.value if seed_input.value is not None else None
|
| 381 |
+
|
| 382 |
+
# Simulate EventList from lightcurve using new method
|
| 383 |
+
event_list_result = context.services.lightcurve.simulate_event_list_from_lightcurve(
|
| 384 |
+
lightcurve=lc,
|
| 385 |
+
method=method,
|
| 386 |
+
seed=seed
|
| 387 |
+
)
|
| 388 |
|
| 389 |
if not event_list_result["success"]:
|
| 390 |
context.update_container('output_box',
|
|
|
|
| 393 |
return
|
| 394 |
|
| 395 |
event_list = event_list_result["data"]
|
| 396 |
+
metadata = event_list_result.get("metadata", {})
|
| 397 |
name = name_input.value
|
| 398 |
+
|
| 399 |
+
# Simulate energies if requested
|
| 400 |
+
energy_metadata = {}
|
| 401 |
+
if simulate_energies_checkbox.value:
|
| 402 |
+
# Parse energy spectrum inputs
|
| 403 |
+
energy_bins_str = energy_bins_input.value.strip()
|
| 404 |
+
energy_counts_str = energy_counts_input.value.strip()
|
| 405 |
+
|
| 406 |
+
if not energy_bins_str or not energy_counts_str:
|
| 407 |
+
context.update_container('output_box',
|
| 408 |
+
create_eventlist_output_box(
|
| 409 |
+
"Error: Energy simulation enabled but spectrum not provided.\n"
|
| 410 |
+
"Please provide both energy bins and counts."
|
| 411 |
+
)
|
| 412 |
+
)
|
| 413 |
+
return
|
| 414 |
+
|
| 415 |
+
try:
|
| 416 |
+
# Parse comma-separated values
|
| 417 |
+
energy_bins = [float(e.strip()) for e in energy_bins_str.split(',')]
|
| 418 |
+
energy_counts = [float(c.strip()) for c in energy_counts_str.split(',')]
|
| 419 |
+
|
| 420 |
+
# Create spectrum
|
| 421 |
+
spectrum = [energy_bins, energy_counts]
|
| 422 |
+
|
| 423 |
+
# Simulate energies
|
| 424 |
+
energy_result = context.services.lightcurve.simulate_energies_for_event_list(
|
| 425 |
+
event_list=event_list,
|
| 426 |
+
spectrum=spectrum
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
if not energy_result["success"]:
|
| 430 |
+
context.update_container('output_box',
|
| 431 |
+
create_eventlist_output_box(f"Error simulating energies: {energy_result['message']}")
|
| 432 |
+
)
|
| 433 |
+
return
|
| 434 |
+
|
| 435 |
+
event_list = energy_result["data"]
|
| 436 |
+
energy_metadata = energy_result.get("metadata", {})
|
| 437 |
+
|
| 438 |
+
except ValueError as ve:
|
| 439 |
+
context.update_container('output_box',
|
| 440 |
+
create_eventlist_output_box(
|
| 441 |
+
f"Error parsing energy spectrum: {str(ve)}\n"
|
| 442 |
+
"Make sure to use comma-separated numbers."
|
| 443 |
+
)
|
| 444 |
+
)
|
| 445 |
+
return
|
| 446 |
+
|
| 447 |
context.state.add_event_data(name, event_list)
|
| 448 |
|
| 449 |
+
# Build output message with method, seed, and energy info
|
| 450 |
+
output_message = (
|
| 451 |
+
f"Event List simulated successfully!\n"
|
| 452 |
+
f"Saved as: {name}\n"
|
| 453 |
+
f"Method: {metadata.get('method', 'unknown').capitalize()}\n"
|
| 454 |
+
f"Seed: {metadata.get('seed', 'random')}\n"
|
| 455 |
+
f"Number of events: {metadata.get('n_events', len(event_list.time))}\n"
|
| 456 |
+
f"Time range: {metadata.get('time_range', (event_list.time[0], event_list.time[-1]))}\n"
|
| 457 |
+
f"Original lightcurve counts: {counts}"
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
if energy_metadata:
|
| 461 |
+
output_message += (
|
| 462 |
+
f"\n\nEnergy simulation:\n"
|
| 463 |
+
f"Energy range: {energy_metadata.get('energy_range', 'N/A')} keV\n"
|
| 464 |
+
f"Mean energy: {energy_metadata.get('mean_energy', 'N/A'):.2f} keV\n"
|
| 465 |
+
f"Number of energy bins: {energy_metadata.get('n_energy_bins', 'N/A')}"
|
| 466 |
)
|
| 467 |
+
|
| 468 |
+
context.update_container('output_box',
|
| 469 |
+
create_eventlist_output_box(output_message)
|
| 470 |
)
|
| 471 |
|
| 472 |
except Exception as e:
|
|
|
|
| 657 |
sim_name_input = pn.widgets.TextInput(
|
| 658 |
name="Simulated Event List Name", placeholder="e.g., my_sim_event_list"
|
| 659 |
)
|
| 660 |
+
|
| 661 |
+
method_selector = pn.widgets.RadioButtonGroup(
|
| 662 |
+
name="Simulation Method",
|
| 663 |
+
options=['Probabilistic (Recommended)', 'Deterministic (Legacy)'],
|
| 664 |
+
value='Probabilistic (Recommended)',
|
| 665 |
+
button_type='default'
|
| 666 |
+
)
|
| 667 |
+
|
| 668 |
+
method_tooltip = pn.widgets.TooltipIcon(
|
| 669 |
+
value=Tooltip(
|
| 670 |
+
content="""Probabilistic (Recommended): Uses inverse CDF sampling for statistically realistic events. Each run produces different results (use seed for reproducibility).
|
| 671 |
+
|
| 672 |
+
Deterministic (Legacy): Creates exact count matching. Same results every time. Not suitable for scientific simulations.""",
|
| 673 |
+
position="bottom",
|
| 674 |
+
)
|
| 675 |
+
)
|
| 676 |
+
|
| 677 |
+
seed_input = pn.widgets.IntInput(
|
| 678 |
+
name="Random Seed (optional, for reproducibility)",
|
| 679 |
+
value=None,
|
| 680 |
+
start=0,
|
| 681 |
+
end=2147483647,
|
| 682 |
+
placeholder="Leave empty for random"
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
seed_tooltip = pn.widgets.TooltipIcon(
|
| 686 |
+
value=Tooltip(
|
| 687 |
+
content="""Set a random seed to make probabilistic simulations reproducible. Same seed = same result. Leave empty for truly random simulation.""",
|
| 688 |
+
position="bottom",
|
| 689 |
+
)
|
| 690 |
+
)
|
| 691 |
+
|
| 692 |
+
simulate_energies_checkbox = pn.widgets.Checkbox(
|
| 693 |
+
name="Simulate photon energies (optional)",
|
| 694 |
+
value=False
|
| 695 |
+
)
|
| 696 |
+
|
| 697 |
+
simulate_energies_tooltip = pn.widgets.TooltipIcon(
|
| 698 |
+
value=Tooltip(
|
| 699 |
+
content="""Simulate realistic photon energies based on a spectral distribution. The spectrum defines energy bins (keV) and counts in each bin. Uses inverse CDF sampling.""",
|
| 700 |
+
position="bottom",
|
| 701 |
+
)
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
energy_bins_input = pn.widgets.TextInput(
|
| 705 |
+
name="Energy bins (keV, comma-separated)",
|
| 706 |
+
placeholder="e.g., 1, 2, 3, 4, 5, 6",
|
| 707 |
+
visible=False
|
| 708 |
+
)
|
| 709 |
+
|
| 710 |
+
energy_counts_input = pn.widgets.TextInput(
|
| 711 |
+
name="Counts per bin (comma-separated)",
|
| 712 |
+
placeholder="e.g., 1000, 2040, 1000, 3000, 4020, 2070",
|
| 713 |
+
visible=False
|
| 714 |
+
)
|
| 715 |
+
|
| 716 |
+
def toggle_energy_inputs(event):
|
| 717 |
+
"""Show/hide energy input fields based on checkbox."""
|
| 718 |
+
energy_bins_input.visible = simulate_energies_checkbox.value
|
| 719 |
+
energy_counts_input.visible = simulate_energies_checkbox.value
|
| 720 |
+
|
| 721 |
+
simulate_energies_checkbox.param.watch(toggle_energy_inputs, 'value')
|
| 722 |
+
|
| 723 |
simulate_button = pn.widgets.Button(
|
| 724 |
name="Simulate Event List", button_type="primary"
|
| 725 |
)
|
|
|
|
| 744 |
max_counts_input,
|
| 745 |
dt_input,
|
| 746 |
sim_name_input,
|
| 747 |
+
method_selector,
|
| 748 |
+
seed_input,
|
| 749 |
+
simulate_energies_checkbox,
|
| 750 |
+
energy_bins_input,
|
| 751 |
+
energy_counts_input,
|
| 752 |
context,
|
| 753 |
warning_handler,
|
| 754 |
)
|
|
|
|
| 761 |
max_counts_input,
|
| 762 |
dt_input,
|
| 763 |
sim_name_input,
|
| 764 |
+
pn.pane.Markdown("---"),
|
| 765 |
+
pn.Row(method_selector, method_tooltip),
|
| 766 |
+
pn.Row(seed_input, seed_tooltip),
|
| 767 |
+
pn.pane.Markdown("---"),
|
| 768 |
+
pn.Row(simulate_energies_checkbox, simulate_energies_tooltip),
|
| 769 |
+
energy_bins_input,
|
| 770 |
+
energy_counts_input,
|
| 771 |
+
pn.pane.Markdown("---"),
|
| 772 |
simulate_button,
|
| 773 |
)
|
| 774 |
return tab_content
|
|
|
|
| 901 |
sort_inplace_checkbox = pn.widgets.Checkbox(name="Sort in place", value=False)
|
| 902 |
sort_button = pn.widgets.Button(name="Sort EventLists", button_type="primary")
|
| 903 |
|
| 904 |
+
# Widgets for Astropy Export
|
| 905 |
+
astropy_export_path_input = pn.widgets.TextInput(
|
| 906 |
+
name="Output file path",
|
| 907 |
+
placeholder="/path/to/output.ecsv"
|
| 908 |
+
)
|
| 909 |
+
astropy_export_format_select = pn.widgets.Select(
|
| 910 |
+
name="Export format",
|
| 911 |
+
options=["ascii.ecsv", "fits", "votable", "hdf5"],
|
| 912 |
+
value="ascii.ecsv"
|
| 913 |
+
)
|
| 914 |
+
export_astropy_button = pn.widgets.Button(
|
| 915 |
+
name="Export to Astropy Table",
|
| 916 |
+
button_type="primary"
|
| 917 |
+
)
|
| 918 |
+
|
| 919 |
+
# Widgets for Astropy Import
|
| 920 |
+
astropy_import_path_input = pn.widgets.TextInput(
|
| 921 |
+
name="Input file path",
|
| 922 |
+
placeholder="/path/to/input.ecsv"
|
| 923 |
+
)
|
| 924 |
+
astropy_import_format_select = pn.widgets.Select(
|
| 925 |
+
name="Import format",
|
| 926 |
+
options=["ascii.ecsv", "fits", "votable", "hdf5"],
|
| 927 |
+
value="ascii.ecsv"
|
| 928 |
+
)
|
| 929 |
+
astropy_import_name_input = pn.widgets.TextInput(
|
| 930 |
+
name="EventList name",
|
| 931 |
+
placeholder="imported_eventlist"
|
| 932 |
+
)
|
| 933 |
+
import_astropy_button = pn.widgets.Button(
|
| 934 |
+
name="Import from Astropy Table",
|
| 935 |
+
button_type="primary"
|
| 936 |
+
)
|
| 937 |
+
|
| 938 |
# Callback to update the properties box
|
| 939 |
def update_event_list_properties(event):
|
| 940 |
selected_indices = multi_event_list_select.value
|
|
|
|
| 1549 |
print(error_message)
|
| 1550 |
warning_handler.warn(error_message, category=RuntimeWarning)
|
| 1551 |
|
| 1552 |
+
# Callback for Exporting to Astropy Table
|
| 1553 |
+
def export_astropy_callback(event):
|
| 1554 |
+
selected_indices = multi_event_list_select.value
|
| 1555 |
+
if not selected_indices:
|
| 1556 |
+
warning_box_container[:] = [
|
| 1557 |
+
create_eventlist_warning_box(
|
| 1558 |
+
"Please select at least one EventList to export."
|
| 1559 |
+
)
|
| 1560 |
+
]
|
| 1561 |
+
return
|
| 1562 |
+
|
| 1563 |
+
if len(selected_indices) > 1:
|
| 1564 |
+
warning_box_container[:] = [
|
| 1565 |
+
create_eventlist_warning_box(
|
| 1566 |
+
"Please select only one EventList for export."
|
| 1567 |
+
)
|
| 1568 |
+
]
|
| 1569 |
+
return
|
| 1570 |
+
|
| 1571 |
+
output_path = astropy_export_path_input.value.strip()
|
| 1572 |
+
if not output_path:
|
| 1573 |
+
warning_box_container[:] = [
|
| 1574 |
+
create_eventlist_warning_box(
|
| 1575 |
+
"Please provide an output file path."
|
| 1576 |
+
)
|
| 1577 |
+
]
|
| 1578 |
+
return
|
| 1579 |
+
|
| 1580 |
+
try:
|
| 1581 |
+
selected_index = selected_indices[0]
|
| 1582 |
+
event_list_name, event_list = context.state.get_event_data()[selected_index]
|
| 1583 |
+
export_format = astropy_export_format_select.value
|
| 1584 |
+
|
| 1585 |
+
# Call the service method
|
| 1586 |
+
result = context.services.data.export_event_list_to_astropy_table(
|
| 1587 |
+
event_list_name=event_list_name,
|
| 1588 |
+
output_path=output_path,
|
| 1589 |
+
fmt=export_format
|
| 1590 |
+
)
|
| 1591 |
+
|
| 1592 |
+
if result["success"]:
|
| 1593 |
+
output_box_container[:] = [
|
| 1594 |
+
create_eventlist_output_box(
|
| 1595 |
+
f"Successfully exported EventList '{event_list_name}' to:\n"
|
| 1596 |
+
f"{output_path}\n"
|
| 1597 |
+
f"Format: {export_format}\n"
|
| 1598 |
+
f"Rows: {result['metadata']['n_rows']}"
|
| 1599 |
+
)
|
| 1600 |
+
]
|
| 1601 |
+
else:
|
| 1602 |
+
warning_box_container[:] = [
|
| 1603 |
+
create_eventlist_warning_box(
|
| 1604 |
+
f"Export failed: {result['message']}"
|
| 1605 |
+
)
|
| 1606 |
+
]
|
| 1607 |
+
|
| 1608 |
+
except Exception as e:
|
| 1609 |
+
error_message = (
|
| 1610 |
+
f"An error occurred during export:\n{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
| 1611 |
+
)
|
| 1612 |
+
print(error_message)
|
| 1613 |
+
warning_handler.warn(error_message, category=RuntimeWarning)
|
| 1614 |
+
|
| 1615 |
+
# Callback for Importing from Astropy Table
|
| 1616 |
+
def import_astropy_callback(event):
|
| 1617 |
+
input_path = astropy_import_path_input.value.strip()
|
| 1618 |
+
if not input_path:
|
| 1619 |
+
warning_box_container[:] = [
|
| 1620 |
+
create_eventlist_warning_box(
|
| 1621 |
+
"Please provide an input file path."
|
| 1622 |
+
)
|
| 1623 |
+
]
|
| 1624 |
+
return
|
| 1625 |
+
|
| 1626 |
+
import_name = astropy_import_name_input.value.strip()
|
| 1627 |
+
if not import_name:
|
| 1628 |
+
warning_box_container[:] = [
|
| 1629 |
+
create_eventlist_warning_box(
|
| 1630 |
+
"Please provide a name for the imported EventList."
|
| 1631 |
+
)
|
| 1632 |
+
]
|
| 1633 |
+
return
|
| 1634 |
+
|
| 1635 |
+
if not os.path.isfile(input_path):
|
| 1636 |
+
warning_box_container[:] = [
|
| 1637 |
+
create_eventlist_warning_box(
|
| 1638 |
+
f"File not found: {input_path}"
|
| 1639 |
+
)
|
| 1640 |
+
]
|
| 1641 |
+
return
|
| 1642 |
+
|
| 1643 |
+
try:
|
| 1644 |
+
import_format = astropy_import_format_select.value
|
| 1645 |
+
|
| 1646 |
+
# Call the service method
|
| 1647 |
+
result = context.services.data.import_event_list_from_astropy_table(
|
| 1648 |
+
file_path=input_path,
|
| 1649 |
+
name=import_name,
|
| 1650 |
+
fmt=import_format
|
| 1651 |
+
)
|
| 1652 |
+
|
| 1653 |
+
if result["success"]:
|
| 1654 |
+
output_box_container[:] = [
|
| 1655 |
+
create_eventlist_output_box(
|
| 1656 |
+
f"Successfully imported EventList '{import_name}' from:\n"
|
| 1657 |
+
f"{input_path}\n"
|
| 1658 |
+
f"Format: {import_format}\n"
|
| 1659 |
+
f"Events: {result['metadata']['n_events']}"
|
| 1660 |
+
)
|
| 1661 |
+
]
|
| 1662 |
+
else:
|
| 1663 |
+
warning_box_container[:] = [
|
| 1664 |
+
create_eventlist_warning_box(
|
| 1665 |
+
f"Import failed: {result['message']}"
|
| 1666 |
+
)
|
| 1667 |
+
]
|
| 1668 |
+
|
| 1669 |
+
except Exception as e:
|
| 1670 |
+
error_message = (
|
| 1671 |
+
f"An error occurred during import:\n{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
| 1672 |
+
)
|
| 1673 |
+
print(error_message)
|
| 1674 |
+
warning_handler.warn(error_message, category=RuntimeWarning)
|
| 1675 |
+
|
| 1676 |
# Assign callbacks to buttons
|
| 1677 |
multi_event_list_select.param.watch(update_event_list_properties, "value")
|
| 1678 |
multi_light_curve_select.param.watch(update_light_curve_properties, "value")
|
|
|
|
| 1684 |
compute_intensity_button.on_click(compute_intensity_callback)
|
| 1685 |
join_button.on_click(join_eventlists_callback)
|
| 1686 |
sort_button.on_click(sort_eventlists_callback)
|
| 1687 |
+
export_astropy_button.on_click(export_astropy_callback)
|
| 1688 |
+
import_astropy_button.on_click(import_astropy_callback)
|
| 1689 |
|
| 1690 |
# Layout for the tab
|
| 1691 |
tab_content = pn.Column(
|
|
|
|
| 1767 |
width=400,
|
| 1768 |
height=300,
|
| 1769 |
),
|
| 1770 |
+
pn.Column(
|
| 1771 |
+
pn.pane.Markdown("## Export to Astropy Table"),
|
| 1772 |
+
astropy_export_path_input,
|
| 1773 |
+
astropy_export_format_select,
|
| 1774 |
+
export_astropy_button,
|
| 1775 |
+
width=400,
|
| 1776 |
+
height=300,
|
| 1777 |
+
),
|
| 1778 |
+
pn.Column(
|
| 1779 |
+
pn.pane.Markdown("## Import from Astropy Table"),
|
| 1780 |
+
astropy_import_path_input,
|
| 1781 |
+
astropy_import_format_select,
|
| 1782 |
+
astropy_import_name_input,
|
| 1783 |
+
import_astropy_button,
|
| 1784 |
+
width=400,
|
| 1785 |
+
height=300,
|
| 1786 |
+
),
|
| 1787 |
flex_direction="row",
|
| 1788 |
flex_wrap="wrap",
|
| 1789 |
align_items="center",
|
services/base_service.py
CHANGED
|
@@ -40,7 +40,8 @@ class BaseService:
|
|
| 40 |
success: bool,
|
| 41 |
data: Any = None,
|
| 42 |
message: str = "",
|
| 43 |
-
error: Optional[str] = None
|
|
|
|
| 44 |
) -> Dict[str, Any]:
|
| 45 |
"""
|
| 46 |
Create a standardized result dictionary.
|
|
@@ -52,23 +53,28 @@ class BaseService:
|
|
| 52 |
data: The result data (e.g., EventList, Lightcurve, DataFrame, etc.)
|
| 53 |
message: User-friendly message describing the result
|
| 54 |
error: Technical error message (if applicable)
|
|
|
|
| 55 |
|
| 56 |
Returns:
|
| 57 |
-
Dictionary with keys: success, data, message, error
|
| 58 |
|
| 59 |
Example:
|
| 60 |
>>> return self.create_result(
|
| 61 |
... success=True,
|
| 62 |
... data=event_list,
|
| 63 |
-
... message="EventList loaded successfully"
|
|
|
|
| 64 |
... )
|
| 65 |
"""
|
| 66 |
-
|
| 67 |
"success": success,
|
| 68 |
"data": data,
|
| 69 |
"message": message,
|
| 70 |
"error": error
|
| 71 |
}
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
def handle_error(
|
| 74 |
self,
|
|
|
|
| 40 |
success: bool,
|
| 41 |
data: Any = None,
|
| 42 |
message: str = "",
|
| 43 |
+
error: Optional[str] = None,
|
| 44 |
+
**kwargs
|
| 45 |
) -> Dict[str, Any]:
|
| 46 |
"""
|
| 47 |
Create a standardized result dictionary.
|
|
|
|
| 53 |
data: The result data (e.g., EventList, Lightcurve, DataFrame, etc.)
|
| 54 |
message: User-friendly message describing the result
|
| 55 |
error: Technical error message (if applicable)
|
| 56 |
+
**kwargs: Additional fields to include in the result (e.g., metadata)
|
| 57 |
|
| 58 |
Returns:
|
| 59 |
+
Dictionary with keys: success, data, message, error, plus any kwargs
|
| 60 |
|
| 61 |
Example:
|
| 62 |
>>> return self.create_result(
|
| 63 |
... success=True,
|
| 64 |
... data=event_list,
|
| 65 |
+
... message="EventList loaded successfully",
|
| 66 |
+
... metadata={'method': 'lazy'}
|
| 67 |
... )
|
| 68 |
"""
|
| 69 |
+
result = {
|
| 70 |
"success": success,
|
| 71 |
"data": data,
|
| 72 |
"message": message,
|
| 73 |
"error": error
|
| 74 |
}
|
| 75 |
+
# Add any additional fields
|
| 76 |
+
result.update(kwargs)
|
| 77 |
+
return result
|
| 78 |
|
| 79 |
def handle_error(
|
| 80 |
self,
|
services/data_service.py
CHANGED
|
@@ -6,6 +6,7 @@ This service handles all EventList-related business logic including:
|
|
| 6 |
- Saving event lists to disk
|
| 7 |
- Validating and managing event list names
|
| 8 |
- Interfacing with StateManager for persistence
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from typing import Dict, Any, Optional, List
|
|
@@ -15,6 +16,7 @@ import requests
|
|
| 15 |
from stingray import EventList
|
| 16 |
from .base_service import BaseService
|
| 17 |
from utils.performance_monitor import performance_monitor
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class DataService(BaseService):
|
|
@@ -381,3 +383,475 @@ class DataService(BaseService):
|
|
| 381 |
data=name,
|
| 382 |
message=f"Name '{name}' is valid and available"
|
| 383 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
- Saving event lists to disk
|
| 7 |
- Validating and managing event list names
|
| 8 |
- Interfacing with StateManager for persistence
|
| 9 |
+
- Lazy loading for large files (memory-efficient)
|
| 10 |
"""
|
| 11 |
|
| 12 |
from typing import Dict, Any, Optional, List
|
|
|
|
| 16 |
from stingray import EventList
|
| 17 |
from .base_service import BaseService
|
| 18 |
from utils.performance_monitor import performance_monitor
|
| 19 |
+
from utils.lazy_loader import LazyEventLoader, assess_loading_risk
|
| 20 |
|
| 21 |
|
| 22 |
class DataService(BaseService):
|
|
|
|
| 383 |
data=name,
|
| 384 |
message=f"Name '{name}' is valid and available"
|
| 385 |
)
|
| 386 |
+
|
| 387 |
+
def check_file_size(self, file_path: str) -> Dict[str, Any]:
|
| 388 |
+
"""
|
| 389 |
+
Check file size and assess loading risk.
|
| 390 |
+
|
| 391 |
+
Args:
|
| 392 |
+
file_path: Path to the file
|
| 393 |
+
|
| 394 |
+
Returns:
|
| 395 |
+
Result dictionary with:
|
| 396 |
+
- file_size_mb: File size in megabytes
|
| 397 |
+
- file_size_gb: File size in gigabytes
|
| 398 |
+
- risk_level: 'safe', 'caution', 'risky', or 'critical'
|
| 399 |
+
- recommend_lazy: Boolean suggesting lazy loading
|
| 400 |
+
- memory_info: System memory information
|
| 401 |
+
|
| 402 |
+
Example:
|
| 403 |
+
>>> result = data_service.check_file_size("/path/to/large.evt")
|
| 404 |
+
>>> if result["data"]["recommend_lazy"]:
|
| 405 |
+
... # Use lazy loading
|
| 406 |
+
... pass
|
| 407 |
+
"""
|
| 408 |
+
try:
|
| 409 |
+
file_size = os.path.getsize(file_path)
|
| 410 |
+
file_size_mb = file_size / (1024**2)
|
| 411 |
+
file_size_gb = file_size / (1024**3)
|
| 412 |
+
|
| 413 |
+
# Assess risk
|
| 414 |
+
risk_level = assess_loading_risk(file_size, file_format='fits')
|
| 415 |
+
|
| 416 |
+
# Recommend lazy loading if file > 1GB or risk >= caution
|
| 417 |
+
recommend_lazy = (file_size_gb > 1.0) or (risk_level in ['caution', 'risky', 'critical'])
|
| 418 |
+
|
| 419 |
+
# Get memory info
|
| 420 |
+
loader = LazyEventLoader(file_path)
|
| 421 |
+
memory_info = loader.get_system_memory_info()
|
| 422 |
+
estimated_memory_mb = loader.estimate_memory_usage() / (1024**2)
|
| 423 |
+
|
| 424 |
+
return self.create_result(
|
| 425 |
+
success=True,
|
| 426 |
+
data={
|
| 427 |
+
'file_size_bytes': file_size,
|
| 428 |
+
'file_size_mb': file_size_mb,
|
| 429 |
+
'file_size_gb': file_size_gb,
|
| 430 |
+
'risk_level': risk_level,
|
| 431 |
+
'recommend_lazy': recommend_lazy,
|
| 432 |
+
'estimated_memory_mb': estimated_memory_mb,
|
| 433 |
+
'memory_info': memory_info
|
| 434 |
+
},
|
| 435 |
+
message=f"File size: {loader.format_file_size(file_size)}, Risk: {risk_level}"
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
except Exception as e:
|
| 439 |
+
return self.handle_error(
|
| 440 |
+
e,
|
| 441 |
+
"Checking file size",
|
| 442 |
+
file_path=file_path
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
def load_event_list_lazy(
|
| 446 |
+
self,
|
| 447 |
+
file_path: str,
|
| 448 |
+
name: str,
|
| 449 |
+
safety_margin: float = 0.5,
|
| 450 |
+
rmf_file: Optional[str] = None,
|
| 451 |
+
additional_columns: Optional[List[str]] = None
|
| 452 |
+
) -> Dict[str, Any]:
|
| 453 |
+
"""
|
| 454 |
+
Load EventList using lazy loading for large files.
|
| 455 |
+
|
| 456 |
+
This method intelligently decides whether to use lazy loading
|
| 457 |
+
or standard loading based on file size and available memory.
|
| 458 |
+
|
| 459 |
+
Args:
|
| 460 |
+
file_path: Path to the event file
|
| 461 |
+
name: Name to assign to the loaded event list
|
| 462 |
+
safety_margin: Fraction of available RAM to use (0.0-1.0)
|
| 463 |
+
rmf_file: Optional path to RMF file for energy calibration
|
| 464 |
+
additional_columns: Optional list of additional columns to read
|
| 465 |
+
|
| 466 |
+
Returns:
|
| 467 |
+
Result dictionary with:
|
| 468 |
+
- success: True if loaded successfully
|
| 469 |
+
- data: The loaded EventList object
|
| 470 |
+
- message: User-friendly status message
|
| 471 |
+
- metadata: Loading method and memory info
|
| 472 |
+
|
| 473 |
+
Example:
|
| 474 |
+
>>> result = data_service.load_event_list_lazy(
|
| 475 |
+
... file_path="/path/to/large.evt",
|
| 476 |
+
... name="large_observation",
|
| 477 |
+
... rmf_file="/path/to/response.rmf",
|
| 478 |
+
... additional_columns=["PI", "ENERGY"]
|
| 479 |
+
... )
|
| 480 |
+
>>> if result["success"]:
|
| 481 |
+
... event_list = result["data"]
|
| 482 |
+
... print(f"Loaded via: {result['metadata']['method']}")
|
| 483 |
+
"""
|
| 484 |
+
with performance_monitor.track_operation("load_event_list_lazy", file_path=file_path):
|
| 485 |
+
try:
|
| 486 |
+
# Validate the name doesn't already exist
|
| 487 |
+
if self.state.has_event_data(name):
|
| 488 |
+
return self.create_result(
|
| 489 |
+
success=False,
|
| 490 |
+
data=None,
|
| 491 |
+
message=f"An event list with the name '{name}' already exists. Please use a different name.",
|
| 492 |
+
error=None
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
# Create lazy loader
|
| 496 |
+
loader = LazyEventLoader(file_path)
|
| 497 |
+
|
| 498 |
+
# Get metadata
|
| 499 |
+
metadata = loader.get_metadata()
|
| 500 |
+
can_load_safe = loader.can_load_safely(safety_margin=safety_margin)
|
| 501 |
+
|
| 502 |
+
if can_load_safe:
|
| 503 |
+
# Safe to load fully
|
| 504 |
+
event_list = loader.load_full(
|
| 505 |
+
rmf_file=rmf_file,
|
| 506 |
+
additional_columns=additional_columns
|
| 507 |
+
)
|
| 508 |
+
method = 'standard'
|
| 509 |
+
message = (
|
| 510 |
+
f"EventList '{name}' loaded successfully via standard method "
|
| 511 |
+
f"({len(event_list.time)} events, "
|
| 512 |
+
f"{loader.format_file_size(loader.file_size)})"
|
| 513 |
+
)
|
| 514 |
+
else:
|
| 515 |
+
# File too large - need to warn user or use streaming
|
| 516 |
+
# For now, we'll still load but warn
|
| 517 |
+
message = (
|
| 518 |
+
f"WARNING: File is large ({loader.format_file_size(loader.file_size)}). "
|
| 519 |
+
f"Loading may consume significant memory. "
|
| 520 |
+
f"Consider using streaming operations instead."
|
| 521 |
+
)
|
| 522 |
+
event_list = loader.load_full(
|
| 523 |
+
rmf_file=rmf_file,
|
| 524 |
+
additional_columns=additional_columns
|
| 525 |
+
)
|
| 526 |
+
method = 'standard_risky'
|
| 527 |
+
|
| 528 |
+
# Add to state manager
|
| 529 |
+
self.state.add_event_data(name, event_list)
|
| 530 |
+
|
| 531 |
+
return self.create_result(
|
| 532 |
+
success=True,
|
| 533 |
+
data=event_list,
|
| 534 |
+
message=message,
|
| 535 |
+
metadata={
|
| 536 |
+
'method': method,
|
| 537 |
+
'file_metadata': metadata,
|
| 538 |
+
'memory_safe': can_load_safe
|
| 539 |
+
}
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
except MemoryError as e:
|
| 543 |
+
return self.create_result(
|
| 544 |
+
success=False,
|
| 545 |
+
data=None,
|
| 546 |
+
message=(
|
| 547 |
+
f"Out of memory loading file. "
|
| 548 |
+
f"File is too large to load into memory. "
|
| 549 |
+
f"Try using streaming operations or processing on a machine with more RAM."
|
| 550 |
+
),
|
| 551 |
+
error=str(e)
|
| 552 |
+
)
|
| 553 |
+
except Exception as e:
|
| 554 |
+
return self.handle_error(
|
| 555 |
+
e,
|
| 556 |
+
"Loading event list with lazy loader",
|
| 557 |
+
file_path=file_path,
|
| 558 |
+
name=name
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
def get_file_metadata(self, file_path: str) -> Dict[str, Any]:
|
| 562 |
+
"""
|
| 563 |
+
Get metadata from a FITS file without loading the event data.
|
| 564 |
+
|
| 565 |
+
This is a fast operation that only reads FITS headers.
|
| 566 |
+
|
| 567 |
+
Args:
|
| 568 |
+
file_path: Path to the FITS file
|
| 569 |
+
|
| 570 |
+
Returns:
|
| 571 |
+
Result dictionary with metadata
|
| 572 |
+
|
| 573 |
+
Example:
|
| 574 |
+
>>> result = data_service.get_file_metadata("/path/to/obs.evt")
|
| 575 |
+
>>> if result["success"]:
|
| 576 |
+
... metadata = result["data"]
|
| 577 |
+
... print(f"Observation duration: {metadata['duration_s']}s")
|
| 578 |
+
"""
|
| 579 |
+
try:
|
| 580 |
+
loader = LazyEventLoader(file_path)
|
| 581 |
+
metadata = loader.get_metadata()
|
| 582 |
+
|
| 583 |
+
return self.create_result(
|
| 584 |
+
success=True,
|
| 585 |
+
data=metadata,
|
| 586 |
+
message=f"Metadata extracted from {os.path.basename(file_path)}"
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
except Exception as e:
|
| 590 |
+
return self.handle_error(
|
| 591 |
+
e,
|
| 592 |
+
"Extracting file metadata",
|
| 593 |
+
file_path=file_path
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
def is_large_file(self, file_path: str, threshold_gb: float = 1.0) -> bool:
|
| 597 |
+
"""
|
| 598 |
+
Check if a file is considered "large".
|
| 599 |
+
|
| 600 |
+
Args:
|
| 601 |
+
file_path: Path to the file
|
| 602 |
+
threshold_gb: Size threshold in gigabytes (default: 1.0 GB)
|
| 603 |
+
|
| 604 |
+
Returns:
|
| 605 |
+
True if file size exceeds threshold
|
| 606 |
+
"""
|
| 607 |
+
try:
|
| 608 |
+
file_size = os.path.getsize(file_path)
|
| 609 |
+
file_size_gb = file_size / (1024**3)
|
| 610 |
+
return file_size_gb > threshold_gb
|
| 611 |
+
except Exception:
|
| 612 |
+
return False
|
| 613 |
+
|
| 614 |
+
def load_event_list_preview(
|
| 615 |
+
self,
|
| 616 |
+
file_path: str,
|
| 617 |
+
name: str,
|
| 618 |
+
preview_duration: float = 100.0,
|
| 619 |
+
rmf_file: Optional[str] = None,
|
| 620 |
+
additional_columns: Optional[List[str]] = None
|
| 621 |
+
) -> Dict[str, Any]:
|
| 622 |
+
"""
|
| 623 |
+
Load only the first segment of a large file as a preview.
|
| 624 |
+
|
| 625 |
+
This is useful for extremely large files that cannot fit in memory.
|
| 626 |
+
Instead of loading the entire file, this loads only the first
|
| 627 |
+
`preview_duration` seconds of data.
|
| 628 |
+
|
| 629 |
+
Args:
|
| 630 |
+
file_path: Path to the event file
|
| 631 |
+
name: Name to assign to the loaded event list
|
| 632 |
+
preview_duration: Duration in seconds to preview (default: 100s)
|
| 633 |
+
rmf_file: Optional path to RMF file for energy calibration
|
| 634 |
+
additional_columns: Optional list of additional columns to read
|
| 635 |
+
|
| 636 |
+
Returns:
|
| 637 |
+
Result dictionary with:
|
| 638 |
+
- success: True if loaded successfully
|
| 639 |
+
- data: The preview EventList object
|
| 640 |
+
- message: User-friendly status message
|
| 641 |
+
- metadata: Preview info (duration, total file size, etc.)
|
| 642 |
+
|
| 643 |
+
Example:
|
| 644 |
+
>>> result = data_service.load_event_list_preview(
|
| 645 |
+
... file_path="/path/to/huge.evt",
|
| 646 |
+
... name="huge_preview",
|
| 647 |
+
... preview_duration=50.0
|
| 648 |
+
... )
|
| 649 |
+
>>> if result["success"]:
|
| 650 |
+
... preview_events = result["data"]
|
| 651 |
+
... print(f"Preview: {len(preview_events.time)} events from first 50s")
|
| 652 |
+
"""
|
| 653 |
+
with performance_monitor.track_operation("load_event_list_preview", file_path=file_path):
|
| 654 |
+
try:
|
| 655 |
+
# Validate the name doesn't already exist
|
| 656 |
+
if self.state.has_event_data(name):
|
| 657 |
+
return self.create_result(
|
| 658 |
+
success=False,
|
| 659 |
+
data=None,
|
| 660 |
+
message=f"An event list with the name '{name}' already exists. Please use a different name.",
|
| 661 |
+
error=None
|
| 662 |
+
)
|
| 663 |
+
|
| 664 |
+
# Create lazy loader
|
| 665 |
+
loader = LazyEventLoader(file_path)
|
| 666 |
+
|
| 667 |
+
# Get metadata
|
| 668 |
+
metadata = loader.get_metadata()
|
| 669 |
+
|
| 670 |
+
# Get first segment of data
|
| 671 |
+
import numpy as np
|
| 672 |
+
segments_iter = loader.stream_segments(segment_size=preview_duration)
|
| 673 |
+
first_segment_times = next(segments_iter)
|
| 674 |
+
|
| 675 |
+
# Create EventList from the preview segment
|
| 676 |
+
# Note: This is a simplified EventList with just times
|
| 677 |
+
from stingray import EventList
|
| 678 |
+
event_list = EventList(
|
| 679 |
+
time=first_segment_times,
|
| 680 |
+
gti=loader.reader.gti,
|
| 681 |
+
mjdref=metadata['mjdref']
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
# Add to state manager
|
| 685 |
+
self.state.add_event_data(name, event_list)
|
| 686 |
+
|
| 687 |
+
return self.create_result(
|
| 688 |
+
success=True,
|
| 689 |
+
data=event_list,
|
| 690 |
+
message=(
|
| 691 |
+
f"Preview loaded: '{name}' - First {preview_duration}s "
|
| 692 |
+
f"({len(event_list.time)} events from "
|
| 693 |
+
f"{loader.format_file_size(loader.file_size)} file)"
|
| 694 |
+
),
|
| 695 |
+
metadata={
|
| 696 |
+
'method': 'preview',
|
| 697 |
+
'preview_duration': preview_duration,
|
| 698 |
+
'total_duration': metadata['duration_s'],
|
| 699 |
+
'file_size_gb': metadata['file_size_gb'],
|
| 700 |
+
'estimated_total_events': metadata['n_events_estimate']
|
| 701 |
+
}
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
except StopIteration:
|
| 705 |
+
return self.create_result(
|
| 706 |
+
success=False,
|
| 707 |
+
data=None,
|
| 708 |
+
message="File has no data in the specified preview duration",
|
| 709 |
+
error="No segments available"
|
| 710 |
+
)
|
| 711 |
+
except Exception as e:
|
| 712 |
+
return self.handle_error(
|
| 713 |
+
e,
|
| 714 |
+
"Loading event list preview",
|
| 715 |
+
file_path=file_path,
|
| 716 |
+
name=name,
|
| 717 |
+
preview_duration=preview_duration
|
| 718 |
+
)
|
| 719 |
+
|
| 720 |
+
def export_event_list_to_astropy_table(
|
| 721 |
+
self,
|
| 722 |
+
event_list_name: str,
|
| 723 |
+
output_path: str,
|
| 724 |
+
fmt: str = 'ascii.ecsv'
|
| 725 |
+
) -> Dict[str, Any]:
|
| 726 |
+
"""
|
| 727 |
+
Export an EventList to Astropy Table format.
|
| 728 |
+
|
| 729 |
+
This provides interoperability with the Astropy ecosystem, allowing
|
| 730 |
+
EventLists to be converted to Astropy tables and saved in various formats.
|
| 731 |
+
|
| 732 |
+
Args:
|
| 733 |
+
event_list_name: Name of the EventList in state
|
| 734 |
+
output_path: Path where to save the table
|
| 735 |
+
fmt: Output format (ascii.ecsv, fits, votable, hdf5, etc.)
|
| 736 |
+
|
| 737 |
+
Returns:
|
| 738 |
+
Result dictionary with success status and message
|
| 739 |
+
|
| 740 |
+
Example:
|
| 741 |
+
>>> result = data_service.export_event_list_to_astropy_table(
|
| 742 |
+
... event_list_name="my_events",
|
| 743 |
+
... output_path="events_table.ecsv",
|
| 744 |
+
... fmt="ascii.ecsv"
|
| 745 |
+
... )
|
| 746 |
+
"""
|
| 747 |
+
try:
|
| 748 |
+
# Get EventList from state
|
| 749 |
+
event_data = self.state.get_event_data()
|
| 750 |
+
event_list = None
|
| 751 |
+
for name, ev in event_data:
|
| 752 |
+
if name == event_list_name:
|
| 753 |
+
event_list = ev
|
| 754 |
+
break
|
| 755 |
+
|
| 756 |
+
if event_list is None:
|
| 757 |
+
return self.create_result(
|
| 758 |
+
success=False,
|
| 759 |
+
data=None,
|
| 760 |
+
message=f"EventList '{event_list_name}' not found in loaded data",
|
| 761 |
+
error="EventList not in state"
|
| 762 |
+
)
|
| 763 |
+
|
| 764 |
+
# Convert to Astropy Table
|
| 765 |
+
table = event_list.to_astropy_table()
|
| 766 |
+
|
| 767 |
+
# Write to file
|
| 768 |
+
table.write(output_path, format=fmt, overwrite=True)
|
| 769 |
+
|
| 770 |
+
return self.create_result(
|
| 771 |
+
success=True,
|
| 772 |
+
data=table,
|
| 773 |
+
message=f"EventList '{event_list_name}' exported to {output_path} ({fmt} format)",
|
| 774 |
+
metadata={
|
| 775 |
+
'format': fmt,
|
| 776 |
+
'output_path': output_path,
|
| 777 |
+
'n_rows': len(table)
|
| 778 |
+
}
|
| 779 |
+
)
|
| 780 |
+
|
| 781 |
+
except Exception as e:
|
| 782 |
+
return self.handle_error(
|
| 783 |
+
e,
|
| 784 |
+
"Exporting EventList to Astropy table",
|
| 785 |
+
event_list_name=event_list_name,
|
| 786 |
+
output_path=output_path,
|
| 787 |
+
fmt=fmt
|
| 788 |
+
)
|
| 789 |
+
|
| 790 |
+
def import_event_list_from_astropy_table(
|
| 791 |
+
self,
|
| 792 |
+
file_path: str,
|
| 793 |
+
name: str,
|
| 794 |
+
fmt: str = 'ascii.ecsv'
|
| 795 |
+
) -> Dict[str, Any]:
|
| 796 |
+
"""
|
| 797 |
+
Import an EventList from Astropy Table format.
|
| 798 |
+
|
| 799 |
+
This allows loading EventLists that were exported as Astropy tables
|
| 800 |
+
or created using Astropy tools.
|
| 801 |
+
|
| 802 |
+
Args:
|
| 803 |
+
file_path: Path to the Astropy table file
|
| 804 |
+
name: Name to assign to the loaded EventList
|
| 805 |
+
fmt: Input format (ascii.ecsv, fits, votable, hdf5, etc.)
|
| 806 |
+
|
| 807 |
+
Returns:
|
| 808 |
+
Result dictionary with EventList data
|
| 809 |
+
|
| 810 |
+
Example:
|
| 811 |
+
>>> result = data_service.import_event_list_from_astropy_table(
|
| 812 |
+
... file_path="events_table.ecsv",
|
| 813 |
+
... name="imported_events",
|
| 814 |
+
... fmt="ascii.ecsv"
|
| 815 |
+
... )
|
| 816 |
+
"""
|
| 817 |
+
try:
|
| 818 |
+
# Check for duplicate names
|
| 819 |
+
if self.state.has_event_data(name):
|
| 820 |
+
return self.create_result(
|
| 821 |
+
success=False,
|
| 822 |
+
data=None,
|
| 823 |
+
message=f"An event list with the name '{name}' already exists",
|
| 824 |
+
error="Duplicate name"
|
| 825 |
+
)
|
| 826 |
+
|
| 827 |
+
# Import table
|
| 828 |
+
from astropy.table import Table
|
| 829 |
+
from stingray import EventList
|
| 830 |
+
|
| 831 |
+
table = Table.read(file_path, format=fmt)
|
| 832 |
+
|
| 833 |
+
# Convert to EventList
|
| 834 |
+
event_list = EventList.from_astropy_table(table)
|
| 835 |
+
|
| 836 |
+
# Add to state
|
| 837 |
+
self.state.add_event_data(name, event_list)
|
| 838 |
+
|
| 839 |
+
return self.create_result(
|
| 840 |
+
success=True,
|
| 841 |
+
data=event_list,
|
| 842 |
+
message=f"EventList '{name}' imported from {file_path} ({fmt} format)",
|
| 843 |
+
metadata={
|
| 844 |
+
'format': fmt,
|
| 845 |
+
'file_path': file_path,
|
| 846 |
+
'n_events': len(event_list.time)
|
| 847 |
+
}
|
| 848 |
+
)
|
| 849 |
+
|
| 850 |
+
except Exception as e:
|
| 851 |
+
return self.handle_error(
|
| 852 |
+
e,
|
| 853 |
+
"Importing EventList from Astropy table",
|
| 854 |
+
file_path=file_path,
|
| 855 |
+
name=name,
|
| 856 |
+
fmt=fmt
|
| 857 |
+
)
|
services/lightcurve_service.py
CHANGED
|
@@ -302,3 +302,180 @@ class LightcurveService(BaseService):
|
|
| 302 |
"Creating EventList from lightcurve",
|
| 303 |
lightcurve_dt=lightcurve.dt if hasattr(lightcurve, 'dt') else None
|
| 304 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
"Creating EventList from lightcurve",
|
| 303 |
lightcurve_dt=lightcurve.dt if hasattr(lightcurve, 'dt') else None
|
| 304 |
)
|
| 305 |
+
|
| 306 |
+
def simulate_event_list_from_lightcurve(
|
| 307 |
+
self,
|
| 308 |
+
lightcurve: Lightcurve,
|
| 309 |
+
method: str = 'probabilistic',
|
| 310 |
+
seed: Optional[int] = None
|
| 311 |
+
) -> Dict[str, Any]:
|
| 312 |
+
"""
|
| 313 |
+
Simulate EventList from Lightcurve using specified method.
|
| 314 |
+
|
| 315 |
+
This method provides two approaches:
|
| 316 |
+
1. Probabilistic (recommended): Uses inverse CDF sampling for
|
| 317 |
+
statistically realistic event generation
|
| 318 |
+
2. Deterministic (legacy): Uses from_lc() for exact count matching
|
| 319 |
+
|
| 320 |
+
Args:
|
| 321 |
+
lightcurve: Lightcurve object to simulate events from
|
| 322 |
+
method: Simulation method - 'probabilistic' (recommended) or 'deterministic'
|
| 323 |
+
seed: Random seed for reproducible probabilistic simulations
|
| 324 |
+
|
| 325 |
+
Returns:
|
| 326 |
+
Result dictionary with EventList and simulation metadata
|
| 327 |
+
|
| 328 |
+
Example:
|
| 329 |
+
>>> result = lightcurve_service.simulate_event_list_from_lightcurve(
|
| 330 |
+
... lightcurve=lc,
|
| 331 |
+
... method='probabilistic',
|
| 332 |
+
... seed=42
|
| 333 |
+
... )
|
| 334 |
+
>>> if result["success"]:
|
| 335 |
+
... event_list = result["data"]
|
| 336 |
+
"""
|
| 337 |
+
try:
|
| 338 |
+
if method not in ['probabilistic', 'deterministic']:
|
| 339 |
+
return self.create_result(
|
| 340 |
+
success=False,
|
| 341 |
+
data=None,
|
| 342 |
+
message=f"Invalid method: {method}. Use 'probabilistic' or 'deterministic'.",
|
| 343 |
+
error=f"Method must be 'probabilistic' or 'deterministic', got '{method}'"
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
if method == 'probabilistic':
|
| 347 |
+
# Recommended method using inverse CDF sampling
|
| 348 |
+
if seed is not None:
|
| 349 |
+
np.random.seed(seed)
|
| 350 |
+
|
| 351 |
+
event_list = EventList()
|
| 352 |
+
event_list.simulate_times(lightcurve)
|
| 353 |
+
|
| 354 |
+
return self.create_result(
|
| 355 |
+
success=True,
|
| 356 |
+
data=event_list,
|
| 357 |
+
message=f"EventList simulated successfully using probabilistic method (seed={seed if seed is not None else 'random'})",
|
| 358 |
+
metadata={
|
| 359 |
+
'method': 'probabilistic',
|
| 360 |
+
'seed': seed,
|
| 361 |
+
'n_events': len(event_list.time),
|
| 362 |
+
'time_range': (float(event_list.time[0]), float(event_list.time[-1]))
|
| 363 |
+
}
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
else: # deterministic
|
| 367 |
+
# Legacy method for backwards compatibility
|
| 368 |
+
event_list = EventList.from_lc(lightcurve)
|
| 369 |
+
|
| 370 |
+
return self.create_result(
|
| 371 |
+
success=True,
|
| 372 |
+
data=event_list,
|
| 373 |
+
message="EventList created using deterministic method (from_lc)",
|
| 374 |
+
metadata={
|
| 375 |
+
'method': 'deterministic',
|
| 376 |
+
'n_events': len(event_list.time)
|
| 377 |
+
}
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
except Exception as e:
|
| 381 |
+
return self.handle_error(
|
| 382 |
+
e,
|
| 383 |
+
"Simulating EventList from lightcurve",
|
| 384 |
+
method=method,
|
| 385 |
+
seed=seed,
|
| 386 |
+
lightcurve_dt=lightcurve.dt if hasattr(lightcurve, 'dt') else None
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
def simulate_energies_for_event_list(
|
| 390 |
+
self,
|
| 391 |
+
event_list: EventList,
|
| 392 |
+
spectrum: List[List[float]]
|
| 393 |
+
) -> Dict[str, Any]:
|
| 394 |
+
"""
|
| 395 |
+
Simulate photon energies for an EventList based on a spectral distribution.
|
| 396 |
+
|
| 397 |
+
Uses inverse CDF method to assign realistic energy values to events
|
| 398 |
+
based on the provided spectrum. The spectrum is a two-dimensional array
|
| 399 |
+
where the first dimension is energy bins (keV) and the second is counts
|
| 400 |
+
in each bin (normalized before simulation).
|
| 401 |
+
|
| 402 |
+
Args:
|
| 403 |
+
event_list: EventList object to add energies to
|
| 404 |
+
spectrum: 2D list [[energies], [counts]]
|
| 405 |
+
Example: [[1, 2, 3, 4, 5, 6], [1000, 2040, 1000, 3000, 4020, 2070]]
|
| 406 |
+
|
| 407 |
+
Returns:
|
| 408 |
+
Result dictionary with updated EventList and simulation metadata
|
| 409 |
+
|
| 410 |
+
Example:
|
| 411 |
+
>>> spectrum = [[1, 2, 3, 4, 5, 6], [1000, 2040, 1000, 3000, 4020, 2070]]
|
| 412 |
+
>>> result = lightcurve_service.simulate_energies_for_event_list(
|
| 413 |
+
... event_list=ev,
|
| 414 |
+
... spectrum=spectrum
|
| 415 |
+
... )
|
| 416 |
+
>>> if result["success"]:
|
| 417 |
+
... ev_with_energies = result["data"]
|
| 418 |
+
"""
|
| 419 |
+
try:
|
| 420 |
+
# Validate spectrum format
|
| 421 |
+
if not isinstance(spectrum, list) or len(spectrum) != 2:
|
| 422 |
+
return self.create_result(
|
| 423 |
+
success=False,
|
| 424 |
+
data=None,
|
| 425 |
+
message="Spectrum must be a 2D list with [energies, counts]",
|
| 426 |
+
error=f"Invalid spectrum format: expected [[energies], [counts]], got {type(spectrum)}"
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
energies, counts = spectrum[0], spectrum[1]
|
| 430 |
+
|
| 431 |
+
if len(energies) != len(counts):
|
| 432 |
+
return self.create_result(
|
| 433 |
+
success=False,
|
| 434 |
+
data=None,
|
| 435 |
+
message=f"Energy bins ({len(energies)}) and counts ({len(counts)}) must have same length",
|
| 436 |
+
error=f"Mismatch: {len(energies)} energies vs {len(counts)} counts"
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
if len(energies) < 2:
|
| 440 |
+
return self.create_result(
|
| 441 |
+
success=False,
|
| 442 |
+
data=None,
|
| 443 |
+
message="Spectrum must have at least 2 energy bins",
|
| 444 |
+
error=f"Only {len(energies)} energy bins provided"
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
# Convert to numpy arrays
|
| 448 |
+
energy_array = np.array(energies, dtype=float)
|
| 449 |
+
count_array = np.array(counts, dtype=float)
|
| 450 |
+
|
| 451 |
+
# Validate energy bins are sorted
|
| 452 |
+
if not np.all(energy_array[:-1] <= energy_array[1:]):
|
| 453 |
+
return self.create_result(
|
| 454 |
+
success=False,
|
| 455 |
+
data=None,
|
| 456 |
+
message="Energy bins must be in ascending order",
|
| 457 |
+
error=f"Energy bins not sorted: {energies}"
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
# Simulate energies using Stingray's method
|
| 461 |
+
event_list.simulate_energies([energy_array.tolist(), count_array.tolist()])
|
| 462 |
+
|
| 463 |
+
return self.create_result(
|
| 464 |
+
success=True,
|
| 465 |
+
data=event_list,
|
| 466 |
+
message=f"Energies simulated successfully for {len(event_list.time)} events",
|
| 467 |
+
metadata={
|
| 468 |
+
'n_energy_bins': len(energies),
|
| 469 |
+
'energy_range': (float(energies[0]), float(energies[-1])),
|
| 470 |
+
'mean_energy': float(np.mean(event_list.energy)) if hasattr(event_list, 'energy') and event_list.energy is not None else None,
|
| 471 |
+
'n_events': len(event_list.time)
|
| 472 |
+
}
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
except Exception as e:
|
| 476 |
+
return self.handle_error(
|
| 477 |
+
e,
|
| 478 |
+
"Simulating energies for EventList",
|
| 479 |
+
n_energy_bins=len(spectrum[0]) if spectrum and len(spectrum) > 0 else 0,
|
| 480 |
+
n_events=len(event_list.time) if hasattr(event_list, 'time') else 0
|
| 481 |
+
)
|
test_astropy_roundtrip.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test script for Astropy export/import roundtrip functionality.
|
| 3 |
+
|
| 4 |
+
This script verifies that EventLists can be exported to Astropy Tables
|
| 5 |
+
and imported back without data loss.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import tempfile
|
| 10 |
+
import os
|
| 11 |
+
from stingray import EventList
|
| 12 |
+
from utils.state_manager import state_manager
|
| 13 |
+
from services import ServiceRegistry
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_astropy_roundtrip():
|
| 17 |
+
"""Test the complete roundtrip: EventList -> Astropy Table -> EventList."""
|
| 18 |
+
print("=" * 60)
|
| 19 |
+
print("Testing Astropy Roundtrip Functionality")
|
| 20 |
+
print("=" * 60)
|
| 21 |
+
|
| 22 |
+
# Initialize services
|
| 23 |
+
services = ServiceRegistry(state_manager)
|
| 24 |
+
|
| 25 |
+
# Create a test EventList
|
| 26 |
+
print("\n1. Creating test EventList...")
|
| 27 |
+
n_events = 1000
|
| 28 |
+
times = np.sort(np.random.uniform(0, 100, n_events))
|
| 29 |
+
energies = np.random.uniform(1, 10, n_events)
|
| 30 |
+
gti = np.array([[0, 100]])
|
| 31 |
+
|
| 32 |
+
test_event_list = EventList(
|
| 33 |
+
time=times,
|
| 34 |
+
energy=energies,
|
| 35 |
+
gti=gti
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
print(f" Created EventList with {len(test_event_list.time)} events")
|
| 39 |
+
print(f" Time range: {test_event_list.time[0]:.2f} - {test_event_list.time[-1]:.2f}")
|
| 40 |
+
print(f" Energy range: {test_event_list.energy.min():.2f} - {test_event_list.energy.max():.2f} keV")
|
| 41 |
+
|
| 42 |
+
# Add to state
|
| 43 |
+
state_manager.add_event_data("test_eventlist", test_event_list)
|
| 44 |
+
|
| 45 |
+
# Test export to different formats
|
| 46 |
+
formats_to_test = ["ascii.ecsv", "fits", "hdf5"]
|
| 47 |
+
|
| 48 |
+
for fmt in formats_to_test:
|
| 49 |
+
print(f"\n{'=' * 60}")
|
| 50 |
+
print(f"Testing format: {fmt}")
|
| 51 |
+
print(f"{'=' * 60}")
|
| 52 |
+
|
| 53 |
+
# Create temporary file
|
| 54 |
+
suffix = {
|
| 55 |
+
"ascii.ecsv": ".ecsv",
|
| 56 |
+
"fits": ".fits",
|
| 57 |
+
"hdf5": ".h5",
|
| 58 |
+
"votable": ".xml"
|
| 59 |
+
}.get(fmt, ".dat")
|
| 60 |
+
|
| 61 |
+
with tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) as tmp:
|
| 62 |
+
temp_path = tmp.name
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
# Export
|
| 66 |
+
print(f"\n2. Exporting EventList to {fmt}...")
|
| 67 |
+
export_result = services.data.export_event_list_to_astropy_table(
|
| 68 |
+
event_list_name="test_eventlist",
|
| 69 |
+
output_path=temp_path,
|
| 70 |
+
fmt=fmt
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
if not export_result["success"]:
|
| 74 |
+
print(f" FAILED: {export_result['message']}")
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
print(f" SUCCESS: Exported to {temp_path}")
|
| 78 |
+
print(f" Rows: {export_result['metadata']['n_rows']}")
|
| 79 |
+
print(f" File size: {os.path.getsize(temp_path) / 1024:.2f} KB")
|
| 80 |
+
|
| 81 |
+
# Import
|
| 82 |
+
print(f"\n3. Importing EventList from {fmt}...")
|
| 83 |
+
import_name = f"imported_{fmt.replace('.', '_')}"
|
| 84 |
+
import_result = services.data.import_event_list_from_astropy_table(
|
| 85 |
+
file_path=temp_path,
|
| 86 |
+
name=import_name,
|
| 87 |
+
fmt=fmt
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
if not import_result["success"]:
|
| 91 |
+
print(f" FAILED: {import_result['message']}")
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
print(f" SUCCESS: Imported as '{import_name}'")
|
| 95 |
+
print(f" Events: {import_result['metadata']['n_events']}")
|
| 96 |
+
|
| 97 |
+
# Verify data integrity
|
| 98 |
+
print(f"\n4. Verifying data integrity...")
|
| 99 |
+
imported_event_list = state_manager.get_event_data(import_name)
|
| 100 |
+
|
| 101 |
+
# Check number of events
|
| 102 |
+
original_n_events = len(test_event_list.time)
|
| 103 |
+
imported_n_events = len(imported_event_list.time)
|
| 104 |
+
|
| 105 |
+
if original_n_events != imported_n_events:
|
| 106 |
+
print(f" WARNING: Event count mismatch!")
|
| 107 |
+
print(f" Original: {original_n_events}, Imported: {imported_n_events}")
|
| 108 |
+
else:
|
| 109 |
+
print(f" Event count: {imported_n_events} (matches)")
|
| 110 |
+
|
| 111 |
+
# Check time data
|
| 112 |
+
time_diff = np.abs(test_event_list.time - imported_event_list.time).max()
|
| 113 |
+
print(f" Max time difference: {time_diff:.2e} seconds")
|
| 114 |
+
|
| 115 |
+
if time_diff < 1e-6:
|
| 116 |
+
print(f" Time data: EXACT MATCH")
|
| 117 |
+
else:
|
| 118 |
+
print(f" Time data: CLOSE MATCH (within tolerance)")
|
| 119 |
+
|
| 120 |
+
# Check energy data
|
| 121 |
+
if hasattr(imported_event_list, 'energy') and imported_event_list.energy is not None:
|
| 122 |
+
energy_diff = np.abs(test_event_list.energy - imported_event_list.energy).max()
|
| 123 |
+
print(f" Max energy difference: {energy_diff:.2e} keV")
|
| 124 |
+
|
| 125 |
+
if energy_diff < 1e-6:
|
| 126 |
+
print(f" Energy data: EXACT MATCH")
|
| 127 |
+
else:
|
| 128 |
+
print(f" Energy data: CLOSE MATCH (within tolerance)")
|
| 129 |
+
else:
|
| 130 |
+
print(f" Energy data: NOT PRESERVED (expected for some formats)")
|
| 131 |
+
|
| 132 |
+
print(f"\n ROUNDTRIP TEST PASSED for {fmt}")
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
print(f"\n ERROR: {str(e)}")
|
| 136 |
+
import traceback
|
| 137 |
+
traceback.print_exc()
|
| 138 |
+
|
| 139 |
+
finally:
|
| 140 |
+
# Cleanup
|
| 141 |
+
if os.path.exists(temp_path):
|
| 142 |
+
os.unlink(temp_path)
|
| 143 |
+
print(f"\n Cleaned up temporary file: {temp_path}")
|
| 144 |
+
|
| 145 |
+
print(f"\n{'=' * 60}")
|
| 146 |
+
print("All roundtrip tests completed")
|
| 147 |
+
print(f"{'=' * 60}")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
test_astropy_roundtrip()
|
tests/test_lazy_loader.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for the LazyEventLoader class.
|
| 3 |
+
|
| 4 |
+
This test suite covers:
|
| 5 |
+
- LazyEventLoader initialization and file handling
|
| 6 |
+
- Metadata extraction without loading full data
|
| 7 |
+
- Memory usage estimation
|
| 8 |
+
- Safety checks and risk assessment
|
| 9 |
+
- File size formatting
|
| 10 |
+
- Error handling for invalid files
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
import os
|
| 15 |
+
import tempfile
|
| 16 |
+
import numpy as np
|
| 17 |
+
from unittest.mock import MagicMock, patch, PropertyMock
|
| 18 |
+
from utils.lazy_loader import LazyEventLoader, assess_loading_risk
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# =============================================================================
|
| 22 |
+
# Fixtures
|
| 23 |
+
# =============================================================================
|
| 24 |
+
|
| 25 |
+
@pytest.fixture
|
| 26 |
+
def mock_fits_file():
|
| 27 |
+
"""Create a temporary mock FITS file."""
|
| 28 |
+
with tempfile.NamedTemporaryFile(suffix='.fits', delete=False) as f:
|
| 29 |
+
# Write some dummy data to make it a non-zero size
|
| 30 |
+
f.write(b'SIMPLE = T' * 100) # Fake FITS header
|
| 31 |
+
temp_path = f.name
|
| 32 |
+
|
| 33 |
+
yield temp_path
|
| 34 |
+
|
| 35 |
+
# Cleanup
|
| 36 |
+
if os.path.exists(temp_path):
|
| 37 |
+
os.remove(temp_path)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.fixture
|
| 41 |
+
def mock_fits_reader():
|
| 42 |
+
"""Create a mock FITSTimeseriesReader."""
|
| 43 |
+
mock_reader = MagicMock()
|
| 44 |
+
mock_reader.gti = np.array([[0, 1000], [1100, 2000]])
|
| 45 |
+
mock_reader.mjdref = 58000.0
|
| 46 |
+
return mock_reader
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# =============================================================================
|
| 50 |
+
# Test: LazyEventLoader Initialization
|
| 51 |
+
# =============================================================================
|
| 52 |
+
|
| 53 |
+
def test_lazy_loader_init_with_nonexistent_file():
|
| 54 |
+
"""Test initialization with non-existent file raises FileNotFoundError."""
|
| 55 |
+
with pytest.raises(FileNotFoundError):
|
| 56 |
+
LazyEventLoader("/path/to/nonexistent/file.fits")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_lazy_loader_init_with_invalid_fits(mock_fits_file):
|
| 60 |
+
"""Test initialization with invalid FITS file raises ValueError."""
|
| 61 |
+
# The mock file isn't a real FITS file, so this should fail
|
| 62 |
+
with pytest.raises(ValueError, match="Failed to open FITS file"):
|
| 63 |
+
LazyEventLoader(mock_fits_file)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 67 |
+
def test_lazy_loader_init_success(mock_reader_class, mock_fits_file):
|
| 68 |
+
"""Test successful initialization."""
|
| 69 |
+
mock_reader_class.return_value = MagicMock()
|
| 70 |
+
|
| 71 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 72 |
+
|
| 73 |
+
assert loader.file_path == mock_fits_file
|
| 74 |
+
assert loader.file_size > 0
|
| 75 |
+
assert loader.reader is not None
|
| 76 |
+
mock_reader_class.assert_called_once_with(mock_fits_file, data_kind="times")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# =============================================================================
|
| 80 |
+
# Test: Metadata Extraction
|
| 81 |
+
# =============================================================================
|
| 82 |
+
|
| 83 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 84 |
+
def test_get_metadata(mock_reader_class, mock_fits_file, mock_fits_reader):
|
| 85 |
+
"""Test metadata extraction without loading event data."""
|
| 86 |
+
mock_reader_class.return_value = mock_fits_reader
|
| 87 |
+
|
| 88 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 89 |
+
metadata = loader.get_metadata()
|
| 90 |
+
|
| 91 |
+
# Check all expected keys present
|
| 92 |
+
assert 'gti' in metadata
|
| 93 |
+
assert 'mjdref' in metadata
|
| 94 |
+
assert 'n_events_estimate' in metadata
|
| 95 |
+
assert 'time_range' in metadata
|
| 96 |
+
assert 'file_size_mb' in metadata
|
| 97 |
+
assert 'file_size_gb' in metadata
|
| 98 |
+
assert 'duration_s' in metadata
|
| 99 |
+
assert 'estimated_count_rate' in metadata
|
| 100 |
+
|
| 101 |
+
# Check values
|
| 102 |
+
assert np.array_equal(metadata['gti'], mock_fits_reader.gti)
|
| 103 |
+
assert metadata['mjdref'] == 58000.0
|
| 104 |
+
assert metadata['duration_s'] == 1900.0 # (1000-0) + (2000-1100)
|
| 105 |
+
assert metadata['n_events_estimate'] > 0
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 109 |
+
def test_get_metadata_time_range(mock_reader_class, mock_fits_file, mock_fits_reader):
|
| 110 |
+
"""Test that time_range is correctly extracted from GTIs."""
|
| 111 |
+
mock_reader_class.return_value = mock_fits_reader
|
| 112 |
+
|
| 113 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 114 |
+
metadata = loader.get_metadata()
|
| 115 |
+
|
| 116 |
+
time_range = metadata['time_range']
|
| 117 |
+
assert time_range == (0.0, 2000.0) # min and max from GTIs
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# =============================================================================
|
| 121 |
+
# Test: Memory Estimation
|
| 122 |
+
# =============================================================================
|
| 123 |
+
|
| 124 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 125 |
+
def test_estimate_memory_usage_fits(mock_reader_class, mock_fits_file):
|
| 126 |
+
"""Test memory estimation for FITS files."""
|
| 127 |
+
mock_reader_class.return_value = MagicMock()
|
| 128 |
+
|
| 129 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 130 |
+
estimated = loader.estimate_memory_usage('fits')
|
| 131 |
+
|
| 132 |
+
# FITS multiplier is 3x (based on Stingray benchmarks: 2GB → 5.2GB = 2.6x, rounded to 3x)
|
| 133 |
+
expected = loader.file_size * 3
|
| 134 |
+
assert estimated == expected
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 138 |
+
def test_estimate_memory_usage_hdf5(mock_reader_class, mock_fits_file):
|
| 139 |
+
"""Test memory estimation for HDF5 files."""
|
| 140 |
+
mock_reader_class.return_value = MagicMock()
|
| 141 |
+
|
| 142 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 143 |
+
estimated = loader.estimate_memory_usage('hdf5')
|
| 144 |
+
|
| 145 |
+
# HDF5 multiplier is 2x (more efficient format)
|
| 146 |
+
expected = loader.file_size * 2
|
| 147 |
+
assert estimated == expected
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 151 |
+
def test_estimate_memory_usage_pickle(mock_reader_class, mock_fits_file):
|
| 152 |
+
"""Test memory estimation for pickle files."""
|
| 153 |
+
mock_reader_class.return_value = MagicMock()
|
| 154 |
+
|
| 155 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 156 |
+
estimated = loader.estimate_memory_usage('pickle')
|
| 157 |
+
|
| 158 |
+
# Pickle multiplier is 1.5x (most efficient format)
|
| 159 |
+
expected = loader.file_size * 1.5
|
| 160 |
+
assert estimated == expected
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 164 |
+
def test_estimate_memory_usage_unknown_format(mock_reader_class, mock_fits_file):
|
| 165 |
+
"""Test memory estimation for unknown format defaults to conservative multiplier."""
|
| 166 |
+
mock_reader_class.return_value = MagicMock()
|
| 167 |
+
|
| 168 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 169 |
+
estimated = loader.estimate_memory_usage('unknown_format')
|
| 170 |
+
|
| 171 |
+
# Default multiplier is 3x (conservative default, same as FITS)
|
| 172 |
+
expected = loader.file_size * 3
|
| 173 |
+
assert estimated == expected
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# =============================================================================
|
| 177 |
+
# Test: Safety Checks
|
| 178 |
+
# =============================================================================
|
| 179 |
+
|
| 180 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 181 |
+
@patch('utils.lazy_loader.psutil.virtual_memory')
|
| 182 |
+
def test_can_load_safely_safe(mock_vmem, mock_reader_class, mock_fits_file):
|
| 183 |
+
"""Test can_load_safely returns True when safe."""
|
| 184 |
+
mock_reader_class.return_value = MagicMock()
|
| 185 |
+
|
| 186 |
+
# Mock large available memory
|
| 187 |
+
mock_vmem.return_value.available = 16 * 1024**3 # 16 GB
|
| 188 |
+
|
| 189 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 190 |
+
# Small file, lots of memory -> should be safe
|
| 191 |
+
assert loader.can_load_safely(safety_margin=0.5) is True
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 195 |
+
@patch('utils.lazy_loader.psutil.virtual_memory')
|
| 196 |
+
def test_can_load_safely_unsafe(mock_vmem, mock_reader_class, mock_fits_file):
|
| 197 |
+
"""Test can_load_safely returns False when unsafe."""
|
| 198 |
+
mock_reader_class.return_value = MagicMock()
|
| 199 |
+
|
| 200 |
+
# Mock small available memory relative to file size
|
| 201 |
+
# File is ~1.1 KB, with 3x multiplier = ~3.3 KB needed
|
| 202 |
+
# Set available to 5 KB, so 50% margin = 2.5 KB safe limit
|
| 203 |
+
# 3.3 KB > 2.5 KB -> should be unsafe
|
| 204 |
+
mock_vmem.return_value.available = 5 * 1024 # 5 KB
|
| 205 |
+
|
| 206 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 207 |
+
# File needs more memory than safe limit -> should be unsafe
|
| 208 |
+
assert loader.can_load_safely(safety_margin=0.5) is False
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 212 |
+
@patch('utils.lazy_loader.psutil.virtual_memory')
|
| 213 |
+
def test_can_load_safely_custom_margin(mock_vmem, mock_reader_class, mock_fits_file):
|
| 214 |
+
"""Test can_load_safely with custom safety margin."""
|
| 215 |
+
mock_reader_class.return_value = MagicMock()
|
| 216 |
+
|
| 217 |
+
# Mock specific available memory
|
| 218 |
+
mock_vmem.return_value.available = 1 * 1024**3 # 1 GB
|
| 219 |
+
|
| 220 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 221 |
+
|
| 222 |
+
# With high safety margin (10%), should be safer
|
| 223 |
+
result_high_margin = loader.can_load_safely(safety_margin=0.1)
|
| 224 |
+
|
| 225 |
+
# With low safety margin (90%), should be less safe
|
| 226 |
+
result_low_margin = loader.can_load_safely(safety_margin=0.9)
|
| 227 |
+
|
| 228 |
+
# High margin is more conservative (more likely to be unsafe)
|
| 229 |
+
# Low margin is less conservative (more likely to be safe)
|
| 230 |
+
# For small test file, both might be True, but the logic is correct
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# =============================================================================
|
| 234 |
+
# Test: System Memory Info
|
| 235 |
+
# =============================================================================
|
| 236 |
+
|
| 237 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 238 |
+
@patch('utils.lazy_loader.psutil.virtual_memory')
|
| 239 |
+
@patch('utils.lazy_loader.psutil.Process')
|
| 240 |
+
def test_get_system_memory_info(mock_process, mock_vmem, mock_reader_class, mock_fits_file):
|
| 241 |
+
"""Test system memory info retrieval."""
|
| 242 |
+
mock_reader_class.return_value = MagicMock()
|
| 243 |
+
|
| 244 |
+
# Mock memory values
|
| 245 |
+
mock_vmem.return_value.total = 16 * 1024**3 # 16 GB
|
| 246 |
+
mock_vmem.return_value.available = 8 * 1024**3 # 8 GB
|
| 247 |
+
mock_vmem.return_value.used = 8 * 1024**3 # 8 GB
|
| 248 |
+
mock_vmem.return_value.percent = 50.0
|
| 249 |
+
|
| 250 |
+
mock_process.return_value.memory_info.return_value.rss = 256 * 1024**2 # 256 MB
|
| 251 |
+
|
| 252 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 253 |
+
mem_info = loader.get_system_memory_info()
|
| 254 |
+
|
| 255 |
+
# Check all expected keys
|
| 256 |
+
assert 'total_mb' in mem_info
|
| 257 |
+
assert 'available_mb' in mem_info
|
| 258 |
+
assert 'used_mb' in mem_info
|
| 259 |
+
assert 'percent' in mem_info
|
| 260 |
+
assert 'process_mb' in mem_info
|
| 261 |
+
|
| 262 |
+
# Check values
|
| 263 |
+
assert mem_info['total_mb'] == 16 * 1024 # 16 GB in MB
|
| 264 |
+
assert mem_info['available_mb'] == 8 * 1024 # 8 GB in MB
|
| 265 |
+
assert mem_info['percent'] == 50.0
|
| 266 |
+
assert mem_info['process_mb'] == 256.0
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# =============================================================================
|
| 270 |
+
# Test: File Size Formatting
|
| 271 |
+
# =============================================================================
|
| 272 |
+
|
| 273 |
+
def test_format_file_size_bytes():
|
| 274 |
+
"""Test formatting bytes."""
|
| 275 |
+
assert LazyEventLoader.format_file_size(500) == "500.0 B"
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def test_format_file_size_kilobytes():
|
| 279 |
+
"""Test formatting kilobytes."""
|
| 280 |
+
assert LazyEventLoader.format_file_size(1500) == "1.5 KB"
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def test_format_file_size_megabytes():
|
| 284 |
+
"""Test formatting megabytes."""
|
| 285 |
+
assert LazyEventLoader.format_file_size(2 * 1024**2) == "2.0 MB"
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def test_format_file_size_gigabytes():
|
| 289 |
+
"""Test formatting gigabytes."""
|
| 290 |
+
assert LazyEventLoader.format_file_size(3.5 * 1024**3) == "3.5 GB"
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def test_format_file_size_terabytes():
|
| 294 |
+
"""Test formatting terabytes."""
|
| 295 |
+
assert LazyEventLoader.format_file_size(1.2 * 1024**4) == "1.2 TB"
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# =============================================================================
|
| 299 |
+
# Test: Risk Assessment Function
|
| 300 |
+
# =============================================================================
|
| 301 |
+
|
| 302 |
+
@patch('utils.lazy_loader.psutil.virtual_memory')
|
| 303 |
+
def test_assess_loading_risk_safe(mock_vmem):
|
| 304 |
+
"""Test risk assessment returns 'safe' for small files."""
|
| 305 |
+
mock_vmem.return_value.available = 16 * 1024**3 # 16 GB
|
| 306 |
+
|
| 307 |
+
file_size = 100 * 1024**2 # 100 MB
|
| 308 |
+
risk = assess_loading_risk(file_size, file_format='fits')
|
| 309 |
+
|
| 310 |
+
# 100 MB * 3 = 300 MB needed
|
| 311 |
+
# 300 MB / 16 GB = ~0.02 (2%) -> safe
|
| 312 |
+
assert risk == 'safe'
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@patch('utils.lazy_loader.psutil.virtual_memory')
|
| 316 |
+
def test_assess_loading_risk_caution(mock_vmem):
|
| 317 |
+
"""Test risk assessment returns 'caution' for medium files."""
|
| 318 |
+
mock_vmem.return_value.available = 2 * 1024**3 # 2 GB
|
| 319 |
+
|
| 320 |
+
file_size = 350 * 1024**2 # 350 MB
|
| 321 |
+
risk = assess_loading_risk(file_size, file_format='fits')
|
| 322 |
+
|
| 323 |
+
# 350 MB * 3 = 1050 MB needed
|
| 324 |
+
# 1050 MB / 2048 MB = ~0.51 (51%) -> caution
|
| 325 |
+
assert risk == 'caution'
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@patch('utils.lazy_loader.psutil.virtual_memory')
|
| 329 |
+
def test_assess_loading_risk_risky(mock_vmem):
|
| 330 |
+
"""Test risk assessment returns 'risky' for large files."""
|
| 331 |
+
mock_vmem.return_value.available = 2 * 1024**3 # 2 GB
|
| 332 |
+
|
| 333 |
+
file_size = 480 * 1024**2 # 480 MB
|
| 334 |
+
risk = assess_loading_risk(file_size, file_format='fits')
|
| 335 |
+
|
| 336 |
+
# 480 MB * 3 = 1440 MB needed
|
| 337 |
+
# 1440 MB / 2048 MB = ~0.70 (70%) -> risky
|
| 338 |
+
assert risk == 'risky'
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@patch('utils.lazy_loader.psutil.virtual_memory')
|
| 342 |
+
def test_assess_loading_risk_critical(mock_vmem):
|
| 343 |
+
"""Test risk assessment returns 'critical' for very large files."""
|
| 344 |
+
mock_vmem.return_value.available = 1 * 1024**3 # 1 GB
|
| 345 |
+
|
| 346 |
+
file_size = 350 * 1024**2 # 350 MB
|
| 347 |
+
risk = assess_loading_risk(file_size, file_format='fits')
|
| 348 |
+
|
| 349 |
+
# 350 MB * 3 = 1050 MB needed
|
| 350 |
+
# 1050 MB / 1024 MB = ~1.03 (103%) -> critical
|
| 351 |
+
assert risk == 'critical'
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
@patch('utils.lazy_loader.psutil.virtual_memory')
|
| 355 |
+
def test_assess_loading_risk_different_formats(mock_vmem):
|
| 356 |
+
"""Test risk assessment with different file formats."""
|
| 357 |
+
mock_vmem.return_value.available = 4 * 1024**3 # 4 GB
|
| 358 |
+
|
| 359 |
+
# Use different file sizes to test format-specific multipliers
|
| 360 |
+
# FITS: 1000 MB * 3 = 3000 MB (73% -> risky)
|
| 361 |
+
risk_fits = assess_loading_risk(1000 * 1024**2, file_format='fits', available_memory=4 * 1024**3)
|
| 362 |
+
|
| 363 |
+
# HDF5: 850 MB * 2 = 1700 MB (41% -> caution)
|
| 364 |
+
risk_hdf5 = assess_loading_risk(850 * 1024**2, file_format='hdf5', available_memory=4 * 1024**3)
|
| 365 |
+
|
| 366 |
+
# Pickle: 600 MB * 1.5 = 900 MB (22% -> safe)
|
| 367 |
+
risk_pickle = assess_loading_risk(600 * 1024**2, file_format='pickle', available_memory=4 * 1024**3)
|
| 368 |
+
|
| 369 |
+
assert risk_fits in ['risky', 'critical']
|
| 370 |
+
assert risk_hdf5 in ['safe', 'caution']
|
| 371 |
+
assert risk_pickle == 'safe'
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
# =============================================================================
|
| 375 |
+
# Test: Context Manager
|
| 376 |
+
# =============================================================================
|
| 377 |
+
|
| 378 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 379 |
+
def test_context_manager(mock_reader_class, mock_fits_file):
|
| 380 |
+
"""Test LazyEventLoader as context manager."""
|
| 381 |
+
mock_reader_class.return_value = MagicMock()
|
| 382 |
+
|
| 383 |
+
with LazyEventLoader(mock_fits_file) as loader:
|
| 384 |
+
assert loader is not None
|
| 385 |
+
assert isinstance(loader, LazyEventLoader)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# =============================================================================
|
| 389 |
+
# Test: String Representation
|
| 390 |
+
# =============================================================================
|
| 391 |
+
|
| 392 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 393 |
+
def test_repr(mock_reader_class, mock_fits_file):
|
| 394 |
+
"""Test string representation."""
|
| 395 |
+
mock_reader_class.return_value = MagicMock()
|
| 396 |
+
|
| 397 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 398 |
+
repr_str = repr(loader)
|
| 399 |
+
|
| 400 |
+
assert 'LazyEventLoader' in repr_str
|
| 401 |
+
assert mock_fits_file in repr_str
|
| 402 |
+
assert 'KB' in repr_str or 'MB' in repr_str or 'GB' in repr_str
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
# =============================================================================
|
| 406 |
+
# Test: Load Full (with mocking)
|
| 407 |
+
# =============================================================================
|
| 408 |
+
|
| 409 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 410 |
+
@patch('utils.lazy_loader.EventList')
|
| 411 |
+
def test_load_full(mock_eventlist_class, mock_reader_class, mock_fits_file):
|
| 412 |
+
"""Test load_full method."""
|
| 413 |
+
mock_reader_class.return_value = MagicMock()
|
| 414 |
+
mock_event_list = MagicMock()
|
| 415 |
+
mock_event_list.time = np.arange(1000)
|
| 416 |
+
mock_eventlist_class.read.return_value = mock_event_list
|
| 417 |
+
|
| 418 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 419 |
+
events = loader.load_full()
|
| 420 |
+
|
| 421 |
+
assert events is not None
|
| 422 |
+
mock_eventlist_class.read.assert_called_once()
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 426 |
+
@patch('utils.lazy_loader.EventList')
|
| 427 |
+
def test_load_full_with_additional_columns(mock_eventlist_class, mock_reader_class, mock_fits_file):
|
| 428 |
+
"""Test load_full with additional columns."""
|
| 429 |
+
mock_reader_class.return_value = MagicMock()
|
| 430 |
+
mock_event_list = MagicMock()
|
| 431 |
+
mock_eventlist_class.read.return_value = mock_event_list
|
| 432 |
+
|
| 433 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 434 |
+
loader.load_full(additional_columns=['DETID', 'RAWX'])
|
| 435 |
+
|
| 436 |
+
# Verify additional_columns was passed
|
| 437 |
+
call_kwargs = mock_eventlist_class.read.call_args[1]
|
| 438 |
+
assert 'additional_columns' in call_kwargs
|
| 439 |
+
assert call_kwargs['additional_columns'] == ['DETID', 'RAWX']
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
# =============================================================================
|
| 443 |
+
# Test: Stream Segments (with mocking)
|
| 444 |
+
# =============================================================================
|
| 445 |
+
|
| 446 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 447 |
+
@patch('utils.lazy_loader.time_intervals_from_gtis')
|
| 448 |
+
def test_stream_segments(mock_time_intervals, mock_reader_class, mock_fits_file, mock_fits_reader):
|
| 449 |
+
"""Test stream_segments method."""
|
| 450 |
+
mock_reader_class.return_value = mock_fits_reader
|
| 451 |
+
|
| 452 |
+
# Mock time intervals
|
| 453 |
+
mock_time_intervals.return_value = (
|
| 454 |
+
np.array([0, 100, 200]),
|
| 455 |
+
np.array([100, 200, 300])
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
# Mock filtered times
|
| 459 |
+
mock_fits_reader.filter_at_time_intervals.return_value = [
|
| 460 |
+
np.array([10, 20, 30]),
|
| 461 |
+
np.array([110, 120]),
|
| 462 |
+
np.array([210, 220, 230, 240])
|
| 463 |
+
]
|
| 464 |
+
|
| 465 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 466 |
+
segments = list(loader.stream_segments(segment_size=100))
|
| 467 |
+
|
| 468 |
+
assert len(segments) == 3
|
| 469 |
+
assert len(segments[0]) == 3 # First segment has 3 events
|
| 470 |
+
assert len(segments[1]) == 2 # Second segment has 2 events
|
| 471 |
+
assert len(segments[2]) == 4 # Third segment has 4 events
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
# =============================================================================
|
| 475 |
+
# Test: Edge Cases
|
| 476 |
+
# =============================================================================
|
| 477 |
+
|
| 478 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 479 |
+
def test_metadata_with_zero_duration(mock_reader_class, mock_fits_file):
|
| 480 |
+
"""Test metadata extraction with zero duration GTIs."""
|
| 481 |
+
mock_reader = MagicMock()
|
| 482 |
+
mock_reader.gti = np.array([[0, 0]]) # Zero duration
|
| 483 |
+
mock_reader.mjdref = 58000.0
|
| 484 |
+
mock_reader_class.return_value = mock_reader
|
| 485 |
+
|
| 486 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 487 |
+
metadata = loader.get_metadata()
|
| 488 |
+
|
| 489 |
+
# Should handle zero duration gracefully
|
| 490 |
+
assert metadata['duration_s'] == 0.0
|
| 491 |
+
assert metadata['estimated_count_rate'] == 0 # Avoid division by zero
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
@patch('utils.lazy_loader.FITSTimeseriesReader')
|
| 495 |
+
def test_metadata_with_no_mjdref(mock_reader_class, mock_fits_file):
|
| 496 |
+
"""Test metadata extraction when MJDREF is missing."""
|
| 497 |
+
mock_reader = MagicMock()
|
| 498 |
+
mock_reader.gti = np.array([[0, 1000]])
|
| 499 |
+
del mock_reader.mjdref # Remove attribute
|
| 500 |
+
mock_reader_class.return_value = mock_reader
|
| 501 |
+
|
| 502 |
+
loader = LazyEventLoader(mock_fits_file)
|
| 503 |
+
metadata = loader.get_metadata()
|
| 504 |
+
|
| 505 |
+
# Should default to 0.0
|
| 506 |
+
assert metadata['mjdref'] == 0.0
|
tests/test_lazy_loading_integration.py
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration tests for lazy loading workflow.
|
| 3 |
+
|
| 4 |
+
This test suite covers end-to-end lazy loading functionality:
|
| 5 |
+
- DataService integration with lazy loading
|
| 6 |
+
- Memory usage verification
|
| 7 |
+
- Performance comparison (standard vs lazy)
|
| 8 |
+
- Error handling with real FITS files
|
| 9 |
+
- StateManager integration
|
| 10 |
+
- Large file handling scenarios
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
import os
|
| 15 |
+
import tempfile
|
| 16 |
+
import numpy as np
|
| 17 |
+
import psutil
|
| 18 |
+
from unittest.mock import patch, MagicMock
|
| 19 |
+
from astropy.io import fits
|
| 20 |
+
from stingray import EventList
|
| 21 |
+
|
| 22 |
+
from services.data_service import DataService
|
| 23 |
+
from utils.state_manager import StateManager
|
| 24 |
+
from utils.lazy_loader import LazyEventLoader, assess_loading_risk
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# =============================================================================
|
| 28 |
+
# Fixtures
|
| 29 |
+
# =============================================================================
|
| 30 |
+
|
| 31 |
+
@pytest.fixture
|
| 32 |
+
def state_manager():
|
| 33 |
+
"""Create a fresh StateManager instance for each test."""
|
| 34 |
+
return StateManager()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@pytest.fixture
|
| 38 |
+
def data_service(state_manager):
|
| 39 |
+
"""Create DataService instance with StateManager."""
|
| 40 |
+
service = DataService(state_manager)
|
| 41 |
+
return service
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.fixture
|
| 45 |
+
def sample_evt_file():
|
| 46 |
+
"""Path to real small sample EVT file."""
|
| 47 |
+
return "files/data/monol_testA.evt"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.fixture
|
| 51 |
+
def sample_fits_file():
|
| 52 |
+
"""Path to real small sample FITS file."""
|
| 53 |
+
return "files/data/lcurveA.fits"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@pytest.fixture
|
| 57 |
+
def synthetic_small_fits():
|
| 58 |
+
"""
|
| 59 |
+
Create a synthetic small FITS file (~100KB) for testing.
|
| 60 |
+
|
| 61 |
+
Yields path to temporary file, cleaned up after test.
|
| 62 |
+
"""
|
| 63 |
+
# Create temporary file
|
| 64 |
+
fd, tmp_path = tempfile.mkstemp(suffix='.evt')
|
| 65 |
+
os.close(fd)
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
# Generate synthetic event data
|
| 69 |
+
n_events = 10000
|
| 70 |
+
tstart = 0.0
|
| 71 |
+
duration = 1000.0
|
| 72 |
+
|
| 73 |
+
times = np.sort(np.random.uniform(tstart, tstart + duration, n_events))
|
| 74 |
+
energy = np.random.uniform(0.5, 10.0, n_events)
|
| 75 |
+
pi = (energy * 100).astype(np.int32)
|
| 76 |
+
|
| 77 |
+
# Create FITS file structure
|
| 78 |
+
# Primary HDU
|
| 79 |
+
primary = fits.PrimaryHDU()
|
| 80 |
+
|
| 81 |
+
# Events extension
|
| 82 |
+
col1 = fits.Column(name='TIME', format='D', array=times)
|
| 83 |
+
col2 = fits.Column(name='ENERGY', format='E', array=energy)
|
| 84 |
+
col3 = fits.Column(name='PI', format='J', array=pi)
|
| 85 |
+
|
| 86 |
+
cols = fits.ColDefs([col1, col2, col3])
|
| 87 |
+
events_hdu = fits.BinTableHDU.from_columns(cols)
|
| 88 |
+
events_hdu.header['EXTNAME'] = 'EVENTS'
|
| 89 |
+
events_hdu.header['TELESCOP'] = 'TEST'
|
| 90 |
+
events_hdu.header['INSTRUME'] = 'SYNTHETIC'
|
| 91 |
+
events_hdu.header['MJDREFI'] = 55000
|
| 92 |
+
events_hdu.header['MJDREFF'] = 0.0
|
| 93 |
+
events_hdu.header['TIMEZERO'] = 0.0
|
| 94 |
+
events_hdu.header['TIMEUNIT'] = 's'
|
| 95 |
+
# Add required timing keywords
|
| 96 |
+
events_hdu.header['TSTART'] = tstart
|
| 97 |
+
events_hdu.header['TSTOP'] = tstart + duration
|
| 98 |
+
events_hdu.header['TIMESYS'] = 'TT'
|
| 99 |
+
events_hdu.header['TIMEREF'] = 'LOCAL'
|
| 100 |
+
|
| 101 |
+
# GTI extension
|
| 102 |
+
gti_start = np.array([tstart])
|
| 103 |
+
gti_stop = np.array([tstart + duration])
|
| 104 |
+
|
| 105 |
+
col1 = fits.Column(name='START', format='D', array=gti_start)
|
| 106 |
+
col2 = fits.Column(name='STOP', format='D', array=gti_stop)
|
| 107 |
+
|
| 108 |
+
gti_cols = fits.ColDefs([col1, col2])
|
| 109 |
+
gti_hdu = fits.BinTableHDU.from_columns(gti_cols)
|
| 110 |
+
gti_hdu.header['EXTNAME'] = 'GTI'
|
| 111 |
+
|
| 112 |
+
# Write FITS file
|
| 113 |
+
hdul = fits.HDUList([primary, events_hdu, gti_hdu])
|
| 114 |
+
hdul.writeto(tmp_path, overwrite=True)
|
| 115 |
+
|
| 116 |
+
yield tmp_path
|
| 117 |
+
|
| 118 |
+
finally:
|
| 119 |
+
# Cleanup
|
| 120 |
+
if os.path.exists(tmp_path):
|
| 121 |
+
os.remove(tmp_path)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@pytest.fixture
|
| 125 |
+
def synthetic_large_fits_info():
|
| 126 |
+
"""
|
| 127 |
+
Return parameters for a hypothetical large FITS file.
|
| 128 |
+
|
| 129 |
+
We don't actually create it (too slow/large), but return
|
| 130 |
+
characteristics for testing logic.
|
| 131 |
+
"""
|
| 132 |
+
return {
|
| 133 |
+
'file_size': 2.5 * 1024**3, # 2.5 GB
|
| 134 |
+
'n_events': 200_000_000, # 200 million events
|
| 135 |
+
'duration': 50000.0, # seconds
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# =============================================================================
|
| 140 |
+
# Integration Tests: DataService with Lazy Loading
|
| 141 |
+
# =============================================================================
|
| 142 |
+
|
| 143 |
+
def test_load_event_list_lazy_small_file_safe(data_service, synthetic_small_fits):
|
| 144 |
+
"""
|
| 145 |
+
Test lazy loading with a small file that's safe to load.
|
| 146 |
+
|
| 147 |
+
Should use standard loading method since file is small.
|
| 148 |
+
"""
|
| 149 |
+
result = data_service.load_event_list_lazy(
|
| 150 |
+
file_path=synthetic_small_fits,
|
| 151 |
+
name="test_small",
|
| 152 |
+
safety_margin=0.5
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# Should succeed
|
| 156 |
+
assert result["success"] is True
|
| 157 |
+
assert result["data"] is not None
|
| 158 |
+
assert isinstance(result["data"], EventList)
|
| 159 |
+
|
| 160 |
+
# Should use standard method for small file
|
| 161 |
+
assert result["metadata"]["method"] == "standard"
|
| 162 |
+
assert result["metadata"]["memory_safe"] is True
|
| 163 |
+
|
| 164 |
+
# Verify data is in state manager
|
| 165 |
+
assert data_service.state.has_event_data("test_small")
|
| 166 |
+
retrieved = data_service.state.get_event_data("test_small")
|
| 167 |
+
assert len(retrieved) == len(result["data"].time)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_load_event_list_lazy_duplicate_name(data_service, synthetic_small_fits):
|
| 171 |
+
"""Test that lazy loading prevents duplicate names."""
|
| 172 |
+
# Load first time
|
| 173 |
+
result1 = data_service.load_event_list_lazy(
|
| 174 |
+
file_path=synthetic_small_fits,
|
| 175 |
+
name="duplicate_test",
|
| 176 |
+
safety_margin=0.5
|
| 177 |
+
)
|
| 178 |
+
assert result1["success"] is True
|
| 179 |
+
|
| 180 |
+
# Try loading again with same name
|
| 181 |
+
result2 = data_service.load_event_list_lazy(
|
| 182 |
+
file_path=synthetic_small_fits,
|
| 183 |
+
name="duplicate_test",
|
| 184 |
+
safety_margin=0.5
|
| 185 |
+
)
|
| 186 |
+
assert result2["success"] is False
|
| 187 |
+
assert "already exists" in result2["message"]
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_load_event_list_lazy_nonexistent_file(data_service):
|
| 191 |
+
"""Test lazy loading with non-existent file."""
|
| 192 |
+
result = data_service.load_event_list_lazy(
|
| 193 |
+
file_path="/nonexistent/file.evt",
|
| 194 |
+
name="test_missing",
|
| 195 |
+
safety_margin=0.5
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
assert result["success"] is False
|
| 199 |
+
assert result["data"] is None
|
| 200 |
+
assert "error" in result
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def test_check_file_size_small_file(data_service, synthetic_small_fits):
|
| 204 |
+
"""Test file size checking with small file."""
|
| 205 |
+
result = data_service.check_file_size(synthetic_small_fits)
|
| 206 |
+
|
| 207 |
+
assert result["success"] is True
|
| 208 |
+
data = result["data"]
|
| 209 |
+
|
| 210 |
+
# Verify structure
|
| 211 |
+
assert "file_size_bytes" in data
|
| 212 |
+
assert "file_size_mb" in data
|
| 213 |
+
assert "file_size_gb" in data
|
| 214 |
+
assert "risk_level" in data
|
| 215 |
+
assert "recommend_lazy" in data
|
| 216 |
+
assert "estimated_memory_mb" in data
|
| 217 |
+
assert "memory_info" in data
|
| 218 |
+
|
| 219 |
+
# Small file should be safe
|
| 220 |
+
assert data["risk_level"] == "safe"
|
| 221 |
+
assert data["recommend_lazy"] is False
|
| 222 |
+
assert data["file_size_gb"] < 0.1
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def test_check_file_size_with_real_evt(data_service, sample_evt_file):
|
| 226 |
+
"""Test file size checking with real sample EVT file."""
|
| 227 |
+
if not os.path.exists(sample_evt_file):
|
| 228 |
+
pytest.skip(f"Sample file {sample_evt_file} not found")
|
| 229 |
+
|
| 230 |
+
result = data_service.check_file_size(sample_evt_file)
|
| 231 |
+
|
| 232 |
+
assert result["success"] is True
|
| 233 |
+
data = result["data"]
|
| 234 |
+
|
| 235 |
+
# Should be safe for small file
|
| 236 |
+
assert data["risk_level"] == "safe"
|
| 237 |
+
assert data["file_size_mb"] < 1.0 # Sample files are < 1MB
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_get_file_metadata(data_service, synthetic_small_fits):
|
| 241 |
+
"""Test metadata extraction without loading full data."""
|
| 242 |
+
result = data_service.get_file_metadata(synthetic_small_fits)
|
| 243 |
+
|
| 244 |
+
assert result["success"] is True
|
| 245 |
+
metadata = result["data"]
|
| 246 |
+
|
| 247 |
+
# Verify metadata structure
|
| 248 |
+
assert "gti" in metadata
|
| 249 |
+
assert "mjdref" in metadata
|
| 250 |
+
assert "n_events_estimate" in metadata
|
| 251 |
+
assert "time_range" in metadata
|
| 252 |
+
assert "file_size_mb" in metadata
|
| 253 |
+
assert "duration_s" in metadata
|
| 254 |
+
|
| 255 |
+
# Verify reasonable values
|
| 256 |
+
assert metadata["duration_s"] > 0
|
| 257 |
+
assert metadata["n_events_estimate"] > 0
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def test_is_large_file(data_service, synthetic_small_fits):
|
| 261 |
+
"""Test large file detection."""
|
| 262 |
+
# Small file
|
| 263 |
+
assert data_service.is_large_file(synthetic_small_fits, threshold_gb=1.0) is False
|
| 264 |
+
|
| 265 |
+
# With very small threshold
|
| 266 |
+
assert data_service.is_large_file(synthetic_small_fits, threshold_gb=0.00001) is True
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# =============================================================================
|
| 270 |
+
# Integration Tests: Memory Usage Monitoring
|
| 271 |
+
# =============================================================================
|
| 272 |
+
|
| 273 |
+
def test_memory_usage_during_loading(data_service, synthetic_small_fits):
|
| 274 |
+
"""
|
| 275 |
+
Test that memory usage is tracked during loading.
|
| 276 |
+
|
| 277 |
+
Verifies performance monitoring integration.
|
| 278 |
+
"""
|
| 279 |
+
# Get initial memory
|
| 280 |
+
process = psutil.Process()
|
| 281 |
+
mem_before = process.memory_info().rss / (1024**2) # MB
|
| 282 |
+
|
| 283 |
+
# Load file
|
| 284 |
+
result = data_service.load_event_list_lazy(
|
| 285 |
+
file_path=synthetic_small_fits,
|
| 286 |
+
name="mem_test",
|
| 287 |
+
safety_margin=0.5
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# Get final memory
|
| 291 |
+
mem_after = process.memory_info().rss / (1024**2) # MB
|
| 292 |
+
|
| 293 |
+
# Should succeed
|
| 294 |
+
assert result["success"] is True
|
| 295 |
+
|
| 296 |
+
# Memory should increase (but not by much for small file)
|
| 297 |
+
mem_increase = mem_after - mem_before
|
| 298 |
+
assert mem_increase >= 0 # Memory should not decrease
|
| 299 |
+
|
| 300 |
+
# For small test file (~100KB), increase should be < 50 MB
|
| 301 |
+
assert mem_increase < 50
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def test_lazy_loader_memory_info(synthetic_small_fits):
|
| 305 |
+
"""Test LazyEventLoader memory info reporting."""
|
| 306 |
+
loader = LazyEventLoader(synthetic_small_fits)
|
| 307 |
+
mem_info = loader.get_system_memory_info()
|
| 308 |
+
|
| 309 |
+
# Verify structure
|
| 310 |
+
assert "total_mb" in mem_info
|
| 311 |
+
assert "available_mb" in mem_info
|
| 312 |
+
assert "used_mb" in mem_info
|
| 313 |
+
assert "percent" in mem_info
|
| 314 |
+
assert "process_mb" in mem_info
|
| 315 |
+
|
| 316 |
+
# Verify reasonable values
|
| 317 |
+
assert mem_info["total_mb"] > 0
|
| 318 |
+
assert mem_info["available_mb"] > 0
|
| 319 |
+
assert 0 <= mem_info["percent"] <= 100
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# =============================================================================
|
| 323 |
+
# Integration Tests: Error Handling
|
| 324 |
+
# =============================================================================
|
| 325 |
+
|
| 326 |
+
def test_load_corrupted_fits_file(data_service):
|
| 327 |
+
"""Test loading a corrupted FITS file."""
|
| 328 |
+
# Create corrupted file
|
| 329 |
+
fd, tmp_path = tempfile.mkstemp(suffix='.evt')
|
| 330 |
+
try:
|
| 331 |
+
os.write(fd, b"This is not a valid FITS file")
|
| 332 |
+
os.close(fd)
|
| 333 |
+
|
| 334 |
+
result = data_service.load_event_list_lazy(
|
| 335 |
+
file_path=tmp_path,
|
| 336 |
+
name="corrupted",
|
| 337 |
+
safety_margin=0.5
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# Should fail gracefully
|
| 341 |
+
assert result["success"] is False
|
| 342 |
+
assert "error" in result
|
| 343 |
+
|
| 344 |
+
finally:
|
| 345 |
+
if os.path.exists(tmp_path):
|
| 346 |
+
os.remove(tmp_path)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def test_load_with_memory_error_simulation(data_service, synthetic_small_fits):
|
| 350 |
+
"""
|
| 351 |
+
Test handling of MemoryError during loading.
|
| 352 |
+
|
| 353 |
+
Simulates out-of-memory condition.
|
| 354 |
+
"""
|
| 355 |
+
# Patch EventList.read to raise MemoryError
|
| 356 |
+
with patch('utils.lazy_loader.EventList.read', side_effect=MemoryError("Out of memory")):
|
| 357 |
+
result = data_service.load_event_list_lazy(
|
| 358 |
+
file_path=synthetic_small_fits,
|
| 359 |
+
name="oom_test",
|
| 360 |
+
safety_margin=0.5
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
# Should fail with specific message
|
| 364 |
+
assert result["success"] is False
|
| 365 |
+
assert "Out of memory" in result["message"] or "memory" in result["message"].lower()
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
# =============================================================================
|
| 369 |
+
# Integration Tests: Performance Comparison
|
| 370 |
+
# =============================================================================
|
| 371 |
+
|
| 372 |
+
def test_standard_vs_lazy_loading_workflow(data_service, synthetic_small_fits):
|
| 373 |
+
"""
|
| 374 |
+
Compare standard vs lazy loading workflow.
|
| 375 |
+
|
| 376 |
+
For small files, both should work, but lazy adds overhead.
|
| 377 |
+
"""
|
| 378 |
+
import time
|
| 379 |
+
|
| 380 |
+
# Test standard loading
|
| 381 |
+
start = time.time()
|
| 382 |
+
result_standard = data_service.load_event_list(
|
| 383 |
+
file_path=synthetic_small_fits,
|
| 384 |
+
name="standard_test",
|
| 385 |
+
fmt="ogip"
|
| 386 |
+
)
|
| 387 |
+
time_standard = time.time() - start
|
| 388 |
+
|
| 389 |
+
assert result_standard["success"] is True
|
| 390 |
+
|
| 391 |
+
# Test lazy loading (with new name)
|
| 392 |
+
start = time.time()
|
| 393 |
+
result_lazy = data_service.load_event_list_lazy(
|
| 394 |
+
file_path=synthetic_small_fits,
|
| 395 |
+
name="lazy_test",
|
| 396 |
+
safety_margin=0.5
|
| 397 |
+
)
|
| 398 |
+
time_lazy = time.time() - start
|
| 399 |
+
|
| 400 |
+
assert result_lazy["success"] is True
|
| 401 |
+
|
| 402 |
+
# Both should produce same size event list
|
| 403 |
+
ev1 = result_standard["data"]
|
| 404 |
+
ev2 = result_lazy["data"]
|
| 405 |
+
assert len(ev1.time) == len(ev2.time)
|
| 406 |
+
|
| 407 |
+
# Print timing info for reference
|
| 408 |
+
print(f"\nTiming comparison:")
|
| 409 |
+
print(f" Standard: {time_standard:.4f}s")
|
| 410 |
+
print(f" Lazy: {time_lazy:.4f}s")
|
| 411 |
+
print(f" Ratio: {time_lazy/time_standard:.2f}x")
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
# =============================================================================
|
| 415 |
+
# Integration Tests: Risk Assessment
|
| 416 |
+
# =============================================================================
|
| 417 |
+
|
| 418 |
+
def test_assess_loading_risk_integration(synthetic_large_fits_info):
|
| 419 |
+
"""Test risk assessment with realistic large file parameters."""
|
| 420 |
+
file_size = synthetic_large_fits_info['file_size']
|
| 421 |
+
|
| 422 |
+
# Get actual available memory
|
| 423 |
+
available_mem = psutil.virtual_memory().available
|
| 424 |
+
|
| 425 |
+
# Assess risk
|
| 426 |
+
risk = assess_loading_risk(file_size, file_format='fits', available_memory=available_mem)
|
| 427 |
+
|
| 428 |
+
# For 2.5 GB file with 8x multiplier (20 GB needed):
|
| 429 |
+
# - If available < 33 GB: critical (>90%)
|
| 430 |
+
# - If available < 67 GB: risky (60-90%)
|
| 431 |
+
# - If available < 22 GB: caution (30-60%)
|
| 432 |
+
# This will vary by system
|
| 433 |
+
|
| 434 |
+
assert risk in ['safe', 'caution', 'risky', 'critical']
|
| 435 |
+
|
| 436 |
+
# Log for debugging
|
| 437 |
+
print(f"\nRisk assessment for {file_size/(1024**3):.1f}GB file:")
|
| 438 |
+
print(f" Available RAM: {available_mem/(1024**3):.1f}GB")
|
| 439 |
+
print(f" Risk level: {risk}")
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def test_lazy_loading_recommendation_logic(data_service, synthetic_small_fits):
|
| 443 |
+
"""Test the logic for recommending lazy loading."""
|
| 444 |
+
result = data_service.check_file_size(synthetic_small_fits)
|
| 445 |
+
|
| 446 |
+
assert result["success"] is True
|
| 447 |
+
data = result["data"]
|
| 448 |
+
|
| 449 |
+
# For small file: should NOT recommend lazy loading
|
| 450 |
+
assert data["recommend_lazy"] is False
|
| 451 |
+
|
| 452 |
+
# Manually test logic with mocked large file
|
| 453 |
+
with patch('os.path.getsize', return_value=2.5 * 1024**3): # 2.5 GB
|
| 454 |
+
result_large = data_service.check_file_size("fake_large.evt")
|
| 455 |
+
|
| 456 |
+
if result_large["success"]:
|
| 457 |
+
# Should recommend lazy for large file
|
| 458 |
+
assert result_large["data"]["recommend_lazy"] is True
|
| 459 |
+
assert result_large["data"]["file_size_gb"] > 1.0
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
# =============================================================================
|
| 463 |
+
# Integration Tests: Streaming Operations
|
| 464 |
+
# =============================================================================
|
| 465 |
+
|
| 466 |
+
def test_lazy_loader_streaming_segments(synthetic_small_fits):
|
| 467 |
+
"""Test streaming segments from LazyEventLoader."""
|
| 468 |
+
loader = LazyEventLoader(synthetic_small_fits)
|
| 469 |
+
|
| 470 |
+
# Stream in 100s segments
|
| 471 |
+
segments = list(loader.stream_segments(segment_size=100.0))
|
| 472 |
+
|
| 473 |
+
# Should get multiple segments
|
| 474 |
+
assert len(segments) > 0
|
| 475 |
+
|
| 476 |
+
# Each segment should be a numpy array
|
| 477 |
+
for segment in segments:
|
| 478 |
+
assert isinstance(segment, np.ndarray)
|
| 479 |
+
assert len(segment) > 0
|
| 480 |
+
|
| 481 |
+
# Total events should match full load
|
| 482 |
+
total_streamed = sum(len(seg) for seg in segments)
|
| 483 |
+
|
| 484 |
+
full_events = loader.load_full()
|
| 485 |
+
assert total_streamed == len(full_events.time)
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def test_lazy_loader_lightcurve_streaming(synthetic_small_fits):
|
| 489 |
+
"""Test streaming lightcurve creation."""
|
| 490 |
+
loader = LazyEventLoader(synthetic_small_fits)
|
| 491 |
+
|
| 492 |
+
# Create lightcurve via streaming
|
| 493 |
+
lc_segments = list(loader.create_lightcurve_streaming(
|
| 494 |
+
segment_size=100.0,
|
| 495 |
+
dt=1.0
|
| 496 |
+
))
|
| 497 |
+
|
| 498 |
+
# Should get segments
|
| 499 |
+
assert len(lc_segments) > 0
|
| 500 |
+
|
| 501 |
+
# Each segment should be (times, counts) tuple
|
| 502 |
+
for times, counts in lc_segments:
|
| 503 |
+
assert isinstance(times, np.ndarray)
|
| 504 |
+
assert isinstance(counts, np.ndarray)
|
| 505 |
+
assert len(times) == len(counts)
|
| 506 |
+
assert len(times) > 0
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
# =============================================================================
|
| 510 |
+
# Integration Tests: Full Workflow
|
| 511 |
+
# =============================================================================
|
| 512 |
+
|
| 513 |
+
def test_complete_lazy_loading_workflow(data_service, synthetic_small_fits):
|
| 514 |
+
"""
|
| 515 |
+
Test complete workflow: check size -> load with lazy -> verify -> delete.
|
| 516 |
+
|
| 517 |
+
This simulates the full user workflow in the dashboard.
|
| 518 |
+
"""
|
| 519 |
+
# Step 1: Check file size
|
| 520 |
+
check_result = data_service.check_file_size(synthetic_small_fits)
|
| 521 |
+
assert check_result["success"] is True
|
| 522 |
+
|
| 523 |
+
file_info = check_result["data"]
|
| 524 |
+
print(f"\nFile info: {file_info['file_size_mb']:.2f} MB, risk: {file_info['risk_level']}")
|
| 525 |
+
|
| 526 |
+
# Step 2: Get metadata (fast preview)
|
| 527 |
+
metadata_result = data_service.get_file_metadata(synthetic_small_fits)
|
| 528 |
+
assert metadata_result["success"] is True
|
| 529 |
+
|
| 530 |
+
metadata = metadata_result["data"]
|
| 531 |
+
print(f"Metadata: ~{metadata['n_events_estimate']} events, {metadata['duration_s']:.1f}s duration")
|
| 532 |
+
|
| 533 |
+
# Step 3: Load with lazy method (auto-decides standard vs lazy)
|
| 534 |
+
load_result = data_service.load_event_list_lazy(
|
| 535 |
+
file_path=synthetic_small_fits,
|
| 536 |
+
name="workflow_test",
|
| 537 |
+
safety_margin=0.5
|
| 538 |
+
)
|
| 539 |
+
assert load_result["success"] is True
|
| 540 |
+
|
| 541 |
+
event_list = load_result["data"]
|
| 542 |
+
print(f"Loaded: {len(event_list.time)} events via {load_result['metadata']['method']} method")
|
| 543 |
+
|
| 544 |
+
# Step 4: Verify data is accessible
|
| 545 |
+
get_result = data_service.get_event_list("workflow_test")
|
| 546 |
+
assert get_result["success"] is True
|
| 547 |
+
assert get_result["data"] is not None
|
| 548 |
+
|
| 549 |
+
# Step 5: List all event lists
|
| 550 |
+
list_result = data_service.list_event_lists()
|
| 551 |
+
assert list_result["success"] is True
|
| 552 |
+
assert len(list_result["data"]) >= 1
|
| 553 |
+
|
| 554 |
+
# Step 6: Delete
|
| 555 |
+
delete_result = data_service.delete_event_list("workflow_test")
|
| 556 |
+
assert delete_result["success"] is True
|
| 557 |
+
|
| 558 |
+
# Verify deleted
|
| 559 |
+
assert not data_service.state.has_event_data("workflow_test")
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def test_multiple_files_mixed_loading(data_service, synthetic_small_fits):
|
| 563 |
+
"""Test loading multiple files with different methods."""
|
| 564 |
+
# Load first file with standard method
|
| 565 |
+
result1 = data_service.load_event_list(
|
| 566 |
+
file_path=synthetic_small_fits,
|
| 567 |
+
name="file1",
|
| 568 |
+
fmt="ogip"
|
| 569 |
+
)
|
| 570 |
+
assert result1["success"] is True
|
| 571 |
+
|
| 572 |
+
# Load second file with lazy method
|
| 573 |
+
result2 = data_service.load_event_list_lazy(
|
| 574 |
+
file_path=synthetic_small_fits,
|
| 575 |
+
name="file2",
|
| 576 |
+
safety_margin=0.5
|
| 577 |
+
)
|
| 578 |
+
assert result2["success"] is True
|
| 579 |
+
|
| 580 |
+
# Both should be accessible
|
| 581 |
+
assert data_service.state.has_event_data("file1")
|
| 582 |
+
assert data_service.state.has_event_data("file2")
|
| 583 |
+
|
| 584 |
+
# List should show both
|
| 585 |
+
list_result = data_service.list_event_lists()
|
| 586 |
+
assert len(list_result["data"]) == 2
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
# =============================================================================
|
| 590 |
+
# Edge Cases
|
| 591 |
+
# =============================================================================
|
| 592 |
+
|
| 593 |
+
def test_empty_file_handling(data_service):
|
| 594 |
+
"""Test handling of empty FITS file."""
|
| 595 |
+
fd, tmp_path = tempfile.mkstemp(suffix='.evt')
|
| 596 |
+
os.close(fd)
|
| 597 |
+
|
| 598 |
+
try:
|
| 599 |
+
result = data_service.load_event_list_lazy(
|
| 600 |
+
file_path=tmp_path,
|
| 601 |
+
name="empty",
|
| 602 |
+
safety_margin=0.5
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
# Should fail (empty file is invalid FITS)
|
| 606 |
+
assert result["success"] is False
|
| 607 |
+
|
| 608 |
+
finally:
|
| 609 |
+
if os.path.exists(tmp_path):
|
| 610 |
+
os.remove(tmp_path)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def test_very_high_safety_margin(data_service, synthetic_small_fits):
|
| 614 |
+
"""Test lazy loading with very conservative safety margin."""
|
| 615 |
+
# 99% safety margin means only use 1% of available RAM
|
| 616 |
+
result = data_service.load_event_list_lazy(
|
| 617 |
+
file_path=synthetic_small_fits,
|
| 618 |
+
name="conservative",
|
| 619 |
+
safety_margin=0.01 # Only use 1% of RAM
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
# Should still succeed for small file
|
| 623 |
+
# (might use 'standard_risky' method if safety check fails)
|
| 624 |
+
assert result["success"] is True
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def test_zero_safety_margin(data_service, synthetic_small_fits):
|
| 628 |
+
"""Test lazy loading with zero safety margin (risky!)."""
|
| 629 |
+
# Safety margin of 0 means no safety checks
|
| 630 |
+
result = data_service.load_event_list_lazy(
|
| 631 |
+
file_path=synthetic_small_fits,
|
| 632 |
+
name="risky",
|
| 633 |
+
safety_margin=0.0
|
| 634 |
+
)
|
| 635 |
+
|
| 636 |
+
# Should fail or warn (depends on implementation)
|
| 637 |
+
# Small file should still load
|
| 638 |
+
assert result["success"] is True or "warning" in result["message"].lower()
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
if __name__ == "__main__":
|
| 642 |
+
pytest.main([__file__, "-v", "--tb=short"])
|
utils/lazy_loader.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lazy Loading Module for Large FITS Files
|
| 3 |
+
|
| 4 |
+
This module provides memory-efficient loading of large X-ray observation files
|
| 5 |
+
using Stingray's FITSTimeseriesReader for streaming data access.
|
| 6 |
+
|
| 7 |
+
Based on Stingray's official performance tutorial:
|
| 8 |
+
https://docs.stingray.science/en/stable/notebooks/Performance/Dealing%20with%20large%20data%20files.html
|
| 9 |
+
|
| 10 |
+
Features:
|
| 11 |
+
- Lazy loading of FITS files without loading entire dataset into memory
|
| 12 |
+
- Memory usage estimation and safety checks
|
| 13 |
+
- Streaming segment access for chunked processing
|
| 14 |
+
- Metadata extraction without full data load
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Dict, List, Optional, Any, Iterator, Tuple
|
| 20 |
+
import numpy as np
|
| 21 |
+
import psutil
|
| 22 |
+
|
| 23 |
+
from stingray.io import FITSTimeseriesReader
|
| 24 |
+
from stingray.gti import time_intervals_from_gtis
|
| 25 |
+
from stingray.utils import histogram
|
| 26 |
+
from stingray import EventList
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class LazyEventLoader:
|
| 32 |
+
"""
|
| 33 |
+
Memory-efficient wrapper for loading large FITS event files.
|
| 34 |
+
|
| 35 |
+
This class uses Stingray's FITSTimeseriesReader to enable lazy loading,
|
| 36 |
+
where data remains in the FITS file until accessed. This allows analysis
|
| 37 |
+
of files larger than available RAM.
|
| 38 |
+
|
| 39 |
+
Example:
|
| 40 |
+
>>> loader = LazyEventLoader("large_observation.evt")
|
| 41 |
+
>>> metadata = loader.get_metadata()
|
| 42 |
+
>>> print(f"File has {metadata['n_events_estimate']} events")
|
| 43 |
+
>>>
|
| 44 |
+
>>> if loader.can_load_safely():
|
| 45 |
+
... # Safe to load into memory
|
| 46 |
+
... events = loader.load_full()
|
| 47 |
+
... else:
|
| 48 |
+
... # Use streaming instead
|
| 49 |
+
... for segment in loader.stream_segments(segment_size=100):
|
| 50 |
+
... process_segment(segment)
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(self, file_path: str):
|
| 54 |
+
"""
|
| 55 |
+
Initialize lazy loader for a FITS file.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
file_path: Path to the FITS event file
|
| 59 |
+
|
| 60 |
+
Raises:
|
| 61 |
+
FileNotFoundError: If file doesn't exist
|
| 62 |
+
ValueError: If file is not a valid FITS event file
|
| 63 |
+
"""
|
| 64 |
+
if not os.path.exists(file_path):
|
| 65 |
+
raise FileNotFoundError(f"File not found: {file_path}")
|
| 66 |
+
|
| 67 |
+
self.file_path = file_path
|
| 68 |
+
self.file_size = os.path.getsize(file_path)
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
# Initialize reader (doesn't load data, just opens file)
|
| 72 |
+
self.reader = FITSTimeseriesReader(file_path, data_kind="times")
|
| 73 |
+
except Exception as e:
|
| 74 |
+
raise ValueError(f"Failed to open FITS file: {e}") from e
|
| 75 |
+
|
| 76 |
+
logger.info(
|
| 77 |
+
f"LazyEventLoader initialized for {file_path} "
|
| 78 |
+
f"({self.format_file_size(self.file_size)})"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
def get_metadata(self) -> Dict[str, Any]:
|
| 82 |
+
"""
|
| 83 |
+
Get file metadata without loading event data.
|
| 84 |
+
|
| 85 |
+
This is a fast operation that only reads the FITS headers,
|
| 86 |
+
not the event data itself.
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Dict containing:
|
| 90 |
+
- gti: Good time intervals
|
| 91 |
+
- mjdref: Reference MJD
|
| 92 |
+
- n_events_estimate: Rough estimate of number of events
|
| 93 |
+
- time_range: (min_time, max_time) from GTIs
|
| 94 |
+
- file_size_mb: File size in megabytes
|
| 95 |
+
- file_size_gb: File size in gigabytes
|
| 96 |
+
- duration_s: Total observation duration in seconds
|
| 97 |
+
"""
|
| 98 |
+
gti = self.reader.gti
|
| 99 |
+
|
| 100 |
+
# Estimate number of events from file size
|
| 101 |
+
# Typical FITS event: ~12 bytes compressed in file
|
| 102 |
+
n_events_estimate = self.file_size / 12
|
| 103 |
+
|
| 104 |
+
# Calculate observation duration from GTIs
|
| 105 |
+
duration_s = float(np.sum(gti[:, 1] - gti[:, 0]))
|
| 106 |
+
|
| 107 |
+
metadata = {
|
| 108 |
+
'gti': gti,
|
| 109 |
+
'mjdref': getattr(self.reader, 'mjdref', 0.0),
|
| 110 |
+
'n_events_estimate': int(n_events_estimate),
|
| 111 |
+
'time_range': (float(gti.min()), float(gti.max())),
|
| 112 |
+
'file_size_mb': self.file_size / (1024**2),
|
| 113 |
+
'file_size_gb': self.file_size / (1024**3),
|
| 114 |
+
'duration_s': duration_s,
|
| 115 |
+
'estimated_count_rate': n_events_estimate / duration_s if duration_s > 0 else 0
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
logger.debug(f"Metadata extracted: {metadata}")
|
| 119 |
+
return metadata
|
| 120 |
+
|
| 121 |
+
def estimate_memory_usage(self, format_type: str = 'fits') -> int:
|
| 122 |
+
"""
|
| 123 |
+
Estimate memory needed to load entire file into EventList.
|
| 124 |
+
|
| 125 |
+
Based on Stingray's official benchmarks:
|
| 126 |
+
- FITS event file: ~3x file size (2.6x measured + safety margin)
|
| 127 |
+
- HDF5: ~2x file size (more efficient format)
|
| 128 |
+
- Pickle: ~1.5x file size (most efficient)
|
| 129 |
+
|
| 130 |
+
Reference: Stingray Performance Tutorial
|
| 131 |
+
https://docs.stingray.science/en/stable/notebooks/Performance/Dealing%20with%20large%20data%20files.html
|
| 132 |
+
Real test: 2GB FITS file → 5.2GB peak memory = 2.6x multiplier
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
format_type: File format type (fits, evt, ogip, hea)
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Estimated peak memory usage in bytes
|
| 139 |
+
"""
|
| 140 |
+
# Memory multipliers based on file type
|
| 141 |
+
# Values based on Stingray's official performance benchmarks
|
| 142 |
+
multipliers = {
|
| 143 |
+
'fits': 3,
|
| 144 |
+
'evt': 3,
|
| 145 |
+
'ogip': 3,
|
| 146 |
+
'hea': 3,
|
| 147 |
+
'hdf5': 2,
|
| 148 |
+
'pickle': 1.5,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
multiplier = multipliers.get(format_type, 3) # Conservative default
|
| 152 |
+
estimated_bytes = self.file_size * multiplier
|
| 153 |
+
|
| 154 |
+
logger.debug(
|
| 155 |
+
f"Estimated memory: {self.format_file_size(estimated_bytes)} "
|
| 156 |
+
f"(multiplier: {multiplier}x)"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return estimated_bytes
|
| 160 |
+
|
| 161 |
+
def can_load_safely(
|
| 162 |
+
self,
|
| 163 |
+
safety_margin: float = 0.5,
|
| 164 |
+
format_type: str = 'fits'
|
| 165 |
+
) -> bool:
|
| 166 |
+
"""
|
| 167 |
+
Check if file can be safely loaded into memory.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
safety_margin: Fraction of available RAM to use (0.0-1.0)
|
| 171 |
+
Default 0.5 means use at most 50% of available RAM
|
| 172 |
+
format_type: File format for memory estimation
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
True if file can be loaded without risk of memory exhaustion
|
| 176 |
+
"""
|
| 177 |
+
available_ram = psutil.virtual_memory().available
|
| 178 |
+
needed_ram = self.estimate_memory_usage(format_type)
|
| 179 |
+
safe_limit = available_ram * safety_margin
|
| 180 |
+
|
| 181 |
+
can_load = needed_ram < safe_limit
|
| 182 |
+
|
| 183 |
+
logger.info(
|
| 184 |
+
f"Memory check: Need {self.format_file_size(needed_ram)}, "
|
| 185 |
+
f"Safe limit {self.format_file_size(safe_limit)} "
|
| 186 |
+
f"({safety_margin*100:.0f}% of {self.format_file_size(available_ram)} available) "
|
| 187 |
+
f"-> {'SAFE' if can_load else 'RISKY'}"
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
return can_load
|
| 191 |
+
|
| 192 |
+
def get_system_memory_info(self) -> Dict[str, Any]:
|
| 193 |
+
"""
|
| 194 |
+
Get current system memory information.
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
Dict with memory stats:
|
| 198 |
+
- total_mb: Total system RAM
|
| 199 |
+
- available_mb: Available RAM
|
| 200 |
+
- used_mb: Used RAM
|
| 201 |
+
- percent: Memory usage percentage
|
| 202 |
+
- process_mb: Current process memory usage
|
| 203 |
+
"""
|
| 204 |
+
vm = psutil.virtual_memory()
|
| 205 |
+
process = psutil.Process()
|
| 206 |
+
|
| 207 |
+
return {
|
| 208 |
+
'total_mb': vm.total / (1024**2),
|
| 209 |
+
'available_mb': vm.available / (1024**2),
|
| 210 |
+
'used_mb': vm.used / (1024**2),
|
| 211 |
+
'percent': vm.percent,
|
| 212 |
+
'process_mb': process.memory_info().rss / (1024**2)
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
def load_full(
|
| 216 |
+
self,
|
| 217 |
+
rmf_file: Optional[str] = None,
|
| 218 |
+
additional_columns: Optional[List[str]] = None
|
| 219 |
+
) -> EventList:
|
| 220 |
+
"""
|
| 221 |
+
Load entire file into EventList.
|
| 222 |
+
|
| 223 |
+
WARNING: Only use this if can_load_safely() returns True!
|
| 224 |
+
For large files, use stream_segments() instead.
|
| 225 |
+
|
| 226 |
+
Args:
|
| 227 |
+
rmf_file: Optional path to RMF file for energy calibration
|
| 228 |
+
additional_columns: Additional FITS columns to load
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
Complete EventList object
|
| 232 |
+
|
| 233 |
+
Raises:
|
| 234 |
+
MemoryError: If system runs out of memory during load
|
| 235 |
+
"""
|
| 236 |
+
logger.info(f"Loading full EventList from {self.file_path}")
|
| 237 |
+
|
| 238 |
+
try:
|
| 239 |
+
# Use EventList.read for full load (works with FITSTimeseriesReader internally)
|
| 240 |
+
events = EventList.read(
|
| 241 |
+
self.file_path,
|
| 242 |
+
fmt='ogip',
|
| 243 |
+
rmf_file=rmf_file,
|
| 244 |
+
additional_columns=additional_columns
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
logger.info(
|
| 248 |
+
f"Loaded {len(events.time)} events "
|
| 249 |
+
f"(memory: {self.get_system_memory_info()['process_mb']:.1f} MB)"
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
return events
|
| 253 |
+
|
| 254 |
+
except MemoryError as e:
|
| 255 |
+
logger.error(f"Out of memory loading {self.file_path}")
|
| 256 |
+
raise MemoryError(
|
| 257 |
+
f"Insufficient memory to load file. "
|
| 258 |
+
f"File size: {self.format_file_size(self.file_size)}. "
|
| 259 |
+
f"Try using stream_segments() instead."
|
| 260 |
+
) from e
|
| 261 |
+
|
| 262 |
+
def stream_segments(
|
| 263 |
+
self,
|
| 264 |
+
segment_size: float
|
| 265 |
+
) -> Iterator[np.ndarray]:
|
| 266 |
+
"""
|
| 267 |
+
Stream event time segments without loading full file.
|
| 268 |
+
|
| 269 |
+
This is the recommended approach for large files. Events are
|
| 270 |
+
read in chunks based on good time intervals.
|
| 271 |
+
|
| 272 |
+
Args:
|
| 273 |
+
segment_size: Size of each segment in seconds
|
| 274 |
+
|
| 275 |
+
Yields:
|
| 276 |
+
numpy arrays of event times for each segment
|
| 277 |
+
|
| 278 |
+
Example:
|
| 279 |
+
>>> loader = LazyEventLoader("large.evt")
|
| 280 |
+
>>> for times in loader.stream_segments(segment_size=100):
|
| 281 |
+
... # Process 100-second chunks
|
| 282 |
+
... lc = histogram(times, bins=1000, range=[times[0], times[-1]])
|
| 283 |
+
... analyze(lc)
|
| 284 |
+
"""
|
| 285 |
+
logger.info(
|
| 286 |
+
f"Streaming segments from {self.file_path} "
|
| 287 |
+
f"(segment_size={segment_size}s)"
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# Get segment boundaries from GTIs
|
| 291 |
+
start, stop = time_intervals_from_gtis(self.reader.gti, segment_size)
|
| 292 |
+
intervals = [[s, e] for s, e in zip(start, stop)]
|
| 293 |
+
|
| 294 |
+
logger.debug(f"Created {len(intervals)} segments")
|
| 295 |
+
|
| 296 |
+
# Stream times for each interval
|
| 297 |
+
times_iter = self.reader.filter_at_time_intervals(
|
| 298 |
+
intervals,
|
| 299 |
+
check_gtis=True
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
segment_count = 0
|
| 303 |
+
for time_segment in times_iter:
|
| 304 |
+
segment_count += 1
|
| 305 |
+
logger.debug(
|
| 306 |
+
f"Yielding segment {segment_count}/{len(intervals)} "
|
| 307 |
+
f"({len(time_segment)} events)"
|
| 308 |
+
)
|
| 309 |
+
yield time_segment
|
| 310 |
+
|
| 311 |
+
logger.info(f"Streamed {segment_count} segments")
|
| 312 |
+
|
| 313 |
+
def create_lightcurve_streaming(
|
| 314 |
+
self,
|
| 315 |
+
segment_size: float,
|
| 316 |
+
dt: float
|
| 317 |
+
) -> Iterator[Tuple[np.ndarray, np.ndarray]]:
|
| 318 |
+
"""
|
| 319 |
+
Create light curve by streaming data in segments.
|
| 320 |
+
|
| 321 |
+
This avoids loading the entire EventList into memory.
|
| 322 |
+
|
| 323 |
+
Args:
|
| 324 |
+
segment_size: Segment size in seconds
|
| 325 |
+
dt: Light curve bin time
|
| 326 |
+
|
| 327 |
+
Yields:
|
| 328 |
+
Tuples of (times, counts) for each light curve segment
|
| 329 |
+
|
| 330 |
+
Example:
|
| 331 |
+
>>> loader = LazyEventLoader("large.evt")
|
| 332 |
+
>>> all_times = []
|
| 333 |
+
>>> all_counts = []
|
| 334 |
+
>>> for times, counts in loader.create_lightcurve_streaming(100, 0.1):
|
| 335 |
+
... all_times.extend(times)
|
| 336 |
+
... all_counts.extend(counts)
|
| 337 |
+
"""
|
| 338 |
+
logger.info(
|
| 339 |
+
f"Creating lightcurve via streaming "
|
| 340 |
+
f"(segment_size={segment_size}s, dt={dt}s)"
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
start, stop = time_intervals_from_gtis(self.reader.gti, segment_size)
|
| 344 |
+
intervals = [[s, e] for s, e in zip(start, stop)]
|
| 345 |
+
times_iter = self.reader.filter_at_time_intervals(intervals, check_gtis=True)
|
| 346 |
+
|
| 347 |
+
for time_segment, (s, e) in zip(times_iter, intervals):
|
| 348 |
+
# Create light curve for this segment
|
| 349 |
+
n_bins = int(np.rint((e - s) / dt))
|
| 350 |
+
|
| 351 |
+
# Use Stingray's optimized histogram (returns only counts)
|
| 352 |
+
counts = histogram(
|
| 353 |
+
time_segment,
|
| 354 |
+
bins=n_bins,
|
| 355 |
+
range=[s, e]
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
# Calculate bin edges manually (Stingray's approach)
|
| 359 |
+
bin_edges = np.linspace(s, e, n_bins + 1)
|
| 360 |
+
|
| 361 |
+
# Bin centers
|
| 362 |
+
times = (bin_edges[:-1] + bin_edges[1:]) / 2
|
| 363 |
+
|
| 364 |
+
yield times, counts
|
| 365 |
+
|
| 366 |
+
@staticmethod
|
| 367 |
+
def format_file_size(size_bytes: int) -> str:
|
| 368 |
+
"""
|
| 369 |
+
Format bytes to human-readable string.
|
| 370 |
+
|
| 371 |
+
Args:
|
| 372 |
+
size_bytes: Size in bytes
|
| 373 |
+
|
| 374 |
+
Returns:
|
| 375 |
+
Human-readable string (e.g., "1.5 GB", "234.5 MB")
|
| 376 |
+
"""
|
| 377 |
+
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
| 378 |
+
if size_bytes < 1024.0:
|
| 379 |
+
return f"{size_bytes:.1f} {unit}"
|
| 380 |
+
size_bytes /= 1024.0
|
| 381 |
+
return f"{size_bytes:.1f} PB"
|
| 382 |
+
|
| 383 |
+
def __repr__(self) -> str:
|
| 384 |
+
"""String representation."""
|
| 385 |
+
return (
|
| 386 |
+
f"LazyEventLoader('{self.file_path}', "
|
| 387 |
+
f"size={self.format_file_size(self.file_size)})"
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
def __enter__(self):
|
| 391 |
+
"""Context manager entry."""
|
| 392 |
+
return self
|
| 393 |
+
|
| 394 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 395 |
+
"""Context manager exit - cleanup if needed."""
|
| 396 |
+
# FITSTimeseriesReader handles its own cleanup
|
| 397 |
+
pass
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def assess_loading_risk(
|
| 401 |
+
file_size: int,
|
| 402 |
+
file_format: str = 'fits',
|
| 403 |
+
available_memory: Optional[int] = None
|
| 404 |
+
) -> str:
|
| 405 |
+
"""
|
| 406 |
+
Assess risk level of loading a file into memory.
|
| 407 |
+
|
| 408 |
+
Args:
|
| 409 |
+
file_size: Size of file in bytes
|
| 410 |
+
file_format: File format type
|
| 411 |
+
available_memory: Available RAM in bytes (auto-detected if None)
|
| 412 |
+
|
| 413 |
+
Returns:
|
| 414 |
+
Risk level: 'safe', 'caution', 'risky', or 'critical'
|
| 415 |
+
"""
|
| 416 |
+
if available_memory is None:
|
| 417 |
+
available_memory = psutil.virtual_memory().available
|
| 418 |
+
|
| 419 |
+
# Estimate memory needed
|
| 420 |
+
# Based on Stingray's official performance benchmarks
|
| 421 |
+
multipliers = {
|
| 422 |
+
'fits': 3, 'evt': 3, 'ogip': 3, 'hea': 3,
|
| 423 |
+
'hdf5': 2, 'pickle': 1.5,
|
| 424 |
+
}
|
| 425 |
+
multiplier = multipliers.get(file_format, 3)
|
| 426 |
+
needed_memory = file_size * multiplier
|
| 427 |
+
|
| 428 |
+
# Calculate ratio
|
| 429 |
+
ratio = needed_memory / available_memory
|
| 430 |
+
|
| 431 |
+
if ratio < 0.3:
|
| 432 |
+
return 'safe' # <30% of RAM
|
| 433 |
+
elif ratio < 0.6:
|
| 434 |
+
return 'caution' # 30-60% of RAM
|
| 435 |
+
elif ratio < 0.9:
|
| 436 |
+
return 'risky' # 60-90% of RAM
|
| 437 |
+
else:
|
| 438 |
+
return 'critical' # >90% of RAM
|