From 5cb6db2676a54edb50734a1aafd37b169dcaefa0 Mon Sep 17 00:00:00 2001 From: Juan Marulanda Date: Wed, 8 Apr 2026 11:14:54 -0400 Subject: [PATCH 1/8] Added function that searches for proposals by PI name --- nslsii/proposals.py | 85 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 nslsii/proposals.py diff --git a/nslsii/proposals.py b/nslsii/proposals.py new file mode 100644 index 0000000..def3b39 --- /dev/null +++ b/nslsii/proposals.py @@ -0,0 +1,85 @@ +from tiled.queries import Key, Regex +from pprint import pprint + + +def get_parent_directory(catalog): + """ + Helper function to extract the parent directory from the catalog. + This function iterates through the documents in the catalog until it finds a resource document. + It is assuming that, sometimes, scans are aborted and a resource document might not be present in the catalog. + Parameters + ---------- + catalog : tiled.client.Catalog + The tiled catalog to extract the parent directory from. + Returns + ------- + str + The parent directory path extracted from the catalog. + """ + + for v in catalog.values(): + docs = [*v.documents()] + for doc_tuple in docs: + if doc_tuple[0] == "resource": + before, match, _ = doc_tuple[1]["root"].partition("proposals") + parent_directory = before + match + return parent_directory + + +def find_proposals(client, pi_name, cycle=None, show_title=True): + """ + Find proposals for a given PI name and optionally filter by cycle. + Parameters + ---------- + client : tiled.client.Client + The tiled client to use for querying the data. + pi_name : str + The full or first name of the principal investigator (PI) to search for. + cycle : str, optional + The cycle to filter proposals by. + show_title : bool, optional + Whether to display the title of the proposals. + + Example + -------- + >>> find_proposals(tiled_reading_client, 'Smith', cycle='2023-2') + """ + + results = client.search(Regex("proposal.pi_name", f"^{pi_name}")) + if cycle is not None: + results = results.search(Key("cycle") == cycle) + proposal_distinct = results.distinct("proposal.proposal_id", counts=True) + + proposal_info = {} + for item in proposal_distinct["metadata"]["start.proposal.proposal_id"]: + if item["count"] > 0: + proposal_results = results.search( + Key("proposal.proposal_id") == item["value"] + ) + scan_single = proposal_results.values().first() + parent_path = get_parent_directory(proposal_results) + + proposal_info[item["value"]] = {"pi_name": pi_name} + if cycle is not None: + proposal_info[item["value"]]["proposal_info"] = { + "cycle": cycle, + "total": item["count"], + "path": f"{parent_path}/{cycle}/pass-{item['value']}/", + } + else: + cycle_distinct = proposal_results.distinct("cycle", counts=True) + proposal_info[item["value"]]["proposal_info"] = [ + { + "cycle": elem["value"], + "total": elem["count"], + "path": f"{parent_path}/{elem['value']}/pass-{item['value']}/", + } + for elem in cycle_distinct["metadata"]["start.cycle"] + ] + + if show_title: + proposal_info[item["value"]]["title"] = scan_single.start["proposal"][ + "title" + ] + + pprint(proposal_info) From 2cbc80689eab63fbebb804dab4254eeb297f8656 Mon Sep 17 00:00:00 2001 From: Juan Marulanda Date: Wed, 8 Apr 2026 11:58:57 -0400 Subject: [PATCH 2/8] Added condition for case with empty results --- nslsii/proposals.py | 55 +++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/nslsii/proposals.py b/nslsii/proposals.py index def3b39..5199aa2 100644 --- a/nslsii/proposals.py +++ b/nslsii/proposals.py @@ -51,35 +51,36 @@ def find_proposals(client, pi_name, cycle=None, show_title=True): proposal_distinct = results.distinct("proposal.proposal_id", counts=True) proposal_info = {} - for item in proposal_distinct["metadata"]["start.proposal.proposal_id"]: - if item["count"] > 0: - proposal_results = results.search( - Key("proposal.proposal_id") == item["value"] - ) - scan_single = proposal_results.values().first() - parent_path = get_parent_directory(proposal_results) + if len(proposal_distinct["metadata"]) > 0: + for item in proposal_distinct["metadata"]["start.proposal.proposal_id"]: + if item["count"] > 0: + proposal_results = results.search( + Key("proposal.proposal_id") == item["value"] + ) + scan_single = proposal_results.values().first() + parent_path = get_parent_directory(proposal_results) - proposal_info[item["value"]] = {"pi_name": pi_name} - if cycle is not None: - proposal_info[item["value"]]["proposal_info"] = { - "cycle": cycle, - "total": item["count"], - "path": f"{parent_path}/{cycle}/pass-{item['value']}/", - } - else: - cycle_distinct = proposal_results.distinct("cycle", counts=True) - proposal_info[item["value"]]["proposal_info"] = [ - { - "cycle": elem["value"], - "total": elem["count"], - "path": f"{parent_path}/{elem['value']}/pass-{item['value']}/", + proposal_info[item["value"]] = {"pi_name": pi_name} + if cycle is not None: + proposal_info[item["value"]]["proposal_info"] = { + "cycle": cycle, + "total": item["count"], + "path": f"{parent_path}/{cycle}/pass-{item['value']}/", } - for elem in cycle_distinct["metadata"]["start.cycle"] - ] + else: + cycle_distinct = proposal_results.distinct("cycle", counts=True) + proposal_info[item["value"]]["proposal_info"] = [ + { + "cycle": elem["value"], + "total": elem["count"], + "path": f"{parent_path}/{elem['value']}/pass-{item['value']}/", + } + for elem in cycle_distinct["metadata"]["start.cycle"] + ] - if show_title: - proposal_info[item["value"]]["title"] = scan_single.start["proposal"][ - "title" - ] + if show_title: + proposal_info[item["value"]]["title"] = scan_single.start[ + "proposal" + ]["title"] pprint(proposal_info) From c21cfaa49438d7bb3fe35018a19ea43edb4f0d09 Mon Sep 17 00:00:00 2001 From: Juan Marulanda Date: Wed, 8 Apr 2026 12:08:00 -0400 Subject: [PATCH 3/8] Fixed display of PI Full Name --- nslsii/proposals.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nslsii/proposals.py b/nslsii/proposals.py index 5199aa2..756eca5 100644 --- a/nslsii/proposals.py +++ b/nslsii/proposals.py @@ -60,7 +60,9 @@ def find_proposals(client, pi_name, cycle=None, show_title=True): scan_single = proposal_results.values().first() parent_path = get_parent_directory(proposal_results) - proposal_info[item["value"]] = {"pi_name": pi_name} + proposal_info[item["value"]] = { + "pi_name": scan_single.start["proposal"]["pi_name"] + } if cycle is not None: proposal_info[item["value"]]["proposal_info"] = { "cycle": cycle, From a68ee1d97c680e76c977d33529771d46a5e37bfa Mon Sep 17 00:00:00 2001 From: Juan Marulanda Date: Wed, 8 Apr 2026 13:24:02 -0400 Subject: [PATCH 4/8] whitespace --- nslsii/proposals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nslsii/proposals.py b/nslsii/proposals.py index 756eca5..dddae53 100644 --- a/nslsii/proposals.py +++ b/nslsii/proposals.py @@ -42,7 +42,7 @@ def find_proposals(client, pi_name, cycle=None, show_title=True): Example -------- - >>> find_proposals(tiled_reading_client, 'Smith', cycle='2023-2') + >>> find_proposals(tiled_reading_client, 'Smith', cycle='2026-1') """ results = client.search(Regex("proposal.pi_name", f"^{pi_name}")) From 3e6983d033a5870a3bbdafbd6501d3e7d1653098 Mon Sep 17 00:00:00 2001 From: Juan Marulanda Date: Wed, 8 Apr 2026 18:41:14 -0400 Subject: [PATCH 5/8] Added conditional for sql database --- nslsii/proposals.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/nslsii/proposals.py b/nslsii/proposals.py index dddae53..b3f4dfd 100644 --- a/nslsii/proposals.py +++ b/nslsii/proposals.py @@ -1,4 +1,4 @@ -from tiled.queries import Key, Regex +from tiled.queries import Key, Like, Regex from pprint import pprint @@ -34,7 +34,7 @@ def find_proposals(client, pi_name, cycle=None, show_title=True): client : tiled.client.Client The tiled client to use for querying the data. pi_name : str - The full or first name of the principal investigator (PI) to search for. + The full or partial (First or Last) name of the principal investigator (PI) to search for. cycle : str, optional The cycle to filter proposals by. show_title : bool, optional @@ -45,10 +45,16 @@ def find_proposals(client, pi_name, cycle=None, show_title=True): >>> find_proposals(tiled_reading_client, 'Smith', cycle='2026-1') """ - results = client.search(Regex("proposal.pi_name", f"^{pi_name}")) - if cycle is not None: - results = results.search(Key("cycle") == cycle) - proposal_distinct = results.distinct("proposal.proposal_id", counts=True) + if client.is_sql: + results = client.search(Like("start.proposal.pi_name", f"%{pi_name}%")) + if cycle is not None: + results = results.search(Key("cycle") == cycle) + proposal_distinct = results.distinct("start.proposal.proposal_id", counts=True) + else: + results = client.search(Regex("proposal.pi_name", f"{pi_name}")) + if cycle is not None: + results = results.search(Key("cycle") == cycle) + proposal_distinct = results.distinct("proposal.proposal_id", counts=True) proposal_info = {} if len(proposal_distinct["metadata"]) > 0: @@ -70,7 +76,12 @@ def find_proposals(client, pi_name, cycle=None, show_title=True): "path": f"{parent_path}/{cycle}/pass-{item['value']}/", } else: - cycle_distinct = proposal_results.distinct("cycle", counts=True) + if client.is_sql: + cycle_distinct = proposal_results.distinct( + "start.cycle", counts=True + ) + else: + cycle_distinct = proposal_results.distinct("cycle", counts=True) proposal_info[item["value"]]["proposal_info"] = [ { "cycle": elem["value"], From 7f9ec8b9cd18240b1b0f3232fd87571725a945f4 Mon Sep 17 00:00:00 2001 From: Juan Marulanda Date: Tue, 14 Apr 2026 11:43:43 -0400 Subject: [PATCH 6/8] Added optional queries and improved use of sql prefix --- nslsii/proposals.py | 71 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/nslsii/proposals.py b/nslsii/proposals.py index b3f4dfd..9407178 100644 --- a/nslsii/proposals.py +++ b/nslsii/proposals.py @@ -1,4 +1,4 @@ -from tiled.queries import Key, Like, Regex +from tiled.queries import Comparison, Eq, Key, Like, NotEq, Regex from pprint import pprint @@ -26,7 +26,7 @@ def get_parent_directory(catalog): return parent_directory -def find_proposals(client, pi_name, cycle=None, show_title=True): +def find_proposals(client, pi_name, cycle=None, optional_queries=None, show_title=True): """ Find proposals for a given PI name and optionally filter by cycle. Parameters @@ -37,24 +37,66 @@ def find_proposals(client, pi_name, cycle=None, show_title=True): The full or partial (First or Last) name of the principal investigator (PI) to search for. cycle : str, optional The cycle to filter proposals by. + optional_queries : dict, optional + Additional query parameters to filter proposals based on specific keys and values in the start document. + If the value is a string, it will be treated as an exact match. + If the value is a number, include the operator in the value as a tuple, for example: + {"start.exposure_time": (">", 10)}. show_title : bool, optional Whether to display the title of the proposals. Example -------- - >>> find_proposals(tiled_reading_client, 'Smith', cycle='2026-1') + >>> find_proposals( + tiled_reading_client, + "Smith", + cycle="2026-1", + optional_queries={"exposure_time": (">", 10)}, + show_title=True, + ) """ + operation_mapping = { + ">": "gt", + "<": "lt", + ">=": "ge", + "<=": "le", + } + + sql_prefix = "start." if client.is_sql else "" + if client.is_sql: - results = client.search(Like("start.proposal.pi_name", f"%{pi_name}%")) - if cycle is not None: - results = results.search(Key("cycle") == cycle) - proposal_distinct = results.distinct("start.proposal.proposal_id", counts=True) + results = client.search(Like(f"{sql_prefix}proposal.pi_name", f"%{pi_name}%")) else: results = client.search(Regex("proposal.pi_name", f"{pi_name}")) - if cycle is not None: - results = results.search(Key("cycle") == cycle) - proposal_distinct = results.distinct("proposal.proposal_id", counts=True) + if cycle is not None: + results = results.search(Key("cycle") == cycle) + if optional_queries is not None: + for key, value in optional_queries.items(): + if isinstance(value, str): + results = results.search(Key(key) == value) + elif isinstance(value, tuple) and len(value) == 2: + operator, operand = value + if operator == "==": + results = results.search(Eq(key, operand)) + elif operator == "!=": + results = results.search(NotEq(key, operand)) + elif operator in operation_mapping: + results = results.search( + Comparison(operation_mapping[operator], key, operand) + ) + else: + raise ValueError( + f"Unsupported operator '{operator}' in optional_queries." + ) + else: + raise ValueError( + f"Invalid value for optional_query key '{key}': {value}" + ) + + proposal_distinct = results.distinct( + f"{sql_prefix}proposal.proposal_id", counts=True + ) proposal_info = {} if len(proposal_distinct["metadata"]) > 0: @@ -76,12 +118,9 @@ def find_proposals(client, pi_name, cycle=None, show_title=True): "path": f"{parent_path}/{cycle}/pass-{item['value']}/", } else: - if client.is_sql: - cycle_distinct = proposal_results.distinct( - "start.cycle", counts=True - ) - else: - cycle_distinct = proposal_results.distinct("cycle", counts=True) + cycle_distinct = proposal_results.distinct( + f"{sql_prefix}cycle", counts=True + ) proposal_info[item["value"]]["proposal_info"] = [ { "cycle": elem["value"], From 80b0b2e2ecfd1d66101baeaaf64f4024cab05eb6 Mon Sep 17 00:00:00 2001 From: Juan Marulanda Date: Tue, 14 Apr 2026 13:52:32 -0400 Subject: [PATCH 7/8] Added more intructions to docstring --- nslsii/proposals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nslsii/proposals.py b/nslsii/proposals.py index 9407178..8c64594 100644 --- a/nslsii/proposals.py +++ b/nslsii/proposals.py @@ -41,7 +41,7 @@ def find_proposals(client, pi_name, cycle=None, optional_queries=None, show_titl Additional query parameters to filter proposals based on specific keys and values in the start document. If the value is a string, it will be treated as an exact match. If the value is a number, include the operator in the value as a tuple, for example: - {"start.exposure_time": (">", 10)}. + {"start.exposure_time": (">", 10)}. Supported operators are "==", "!=", ">", "<", ">=", and "<=". show_title : bool, optional Whether to display the title of the proposals. From 89c0b70e8e2e56afd583320219ff92cf2a2b2655 Mon Sep 17 00:00:00 2001 From: Juan Marulanda Date: Tue, 2 Jun 2026 14:04:21 -0400 Subject: [PATCH 8/8] Added type hints to function argurments --- nslsii/proposals.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/nslsii/proposals.py b/nslsii/proposals.py index 8c64594..7c03d45 100644 --- a/nslsii/proposals.py +++ b/nslsii/proposals.py @@ -1,16 +1,17 @@ from tiled.queries import Comparison, Eq, Key, Like, NotEq, Regex from pprint import pprint +from bluesky_tiled_plugins.clients.catalog_of_bluesky_runs import CatalogOfBlueskyRuns -def get_parent_directory(catalog): +def get_parent_directory(catalog: CatalogOfBlueskyRuns) -> str: """ Helper function to extract the parent directory from the catalog. This function iterates through the documents in the catalog until it finds a resource document. It is assuming that, sometimes, scans are aborted and a resource document might not be present in the catalog. Parameters ---------- - catalog : tiled.client.Catalog - The tiled catalog to extract the parent directory from. + catalog : CatalogOfBlueskyRuns + The catalog of Bluesky runs to extract the parent directory from. Returns ------- str @@ -26,13 +27,13 @@ def get_parent_directory(catalog): return parent_directory -def find_proposals(client, pi_name, cycle=None, optional_queries=None, show_title=True): +def find_proposals(client: CatalogOfBlueskyRuns, pi_name: str, cycle: str = None, optional_queries: dict = None, show_title: bool = True): """ Find proposals for a given PI name and optionally filter by cycle. Parameters ---------- - client : tiled.client.Client - The tiled client to use for querying the data. + client : CatalogOfBlueskyRuns + The catalog of Bluesky runs to use for querying the data. pi_name : str The full or partial (First or Last) name of the principal investigator (PI) to search for. cycle : str, optional @@ -64,7 +65,6 @@ def find_proposals(client, pi_name, cycle=None, optional_queries=None, show_titl } sql_prefix = "start." if client.is_sql else "" - if client.is_sql: results = client.search(Like(f"{sql_prefix}proposal.pi_name", f"%{pi_name}%")) else: