Coverage for python/lsst/summit/utils/consdbClient.py: 68%

195 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 10:53 +0000

1# This file is part of summit_utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21 

22import logging 

23import os 

24from collections.abc import Mapping 

25from dataclasses import dataclass 

26from typing import Any 

27from urllib.parse import quote, urlparse 

28 

29import numpy as np 

30import requests 

31from astropy.table import Column, Table 

32 

33__all__ = ["ConsDbClient", "FlexibleMetadataInfo", "getCcdVisitTableForDay", "getWideQuicklookTableForDay"] 

34 

35 

36logger = logging.getLogger(__name__) 

37 

38# Timeouts in seconds applied to every request made by ConsDbClient. 

39# The read timeout is generous because some queries are genuinely slow. 

40DEFAULT_CONNECT_TIMEOUT: float = 10.0 

41DEFAULT_READ_TIMEOUT: float = 600.0 

42 

43 

44def _urljoin(*args: str) -> str: 

45 """Join parts of a URL with slashes. 

46 

47 Does not do any quoting. Mostly to remove a level of list-making. 

48 

49 Parameters 

50 ---------- 

51 *args : `str` 

52 Each parameter is a URL part. 

53 

54 Returns 

55 ------- 

56 url : `str` 

57 The joined URL. 

58 """ 

59 return "/".join(args) 

60 

61 

62def _check_status(r: requests.Response) -> None: 

63 """Check the status of an HTTP response and raise if an error. 

64 

65 Adds additional response information to the raise_for_status exception. 

66 

67 Parameters 

68 ---------- 

69 r : `requests.Response` 

70 The response to check. 

71 

72 Raises 

73 ------ 

74 requests.HTTPError 

75 Raised if a non-successful status is returned. 

76 """ 

77 try: 

78 r.raise_for_status() 

79 except requests.HTTPError as e: 

80 try: 

81 json_data = e.response.json() 

82 e.add_note(str(json_data)) 

83 if "message" in json_data: 83 ↛ 87line 83 didn't jump to line 87 because the condition on line 83 was always true

84 e.add_note(f"\n\n{json_data['message']}") 

85 except requests.JSONDecodeError: 

86 pass 

87 raise e 

88 

89 

90def clean_url(resp: requests.Response, *args, **kwargs) -> requests.Response: 

91 """Parse url from response and remove netloc portion. 

92 

93 Set new url in response and return response 

94 

95 Parameters 

96 ---------- 

97 resp : `requests.Response` 

98 The response that could contain a URL with tokens 

99 """ 

100 url = urlparse(resp.url) 

101 short_user = f"{url.username[:2]}***" if url.username is not None else "" 

102 short_pass = f":{url.password[:2]}***" if url.password is not None else "" 

103 netloc = f"{short_user}{short_pass}@{url.hostname}" 

104 resp.url = url._replace(netloc=netloc).geturl() 

105 return resp 

106 

107 

108@dataclass 

109class FlexibleMetadataInfo: 

110 """Description of a flexible metadata value. 

111 

112 Parameters 

113 ---------- 

114 dtype : `str` 

115 Data type of the flexible metadata value. 

116 One of ``bool``, ``int``, ``float``, or ``str``. 

117 doc : `str` 

118 Documentation string. 

119 unit : `str`, optional 

120 Unit of the value. 

121 ucd : `str`, optional 

122 IVOA Unified Content Descriptor 

123 (https://www.ivoa.net/documents/UCD1+/). 

124 """ 

125 

126 dtype: str 

127 doc: str 

128 unit: str | None = None 

129 ucd: str | None = None 

130 

131 

132class ConsDbClient: 

133 """A client library for accessing the Consolidated Database. 

134 

135 This library provides a basic interface for using flexible metadata 

136 (key/value pairs associated with observation ids from an observation 

137 type table), determining the schema of ConsDB tables, querying the 

138 ConsDB using a general SQL SELECT statement, and inserting into 

139 ConsDB tables. 

140 

141 Parameters 

142 ---------- 

143 url : `str` | `None` 

144 Base URL of the Web service, defaults to the value of environment 

145 variable ``LSST_CONSDB_PQ_URL`` (the location of the publish/query 

146 service). 

147 token : `str` | `None` 

148 Authentication token for the RSP. The token must begin with "gt-". 

149 connect_timeout : `float` | `None` 

150 Seconds to wait for the connection to be established, defaults to 

151 `DEFAULT_CONNECT_TIMEOUT`. 

152 read_timeout : `float` | `None` 

153 Seconds to wait between bytes sent by the server, defaults to 

154 `DEFAULT_READ_TIMEOUT`. Pass `None` to wait indefinitely, which is 

155 rarely what you want. 

156 

157 Notes 

158 ----- 

159 This client is a thin layer over the publish/query Web service, which 

160 avoids having a dependency on database drivers. 

161 

162 It enforces the return of query results as Astropy Tables. 

163 """ 

164 

165 def __init__( 

166 self, 

167 url: str | None = None, 

168 token: str | None = None, 

169 connect_timeout: float | None = DEFAULT_CONNECT_TIMEOUT, 

170 read_timeout: float | None = DEFAULT_READ_TIMEOUT, 

171 ): 

172 self.session = requests.Session() 

173 self.session.hooks["response"].append(clean_url) 

174 self.connect_timeout = connect_timeout 

175 self.read_timeout = read_timeout 

176 

177 if token is not None: 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true

178 if not token.startswith("gt-"): 

179 raise ValueError("token must start with `gt-`.") 

180 

181 self.session.headers.update({"Authorization": f"Bearer {token}"}) 

182 

183 if url is None: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true

184 self.url = os.environ["LSST_CONSDB_PQ_URL"] 

185 else: 

186 self.url = url 

187 self.url = self.url.rstrip("/") 

188 

189 @property 

190 def timeout(self) -> tuple[float | None, float | None]: 

191 """The ``(connect, read)`` timeout pair passed to requests 

192 (`tuple` [ `float` | `None`, `float` | `None` ], read-only). 

193 """ 

194 return (self.connect_timeout, self.read_timeout) 

195 

196 def _handle_get(self, url: str, query: dict[str, str | list[str]] | None = None) -> Any: 

197 """Submit GET requests to the server. 

198 

199 Parameters 

200 ---------- 

201 url : `str` 

202 URL to GET. 

203 query : `dict` [`str`, `str` | `list` [`str`]], optional 

204 Query parameters to attach to the URL. 

205 

206 Raises 

207 ------ 

208 requests.RequestException 

209 Raised if any kind of connection error occurs. 

210 requests.Timeout 

211 Raised if the request exceeds the client timeout. 

212 requests.HTTPError 

213 Raised if a non-successful status is returned. 

214 requests.JSONDecodeError 

215 Raised if the result does not decode as JSON. 

216 

217 Returns 

218 ------- 

219 result : `Any` 

220 Result of decoding the Web service result content as JSON. 

221 """ 

222 logger.debug(f"GET {url}") 

223 response = self.session.get(url, params=query, timeout=self.timeout) 

224 _check_status(response) 

225 return response.json() 

226 

227 def _handle_post(self, url: str, data: dict[str, Any]) -> requests.Response: 

228 """Submit POST requests to the server. 

229 

230 Parameters 

231 ---------- 

232 url : `str` 

233 URL to POST. 

234 data : `dict` [`str`, `Any`] 

235 Key/value pairs of data to POST. 

236 

237 Raises 

238 ------ 

239 requests.RequestException 

240 Raised if any kind of connection error occurs. 

241 requests.Timeout 

242 Raised if the request exceeds the client timeout. 

243 requests.HTTPError 

244 Raised if a non-successful status is returned. 

245 

246 Returns 

247 ------- 

248 result : `requests.Response` 

249 The raw Web service result object. 

250 """ 

251 logger.debug(f"POST {url}: {data}") 

252 response = self.session.post(url, json=data, timeout=self.timeout) 

253 _check_status(response) 

254 return response 

255 

256 @staticmethod 

257 def compute_flexible_metadata_table_name(instrument: str, obs_type: str) -> str: 

258 """Compute the name of a flexible metadata table. 

259 

260 Each instrument and observation type made with that instrument can 

261 have a flexible metadata table. This function is useful when 

262 issuing SQL queries, and it avoids a round-trip to the server. 

263 

264 Parameters 

265 ---------- 

266 instrument : `str` 

267 Name of the instrument (e.g. ``LATISS``). 

268 obs_type : `str` 

269 Name of the observation type (e.g. ``Exposure``). 

270 

271 Returns 

272 ------- 

273 table_name : `str` 

274 Name of the appropriate flexible metadata table. 

275 """ 

276 return f"cdb_{instrument}.{obs_type}_flexdata" 

277 

278 @staticmethod 

279 def compute_fixed_metadata_namespace(instrument: str) -> str: 

280 """Compute the namespace for a fixed metadata table. 

281 

282 Each instrument has its own namespace in the ConsDB. 

283 This function is useful when issuing SQL queries, and it avoids a 

284 round-trip to the server. 

285 

286 Parameters 

287 ---------- 

288 instrument : `str` 

289 Name of the instrument (e.g. ``LATISS``). 

290 

291 Returns 

292 ------- 

293 namespace_name : `str` 

294 Name of the appropriate namespace 

295 """ 

296 return f"cdb_{instrument}" 

297 

298 def add_flexible_metadata_key( 

299 self, 

300 instrument: str, 

301 obs_type: str, 

302 key: str, 

303 dtype: str, 

304 doc: str, 

305 unit: str | None = None, 

306 ucd: str | None = None, 

307 ) -> requests.Response: 

308 """Add a key to a flexible metadata table. 

309 

310 Parameters 

311 ---------- 

312 instrument : `str` 

313 Name of the instrument (e.g. ``LATISS``). 

314 obs_type : `str` 

315 Name of the observation type (e.g. ``Exposure``). 

316 key : `str` 

317 Name of the key to be added (must not already exist). 

318 dtype : `str` 

319 One of ``bool``, ``int``, ``float``, or ``str``. 

320 doc : `str` 

321 Documentation string for the key. 

322 unit : `str`, optional 

323 Unit for the value. Should be from the IVOA 

324 (https://www.ivoa.net/documents/VOUnits/) or astropy. 

325 ucd : `str`, optional 

326 IVOA Unified Content Descriptor 

327 (https://www.ivoa.net/documents/UCD1+/). 

328 

329 Returns 

330 ------- 

331 response : `requests.Response` 

332 HTTP response from the server, with 200 status for success. 

333 

334 Raises 

335 ------ 

336 requests.RequestException 

337 Raised if any kind of connection error occurs. 

338 requests.HTTPError 

339 Raised if a non-successful status is returned. 

340 """ 

341 data = {"key": key, "dtype": dtype, "doc": doc} 

342 if unit is not None: 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true

343 data["unit"] = unit 

344 if ucd is not None: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 data["ucd"] = ucd 

346 url = _urljoin(self.url, "flex", quote(instrument), quote(obs_type), "addkey") 

347 return self._handle_post(url, data) 

348 

349 def get_flexible_metadata_keys(self, instrument: str, obs_type: str) -> dict[str, FlexibleMetadataInfo]: 

350 """Retrieve the valid keys for a flexible metadata table. 

351 

352 Parameters 

353 ---------- 

354 instrument : `str` 

355 Name of the instrument (e.g. ``LATISS``). 

356 obs_type : `str` 

357 Name of the observation type (e.g. ``Exposure``). 

358 

359 Returns 

360 ------- 

361 key_info : `dict` [ `str`, `FlexibleMetadataInfo` ] 

362 Dict of keys and information values. 

363 

364 Raises 

365 ------ 

366 requests.RequestException 

367 Raised if any kind of connection error occurs. 

368 requests.HTTPError 

369 Raised if a non-successful status is returned. 

370 """ 

371 url = _urljoin(self.url, "flex", quote(instrument), quote(obs_type), "schema") 

372 result = self._handle_get(url) 

373 return {key: FlexibleMetadataInfo(*value) for key, value in result.items()} 

374 

375 def get_flexible_metadata( 

376 self, instrument: str, obs_type: str, obs_id: int, keys: list[str] | None = None 

377 ) -> dict[str, Any]: 

378 """Get the flexible metadata for an observation. 

379 

380 Parameters 

381 ---------- 

382 instrument : `str` 

383 Name of the instrument (e.g. ``LATISS``). 

384 obs_type : `str` 

385 Name of the observation type (e.g. ``Exposure``). 

386 obs_id : `int` 

387 Unique observation id. 

388 keys : `list` [ `str` ], optional 

389 List of keys to be retrieved; all if not specified. 

390 

391 Returns 

392 ------- 

393 result_dict : `dict` [ `str`, `Any` ] 

394 Dictionary of key/value pairs for the observation. 

395 

396 Raises 

397 ------ 

398 requests.RequestException 

399 Raised if any kind of connection error occurs. 

400 requests.HTTPError 

401 Raised if a non-successful status is returned. 

402 """ 

403 url = _urljoin( 

404 self.url, 

405 "flex", 

406 quote(instrument), 

407 quote(obs_type), 

408 "obs", 

409 quote(str(obs_id)), 

410 ) 

411 return self._handle_get(url, {"k": keys} if keys else None) 

412 

413 def get_all_metadata( 

414 self, instrument: str, obs_type: str, obs_id: int, flex: bool = False 

415 ) -> dict[str, Any]: 

416 """Get all metadata for an observation. 

417 

418 Parameters 

419 ---------- 

420 instrument : `str` 

421 Name of the instrument (e.g. ``LATISS``). 

422 obs_type : `str` 

423 Name of the observation type (e.g. ``Exposure``). 

424 obs_id : `int` 

425 Unique observation id. 

426 flex : `bool` 

427 Include flexible metadata. 

428 

429 Returns 

430 ------- 

431 result_dict : `dict` [ `str`, `Any` ] 

432 Dictionary of key/value pairs for the observation. 

433 

434 Raises 

435 ------ 

436 requests.RequestException 

437 Raised if any kind of connection error occurs. 

438 requests.HTTPError 

439 Raised if a non-successful status is returned. 

440 """ 

441 url = _urljoin( 

442 self.url, 

443 "query", 

444 quote(instrument), 

445 quote(obs_type), 

446 "obs", 

447 quote(str(obs_id)), 

448 ) 

449 return self._handle_get(url, {"flex": "1"} if flex else None) 

450 

451 def insert_flexible_metadata( 

452 self, 

453 instrument: str, 

454 obs_type: str, 

455 obs_id: int, 

456 values: dict[str, Any] | None = None, 

457 *, 

458 allow_update: bool = False, 

459 **kwargs, 

460 ) -> requests.Response: 

461 """Set flexible metadata values for an observation. 

462 

463 Parameters 

464 ---------- 

465 instrument : `str` 

466 Name of the instrument (e.g. ``LATISS``). 

467 obs_type : `str` 

468 Name of the observation type (e.g. ``Exposure``). 

469 obs_id : `int` 

470 Unique observation id. 

471 values : `dict` [ `str`, `Any` ], optional 

472 Dictionary of key/value pairs to add for the observation. 

473 allow_update : `bool`, optional 

474 If ``True``, allow replacement of values of existing keys. 

475 **kwargs : `dict` 

476 Additional key/value pairs, overriding ``values``. 

477 

478 Returns 

479 ------- 

480 response : `requests.Response` 

481 HTTP response from the server, with 200 status for success. 

482 

483 Raises 

484 ------ 

485 ValueError 

486 Raised if no values are provided in ``values`` or kwargs. 

487 requests.RequestException 

488 Raised if any kind of connection error occurs. 

489 requests.HTTPError 

490 Raised if a non-successful status is returned. 

491 """ 

492 if values: 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true

493 values.update(kwargs) 

494 else: 

495 values = kwargs 

496 if not values: 496 ↛ 498line 496 didn't jump to line 498 because the condition on line 496 was always true

497 raise ValueError(f"No values to set for {instrument} {obs_type} {obs_id}") 

498 data = {"values": values} 

499 url = _urljoin( 

500 self.url, 

501 "flex", 

502 quote(instrument), 

503 quote(obs_type), 

504 "obs", 

505 quote(str(obs_id)), 

506 ) 

507 if allow_update: 

508 url += "?u=1" 

509 return self._handle_post(url, data) 

510 

511 def insert( 

512 self, 

513 instrument: str, 

514 table: str, 

515 obs_id: tuple[int, int] | tuple[int, int, int] | int, 

516 values: Mapping[str, Any], 

517 *, 

518 allow_update: bool = False, 

519 **kwargs, 

520 ) -> requests.Response: 

521 """Insert values into a single ConsDB fixed metadata table. 

522 

523 Parameters 

524 ---------- 

525 instrument : `str` 

526 Name of the instrument (e.g. ``LATISS``). 

527 table : `str` 

528 Name of the table to insert into. 

529 obs_id : `tuple`[`int`, `int`] | `tuple`[`int`, `int`, `int`] | `int` 

530 How to address the row to insert: 

531 

532 - `int`: the natural observation id (e.g. ``exposure_id``, 

533 ``ccdexposure_id``), targeting the ``.../obs/{obs_id}`` 

534 endpoint. 

535 - 2-tuple ``(day_obs, seq_num)``: targeting the 

536 ``.../by_seq_num/{day_obs}/{seq_num}`` endpoint, for 

537 exposure-level tables. 

538 - 3-tuple ``(day_obs, seq_num, detector)``: targeting the 

539 ``.../by_seq_num/{day_obs}/{seq_num}/{detector}`` endpoint, 

540 for ccdexposure-level (per-detector) tables. 

541 values : `Mapping` [ `str`, `Any` ] 

542 Dictionary-like mapping of column/value pairs to add for the 

543 observation. 

544 allow_update : `bool`, optional 

545 If ``True``, allow replacement of values of existing columns. 

546 **kwargs : `dict` 

547 Additional column/value pairs, overriding ``values``. 

548 

549 Returns 

550 ------- 

551 response : `requests.Response` 

552 HTTP response from the server, with 200 status for success. 

553 

554 Raises 

555 ------ 

556 ValueError 

557 Raised if no values are provided in ``values`` or kwargs. 

558 requests.RequestException 

559 Raised if any kind of connection error occurs. 

560 requests.HTTPError 

561 Raised if a non-successful status is returned. 

562 """ 

563 # Build a new merged dict to avoid mutating the incoming Mapping. 

564 merged_values: dict[str, Any] = {**(dict(values) if values else {}), **kwargs} 

565 if not merged_values: 

566 raise ValueError(f"No values to insert for {instrument} {table} {obs_id}") 

567 

568 data: dict[str, Any] 

569 if isinstance(obs_id, tuple): 

570 assert len(obs_id) in ( 

571 2, 

572 3, 

573 ), f"obs_id tuple must be (day_obs, seq_num) or (day_obs, seq_num, detector); got {obs_id!r}" 

574 

575 data = {"table": table, "values": merged_values} 

576 url = _urljoin( 

577 self.url, 

578 "insert", 

579 quote(instrument), 

580 quote(table), 

581 "by_seq_num", 

582 # Two segments for exposure-level tables, three when a 

583 # detector is supplied for ccdexposure-level tables. 

584 *(quote(str(part)) for part in obs_id), 

585 ) 

586 else: 

587 data = {"table": table, "obs_id": obs_id, "values": merged_values} 

588 url = _urljoin( 

589 self.url, 

590 "insert", 

591 quote(instrument), 

592 quote(table), 

593 "obs", 

594 quote(str(obs_id)), 

595 ) 

596 if allow_update: 

597 url += "?u=1" 

598 return self._handle_post(url, data) 

599 

600 def insert_multiple( 

601 self, 

602 instrument: str, 

603 table: str, 

604 obs_dict: dict[int, dict[str, Any]], 

605 *, 

606 allow_update=False, 

607 ) -> requests.Response: 

608 """Insert values into a single ConsDB fixed metadata table. 

609 

610 Parameters 

611 ---------- 

612 instrument : `str` 

613 Name of the instrument (e.g. ``LATISS``). 

614 table : `str` 

615 Name of the table to insert into. 

616 obs_dict : `dict` [ `int`, `dict` [ `str`, `Any` ] ] 

617 Dictionary of observation ids, each with a dictionary of 

618 column/value pairs to add for each observation. 

619 allow_update : `bool`, optional 

620 If ``True``, allow replacement of values of existing columns. 

621 

622 Returns 

623 ------- 

624 response : `requests.Response` 

625 HTTP response from the server, with 200 status for success. 

626 

627 Raises 

628 ------ 

629 ValueError 

630 Raised if no values are provided in ``obs_dict``. 

631 requests.RequestException 

632 Raised if any kind of connection error occurs. 

633 requests.HTTPError 

634 Raised if a non-successful status is returned. 

635 """ 

636 if not obs_dict: 

637 raise ValueError(f"No values to insert for {instrument} {table}") 

638 data = {"table": table, "obs_dict": obs_dict} 

639 url = _urljoin( 

640 self.url, 

641 "insert", 

642 quote(instrument), 

643 quote(table), 

644 ) 

645 if allow_update: 

646 url += "?u=1" 

647 return self._handle_post(url, data) 

648 

649 def query(self, query: str) -> Table: 

650 """Query the ConsDB database. 

651 

652 Parameters 

653 ---------- 

654 query : `str` 

655 A SQL query (currently) to the database. 

656 

657 Returns 

658 ------- 

659 result : `Table` 

660 An ``astropy.Table`` containing the query results. 

661 

662 Raises 

663 ------ 

664 requests.RequestException 

665 Raised if any kind of connection error occurs. 

666 requests.HTTPError 

667 Raised if a non-successful status is returned. 

668 

669 Notes 

670 ----- 

671 This is a very general query interface because it is expected that 

672 a wide variety of types of queries will be needed. If some types prove 

673 to be common, syntactic sugar could be added to make them simpler. 

674 """ 

675 url = _urljoin(self.url, "query") 

676 data = {"query": query} 

677 result = self._handle_post(url, data).json() 

678 

679 columns = result.get("columns", []) 

680 if not columns: 680 ↛ 682line 680 didn't jump to line 682 because the condition on line 680 was never true

681 # No result columns 

682 return Table(rows=[]) 

683 

684 rows = result.get("data", []) 

685 if not rows: 

686 # No result rows 

687 return Table(names=columns) 

688 

689 return Table(rows=rows, names=columns) 

690 

691 def schema( 

692 self, instrument: str | None = None, table: str | None = None 

693 ) -> dict[str, tuple[str, str]] | list[str]: 

694 """Retrieve information about ConsDB. 

695 

696 If ``instrument`` and ``table`` are given, return the schema of a 

697 fixed metadata table in ConsDB. 

698 

699 If only ``instrument`` is given, return the names of all tables 

700 for that instrument. 

701 

702 If no arguments are given, return the names of all instruments. 

703 

704 Parameters 

705 ---------- 

706 instrument : `str`, optional 

707 Name of the instrument (e.g. ``LATISS``). 

708 table : `str`, optional 

709 Name of the table to insert into. 

710 

711 Returns 

712 ------- 

713 info : `list` [ `str` ] or `dict` [ `str`, `tuple` [ `str`, `str` ] ] 

714 A list of instrument strings or table names, or else a dict of 

715 columns with values that are tuples containing a data type string 

716 and a documentation string. 

717 

718 Raises 

719 ------ 

720 ValueError 

721 Raised if only ``table`` is given. 

722 requests.RequestException 

723 Raised if any kind of connection error occurs. 

724 requests.HTTPError 

725 Raised if a non-successful status is returned. 

726 

727 Notes 

728 ----- 

729 Fixed metadata data types may use the full database vocabulary, 

730 unlike flexible metadata data types. 

731 """ 

732 if instrument is None: 

733 if table is not None: 733 ↛ 734line 733 didn't jump to line 734 because the condition on line 733 was never true

734 raise ValueError("Must specify instrument if table is given") 

735 url = _urljoin(self.url, "schema") 

736 elif table is None: 736 ↛ 737line 736 didn't jump to line 737 because the condition on line 736 was never true

737 url = _urljoin(self.url, "schema", quote(instrument)) 

738 else: 

739 url = _urljoin(self.url, "schema", quote(instrument), quote(table)) 

740 result = self._handle_get(url) 

741 if instrument is not None and table is not None: 

742 return {key: (str(value[0]), str(value[1])) for key, value in result.items()} 

743 else: 

744 return [str(value) for value in result] 

745 

746 

747def getCcdVisitTableForDay( 

748 client: ConsDbClient, 

749 dayObs: int, 

750 visitTableItems: list[str] | None = None, 

751 detectors: list[int] | None = None, 

752 withZeropoint: bool = False, 

753) -> Table: 

754 """Get the ccdvisit1_quicklook table for a given dayObs. 

755 

756 Parameters 

757 ---------- 

758 client : `ConsDbClient` 

759 The ConsDbClient to use. 

760 dayObs : `int` 

761 The dayObs to query for. 

762 visitTableItems : `list` of `str`, optional 

763 Additional items from the visit1 table to include. 

764 detectors : `list` of `int`, optional 

765 If given, only return rows for these detectors. 

766 withZeropoint : `bool`, optional 

767 If ``True``, only return rows with a non-null zeropoint. 

768 

769 Returns 

770 ------- 

771 table : `astropy.table.Table` 

772 The resulting table. 

773 """ 

774 # ``cvq.*`` already pulls in every column of ccdvisit1_quicklook, so any 

775 # column we also name explicitly from ccdvisit1/visit1 has to be dropped 

776 # from the SELECT when the quicklook table itself already defines it. 

777 # ConsDB has been denormalising identity columns (visit_id, detector, 

778 # seq_num, ...) onto the quicklook tables, and selecting such a column 

779 # twice makes the server return duplicate column names, which astropy then 

780 # refuses to build a Table from. getWideQuicklookTableForDay does the same 

781 # dedup for the visit1 join. 

782 cvqCols = set(client.query("SELECT * FROM cdb_LSSTCam.ccdvisit1_quicklook LIMIT 0").colnames) 

783 

784 visitItems = ["band", "exp_time", "seq_num", "day_obs", "img_type"] 

785 if visitTableItems: 785 ↛ 786line 785 didn't jump to line 786 because the condition on line 785 was never true

786 visitItems += visitTableItems 

787 

788 selectClauses = ["cvq.*"] 

789 claimed = set(cvqCols) # cvq.* already provides all of these names 

790 for col in ("detector", "visit_id"): 

791 if col not in claimed: 

792 selectClauses.append(f"cv.{col}") 

793 claimed.add(col) 

794 for col in visitItems: 

795 if col not in claimed: 

796 selectClauses.append(f"v.{col}") 

797 claimed.add(col) 

798 

799 query = ( 

800 f"SELECT {', '.join(selectClauses)} " 

801 "FROM cdb_LSSTCam.ccdvisit1_quicklook as cvq, " 

802 "cdb_LSSTCam.ccdvisit1 as cv, " 

803 "cdb_LSSTCam.visit1 as v " 

804 ) 

805 where = f"WHERE cvq.ccdvisit_id=cv.ccdvisit_id and cv.visit_id=v.visit_id and v.day_obs={dayObs}" 

806 if detectors: 806 ↛ 807line 806 didn't jump to line 807 because the condition on line 806 was never true

807 where += f" and cv.detector in ({','.join([str(d) for d in detectors])})" 

808 if withZeropoint: 808 ↛ 809line 808 didn't jump to line 809 because the condition on line 808 was never true

809 where += " and cvq.zero_point is not null" 

810 

811 table = client.query(query + where) 

812 return table 

813 

814 

815def columnsEqual(a: Column, b: Column) -> bool: 

816 """Check if two columns are equal, taking masks into account. 

817 

818 Parameters 

819 ---------- 

820 a : `Column` 

821 First column to compare. 

822 b : `Column` 

823 Second column to compare. 

824 

825 Returns 

826 ------- 

827 equal : `bool` 

828 True if the columns are equal, False otherwise. 

829 """ 

830 aArr = np.asanyarray(a) 

831 bArr = np.asanyarray(b) 

832 if aArr.shape != bArr.shape: 

833 return False 

834 

835 aMask = getattr(a, "mask", None) 

836 bMask = getattr(b, "mask", None) 

837 

838 if aMask is None and bMask is None: 

839 return bool(np.all(aArr == bArr)) 

840 

841 if aMask is None: 

842 aMask = np.zeros(aArr.shape, dtype=bool) 

843 if bMask is None: 

844 bMask = np.zeros(bArr.shape, dtype=bool) 

845 

846 aMaskArr = np.asanyarray(aMask) 

847 bMaskArr = np.asanyarray(bMask) 

848 

849 if np.any(aMaskArr ^ bMaskArr): 

850 return False # one masked where the other isn't 

851 

852 present = ~aMaskArr 

853 return bool(np.all(aArr[present] == bArr[present])) 

854 

855 

856def getWideQuicklookTableForDay(client: ConsDbClient, dayObs: int) -> Table: 

857 """Get a wide quicklook table for a given dayObs. 

858 

859 Joins all columns from the visit1 table to the visit1_quicklook table. Note 

860 that the visit1 table already contains all the columns from the exposure 

861 table, and is just keyed by the exposure_id instead of the visit_id. 

862 

863 Parameters 

864 ---------- 

865 client : `ConsDbClient` 

866 The ConsDbClient to use. 

867 dayObs : `int` 

868 The dayObs to query for. 

869 

870 Returns 

871 ------- 

872 table : `astropy.table.Table` 

873 The resulting wide quicklook table. 

874 """ 

875 vqCols = set(client.query("SELECT * FROM cdb_LSSTCam.visit1_quicklook LIMIT 0").colnames) 

876 vCols = set(client.query("SELECT * FROM cdb_LSSTCam.visit1 LIMIT 0").colnames) 

877 

878 vOnlyCols = vCols - vqCols # exclude visit_id and all duplicates 

879 

880 selectClauses = ["vq.*"] + [f"v.{col}" for col in sorted(vOnlyCols)] 

881 

882 query = f""" 

883 SELECT {', '.join(selectClauses)} 

884 FROM cdb_LSSTCam.visit1_quicklook vq 

885 INNER JOIN cdb_LSSTCam.visit1 v USING (visit_id) 

886 WHERE vq.day_obs = {dayObs} 

887 """ 

888 return client.query(query)