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

195 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-17 10:13 +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 # Use ``r`` rather than ``e.response``: raise_for_status always 

82 # attaches this response, but its type is Response | None. 

83 json_data = r.json() 

84 e.add_note(str(json_data)) 

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

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

87 except requests.JSONDecodeError: 

88 pass 

89 raise e 

90 

91 

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

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

94 

95 Set new url in response and return response 

96 

97 Parameters 

98 ---------- 

99 resp : `requests.Response` 

100 The response that could contain a URL with tokens 

101 """ 

102 url = urlparse(resp.url) 

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

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

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

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

107 return resp 

108 

109 

110@dataclass 

111class FlexibleMetadataInfo: 

112 """Description of a flexible metadata value. 

113 

114 Parameters 

115 ---------- 

116 dtype : `str` 

117 Data type of the flexible metadata value. 

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

119 doc : `str` 

120 Documentation string. 

121 unit : `str`, optional 

122 Unit of the value. 

123 ucd : `str`, optional 

124 IVOA Unified Content Descriptor 

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

126 """ 

127 

128 dtype: str 

129 doc: str 

130 unit: str | None = None 

131 ucd: str | None = None 

132 

133 

134class ConsDbClient: 

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

136 

137 This library provides a basic interface for using flexible metadata 

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

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

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

141 ConsDB tables. 

142 

143 Parameters 

144 ---------- 

145 url : `str` | `None` 

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

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

148 service). 

149 token : `str` | `None` 

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

151 connect_timeout : `float` | `None` 

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

153 `DEFAULT_CONNECT_TIMEOUT`. 

154 read_timeout : `float` | `None` 

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

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

157 rarely what you want. 

158 

159 Notes 

160 ----- 

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

162 avoids having a dependency on database drivers. 

163 

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

165 """ 

166 

167 def __init__( 

168 self, 

169 url: str | None = None, 

170 token: str | None = None, 

171 connect_timeout: float | None = DEFAULT_CONNECT_TIMEOUT, 

172 read_timeout: float | None = DEFAULT_READ_TIMEOUT, 

173 ): 

174 self.session = requests.Session() 

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

176 self.connect_timeout = connect_timeout 

177 self.read_timeout = read_timeout 

178 

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

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

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

182 

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

184 

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

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

187 else: 

188 self.url = url 

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

190 

191 @property 

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

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

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

195 """ 

196 return (self.connect_timeout, self.read_timeout) 

197 

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

199 """Submit GET requests to the server. 

200 

201 Parameters 

202 ---------- 

203 url : `str` 

204 URL to GET. 

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

206 Query parameters to attach to the URL. 

207 

208 Raises 

209 ------ 

210 requests.RequestException 

211 Raised if any kind of connection error occurs. 

212 requests.Timeout 

213 Raised if the request exceeds the client timeout. 

214 requests.HTTPError 

215 Raised if a non-successful status is returned. 

216 requests.JSONDecodeError 

217 Raised if the result does not decode as JSON. 

218 

219 Returns 

220 ------- 

221 result : `Any` 

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

223 """ 

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

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

226 _check_status(response) 

227 return response.json() 

228 

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

230 """Submit POST requests to the server. 

231 

232 Parameters 

233 ---------- 

234 url : `str` 

235 URL to POST. 

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

237 Key/value pairs of data to POST. 

238 

239 Raises 

240 ------ 

241 requests.RequestException 

242 Raised if any kind of connection error occurs. 

243 requests.Timeout 

244 Raised if the request exceeds the client timeout. 

245 requests.HTTPError 

246 Raised if a non-successful status is returned. 

247 

248 Returns 

249 ------- 

250 result : `requests.Response` 

251 The raw Web service result object. 

252 """ 

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

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

255 _check_status(response) 

256 return response 

257 

258 @staticmethod 

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

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

261 

262 Each instrument and observation type made with that instrument can 

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

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

265 

266 Parameters 

267 ---------- 

268 instrument : `str` 

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

270 obs_type : `str` 

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

272 

273 Returns 

274 ------- 

275 table_name : `str` 

276 Name of the appropriate flexible metadata table. 

277 """ 

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

279 

280 @staticmethod 

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

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

283 

284 Each instrument has its own namespace in the ConsDB. 

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

286 round-trip to the server. 

287 

288 Parameters 

289 ---------- 

290 instrument : `str` 

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

292 

293 Returns 

294 ------- 

295 namespace_name : `str` 

296 Name of the appropriate namespace 

297 """ 

298 return f"cdb_{instrument}" 

299 

300 def add_flexible_metadata_key( 

301 self, 

302 instrument: str, 

303 obs_type: str, 

304 key: str, 

305 dtype: str, 

306 doc: str, 

307 unit: str | None = None, 

308 ucd: str | None = None, 

309 ) -> requests.Response: 

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

311 

312 Parameters 

313 ---------- 

314 instrument : `str` 

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

316 obs_type : `str` 

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

318 key : `str` 

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

320 dtype : `str` 

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

322 doc : `str` 

323 Documentation string for the key. 

324 unit : `str`, optional 

325 Unit for the value. Should be from the IVOA 

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

327 ucd : `str`, optional 

328 IVOA Unified Content Descriptor 

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

330 

331 Returns 

332 ------- 

333 response : `requests.Response` 

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

335 

336 Raises 

337 ------ 

338 requests.RequestException 

339 Raised if any kind of connection error occurs. 

340 requests.HTTPError 

341 Raised if a non-successful status is returned. 

342 """ 

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

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

345 data["unit"] = unit 

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

347 data["ucd"] = ucd 

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

349 return self._handle_post(url, data) 

350 

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

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

353 

354 Parameters 

355 ---------- 

356 instrument : `str` 

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

358 obs_type : `str` 

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

360 

361 Returns 

362 ------- 

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

364 Dict of keys and information values. 

365 

366 Raises 

367 ------ 

368 requests.RequestException 

369 Raised if any kind of connection error occurs. 

370 requests.HTTPError 

371 Raised if a non-successful status is returned. 

372 """ 

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

374 result = self._handle_get(url) 

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

376 

377 def get_flexible_metadata( 

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

379 ) -> dict[str, Any]: 

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

381 

382 Parameters 

383 ---------- 

384 instrument : `str` 

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

386 obs_type : `str` 

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

388 obs_id : `int` 

389 Unique observation id. 

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

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

392 

393 Returns 

394 ------- 

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

396 Dictionary of key/value pairs for the observation. 

397 

398 Raises 

399 ------ 

400 requests.RequestException 

401 Raised if any kind of connection error occurs. 

402 requests.HTTPError 

403 Raised if a non-successful status is returned. 

404 """ 

405 url = _urljoin( 

406 self.url, 

407 "flex", 

408 quote(instrument), 

409 quote(obs_type), 

410 "obs", 

411 quote(str(obs_id)), 

412 ) 

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

414 

415 def get_all_metadata( 

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

417 ) -> dict[str, Any]: 

418 """Get all metadata for an observation. 

419 

420 Parameters 

421 ---------- 

422 instrument : `str` 

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

424 obs_type : `str` 

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

426 obs_id : `int` 

427 Unique observation id. 

428 flex : `bool` 

429 Include flexible metadata. 

430 

431 Returns 

432 ------- 

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

434 Dictionary of key/value pairs for the observation. 

435 

436 Raises 

437 ------ 

438 requests.RequestException 

439 Raised if any kind of connection error occurs. 

440 requests.HTTPError 

441 Raised if a non-successful status is returned. 

442 """ 

443 url = _urljoin( 

444 self.url, 

445 "query", 

446 quote(instrument), 

447 quote(obs_type), 

448 "obs", 

449 quote(str(obs_id)), 

450 ) 

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

452 

453 def insert_flexible_metadata( 

454 self, 

455 instrument: str, 

456 obs_type: str, 

457 obs_id: int, 

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

459 *, 

460 allow_update: bool = False, 

461 **kwargs: Any, 

462 ) -> requests.Response: 

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

464 

465 Parameters 

466 ---------- 

467 instrument : `str` 

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

469 obs_type : `str` 

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

471 obs_id : `int` 

472 Unique observation id. 

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

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

475 allow_update : `bool`, optional 

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

477 **kwargs : `dict` 

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

479 

480 Returns 

481 ------- 

482 response : `requests.Response` 

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

484 

485 Raises 

486 ------ 

487 ValueError 

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

489 requests.RequestException 

490 Raised if any kind of connection error occurs. 

491 requests.HTTPError 

492 Raised if a non-successful status is returned. 

493 """ 

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

495 values.update(kwargs) 

496 else: 

497 values = kwargs 

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

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

500 data = {"values": values} 

501 url = _urljoin( 

502 self.url, 

503 "flex", 

504 quote(instrument), 

505 quote(obs_type), 

506 "obs", 

507 quote(str(obs_id)), 

508 ) 

509 if allow_update: 

510 url += "?u=1" 

511 return self._handle_post(url, data) 

512 

513 def insert( 

514 self, 

515 instrument: str, 

516 table: str, 

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

518 values: Mapping[str, Any], 

519 *, 

520 allow_update: bool = False, 

521 **kwargs: Any, 

522 ) -> requests.Response: 

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

524 

525 Parameters 

526 ---------- 

527 instrument : `str` 

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

529 table : `str` 

530 Name of the table to insert into. 

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

532 How to address the row to insert: 

533 

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

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

536 endpoint. 

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

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

539 exposure-level tables. 

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

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

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

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

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

545 observation. 

546 allow_update : `bool`, optional 

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

548 **kwargs : `dict` 

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

550 

551 Returns 

552 ------- 

553 response : `requests.Response` 

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

555 

556 Raises 

557 ------ 

558 ValueError 

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

560 requests.RequestException 

561 Raised if any kind of connection error occurs. 

562 requests.HTTPError 

563 Raised if a non-successful status is returned. 

564 """ 

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

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

567 if not merged_values: 

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

569 

570 data: dict[str, Any] 

571 if isinstance(obs_id, tuple): 

572 assert len(obs_id) in ( 

573 2, 

574 3, 

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

576 

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

578 url = _urljoin( 

579 self.url, 

580 "insert", 

581 quote(instrument), 

582 quote(table), 

583 "by_seq_num", 

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

585 # detector is supplied for ccdexposure-level tables. 

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

587 ) 

588 else: 

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

590 url = _urljoin( 

591 self.url, 

592 "insert", 

593 quote(instrument), 

594 quote(table), 

595 "obs", 

596 quote(str(obs_id)), 

597 ) 

598 if allow_update: 

599 url += "?u=1" 

600 return self._handle_post(url, data) 

601 

602 def insert_multiple( 

603 self, 

604 instrument: str, 

605 table: str, 

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

607 *, 

608 allow_update: bool = False, 

609 ) -> requests.Response: 

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

611 

612 Parameters 

613 ---------- 

614 instrument : `str` 

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

616 table : `str` 

617 Name of the table to insert into. 

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

619 Dictionary of observation ids, each with a dictionary of 

620 column/value pairs to add for each observation. 

621 allow_update : `bool`, optional 

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

623 

624 Returns 

625 ------- 

626 response : `requests.Response` 

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

628 

629 Raises 

630 ------ 

631 ValueError 

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

633 requests.RequestException 

634 Raised if any kind of connection error occurs. 

635 requests.HTTPError 

636 Raised if a non-successful status is returned. 

637 """ 

638 if not obs_dict: 

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

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

641 url = _urljoin( 

642 self.url, 

643 "insert", 

644 quote(instrument), 

645 quote(table), 

646 ) 

647 if allow_update: 

648 url += "?u=1" 

649 return self._handle_post(url, data) 

650 

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

652 """Query the ConsDB database. 

653 

654 Parameters 

655 ---------- 

656 query : `str` 

657 A SQL query (currently) to the database. 

658 

659 Returns 

660 ------- 

661 result : `Table` 

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

663 

664 Raises 

665 ------ 

666 requests.RequestException 

667 Raised if any kind of connection error occurs. 

668 requests.HTTPError 

669 Raised if a non-successful status is returned. 

670 

671 Notes 

672 ----- 

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

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

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

676 """ 

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

678 data = {"query": query} 

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

680 

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

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

683 # No result columns 

684 return Table(rows=[]) 

685 

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

687 if not rows: 

688 # No result rows 

689 return Table(names=columns) 

690 

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

692 

693 def schema( 

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

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

696 """Retrieve information about ConsDB. 

697 

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

699 fixed metadata table in ConsDB. 

700 

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

702 for that instrument. 

703 

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

705 

706 Parameters 

707 ---------- 

708 instrument : `str`, optional 

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

710 table : `str`, optional 

711 Name of the table to insert into. 

712 

713 Returns 

714 ------- 

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

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

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

718 and a documentation string. 

719 

720 Raises 

721 ------ 

722 ValueError 

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

724 requests.RequestException 

725 Raised if any kind of connection error occurs. 

726 requests.HTTPError 

727 Raised if a non-successful status is returned. 

728 

729 Notes 

730 ----- 

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

732 unlike flexible metadata data types. 

733 """ 

734 if instrument is None: 

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

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

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

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

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

740 else: 

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

742 result = self._handle_get(url) 

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

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

745 else: 

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

747 

748 

749def getCcdVisitTableForDay( 

750 client: ConsDbClient, 

751 dayObs: int, 

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

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

754 withZeropoint: bool = False, 

755) -> Table: 

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

757 

758 Parameters 

759 ---------- 

760 client : `ConsDbClient` 

761 The ConsDbClient to use. 

762 dayObs : `int` 

763 The dayObs to query for. 

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

765 Additional items from the visit1 table to include. 

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

767 If given, only return rows for these detectors. 

768 withZeropoint : `bool`, optional 

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

770 

771 Returns 

772 ------- 

773 table : `astropy.table.Table` 

774 The resulting table. 

775 """ 

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

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

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

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

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

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

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

783 # dedup for the visit1 join. 

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

785 

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

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

788 visitItems += visitTableItems 

789 

790 selectClauses = ["cvq.*"] 

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

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

793 if col not in claimed: 

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

795 claimed.add(col) 

796 for col in visitItems: 

797 if col not in claimed: 

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

799 claimed.add(col) 

800 

801 query = ( 

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

803 "FROM cdb_LSSTCam.ccdvisit1_quicklook as cvq, " 

804 "cdb_LSSTCam.ccdvisit1 as cv, " 

805 "cdb_LSSTCam.visit1 as v " 

806 ) 

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

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

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

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

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

812 

813 table = client.query(query + where) 

814 return table 

815 

816 

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

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

819 

820 Parameters 

821 ---------- 

822 a : `Column` 

823 First column to compare. 

824 b : `Column` 

825 Second column to compare. 

826 

827 Returns 

828 ------- 

829 equal : `bool` 

830 True if the columns are equal, False otherwise. 

831 """ 

832 aArr = np.asanyarray(a) 

833 bArr = np.asanyarray(b) 

834 if aArr.shape != bArr.shape: 

835 return False 

836 

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

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

839 

840 if aMask is None and bMask is None: 

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

842 

843 if aMask is None: 

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

845 if bMask is None: 

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

847 

848 aMaskArr = np.asanyarray(aMask) 

849 bMaskArr = np.asanyarray(bMask) 

850 

851 if np.any(aMaskArr ^ bMaskArr): 

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

853 

854 present = ~aMaskArr 

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

856 

857 

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

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

860 

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

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

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

864 

865 Parameters 

866 ---------- 

867 client : `ConsDbClient` 

868 The ConsDbClient to use. 

869 dayObs : `int` 

870 The dayObs to query for. 

871 

872 Returns 

873 ------- 

874 table : `astropy.table.Table` 

875 The resulting wide quicklook table. 

876 """ 

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

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

879 

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

881 

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

883 

884 query = f""" 

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

886 FROM cdb_LSSTCam.visit1_quicklook vq 

887 INNER JOIN cdb_LSSTCam.visit1 v USING (visit_id) 

888 WHERE vq.day_obs = {dayObs} 

889 """ 

890 return client.query(query)