diff --git a/.github/workflows/code-quality-main.yaml b/.github/workflows/code-quality-main.yaml
index d3369699..691b47c7 100644
--- a/.github/workflows/code-quality-main.yaml
+++ b/.github/workflows/code-quality-main.yaml
@@ -13,10 +13,16 @@ jobs:

     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4

-      - name: Set up Python
-        uses: actions/setup-python@v3
+      - name: Set up Python 3.10
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.10"
+
+      - name: Install packages
+        run: |
+          pip install .[dev]

       - name: Run pre-commits
-        uses: pre-commit/action@v3.0.0
+        uses: pre-commit/action@v3.0.1
diff --git a/.github/workflows/code-quality-pr.yaml b/.github/workflows/code-quality-pr.yaml
index 7ca7753b..bee2e11a 100644
--- a/.github/workflows/code-quality-pr.yaml
+++ b/.github/workflows/code-quality-pr.yaml
@@ -16,10 +16,16 @@ jobs:

     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4

-      - name: Set up Python
-        uses: actions/setup-python@v3
+      - name: Set up Python 3.10
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.10"
+
+      - name: Install packages
+        run: |
+          pip install .[dev]

       - name: Find modified files
         id: file_changes
@@ -31,6 +37,6 @@ jobs:
         run: echo '${{ steps.file_changes.outputs.files}}'

       - name: Run pre-commits
-        uses: pre-commit/action@v3.0.0
+        uses: pre-commit/action@v3.0.1
         with:
           extra_args: --files ${{ steps.file_changes.outputs.files}}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 591bc53c..82105177 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,7 +5,7 @@ exclude: "to_organize"

 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.4.0
+    rev: v5.0.0
     hooks:
       # list of supported hooks: https://pre-commit.com/hooks.html
       - id: trailing-whitespace
diff --git a/README.md b/README.md
index eb335ea8..a375aefa 100644
--- a/README.md
+++ b/README.md
@@ -217,15 +217,18 @@ normal_spo2:
 Fields for a "plain" predicate:

 - `code` (required): Must be one of the following:
-  - a string with `//` sequence separating the column name and column value.
-  - a list of strings as above in the form of {any: \[???, ???, ...\]}, which will match any of the listed codes.
-  - a regex in the form of {regex: "???"}, which will match any code that matches that regular expression.
+  - a string matching values in a column named `code` (for `MEDS` only).
+  - a string with a `//` sequence separating the column name and the matching column value (for `ESGPT` only).
+  - a list of strings as above in the form of `{any: \[???, ???, ...\]}` (or the corresponding expanded indented `YAML` format), which will match any of the listed codes.
+  - a regex in the form of `{regex: "???"}` (or the corresponding expanded indented `YAML` format), which will match any code that matches that regular expression.
 - `value_min` (optional): Must be float or integer specifying the minimum value of the predicate, if the variable is presented as numerical values.
 - `value_max` (optional): Must be float or integer specifying the maximum value of the predicate, if the variable is presented as numerical values.
 - `value_min_inclusive` (optional): Must be a boolean specifying whether `value_min` is inclusive or not.
 - `value_max_inclusive` (optional): Must be a boolean specifying whether `value_max` is inclusive or not.
 - `other_cols` (optional): Must be a 1-to-1 dictionary of column name and column value, which places additional constraints on further columns.

+**Note**: For memory optimization, we strongly recommend using either the List of Values or Regular Expression formats whenever possible, especially when needing to match multiple values. Defining each code as an individual string will increase memory usage significantly, as each code generates a separate predicate column. Using a list or regex consolidates multiple matching codes under a single column, reducing the overall memory footprint.
+
 #### Derived Predicates

 "Derived" predicates combine existing "plain" predicates using `and` / `or` keywords and have exactly 1 required `expr` field: For instance, the following defines a predicate representing either death or discharge (by combining "plain" predicates of `death` and `discharge`):
diff --git a/docs/source/configuration.md b/docs/source/configuration.md
index ca83871a..6571ae9e 100644
--- a/docs/source/configuration.md
+++ b/docs/source/configuration.md
@@ -49,20 +49,29 @@ These configs consist of the following four fields:
   The field can additionally be a dictionary with either a `regex` key and the value being a regular
   expression (satisfied if the regular expression evaluates to True), or a `any` key and the value being a
   list of strings (satisfied if there is an occurrence for any code in the list).
+
+  **Note**: Each individual definition of `PlainPredicateConfig` and `code` will generate a separate predicate
+  column. Thus, for memory optimization, it is strongly recommended to match multiple values using either the
+  List of Values or Regular Expression formats whenever possible.
+
 - `value_min`: If specified, an observation will only satisfy this predicate if the occurrence of the
   underlying `code` with a reported numerical value that is either greater than or greater than or equal to
   `value_min` (with these options being decided on the basis of `value_min_inclusive`, where
   `value_min_inclusive=True` indicating that an observation satisfies this predicate if its value is greater
   than or equal to `value_min`, and `value_min_inclusive=False` indicating a greater than but not equal to
   will be used).
+
 - `value_max`: If specified, an observation will only satisfy this predicate if the occurrence of the
   underlying `code` with a reported numerical value that is either less than or less than or equal to
   `value_max` (with these options being decided on the basis of `value_max_inclusive`, where
   `value_max_inclusive=True` indicating that an observation satisfies this predicate if its value is less
   than or equal to `value_max`, and `value_max_inclusive=False` indicating a less than but not equal to
   will be used).
+
 - `value_min_inclusive`: See `value_min`
+
 - `value_max_inclusive`: See `value_max`
+
 - `other_cols`: This optional field accepts a 1-to-1 dictionary of column names to column values, and can be
   used to specify further constraints on other columns (ie., not `code`) for this predicate.

diff --git a/pyproject.toml b/pyproject.toml
index 0a3399d6..4b2b26a8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,7 +37,7 @@ expand_shards = "aces.expand_shards:main"

 [project.optional-dependencies]
 dev = [
-  "pre-commit", "pytest", "pytest-cov", "pytest-subtests", "rootutils", "hypothesis"
+  "pre-commit<4", "pytest", "pytest-cov", "pytest-subtests", "rootutils", "hypothesis"
 ]
 profiling = ["psutil"]

diff --git a/src/aces/config.py b/src/aces/config.py
index b2a31835..415954ad 100644
--- a/src/aces/config.py
+++ b/src/aces/config.py
@@ -1074,7 +1074,7 @@ class TaskExtractorConfig:
         >>> config = TaskExtractorConfig(predicates=predicates, trigger=trigger, windows={})
         Traceback (most recent call last):
             ...
-        KeyError: "Missing 1 relationships:\\nDerived predicate 'foobar' references undefined predicate 'bar'"
+        KeyError: "Missing 1 relationships: Derived predicate 'foobar' references undefined predicate 'bar'"

         >>> predicates = {"foo": PlainPredicateConfig("foo")}
         >>> trigger = EventConfig("foo")
@@ -1166,6 +1166,7 @@ def load(cls, config_path: str | Path, predicates_path: str | Path = None) -> Ta
                                                     start_inclusive=True, end_inclusive=True, has={},
                                                     label=None, index_timestamp=None)},
                                 label_window=None, index_timestamp_window=None)
+
             >>> predicates_dict = {
             ...     "metadata": {'description': 'A test predicates file'},
             ...     "description": 'this is a test',
@@ -1195,6 +1196,83 @@ def load(cls, config_path: str | Path, predicates_path: str | Path = None) -> Ta
                                                     start_inclusive=True, end_inclusive=True, has={},
                                                     label=None, index_timestamp=None)},
                                 label_window=None, index_timestamp_window=None)
+
+            >>> config_dict = {
+            ...     "metadata": {'description': 'A test configuration file'},
+            ...     "description": 'this is a test for joining static and plain predicates',
+            ...     "patient_demographics": {"male": {"code": "MALE"}, "female": {"code": "FEMALE"}},
+            ...     "predicates": {"normal_male_lab_range": {"code": "LAB", "value_min": 0, "value_max": 100,
+            ...                  "value_min_inclusive": True, "value_max_inclusive": True},
+            ...                  "normal_female_lab_range": {"code": "LAB", "value_min": 0, "value_max": 90,
+            ...                  "value_min_inclusive": True, "value_max_inclusive": True},
+            ...                  "normal_lab_male": {"expr": "and(normal_male_lab_range, male)"},
+            ...                  "normal_lab_female": {"expr": "and(normal_female_lab_range, female)"}},
+            ...     "trigger": "_ANY_EVENT",
+            ...     "windows": {
+            ...         "start": {
+            ...             "start": None, "end": "trigger + 24h", "start_inclusive": True,
+            ...             "end_inclusive": True, "has": {"normal_lab_male": "(1, None)"},
+            ...         }
+            ...     },
+            ... }
+            >>> with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml") as f:
+            ...     config_path = Path(f.name)
+            ...     yaml.dump(config_dict, f)
+            ...     cfg = TaskExtractorConfig.load(config_path)
+            >>> cfg.predicates.keys() # doctest: +NORMALIZE_WHITESPACE
+            dict_keys(['normal_lab_male', 'normal_male_lab_range', 'female', 'male'])
+
+            >>> config_dict = {
+            ...     "metadata": {'description': 'A test configuration file'},
+            ...     "description": 'this is a test for nested derived predicates',
+            ...     "patient_demographics": {"male": {"code": "MALE"}, "female": {"code": "FEMALE"}},
+            ...     "predicates": {"abnormally_low_male_lab_range": {"code": "LAB", "value_max": 90,
+            ...                  "value_max_inclusive": False},
+            ...                  "abnormally_low_female_lab_range": {"code": "LAB", "value_max": 80,
+            ...                  "value_max_inclusive": False},
+            ...                  "abnormally_high_lab_range": {"code": "LAB", "value_min": 120,
+            ...                  "value_min_inclusive": False},
+            ...                  "abnormal_lab_male_range": {"expr":
+            ...                             "or(abnormally_low_male_lab_range, abnormally_high_lab_range)"},
+            ...                  "abnormal_lab_female_range": {"expr":
+            ...                             "or(abnormally_low_female_lab_range, abnormally_high_lab_range)"},
+            ...                  "abnormal_lab_male": {"expr": "and(abnormal_lab_male_range, male)"},
+            ...                  "abnormal_lab_female": {"expr": "and(abnormal_lab_female_range, female)"},
+            ...                  "abnormal_labs": {"expr": "or(abnormal_lab_male, abnormal_lab_female)"}},
+            ...     "trigger": "_ANY_EVENT",
+            ...     "windows": {
+            ...         "start": {
+            ...             "start": None, "end": "trigger + 24h", "start_inclusive": True,
+            ...             "end_inclusive": True, "has": {"abnormal_labs": "(1, None)"},
+            ...         }
+            ...     },
+            ... }
+            >>> with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml") as f:
+            ...     config_path = Path(f.name)
+            ...     yaml.dump(config_dict, f)
+            ...     cfg = TaskExtractorConfig.load(config_path)
+            >>> cfg.predicates.keys() # doctest: +NORMALIZE_WHITESPACE
+            dict_keys(['abnormal_lab_female', 'abnormal_lab_female_range', 'abnormal_lab_male',
+            'abnormal_lab_male_range', 'abnormal_labs', 'abnormally_high_lab_range',
+            'abnormally_low_female_lab_range', 'abnormally_low_male_lab_range', 'female', 'male'])
+
+            >>> predicates_dict = {
+            ...     "metadata": {'description': 'A test predicates file'},
+            ...     "description": 'this is a test',
+            ...     "patient_demographics": {"brown_eyes": {"code": "eye_color//BR"}},
+            ...     "predicates": {'admission': "invalid"},
+            ... }
+            >>> with (tempfile.NamedTemporaryFile(mode="w", suffix=".yaml") as config_fp,
+            ...      tempfile.NamedTemporaryFile(mode="w", suffix=".yaml") as pred_fp):
+            ...     config_path = Path(config_fp.name)
+            ...     pred_path = Path(pred_fp.name)
+            ...     yaml.dump(no_predicates_config, config_fp)
+            ...     yaml.dump(predicates_dict, pred_fp)
+            ...     cfg = TaskExtractorConfig.load(config_path, pred_path) # doctest: +NORMALIZE_WHITESPACE
+            Traceback (most recent call last):
+                ...
+            ValueError: Predicate 'admission' is not defined correctly in the configuration file. Currently
+            defined as the string: invalid. Please refer to the documentation for the supported formats.
         """
         if isinstance(config_path, str):
             config_path = Path(config_path)
@@ -1258,6 +1336,7 @@ def load(cls, config_path: str | Path, predicates_path: str | Path = None) -> Ta

         final_predicates = {**predicates, **overriding_predicates}
         final_demographics = {**patient_demographics, **overriding_demographics}
+        all_predicates = {**final_predicates, **final_demographics}

         logger.info("Parsing windows...")
         if windows is None:
@@ -1271,23 +1350,45 @@ def load(cls, config_path: str | Path, predicates_path: str | Path = None) -> Ta
         logger.info("Parsing trigger event...")
         trigger = EventConfig(trigger)

+        # add window referenced predicates
         referenced_predicates = {pred for w in windows.values() for pred in w.referenced_predicates}
+
+        # add trigger predicate
         referenced_predicates.add(trigger.predicate)
+
+        # add label predicate if it exists and not already added
         label_reference = [w.label for w in windows.values() if w.label]
         if label_reference:
             referenced_predicates.update(set(label_reference))
-        current_predicates = set(referenced_predicates)
+
         special_predicates = {ANY_EVENT_COLUMN, START_OF_RECORD_KEY, END_OF_RECORD_KEY}
-        for pred in current_predicates - special_predicates:
-            if pred not in final_predicates:
+        for pred in set(referenced_predicates) - special_predicates:
+            if pred not in all_predicates:
                 raise KeyError(
-                    f"Something referenced predicate {pred} that wasn't defined in the configuration."
-                )
-            if "expr" in final_predicates[pred]:
-                referenced_predicates.update(
-                    DerivedPredicateConfig(**final_predicates[pred]).input_predicates
+                    f"Something referenced predicate '{pred}' that wasn't defined in the configuration."
                 )

+            if "expr" in all_predicates[pred]:
+                stack = list(DerivedPredicateConfig(**all_predicates[pred]).input_predicates)
+
+                while stack:
+                    nested_pred = stack.pop()
+
+                    if nested_pred not in all_predicates:
+                        raise KeyError(
+                            f"Predicate '{nested_pred}' referenced in '{pred}' is not defined in the "
+                            "configuration."
+                        )
+
+                    # if nested_pred is a DerivedPredicateConfig, unpack input_predicates and add to stack
+                    if "expr" in all_predicates[nested_pred]:
+                        derived_config = DerivedPredicateConfig(**all_predicates[nested_pred])
+                        stack.extend(derived_config.input_predicates)
+                        referenced_predicates.add(nested_pred)  # also add itself to referenced_predicates
+                    else:
+                        # if nested_pred is a PlainPredicateConfig, only add it to referenced_predicates
+                        referenced_predicates.add(nested_pred)
+
         logger.info("Parsing predicates...")
         predicates_to_parse = {k: v for k, v in final_predicates.items() if k in referenced_predicates}
         predicate_objs = {}
@@ -1295,6 +1396,12 @@ def load(cls, config_path: str | Path, predicates_path: str | Path = None) -> Ta
             if "expr" in p:
                 predicate_objs[n] = DerivedPredicateConfig(**p)
             else:
+                if isinstance(p, str):
+                    raise ValueError(
+                        f"Predicate '{n}' is not defined correctly in the configuration file. "
+                        f"Currently defined as the string: {p}. "
+                        "Please refer to the documentation for the supported formats."
+                    )
                 config_data = {k: v for k, v in p.items() if k in PlainPredicateConfig.__dataclass_fields__}
                 other_cols = {k: v for k, v in p.items() if k not in config_data}
                 predicate_objs[n] = PlainPredicateConfig(**config_data, other_cols=other_cols)
@@ -1344,7 +1451,7 @@ def _initialize_predicates(self):
                 )
         if missing_predicates:
             raise KeyError(
-                f"Missing {len(missing_predicates)} relationships:\n" + "\n".join(missing_predicates)
+                f"Missing {len(missing_predicates)} relationships: " + "; ".join(missing_predicates)
             )

         self._predicate_dag_graph = nx.DiGraph(dag_relationships)
diff --git a/src/aces/predicates.py b/src/aces/predicates.py
index 2f10a76c..e4a1ca09 100644
--- a/src/aces/predicates.py
+++ b/src/aces/predicates.py
@@ -609,6 +609,64 @@ def get_predicates_df(cfg: TaskExtractorConfig, data_config: DictConfig) -> pl.D
         │ 2          ┆ 2021-01-02 00:00:00 ┆ 1   ┆ 0    ┆ 1          ┆ 1             ┆ 0           │
         │ 2          ┆ 2021-01-02 12:00:00 ┆ 0   ┆ 0    ┆ 1          ┆ 0             ┆ 1           │
         └────────────┴─────────────────────┴─────┴──────┴────────────┴───────────────┴─────────────┘
+
+        >>> data = pl.DataFrame({
+        ...     "subject_id": [1, 1, 1, 2, 2],
+        ...     "timestamp": [
+        ...         None,
+        ...         "01/01/2021 00:00",
+        ...         "01/01/2021 12:00",
+        ...         "01/02/2021 00:00",
+        ...         "01/02/2021 12:00"],
+        ...     "adm":       [0, 1, 0, 1, 0],
+        ...     "male":      [1, 0, 0, 0, 0],
+        ... })
+        >>> predicates = {
+        ...     "adm": PlainPredicateConfig("adm"),
+        ...     "male": PlainPredicateConfig("male", static=True), # predicate match based on name for direct
+        ...     "male_adm": DerivedPredicateConfig("and(male, adm)", static=['male']),
+        ... }
+        >>> trigger = EventConfig("adm")
+        >>> windows = {
+        ...     "input": WindowConfig(
+        ...         start=None,
+        ...         end="trigger + 24h",
+        ...         start_inclusive=True,
+        ...         end_inclusive=True,
+        ...         has={"_ANY_EVENT": "(32, None)"},
+        ...     ),
+        ...     "gap": WindowConfig(
+        ...         start="input.end",
+        ...         end="start + 24h",
+        ...         start_inclusive=False,
+        ...         end_inclusive=True,
+        ...         has={
+        ...             "adm": "(None, 0)",
+        ...             "male_adm": "(None, 0)",
+        ...         },
+        ...     ),
+        ... }
+        >>> config = TaskExtractorConfig(predicates=predicates, trigger=trigger, windows=windows)
+        >>> with tempfile.NamedTemporaryFile(mode="w", suffix=".csv") as f:
+        ...     data_path = Path(f.name)
+        ...     data.write_csv(data_path)
+        ...     data_config = DictConfig({
+        ...         "path": str(data_path), "standard": "direct", "ts_format": "%m/%d/%Y %H:%M"
+        ...     })
+        ...     get_predicates_df(config, data_config)
+        shape: (5, 6)
+        ┌────────────┬─────────────────────┬─────┬──────┬──────────┬────────────┐
+        │ subject_id ┆ timestamp           ┆ adm ┆ male ┆ male_adm ┆ _ANY_EVENT │
+        │ ---        ┆ ---                 ┆ --- ┆ ---  ┆ ---      ┆ ---        │
+        │ i64        ┆ datetime[μs]        ┆ i64 ┆ i64  ┆ i64      ┆ i64        │
+        ╞════════════╪═════════════════════╪═════╪══════╪══════════╪════════════╡
+        │ 1          ┆ null                ┆ 0   ┆ 1    ┆ 0        ┆ null       │
+        │ 1          ┆ 2021-01-01 00:00:00 ┆ 1   ┆ 1    ┆ 1        ┆ 1          │
+        │ 1          ┆ 2021-01-01 12:00:00 ┆ 0   ┆ 1    ┆ 0        ┆ 1          │
+        │ 2          ┆ 2021-01-02 00:00:00 ┆ 1   ┆ 0    ┆ 0        ┆ 1          │
+        │ 2          ┆ 2021-01-02 12:00:00 ┆ 0   ┆ 0    ┆ 0        ┆ 1          │
+        └────────────┴─────────────────────┴─────┴──────┴──────────┴────────────┘
+
         >>> with tempfile.NamedTemporaryFile(mode="w", suffix=".csv") as f:
         ...     data_path = Path(f.name)
         ...     data.write_csv(data_path)
@@ -638,15 +696,26 @@ def get_predicates_df(cfg: TaskExtractorConfig, data_config: DictConfig) -> pl.D
             raise ValueError(f"Invalid data standard: {standard}. Options are 'direct', 'MEDS', 'ESGPT'.")
     predicate_cols = list(plain_predicates.keys())

+    data = data.sort(by=["subject_id", "timestamp"], nulls_last=False)
+
     # derived predicates
     logger.info("Loaded plain predicates. Generating derived predicate columns...")
+    static_variables = [pred for pred in cfg.plain_predicates if cfg.plain_predicates[pred].static]
     for name, code in cfg.derived_predicates.items():
+        if any(x in static_variables for x in code.input_predicates):
+            data = data.with_columns(
+                [
+                    pl.col(static_var)
+                    .first()
+                    .over("subject_id")  # take the first value in each subject_id group and propagate it
+                    .alias(static_var)
+                    for static_var in static_variables
+                ]
+            )
         data = data.with_columns(code.eval_expr().cast(PRED_CNT_TYPE).alias(name))
         logger.info(f"Added predicate column '{name}'.")
         predicate_cols.append(name)

-    data = data.sort(by=["subject_id", "timestamp"], nulls_last=False)
-
     # add special predicates:
     # a column of 1s representing any predicate
     # a column of 0s with 1 in the first event of each subject_id representing the start of record
diff --git a/src/aces/query.py b/src/aces/query.py
index 701bf8f7..07cef479 100644
--- a/src/aces/query.py
+++ b/src/aces/query.py
@@ -106,8 +106,9 @@ def query(cfg: TaskExtractorConfig, predicates_df: pl.DataFrame) -> pl.DataFrame
         return pl.DataFrame()

     result = extract_subtree(cfg.window_tree, prospective_root_anchors, predicates_df)
-    if result.is_empty():
-        logger.info("No valid rows found.")
+    if result.is_empty():  # pragma: no cover
+        logger.warning("No valid rows found.")
+        return pl.DataFrame()
     else:
         # number of patients
         logger.info(
@@ -125,7 +126,7 @@ def query(cfg: TaskExtractorConfig, predicates_df: pl.DataFrame) -> pl.DataFrame

     # add label column if specified
     if cfg.label_window:
-        logger.info(
+        logger.info(  # pragma: no cover
             f"Extracting label '{cfg.windows[cfg.label_window].label}' from window "
             f"'{cfg.label_window}'..."
         )
@@ -137,9 +138,16 @@ def query(cfg: TaskExtractorConfig, predicates_df: pl.DataFrame) -> pl.DataFrame
         )
         to_return_cols.insert(1, "label")

+        if result["label"].n_unique() == 1:  # pragma: no cover
+            logger.warning(
+                f"All labels in the extracted cohort are the same: '{result['label'][0]}'. "
+                "This may indicate an issue with the task logic. "
+                "Please double-check your configuration file if this is not expected."
+            )
+
     # add index_timestamp column if specified
     if cfg.index_timestamp_window:
-        logger.info(
+        logger.info(  # pragma: no cover
             f"Setting index timestamp as '{cfg.windows[cfg.index_timestamp_window].index_timestamp}' "
             f"of window '{cfg.index_timestamp_window}'..."
         )

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c22ef161..4e51b115 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -17,16 +17,16 @@ jobs:

     steps:
       - name: Checkout
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4

       - name: Set up Python 3.10
-        uses: actions/setup-python@v3
+        uses: actions/setup-python@v5
         with:
           python-version: "3.10"

       - name: Install packages
         run: |
-          pip install -e .[dev]
+          pip install .[dev]

       #----------------------------------------------
       #              run test suite

Task definition matters in terms of memory usage
As suggested by @Oufattole, this kind of config seems to use excessive amounts of memory (more than 400 GB on MIMIC-IV in my case)
![image](https://github.com/user-attachments/assets/36e87b96-d4b1-491b-b664-f1f39fd1d2ad)
using regex mitigates this:
![image](https://github.com/user-attachments/assets/827523c3-8a6e-4362-89c1-0177133762ee)

https://github.com/Oufattole/meds-torch/blob/main/MIMICIV_INDUCTIVE_EXPERIMENTS/configs/tasks/mortality/in_icu/first_24h.yaml

I'm not sure what the reason could be, but perhaps it's something to attend users to. Tested with es-aces 0.5.1 and command: `aces-cli --multirun hydra/launcher=joblib data=sharded data.standard=meds data.root="$MIMICIV_MEDS_DIR/data" "data.shard=$(expand_shards $MIMICIV_MEDS_DIR/data)" cohort_dir="$cohort_dir" cohort_name="$TASK_NAME"`

Yep this is a limitation with the creation of the predicates as memory peaks during this process (also brought up in #89, which was closed after #90 was merged with the ability to match using regex).

Basically, each time a predicate is defined in a configuration file, a column is created which corresponds to that predicate. So, in the first example, defining the different types of admission predicates ultimately creates 10 columns, whereas the regex option will only create 1 column that matches everything. And when your dataframe is relatively large, adding these columns really blow up the memory required.

I'll add a note about this in the documentation as well as the repo README - thanks!


diff --git a/src/cogent3/app/align.py b/src/cogent3/app/align.py
index 35d379900..0ba0df9e7 100644
--- a/src/cogent3/app/align.py
+++ b/src/cogent3/app/align.py
@@ -828,7 +828,7 @@ def cogent3_score(aln: AlignedSeqsType) -> float:
     if aln.num_seqs == 1 or len(aln) == 0:
         msg = "zero length" if len(aln) == 0 else "single sequence"
         return NotCompleted(
-            "FAL",
+            "FAIL",
             "cogent3_score",
             f"cannot compute alignment quality because {msg}",
             source=aln.info,
@@ -838,7 +838,7 @@ def cogent3_score(aln: AlignedSeqsType) -> float:
     return align_params.get(
         "lnL",
         NotCompleted(
-            "FAL", "cogent3_score", "no alignment quality score", source=aln.info
+            "FAIL", "cogent3_score", "no alignment quality score", source=aln.info
         ),
     )

diff --git a/src/cogent3/app/sample.py b/src/cogent3/app/sample.py
index 5ba807e4f..bd7a81869 100644
--- a/src/cogent3/app/sample.py
+++ b/src/cogent3/app/sample.py
@@ -16,6 +16,7 @@
 # set.

 MolTypes = Union[str, MolType]
+OptInt = Optional[int]

 def intersection(groups):
@@ -892,6 +893,7 @@ def __init__(
         self,
         quantile: Optional[float] = None,
         gap_fraction: int = 1,
+        ambig_fraction: OptInt = None,  # refactor: set default to 1 when support for old style aln is dropped
         moltype: MolTypes = "dna",
     ):
         """
@@ -905,6 +907,10 @@ def __init__(
         gap_fraction
             sequences whose proportion of gaps is >= this value are excluded, the
             default excludes sequences that are just gaps.
+        ambig_fraction
+            sequences whose proportion of ambiguous characters is >= this value
+            are excluded. Only supported for new style alignments. By default,
+            not applied.
         moltype
             molecular type, can be string or instance

@@ -954,8 +960,10 @@ def __init__(
         assert (
             moltype.label.lower() in "dna rna protein protein_with_stop"
         ), "moltype must be one of DNA, RNA or PROTEIN"
+        # refactor: design, this should raise a MolTypeError
         self._quantile = quantile
         self._gap_fraction = gap_fraction
+        self._ambig_fraction = ambig_fraction
         self._moltype = moltype

     T = Union[SerialisableType, AlignedSeqsType]
@@ -967,6 +975,24 @@ def main(self, aln: AlignedSeqsType) -> T:
         gaps_per_seq = aln.count_gaps_per_seq()
         length = len(aln)
         keep = [n for n, c in gaps_per_seq.items() if c / length < self._gap_fraction]
+
+        from cogent3.core import new_alignment
+
+        if self._ambig_fraction is not None:
+            if isinstance(aln, new_alignment.Alignment):
+                ambigs_per_seq = aln.count_ambiguous_per_seq()
+                keep = [
+                    n for n in keep if ambigs_per_seq[n] / length < self._ambig_fraction
+                ]
+            else:
+                msg = (
+                    "ambig_fraction cannot be applied to old style alignments, set "
+                    "new_type=True in make_aligned_seqs to create an Alignment which "
+                    "supports this feature"
+                )
+
+                return NotCompleted("FAIL", self, msg, aln.info.source)
+
         result = aln.take_seqs(keep)
         if self._quantile is not None:
             result = result.omit_bad_seqs(quantile=self._quantile)
diff --git a/src/cogent3/core/new_alignment.py b/src/cogent3/core/new_alignment.py
index 4df43ebc1..66b4ed777 100644
--- a/src/cogent3/core/new_alignment.py
+++ b/src/cogent3/core/new_alignment.py
@@ -43,7 +43,7 @@
 from cogent3.maths.stats.number import CategoryCounter
 from cogent3.util import progress_display as UI
 from cogent3.util import warning as c3warn
-from cogent3.util.deserialise import deserialise_object, register_deserialiser
+from cogent3.util.deserialise import register_deserialiser
 from cogent3.util.dict_array import DictArray, DictArrayTemplate
 from cogent3.util.io import atomic_write, get_format_suffixes
 from cogent3.util.misc import (
@@ -1592,6 +1592,17 @@ def counts(
         )
         return per_seq.motif_totals()

+    def count_ambiguous_per_seq(self) -> DictArray:
+        """Counts of ambiguous characters per sequence."""
+
+        darr = DictArrayTemplate(self.names)
+        counts = numpy.array(
+            [self.seqs[name].count_ambiguous() for name in self.names],
+            dtype=numpy.uint32,
+        )
+
+        return darr.wrap(counts)
+
     def get_motif_probs(
         self,
         alphabet: new_alphabet.AlphabetABC = None,
@@ -4496,7 +4507,7 @@ def count_gaps_per_seq(
         unique: bool = False,
         include_ambiguity: bool = True,
         drawable: bool = False,
-    ):
+    ) -> DictArray:
         """return counts of gaps per sequence as a DictArray

         Parameters
@@ -4551,6 +4562,15 @@ def count_gaps_per_seq(

         return result

+    def count_ambiguous_per_seq(self) -> DictArray:
+        """Return the counts of ambiguous characters per sequence as a DictArray."""
+
+        gap_index = self.moltype.most_degen_alphabet().gap_index
+        ambigs_pos = self.array_seqs > gap_index
+        ambigs = ambigs_pos.sum(axis=1)
+
+        return DictArray.from_array_names(ambigs, self.names)
+
     def variable_positions(
         self,
         include_gap_motif: bool = True,
diff --git a/src/cogent3/core/new_sequence.py b/src/cogent3/core/new_sequence.py
index b859978f9..8d74a9602 100644
--- a/src/cogent3/core/new_sequence.py
+++ b/src/cogent3/core/new_sequence.py
@@ -363,6 +363,12 @@ def counts(

         return counts

+    def count_ambiguous(self) -> int:
+        """Returns the number of ambiguous characters in the sequence."""
+        data = numpy.array(self)
+        gap_index = self.moltype.most_degen_alphabet().gap_index
+        return numpy.sum(data > gap_index)
+
     def __lt__(self, other):
         """compares based on the sequence string."""
         return str(self) < str(other)

diff --git a/tests/test_app/test_sample.py b/tests/test_app/test_sample.py
index eb1ca31dc..f0768b699 100644
--- a/tests/test_app/test_sample.py
+++ b/tests/test_app/test_sample.py
@@ -293,40 +293,6 @@ def test_fixedlength(self):
         for name, seq in got.to_dict().items():
             self.assertIn(seq, expect[name])

-    def test_omit_bad_seqs(self):
-        """correctly omit bad sequences from an alignment"""
-        data = {
-            "s1": "---ACC---TT-",
-            "s2": "---ACC---TT-",
-            "s3": "---ACC---TT-",
-            "s4": "--AACCG-GTT-",
-            "s5": "--AACCGGGTTT",
-            "s6": "AGAACCGGGTT-",
-            "s7": "------------",
-        }
-        aln = make_aligned_seqs(data=data, moltype=DNA)
-        # default just eliminates strict gap sequences
-        dropbad = sample.omit_bad_seqs()
-        got = dropbad(aln)
-        expect = data.copy()
-        del expect["s7"]
-        self.assertEqual(got.to_dict(), expect)
-        # providing a more stringent gap_frac
-        dropbad = sample.omit_bad_seqs(gap_fraction=0.5)
-        got = dropbad(aln)
-        expect = data.copy()
-        for n in ("s1", "s2", "s3", "s7"):
-            del expect[n]
-        self.assertEqual(got.to_dict(), expect)
-
-        # setting quantile drops additional sequences
-        dropbad = sample.omit_bad_seqs(quantile=6 / 7)
-        got = dropbad(aln)
-        expect = data.copy()
-        for n in ("s6", "s7"):
-            del expect[n]
-        self.assertEqual(got.to_dict(), expect)
-
     def test_omit_duplicated(self):
         """correctly drop duplicated sequences"""
         # strict omit_duplicated
@@ -565,3 +531,92 @@ def test_concat_empty(data):
     ccat = sample.concat()
     got = ccat(data)
     assert isinstance(got, NotCompleted)
+
+
+@pytest.fixture
+def bad_gap_data():
+    return {
+        "s1": "---ACC---TT-",
+        "s2": "---ACC---TT-",
+        "s3": "---ACC---TT-",
+        "s4": "--AACCG-GTT-",
+        "s5": "--AACCGGGTTT",
+        "s6": "AGAACCGGGTT-",
+        "s7": "------------",
+    }
+
+
+@pytest.mark.parametrize(
+    "gap_fraction, quantile, expected_keys",
+    [
+        (
+            1,
+            None,
+            {"s1", "s2", "s3", "s4", "s5", "s6"},
+        ),  # default just eliminates strict gap sequences
+        (0.5, None, {"s4", "s5", "s6"}),  # providing a more stringent gap_frac
+        (
+            1,
+            6 / 7,
+            {"s1", "s2", "s3", "s4", "s5"},
+        ),  # setting quantile drops additional sequences
+    ],
+)
+def test_omit_bad_seqs(bad_gap_data, gap_fraction, quantile, expected_keys):
+    """correctly omit bad sequences from an alignment"""
+
+    dropbad = sample.omit_bad_seqs(gap_fraction=gap_fraction, quantile=quantile)
+    aln = make_aligned_seqs(bad_gap_data, moltype=DNA)
+    got = dropbad(aln)
+    expected = {k: bad_gap_data[k] for k in expected_keys}
+    assert got.to_dict() == expected
+
+
+@pytest.fixture
+def bad_ambig_gap_data():
+    return {
+        "s1": "---ACC---TT-",
+        "s2": "---ACC---TT-",
+        "s3": "SSSACCSSSTTS",
+        "s4": "SSAACCGSGTTS",
+        "s5": "--AACCGGGTTT",
+        "s6": "AGAACCGGGTT-",
+        "s7": "SSSSSSSSSSSS",
+    }
+
+
+def test_omit_bad_seqs_ambigs(bad_ambig_gap_data):
+    """correctly omit sequences from a new style alignment via fraction of ambiguous data"""
+    from cogent3.core import new_alignment
+
+    aln = new_alignment.make_aligned_seqs(bad_ambig_gap_data, moltype="dna")
+
+    # drop sequences with all ambiguous data
+    dropbad = sample.omit_bad_seqs(ambig_fraction=1)
+    got = dropbad(aln)
+    assert set(got.to_dict().keys()) == {"s1", "s2", "s3", "s4", "s5", "s6"}
+
+    # drop sequences with more than 50% ambiguous data
+    dropbad = sample.omit_bad_seqs(ambig_fraction=0.5)
+    got = dropbad(aln)
+    assert set(got.to_dict().keys()) == {"s1", "s2", "s4", "s5", "s6"}
+
+    # drop sequences with any ambiguous data
+    dropbad = sample.omit_bad_seqs(ambig_fraction=0.01)
+    got = dropbad(aln)
+    assert set(got.to_dict().keys()) == {"s1", "s2", "s5", "s6"}
+
+    # drop sequences with more than 50% ambiguous data and 50% gaps
+    dropbad = sample.omit_bad_seqs(gap_fraction=0.5, ambig_fraction=0.5)
+    got = dropbad(aln)
+    assert set(got.to_dict().keys()) == {"s4", "s5", "s6"}
+
+
+def test_omit_bad_seqs_ambigs_old_aln(bad_ambig_gap_data):
+    # ambig_fraction should be ignored if using old style alignment
+
+    aln = make_aligned_seqs(bad_ambig_gap_data, moltype=DNA)
+    dropbad = sample.omit_bad_seqs(gap_fraction=0.5, ambig_fraction=0.5)
+
+    got = dropbad(aln)
+    assert isinstance(got, NotCompleted)
diff --git a/tests/test_core/test_new_alignment.py b/tests/test_core/test_new_alignment.py
index e8bd69342..a0d9ad405 100644
--- a/tests/test_core/test_new_alignment.py
+++ b/tests/test_core/test_new_alignment.py
@@ -1839,6 +1839,22 @@ def test_sequence_collection_counts():
     assert all(got[k] == v for k, v in expect.items())

+@pytest.mark.parametrize(
+    "mk_cls", [new_alignment.make_unaligned_seqs, new_alignment.make_aligned_seqs]
+)
+def test_sequence_collection_count_ambiguous_per_seq(mk_cls):
+    data = {
+        "a": "AGGGT",
+        "b": "AGGN?",
+        "c": "?????",
+        "d": "-----",
+    }
+    coll = mk_cls(data, moltype="dna")
+    got = coll.count_ambiguous_per_seq()
+    expect = {"a": 0, "b": 2, "c": 5, "d": 0}
+    assert got == expect
+
+
 def test_sequence_collection_make_feature(seqs):
     data = {"seqid": "seq1", "biotype": "xyz", "name": "abc", "spans": [(1, 2)]}
     feat = seqs.make_feature(feature=data)
diff --git a/tests/test_core/test_new_sequence.py b/tests/test_core/test_new_sequence.py
index 7a6883a35..e19e793b8 100644
--- a/tests/test_core/test_new_sequence.py
+++ b/tests/test_core/test_new_sequence.py
@@ -831,6 +831,20 @@ def test_counts():
     assert dict(got) == expect

+@pytest.mark.parametrize(
+    "data, expect",
+    [
+        ("CCTTNN", 2),
+        ("???MMM", 6),
+        ("TGCAGG", 0),
+        ("", 0),
+    ],
+)
+def test_count_ambiguous(data, expect):
+    seq = new_moltype.DNA.make_seq(seq=data)
+    assert seq.count_ambiguous() == expect
+
+
 def test_strand_symmetry():
     """correctly compute test of strand symmetry"""
     from cogent3 import get_moltype

ENH: add a SequenceCollection.count_ambiguous_per_seq method
This is really valuable for identifying low quality sequences. It should also be added to the `drop_bad_seqs()` method.

This should be implemented with `cogent3.core.new_sequence.Sequence.count_ambiguous()` and `cogent3.core.new_alignment.SequenceCollection.count_ambiguous_per_seq()`


diff --git a/CHANGES/7829.misc b/CHANGES/7829.misc
new file mode 100644
index 00000000000..9eb060f4713
--- /dev/null
+++ b/CHANGES/7829.misc
@@ -0,0 +1,3 @@
+Improved URL handler resolution time by indexing resources in the UrlDispatcher.
+For applications with a large number of handlers, this should increase performance significantly.
+-- by :user:`bdraco`
diff --git a/aiohttp/web_urldispatcher.py b/aiohttp/web_urldispatcher.py
index 5c8032364c2..36b8ea8a550 100644
--- a/aiohttp/web_urldispatcher.py
+++ b/aiohttp/web_urldispatcher.py
@@ -716,13 +716,20 @@ class PrefixedSubAppResource(PrefixResource):
     def __init__(self, prefix: str, app: "Application") -> None:
         super().__init__(prefix)
         self._app = app
-        for resource in app.router.resources():
-            resource.add_prefix(prefix)
+        self._add_prefix_to_resources(prefix)

     def add_prefix(self, prefix: str) -> None:
         super().add_prefix(prefix)
-        for resource in self._app.router.resources():
+        self._add_prefix_to_resources(prefix)
+
+    def _add_prefix_to_resources(self, prefix: str) -> None:
+        router = self._app.router
+        for resource in router.resources():
+            # Since the canonical path of a resource is about
+            # to change, we need to unindex it and then reindex
+            router.unindex_resource(resource)
             resource.add_prefix(prefix)
+            router.index_resource(resource)

     def url_for(self, *args: str, **kwargs: str) -> URL:
         raise RuntimeError(".url_for() is not supported " "by sub-application root")
@@ -731,11 +738,6 @@ def get_info(self) -> _InfoDict:
         return {"app": self._app, "prefix": self._prefix}

     async def resolve(self, request: Request) -> _Resolve:
-        if (
-            not request.url.raw_path.startswith(self._prefix2)
-            and request.url.raw_path != self._prefix
-        ):
-            return None, set()
         match_info = await self._app.router.resolve(request)
         match_info.add_app(self._app)
         if isinstance(match_info.http_exception, HTTPMethodNotAllowed):
@@ -974,17 +976,45 @@ def __contains__(self, route: object) -> bool:

 class UrlDispatcher(AbstractRouter, Mapping[str, AbstractResource]):
     NAME_SPLIT_RE = re.compile(r"[.:-]")
+    HTTP_NOT_FOUND = HTTPNotFound()

     def __init__(self) -> None:
         super().__init__()
         self._resources: List[AbstractResource] = []
         self._named_resources: Dict[str, AbstractResource] = {}
+        self._resource_index: dict[str, list[AbstractResource]] = {}
+        self._matched_sub_app_resources: List[MatchedSubAppResource] = []

     async def resolve(self, request: Request) -> UrlMappingMatchInfo:
-        method = request.method
+        resource_index = self._resource_index
         allowed_methods: Set[str] = set()

-        for resource in self._resources:
+        # Walk the url parts looking for candidates. We walk the url backwards
+        # to ensure the most explicit match is found first. If there are multiple
+        # candidates for a given url part because there are multiple resources
+        # registered for the same canonical path, we resolve them in a linear
+        # fashion to ensure registration order is respected.
+        url_part = request.rel_url.raw_path
+        while url_part:
+            for candidate in resource_index.get(url_part, ()):
+                match_dict, allowed = await candidate.resolve(request)
+                if match_dict is not None:
+                    return match_dict
+                else:
+                    allowed_methods |= allowed
+            if url_part == "/":
+                break
+            url_part = url_part.rpartition("/")[0] or "/"
+
+        #
+        # We didn't find any candidates, so we'll try the matched sub-app
+        # resources which we have to walk in a linear fashion because they
+        # have regex/wildcard match rules and we cannot index them.
+        #
+        # For most cases we do not expect there to be many of these since
+        # currently they are only added by `add_domain`
+        #
+        for resource in self._matched_sub_app_resources:
             match_dict, allowed = await resource.resolve(request)
             if match_dict is not None:
                 return match_dict
@@ -992,9 +1022,9 @@ async def resolve(self, request: Request) -> UrlMappingMatchInfo:
                 allowed_methods |= allowed

         if allowed_methods:
-            return MatchInfoError(HTTPMethodNotAllowed(method, allowed_methods))
-        else:
-            return MatchInfoError(HTTPNotFound())
+            return MatchInfoError(HTTPMethodNotAllowed(request.method, allowed_methods))
+
+        return MatchInfoError(self.HTTP_NOT_FOUND)

     def __iter__(self) -> Iterator[str]:
         return iter(self._named_resources)
@@ -1050,6 +1080,30 @@ def register_resource(self, resource: AbstractResource) -> None:
             self._named_resources[name] = resource
         self._resources.append(resource)

+        if isinstance(resource, MatchedSubAppResource):
+            # We cannot index match sub-app resources because they have match rules
+            self._matched_sub_app_resources.append(resource)
+        else:
+            self.index_resource(resource)
+
+    def _get_resource_index_key(self, resource: AbstractResource) -> str:
+        """Return a key to index the resource in the resource index."""
+        # strip at the first { to allow for variables
+        return resource.canonical.partition("{")[0].rstrip("/") or "/"
+
+    def index_resource(self, resource: AbstractResource) -> None:
+        """Add a resource to the resource index."""
+        resource_key = self._get_resource_index_key(resource)
+        # There may be multiple resources for a canonical path
+        # so we keep them in a list to ensure that registration
+        # order is respected.
+        self._resource_index.setdefault(resource_key, []).append(resource)
+
+    def unindex_resource(self, resource: AbstractResource) -> None:
+        """Remove a resource from the resource index."""
+        resource_key = self._get_resource_index_key(resource)
+        self._resource_index[resource_key].remove(resource)
+
     def add_resource(self, path: str, *, name: Optional[str] = None) -> Resource:
         if path and not path.startswith("/"):
             raise ValueError("path should be started with / or be empty")
diff --git a/docs/web_reference.rst b/docs/web_reference.rst
index 6652edb8490..f84b06d8045 100644
--- a/docs/web_reference.rst
+++ b/docs/web_reference.rst
@@ -1865,20 +1865,38 @@ unique *name* and at least one :term:`route`.

 :term:`web-handler` lookup is performed in the following way:

-1. Router iterates over *resources* one-by-one.
-2. If *resource* matches to requested URL the resource iterates over
-   own *routes*.
-3. If route matches to requested HTTP method (or ``'*'`` wildcard) the
-   route's handler is used as found :term:`web-handler`. The lookup is
-   finished.
-4. Otherwise router tries next resource from the *routing table*.
-5. If the end of *routing table* is reached and no *resource* /
-   *route* pair found the *router* returns special :class:`~aiohttp.abc.AbstractMatchInfo`
+1. The router splits the URL and checks the index from longest to shortest.
+   For example, '/one/two/three' will first check the index for
+   '/one/two/three', then '/one/two' and finally '/'.
+2. If the URL part is found in the index, the list of routes for
+   that URL part is iterated over. If a route matches to requested HTTP
+   method (or ``'*'`` wildcard) the route's handler is used as the chosen
+   :term:`web-handler`. The lookup is finished.
+3. If the route is not found in the index, the router tries to find
+   the route in the list of :class:`~aiohttp.web.MatchedSubAppResource`,
+   (current only created from :meth:`~aiohttp.web.Application.add_domain`),
+   and will iterate over the list of
+   :class:`~aiohttp.web.MatchedSubAppResource` in a linear fashion
+   until a match is found.
+4. If no *resource* / *route* pair was found, the *router*
+   returns the special :class:`~aiohttp.abc.AbstractMatchInfo`
    instance with :attr:`aiohttp.abc.AbstractMatchInfo.http_exception` is not ``None``
    but :exc:`HTTPException` with  either *HTTP 404 Not Found* or
    *HTTP 405 Method Not Allowed* status code.
    Registered :meth:`~aiohttp.abc.AbstractMatchInfo.handler` raises this exception on call.

+Fixed paths are preferred over variable paths. For example,
+if you have two routes ``/a/b`` and ``/a/{name}``, then the first
+route will always be preferred over the second one.
+
+If there are multiple dynamic paths with the same fixed prefix,
+they will be resolved in order of registration.
+
+For example, if you have two dynamic routes that are prefixed
+with the fixed ``/users`` path such as ``/users/{x}/{y}/z`` and
+``/users/{x}/y/z``, the first one will be preferred over the
+second one.
+
 User should never instantiate resource classes but give it by
 :meth:`UrlDispatcher.add_resource` call.

@@ -1900,7 +1918,10 @@ Resource classes hierarchy::
      Resource
        PlainResource
        DynamicResource
+     PrefixResource
        StaticResource
+       PrefixedSubAppResource
+          MatchedSubAppResource

 .. class:: AbstractResource

diff --git a/tests/test_urldispatch.py b/tests/test_urldispatch.py
index 5f5687eacc3..adb1e52e781 100644
--- a/tests/test_urldispatch.py
+++ b/tests/test_urldispatch.py
@@ -1264,10 +1264,17 @@ async def test_prefixed_subapp_overlap(app: Any) -> None:
     subapp2.router.add_get("/b", handler2)
     app.add_subapp("/ss", subapp2)

+    subapp3 = web.Application()
+    handler3 = make_handler()
+    subapp3.router.add_get("/c", handler3)
+    app.add_subapp("/s/s", subapp3)
+
     match_info = await app.router.resolve(make_mocked_request("GET", "/s/a"))
     assert match_info.route.handler is handler1
     match_info = await app.router.resolve(make_mocked_request("GET", "/ss/b"))
     assert match_info.route.handler is handler2
+    match_info = await app.router.resolve(make_mocked_request("GET", "/s/s/c"))
+    assert match_info.route.handler is handler3

 async def test_prefixed_subapp_empty_route(app: Any) -> None:
diff --git a/tests/test_web_urldispatcher.py b/tests/test_web_urldispatcher.py
index 79bd42a3a01..716fa78fd4a 100644
--- a/tests/test_web_urldispatcher.py
+++ b/tests/test_web_urldispatcher.py
@@ -10,7 +10,7 @@

 from aiohttp import web
 from aiohttp.pytest_plugin import AiohttpClient
-from aiohttp.web_urldispatcher import SystemRoute
+from aiohttp.web_urldispatcher import Resource, SystemRoute

 @pytest.mark.parametrize(
@@ -142,7 +142,6 @@ async def test_access_to_the_file_with_spaces(
     r = await client.get(url)
     assert r.status == 200
     assert (await r.text()) == data
-    await r.release()

 async def test_access_non_existing_resource(
@@ -544,3 +543,86 @@ async def handler(request: web.Request) -> web.Response:
     r = await client.get(yarl.URL(urlencoded_path, encoded=True))
     assert r.status == expected_http_resp_status
     await r.release()
+
+
+async def test_order_is_preserved(aiohttp_client: AiohttpClient) -> None:
+    """Test route order is preserved.
+
+    Note that fixed/static paths are always preferred over a regex path.
+    """
+    app = web.Application()
+
+    async def handler(request: web.Request) -> web.Response:
+        assert isinstance(request.match_info._route.resource, Resource)
+        return web.Response(text=request.match_info._route.resource.canonical)
+
+    app.router.add_get("/first/x/{b}/", handler)
+    app.router.add_get(r"/first/{x:.*/b}", handler)
+
+    app.router.add_get(r"/second/{user}/info", handler)
+    app.router.add_get("/second/bob/info", handler)
+
+    app.router.add_get("/third/bob/info", handler)
+    app.router.add_get(r"/third/{user}/info", handler)
+
+    app.router.add_get(r"/forth/{name:\d+}", handler)
+    app.router.add_get("/forth/42", handler)
+
+    app.router.add_get("/fifth/42", handler)
+    app.router.add_get(r"/fifth/{name:\d+}", handler)
+
+    client = await aiohttp_client(app)
+
+    r = await client.get("/first/x/b/")
+    assert r.status == 200
+    assert await r.text() == "/first/x/{b}/"
+
+    r = await client.get("/second/frank/info")
+    assert r.status == 200
+    assert await r.text() == "/second/{user}/info"
+
+    # Fixed/static paths are always preferred over regex paths
+    r = await client.get("/second/bob/info")
+    assert r.status == 200
+    assert await r.text() == "/second/bob/info"
+
+    r = await client.get("/third/bob/info")
+    assert r.status == 200
+    assert await r.text() == "/third/bob/info"
+
+    r = await client.get("/third/frank/info")
+    assert r.status == 200
+    assert await r.text() == "/third/{user}/info"
+
+    r = await client.get("/forth/21")
+    assert r.status == 200
+    assert await r.text() == "/forth/{name}"
+
+    # Fixed/static paths are always preferred over regex paths
+    r = await client.get("/forth/42")
+    assert r.status == 200
+    assert await r.text() == "/forth/42"
+
+    r = await client.get("/fifth/21")
+    assert r.status == 200
+    assert await r.text() == "/fifth/{name}"
+
+    r = await client.get("/fifth/42")
+    assert r.status == 200
+    assert await r.text() == "/fifth/42"
+
+
+async def test_url_with_many_slashes(aiohttp_client: AiohttpClient) -> None:
+    app = web.Application()
+
+    class MyView(web.View):
+        async def get(self) -> web.Response:
+            return web.Response()
+
+    app.router.add_routes([web.view("/a", MyView)])
+
+    client = await aiohttp_client(app)
+
+    r = await client.get("///a")
+    assert r.status == 200
+    await r.release()

UrlDispatcher improvements
### Describe the bug

The UrlDispatcher currently has to do a linear search to dispatch urls which has an average time complexity of `O(n)`. This means that urls that are registered first can be found faster than urls that are registered later.

### To Reproduce

Results
```
% python3 url_dispatcher.py
resolve_time_first_url: 0.0017513339989818633
resolve_time_last_url: 2.258735582989175
```

This can be demonstrated with the following benchmark script
```python
import asyncio
import timeit
from unittest.mock import MagicMock, AsyncMock

from yarl import URL

from aiohttp import web
from aiohttp.web_urldispatcher import (
    UrlDispatcher,
)

URL_COUNT = 5000

async def url_dispatcher_performance():
    """Filter out large cookies from the cookie jar."""
    dispatcher = UrlDispatcher()
    for i in range(URL_COUNT):
        dispatcher.add_get(f"/static/sub/path{i}", AsyncMock())

    first_url = "/static/sub/path0"
    last_url = f"/static/sub/path{URL_COUNT-1}"

    request_first_url = web.Request(
        MagicMock(url=URL(first_url), method="GET"),
        MagicMock(),
        protocol=MagicMock(),
        host="example.com",
        task=MagicMock(),
        loop=asyncio.get_running_loop(),
        payload_writer=MagicMock(),
    )
    match_info = await dispatcher.resolve(request_first_url)
    assert match_info.route.resource.canonical == first_url

    request_last_url = web.Request(
        MagicMock(url=URL(last_url), method="GET"),
        MagicMock(),
        protocol=MagicMock(),
        host="example.com",
        task=MagicMock(),
        loop=asyncio.get_running_loop(),
        payload_writer=MagicMock(),
    )
    match_info = await dispatcher.resolve(request_last_url)
    assert match_info.route.resource.canonical == last_url

    async def resolve_times(request: web.Request, times: int) -> float:
        start = timeit.default_timer()
        for _ in range(times):
            await dispatcher.resolve(request)
        end = timeit.default_timer()
        return end - start

    run_count = 2000
    resolve_time_first_url = await resolve_times(request_first_url, run_count)
    resolve_time_last_url = await resolve_times(request_last_url, run_count)
    print(f"resolve_time_first_url: {resolve_time_first_url}")
    print(f"resolve_time_last_url: {resolve_time_last_url}")

asyncio.run(url_dispatcher_performance())

```

### Expected behavior

Ideally the time complexity is at most `O(url_parts)`

### Logs/tracebacks

```python-traceback
n/a
```

### Python Version

```console
n/a
```

### aiohttp Version

```console
master
```

### multidict Version

```console
n/a
```

### yarl Version

```console
n/a
```

### OS

n/a

### Related component

Server

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct
UrlDispatcher improvements
### Describe the bug

The UrlDispatcher currently has to do a linear search to dispatch urls which has an average time complexity of `O(n)`. This means that urls that are registered first can be found faster than urls that are registered later.

### To Reproduce

Results
```
% python3 url_dispatcher.py
resolve_time_first_url: 0.0017513339989818633
resolve_time_last_url: 2.258735582989175
```

This can be demonstrated with the following benchmark script
```python
import asyncio
import timeit
from unittest.mock import MagicMock, AsyncMock

from yarl import URL

from aiohttp import web
from aiohttp.web_urldispatcher import (
    UrlDispatcher,
)

URL_COUNT = 5000

async def url_dispatcher_performance():
    """Filter out large cookies from the cookie jar."""
    dispatcher = UrlDispatcher()
    for i in range(URL_COUNT):
        dispatcher.add_get(f"/static/sub/path{i}", AsyncMock())

    first_url = "/static/sub/path0"
    last_url = f"/static/sub/path{URL_COUNT-1}"

    request_first_url = web.Request(
        MagicMock(url=URL(first_url), method="GET"),
        MagicMock(),
        protocol=MagicMock(),
        host="example.com",
        task=MagicMock(),
        loop=asyncio.get_running_loop(),
        payload_writer=MagicMock(),
    )
    match_info = await dispatcher.resolve(request_first_url)
    assert match_info.route.resource.canonical == first_url

    request_last_url = web.Request(
        MagicMock(url=URL(last_url), method="GET"),
        MagicMock(),
        protocol=MagicMock(),
        host="example.com",
        task=MagicMock(),
        loop=asyncio.get_running_loop(),
        payload_writer=MagicMock(),
    )
    match_info = await dispatcher.resolve(request_last_url)
    assert match_info.route.resource.canonical == last_url

    async def resolve_times(request: web.Request, times: int) -> float:
        start = timeit.default_timer()
        for _ in range(times):
            await dispatcher.resolve(request)
        end = timeit.default_timer()
        return end - start

    run_count = 2000
    resolve_time_first_url = await resolve_times(request_first_url, run_count)
    resolve_time_last_url = await resolve_times(request_last_url, run_count)
    print(f"resolve_time_first_url: {resolve_time_first_url}")
    print(f"resolve_time_last_url: {resolve_time_last_url}")

asyncio.run(url_dispatcher_performance())

```

### Expected behavior

Ideally the time complexity is at most `O(url_parts)`

### Logs/tracebacks

```python-traceback
n/a
```

### Python Version

```console
n/a
```

### aiohttp Version

```console
master
```

### multidict Version

```console
n/a
```

### yarl Version

```console
n/a
```

### OS

n/a

### Related component

Server

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct


diff --git a/oauthenticator/google.py b/oauthenticator/google.py
index f06f6aca..28b76d48 100644
--- a/oauthenticator/google.py
+++ b/oauthenticator/google.py
@@ -14,6 +14,7 @@

 class GoogleOAuthenticator(OAuthenticator, GoogleOAuth2Mixin):
     user_auth_state_key = "google_user"
+    _service_credentials = {}

     @default("login_service")
     def _login_service_default(self):
@@ -243,7 +244,7 @@ async def update_auth_model(self, auth_model):

         user_groups = set()
         if self.allowed_google_groups or self.admin_google_groups:
-            user_groups = self._fetch_member_groups(user_email, user_domain)
+            user_groups = await self._fetch_member_groups(user_email, user_domain)
         # sets are not JSONable, cast to list for auth_state
         user_info["google_groups"] = list(user_groups)

@@ -314,6 +315,36 @@ async def check_allowed(self, username, auth_model):
         # users should be explicitly allowed via config, otherwise they aren't
         return False

+    def _get_service_credentials(self, user_email_domain):
+        """
+        Returns the stored credentials or fetches and stores new ones.
+
+        Checks if the credentials are valid before returning them. Refreshes
+        if necessary and stores the refreshed credentials.
+        """
+        if (
+            user_email_domain not in self._service_credentials
+            or not self._is_token_valid(user_email_domain)
+        ):
+            self._service_credentials[user_email_domain] = (
+                self._setup_service_credentials(user_email_domain)
+            )
+
+        return self._service_credentials
+
+    def _is_token_valid(self, user_email_domain):
+        """
+        Checks if the stored token is valid.
+        """
+        if not self._service_credentials[user_email_domain]:
+            return False
+        if not self._service_credentials[user_email_domain].token:
+            return False
+        if self._service_credentials[user_email_domain].expired:
+            return False
+
+        return True
+
     def _service_client_credentials(self, scopes, user_email_domain):
         """
         Return a configured service client credentials for the API.
@@ -338,71 +369,57 @@ def _service_client_credentials(self, scopes, user_email_domain):

         return credentials

-    def _service_client(self, service_name, service_version, credentials, http=None):
+    def _setup_service_credentials(self, user_email_domain):
         """
-        Return a configured service client for the API.
+        Set up the oauth credentials for Google API.
         """
+        credentials = self._service_client_credentials(
+            scopes=[f"{self.google_api_url}/auth/admin.directory.group.readonly"],
+            user_email_domain=user_email_domain,
+        )
+
         try:
-            from googleapiclient.discovery import build
+            from google.auth.transport import requests
         except:
             raise ImportError(
-                "Could not import googleapiclient.discovery's build,"
+                "Could not import google.auth.transport's requests,"
                 "you may need to run 'pip install oauthenticator[googlegroups]' or not declare google groups"
             )

-        self.log.debug(
-            f"service_name is {service_name}, service_version is {service_version}"
-        )
-
-        return build(
-            serviceName=service_name,
-            version=service_version,
-            credentials=credentials,
-            cache_discovery=False,
-            http=http,
-        )
-
-    def _setup_service(self, user_email_domain, http=None):
-        """
-        Set up the service client for Google API.
-        """
-        credentials = self._service_client_credentials(
-            scopes=[f"{self.google_api_url}/auth/admin.directory.group.readonly"],
-            user_email_domain=user_email_domain,
-        )
-        service = self._service_client(
-            service_name='admin',
-            service_version='directory_v1',
-            credentials=credentials,
-            http=http,
-        )
-        return service
+        request = requests.Request()
+        credentials.refresh(request)
+        self.log.debug(f"Credentials refreshed for {user_email_domain}")
+        return credentials

-    def _fetch_member_groups(
+    async def _fetch_member_groups(
         self,
         member_email,
         user_email_domain,
         http=None,
         checked_groups=None,
         processed_groups=None,
+        credentials=None,
     ):
         """
         Return a set with the google groups a given user/group is a member of, including nested groups if allowed.
         """
-        # FIXME: When this function is used and waiting for web request
-        #        responses, JupyterHub gets blocked from doing other things.
-        #        Ideally the web requests should be made using an async client
-        #        that can be awaited while JupyterHub handles other things.
-        #
-        if not hasattr(self, 'service'):
-            self.service = self._setup_service(user_email_domain, http)
-
+        # WARNING: There's a race condition here if multiple users login at the same time.
+        # This is currently ignored.
+        credentials = credentials or self._get_service_credentials(user_email_domain)
+        token = credentials[user_email_domain].token
         checked_groups = checked_groups or set()
         processed_groups = processed_groups or set()

-        resp = self.service.groups().list(userKey=member_email).execute()
+        headers = {'Authorization': f'Bearer {token}'}
+        url = f'https://www.googleapis.com/admin/directory/v1/groups?userKey={member_email}'
+        group_data = await self.httpfetch(
+            url, headers=headers, label="fetching google groups"
+        )
+
         member_groups = {
-            g['email'].split('@')[0] for g in resp.get('groups', []) if g.get('email')
+            g['email'].split('@')[0]
+            for g in group_data.get('groups', [])
+            if g.get('email')
         }
         self.log.debug(f"Fetched groups for {member_email}: {member_groups}")

@@ -414,7 +431,7 @@ def _fetch_member_groups(
                 if group in processed_groups:
                     continue
                 processed_groups.add(group)
-                nested_groups = self._fetch_member_groups(
+                nested_groups = await self._fetch_member_groups(
                     f"{group}@{user_email_domain}",
                     user_email_domain,
                     http,
diff --git a/setup.py b/setup.py
index 480e04d3..615695be 100644
--- a/setup.py
+++ b/setup.py
@@ -91,7 +91,6 @@ def run(self):
     # googlegroups is required for use of GoogleOAuthenticator configured with
     # either admin_google_groups and/or allowed_google_groups.
     'googlegroups': [
-        'google-api-python-client',
         'google-auth-oauthlib',
     ],
     # mediawiki is required for use of MWOAuthenticator
@@ -105,7 +104,6 @@ def run(self):
         'pytest-cov',
         'requests-mock',
         # dependencies from googlegroups:
-        'google-api-python-client',
         'google-auth-oauthlib',
         # dependencies from mediawiki:
         'mwoauth>=0.3.8',

diff --git a/oauthenticator/tests/test_google.py b/oauthenticator/tests/test_google.py
index 4c3a9e0c..070de014 100644
--- a/oauthenticator/tests/test_google.py
+++ b/oauthenticator/tests/test_google.py
@@ -3,6 +3,7 @@
 import logging
 import re
 from unittest import mock
+from unittest.mock import AsyncMock

 from pytest import fixture, mark, raises
 from traitlets.config import Config
@@ -211,7 +212,7 @@ async def test_google(
     handled_user_model = user_model("user1@example.com", "user1")
     handler = google_client.handler_for_user(handled_user_model)
     with mock.patch.object(
-        authenticator, "_fetch_member_groups", lambda *args: {"group1"}
+        authenticator, "_fetch_member_groups", AsyncMock(return_value={"group1"})
     ):
         auth_model = await authenticator.get_authenticated_user(handler, None)

The google authenticator's calls to check groups is done sync, can it be made async?
Quickly written, I can fill this in in more detail later.


diff --git a/changelog/2542.internal.rst b/changelog/2542.internal.rst
new file mode 100644
index 0000000000..628869c5d5
--- /dev/null
+++ b/changelog/2542.internal.rst
@@ -0,0 +1,1 @@
+Refactored `~plasmapy.formulary.lengths.gyroradius` to reduce the cognitive complexity of the function.
diff --git a/plasmapy/formulary/lengths.py b/plasmapy/formulary/lengths.py
index fd7de3d96a..5fd35df826 100644
--- a/plasmapy/formulary/lengths.py
+++ b/plasmapy/formulary/lengths.py
@@ -103,7 +103,7 @@ def Debye_length(T_e: u.Quantity[u.K], n_e: u.Quantity[u.m**-3]) -> u.Quantity[u
     validations_on_return={"equivalencies": u.dimensionless_angles()},
 )
 @particle_input(any_of={"charged", "uncharged"})
-def gyroradius(  # noqa: C901
+def gyroradius(
     B: u.Quantity[u.T],
     particle: ParticleLike,
     *,
@@ -250,133 +250,67 @@ def gyroradius(  # noqa: C901
     <Quantity 0.30428378 pc>
     """

-    # Define helper functions for input processing and gyroradius calculation
-
-    # check 1: ensure either Vperp or T invalid, keeping in mind that
-    # the underlying values of the astropy quantity may be numpy arrays
-    def _raise_error_if_both_vperp_and_t_are_given(isfinite_Vperp, isfinite_T):
-        if np.any(np.logical_and(isfinite_Vperp, isfinite_T)):
-            raise ValueError(
-                "Must give Vperp or T, but not both, as arguments to gyroradius"
-            )
-
-    def _raise_error_if_lorentzfactor_not_scalar(lorentzfactor):
-        if nans_in_both_T_and_Vperp and not np.isscalar(lorentzfactor):
-            raise ValueError(
-                "Inferring velocity(s) from more than one Lorentz "
-                "factor is not currently supported"
-            )
-
-    def _calculate_vperp_from_lorentzfactor(
-        isfinite_Vperp, Vperp, particle, lorentzfactor, relativistic
-    ):
-        if relativistic and nans_in_both_T_and_Vperp:
-            Vperp = np.copy(Vperp)
-            rbody = RelativisticBody(particle, lorentz_factor=lorentzfactor)
-            Vperp[~isfinite_Vperp] = rbody.velocity
-        return Vperp
-
-    def _warn_if_lorentz_factor_and_relativistic(
-        isfinite_lorentzfactor, relativistic
-    ) -> None:
-        if np.any(isfinite_lorentzfactor) and relativistic:
-            warnings.warn(
-                "lorentzfactor is given along with Vperp or T, will lead "
-                "to inaccurate predictions unless they correspond"
-            )
-
-    # check 2: get Vperp as the thermal speed if is not already a valid input
-    def get_Vperp(T, Vperp, particle, isfinite_T, isfinite_Vperp):
-        is_scalar_Vperp = np.isscalar(Vperp.value)
-        is_scalar_T = np.isscalar(T.value)
-
-        if is_scalar_Vperp and is_scalar_T:
-            # both T and Vperp are scalars
-            # we know exactly one of them is nan from check 1
-            return speeds.thermal_speed(T, particle=particle) if isfinite_T else Vperp
-            # T is valid, so use it to determine Vperp
-            # else: Vperp is already valid, do nothing
-
-        elif is_scalar_Vperp:
-            # this means either Vperp must be nan, or T must be an array of all nan,
-            # or else we couldn't have gotten through check 1
-            return (
-                np.repeat(Vperp, len(T))
-                if isfinite_Vperp
-                # Vperp is valid, T is a vector that is all nan
-                else speeds.thermal_speed(T, particle=particle)
-                # normal case where Vperp is scalar nan and T is valid array
-            )
-
-        elif is_scalar_T:  # only Vperp is an array
-            # this means either T must be nan, or V_perp must be an array of all nan,
-            # or else we couldn't have gotten through check 1
-            return (
-                speeds.thermal_speed(np.repeat(T, len(Vperp)), particle=particle)
-                if isfinite_T
-                # T is valid, V_perp is an array of all nan
-                else Vperp
-                # else: normal case where T is scalar nan and Vperp is already a valid
-                # array so, do nothing
-            )
-        else:  # both T and Vperp are arrays
-            # we know all the elementwise combinations have one nan and one finite,
-            # due to check 1 use the valid Vperps, and replace the others with those
-            # calculated from T
-            Vperp = Vperp.copy()
-            Vperp[isfinite_T] = speeds.thermal_speed(T[isfinite_T], particle=particle)
-            return Vperp
-
-    def get_result(lorentzfactor, isfinite_lorentzfactor, particle, Vperp, omega_ci):
-        if np.all(isfinite_lorentzfactor):
-            return lorentzfactor * np.abs(Vperp) / omega_ci
-
-        elif not np.any(isfinite_lorentzfactor):
-            lorentzfactor = RelativisticBody(particle, V=Vperp).lorentz_factor
-            return lorentzfactor * np.abs(Vperp) / omega_ci
-
-        else:
-            # the lorentzfactor is neither completely valid nor completely invalid,
-            # so we have to correct the missing parts, note that we don't actually
-            # have to check if it is a scalar since scalars cannot be partially valid
-            rbody = RelativisticBody(particle, V=Vperp)
-            lorentzfactor = np.copy(lorentzfactor)
-            lorentzfactor[~isfinite_lorentzfactor] = rbody.lorentz_factor[
-                ~isfinite_lorentzfactor
-            ]
-            return lorentzfactor * np.abs(Vperp) / omega_ci
-
-    # Initial setup and input validation
-    if not relativistic and not np.isnan(lorentzfactor):
-        raise ValueError("Lorentz factor is provided but relativistic is set to false")
-    lorentzfactor = lorentzfactor if relativistic else 1.0
-
     if T is None:
         T = np.nan * u.K

+    # Determine output shape and broadcast inputs accordingly
+    target_shape = np.broadcast(T, Vperp, lorentzfactor, particle).shape  # type: ignore[arg-type]
+    lorentzfactor_in = lorentzfactor
+    lorentzfactor = np.array(np.broadcast_to(lorentzfactor, target_shape))
+    Vperp = np.array(np.broadcast_to(Vperp, target_shape, subok=True), subok=True)
+    T = np.array(np.broadcast_to(T, target_shape, subok=True), subok=True)
+
     isfinite_T = np.isfinite(T)
     isfinite_Vperp = np.isfinite(Vperp)
     isfinite_lorentzfactor = np.isfinite(lorentzfactor)
-    nans_in_both_T_and_Vperp = np.any(np.logical_not(isfinite_T)) and np.any(
-        np.logical_not(isfinite_Vperp)
-    )

-    # Call defined functions to process inputs
-    _raise_error_if_both_vperp_and_t_are_given(isfinite_Vperp, isfinite_T)
+    # Check if lorentzfactor is given despite relativistic being false
+    if not relativistic and not np.isnan(lorentzfactor):
+        raise ValueError("Lorentz factor is provided but relativistic is set to false")

-    if nans_in_both_T_and_Vperp:
-        _raise_error_if_lorentzfactor_not_scalar(lorentzfactor)
-        Vperp = _calculate_vperp_from_lorentzfactor(
-            isfinite_Vperp, Vperp, particle, lorentzfactor, relativistic
+    # Check if V and T are both given at the same time at any position
+    if np.any(isfinite_Vperp & isfinite_T):
+        raise ValueError(
+            "Must give Vperp or T, but not both, as arguments to gyroradius"
         )

-    _warn_if_lorentz_factor_and_relativistic(isfinite_lorentzfactor, relativistic)
+    # Check if V or T and lorentzfactor are both given at the same time at any position
+    if np.any(isfinite_lorentzfactor & (isfinite_Vperp | isfinite_T)):
+        warnings.warn(
+            "lorentzfactor is given along with Vperp or T, will lead "
+            "to inaccurate predictions unless they correspond"
+        )

-    Vperp = get_Vperp(T, Vperp, particle, isfinite_T, isfinite_Vperp)
+    # Check if V and T are both missing at any position but lorentzfactor is not scalar
+    if np.any(~isfinite_Vperp & ~isfinite_T) and not np.isscalar(lorentzfactor_in):
+        raise ValueError(
+            "Inferring velocity(s) from more than one Lorentz "
+            "factor is not currently supported"
+        )

-    omega_ci = frequencies.gyrofrequency(B, particle)
+    # In the positions where Vperp is missing but T is given, calculate the thermal speed
+    if np.any(isfinite_T):
+        Vperp[isfinite_T] = speeds.thermal_speed(T[isfinite_T], particle=particle)
+    isfinite_Vperp = np.isfinite(Vperp)

-    return get_result(lorentzfactor, isfinite_lorentzfactor, particle, Vperp, omega_ci)
+    # In the positions where Vperp is still missing, calculate Vperp from lorentzfactor
+    if np.any(~isfinite_Vperp):
+        Vperp[~isfinite_Vperp] = RelativisticBody(
+            particle, lorentz_factor=lorentzfactor_in
+        ).velocity
+
+    # Calculate the final lorentzfactor
+    if relativistic:
+        # Fill in missing entries of lorentzfactor by calculating it using Vperp
+        lorentzfactor[~isfinite_lorentzfactor] = RelativisticBody(
+            particle, V=Vperp
+        ).lorentz_factor[~isfinite_lorentzfactor]
+    else:
+        lorentzfactor = 1.0
+
+    # Calculate gyrofrequency and finally the gyroradius
+    omega_ci = frequencies.gyrofrequency(B, particle)
+    return lorentzfactor * np.abs(Vperp) / omega_ci

 rc_ = gyroradius

Reduce cognitive complexity of `gyroradius`
Right now [`plasmapy.formulary.lengths.gyroradius`](https://github.com/PlasmaPy/PlasmaPy/blob/fc12bbd286ae9db82ab313d8c380808b814a462c/plasmapy/formulary/lengths.py#LL102C5-L102C15) has several nested conditionals.  As a consequence, the code for `gyroradius` has high [cognitive complexity](https://www.sonarsource.com/docs/CognitiveComplexity.pdf), which makes it hard to read and hard to change. Part of the reason is that we are enabling `gyroradius` to get the speed perpendicular to the magnetic field using a bunch of methods, while also making sure that `gyroradius` works with `Quantity` arrays and validating inputs.

It would be helpful to clean up the function to reduce its cognitive complexity.  This could include breaking out some of the content of conditionals into private functions, possibly with function names that say what it is doing instead of using comments.

This could be a good first contribution, but would probably require some familiarity with `astropy.units` and `RelativisticBody`.  Pull requests that partially address this would be welcome too.

Come to think of it, once PlasmaPy goes Python 3.10+ (for our first release after April 2024), we might be able to use [structural pattern matching](https://docs.python.org/3/whatsnew/3.10.html#pep-634-structural-pattern-matching).

Another possibility here would be to use [guard clauses](https://medium.com/lemon-code/guard-clauses-3bc0cd96a2d3).
Hi, I'm a first time contributor and I took a crack at a solution which involves refactoring the conditionals into separate functions. I'd like to submit a pull request with my changes. Please let know if you have any feedback or concerns.


diff --git a/crepr/crepr.py b/crepr/crepr.py
index f6d3f98..c3a4109 100644
--- a/crepr/crepr.py
+++ b/crepr/crepr.py
@@ -12,6 +12,7 @@
 import inspect
 import pathlib
 import uuid
+from collections.abc import Callable
 from collections.abc import Iterable
 from collections.abc import Iterator
 from types import MappingProxyType
@@ -353,6 +354,32 @@ def get_modules(
         yield module, file_path

+def print_changes(changes: dict[int, Change], action: str) -> None:
+    """Print changes for each class."""
+    for change in changes.values():
+        typer.echo(f"__repr__ {action} for class: {change['class_name']}")
+        typer.echo("\n".join(change["lines"]))
+        typer.echo("")
+
+
+def apply_changes(
+    module: ModuleType,
+    changes: dict[int, Change],
+    file_path: pathlib.Path,
+    diff: bool,  # noqa: FBT001
+    change_func: Callable[[ModuleType, dict[int, Change]], list[str]],
+) -> None:
+    """Apply changes to the module and handle diff or file writing."""
+    src = change_func(module, changes)
+    if diff:
+        before = inspect.getsource(module).splitlines()
+        _diff = difflib.unified_diff(before, src, lineterm="")
+        typer.echo("\n".join(_diff))
+    else:
+        with file_path.open(mode="w", encoding="UTF-8") as f:
+            f.write("\n".join(src))
+
+
 @app.command()
 def add(
     files: Annotated[list[pathlib.Path], file_arg],
@@ -365,17 +392,17 @@ def add(
         changes = create_repr(module, kwarg_splat, ignore_existing)
         if not changes:
             continue
-        src = insert_changes(module, changes)
+
         if diff is None:
-            typer.echo("\n".join(src))
-        elif diff:
-            before = inspect.getsource(module).splitlines()
-            _diff = difflib.unified_diff(before, src, lineterm="")
-            typer.echo("\n".join(_diff))
-            continue
+            print_changes(changes, "generated")
         else:
-            with file_path.open(mode="w", encoding="UTF-8") as f:
-                f.write("\n".join(src))
+            apply_changes(
+                module,
+                changes,
+                file_path,
+                diff=diff,
+                change_func=insert_changes,
+            )

 @app.command()
@@ -388,17 +415,17 @@ def remove(
         changes = remove_repr(module)
         if not changes:
             continue
-        src = remove_changes(module, changes)
+
         if diff is None:
-            typer.echo("\n".join(src))
-        elif diff:
-            before = inspect.getsource(module).splitlines()
-            _diff = difflib.unified_diff(before, src, lineterm="")
-            typer.echo("\n".join(_diff))
-            continue
+            print_changes(changes, "removed")
         else:
-            with file_path.open(mode="w", encoding="UTF-8") as f:
-                f.write("\n".join(src))
+            apply_changes(
+                module,
+                changes,
+                file_path,
+                diff=diff,
+                change_func=remove_changes,
+            )

 @app.command()

diff --git a/tests/run_test.py b/tests/run_test.py
index f1582b5..307aced 100644
--- a/tests/run_test.py
+++ b/tests/run_test.py
@@ -188,8 +188,9 @@ def test_show() -> None:
     result = runner.invoke(crepr.app, ["add", "tests/classes/kw_only_test.py"])

     assert result.exit_code == 0
+    assert "__repr__ generated for class: KwOnly" in result.stdout
     assert "Create a string (c)representation for KwOnly" in result.stdout
-    assert len(result.stdout.splitlines()) == 20
+    assert len(result.stdout.splitlines()) == 10

 def test_show_no_init() -> None:
@@ -275,8 +276,8 @@ def test_show_remove() -> None:
     result = runner.invoke(crepr.app, ["remove", "tests/remove/kw_only_test.py"])

     assert result.exit_code == 0
-    assert "__repr__" not in result.stdout
-    assert len(result.stdout.splitlines()) == 13
+    assert "__repr__ removed for class: KwOnly" in result.stdout
+    assert len(result.stdout.splitlines()) == 10

 def test_show_remove_no_repr() -> None:
@@ -366,3 +367,35 @@ def test_add_ignore_existing_true() -> None:
         assert "Create a string (c)representation for ExistingRepr" not in content

     pathlib.Path.unlink(temp_file_path)
+
+
+def test_add_show_only_changes() -> None:
+    """Test that only proposed changes are shown without --diff or --inline."""
+    result = runner.invoke(crepr.app, ["add", "tests/classes/kw_only_test.py"])
+
+    assert result.exit_code == 0
+    assert "__repr__ generated for class: KwOnly" in result.stdout
+    assert "def __repr__(self) -> str:" in result.stdout
+    assert (
+        "return (f'{self.__class__.__module__}.{self.__class__.__name__}("
+        in result.stdout
+    )
+    assert "f'name={self.name!r}, '" in result.stdout
+    assert "f'age={self.age!r}, '" in result.stdout
+    assert "')')" in result.stdout
+
+
+def test_remove_show_only_changes() -> None:
+    """Test that only proposed removals are shown without --diff or --inline."""
+    result = runner.invoke(crepr.app, ["remove", "tests/remove/kw_only_test.py"])
+
+    assert result.exit_code == 0
+    assert "__repr__ removed for class: KwOnly" in result.stdout
+    assert "def __repr__(self: Self) -> str:" in result.stdout
+    assert '"""Create a string (c)representation for KwOnly."""' in result.stdout
+    assert "return (" in result.stdout
+    assert 'f"{self.__class__.__module__}.{self.__class__.__name__}("' in result.stdout
+    assert 'f"name={self.name!r}, "' in result.stdout
+    assert 'f"age={self.age!r}, "' in result.stdout
+    assert '")"\n' in result.stdout
+    assert ")" in result.stdout

Sparse output of the changes.
For the `add` and `remove` commands:
Without the `--diff` or `--inline` switch, only the proposed changes should be presented on the screen, with the name of the class the `__repr__` is generated or removed from.

enhancement
Tests
Review effort [1-5]: 2
hacktoberfest-accepted