Age-1 exclusion workflow#

Not reinventing the wheel

This notebook documents how to integrate age-1 exclusion into the previous year-specific workflow example. Rather than reproducing the full script, this page isolates the exact parameter triggers, conditional processing blocks, and output variables that enable the age-1 exclusion pathway. It also explains how to expose the exclusion behavior as a CLI flag so the same year-specific script can produce either an all-ages run or an age-2+ run without modifying the source code. The discussion uses the same technical framing as the workflow overview and builds directly on the year-specific workflows and CLI walkthrough pages.

Architecture overview#

The age-1 exclusion branch does not require a separate workflow script. It is implemented as a set of conditional blocks inside an existing year-specific script (hake_<year>.py), gated by two parameters: REMOVE_AGE1 and AGE1_DOMINATED_HAULS. The dispatcher (year_specific_workflow.py) remains unchanged in structure. A new --remove-age1 CLI flag is forwarded from the dispatcher to the year-specific script, where a shared hook in cli_utils.py reads the flag and sets REMOVE_AGE1 at runtime. This means the same hake_<year>.py file can produce a full all-ages run or an age-2+ run from the same CLI contract, controlled entirely by whether --remove-age1 is passed.

../_images/age1_exclusion_workflow_steps.svg

Source files and usage#

Three cooperating source layers implement the age-1 exclusion contract. The dispatcher layer in year_specific_workflow.py parses --year, --verbose, --compare, and the new --remove-age1 flag, then routes execution to the selected year script using runpy.run_path. The shared-hook layer in cli_utils.py defines get_verbose(), get_compare(), and the new get_remove_age1(), which year scripts consume to set VERBOSE, COMPARE, and REMOVE_AGE1 at runtime without touching their source code. The year-specific script layer contains all year-specific file paths and parameters and includes the three conditional age-1 blocks that reference REMOVE_AGE1 and AGE1_DOMINATED_HAULS.

../_images/age1_exclusion_orchestration_flow.svg

Dispatcher CLI arguments#

The dispatcher interface adds one new flag to the existing set:

  • --year (required): Selects which year script to execute (for example, hake_2019.py or hake_2023.py).

  • --verbose (optional flag): Enables stage-level logging output in the selected year script.

  • --compare (optional flag): Enables the EchoPro-vs-Echopop comparison pathway.

  • --remove-age1 (optional flag): New. Signals the year script to activate the age-1 exclusion branch, which removes haul-level and signal-level age-1 contributions and produces age-2+ estimates.

Because the dispatcher uses parse_known_args(), the --remove-age1 flag is forwarded to the year script without being consumed or dropped. The year script then reads the flag through the get_remove_age1() hook and uses it to set REMOVE_AGE1 before the processing logic begins. An empty AGE1_DOMINATED_HAULS = [] still controls the optional haul filter independently; users who have identified hauls dominated by age-1 fish can populate this list regardless of whether --remove-age1 is set.

Dispatcher CLI design#

The updated dispatcher adds --remove-age1 alongside the existing flags and forwards it to the year script through the same sys.argv forwarding pattern.

year_specific_workflow.py — updated with --remove-age1
# CLI hook
import argparse
import runpy
import sys
import os

# Set up argument parser and parsing
parser = argparse.ArgumentParser()
parser.add_argument("--year", required=True, help="Which workflow script to run (e.g. hake_1995)")
parser.add_argument("--verbose", action="store_true", help="Enable verbose logging")
parser.add_argument("--compare", action="store_false", help="Compare output reports to EchoPro")
parser.add_argument("--remove-age1", action="store_true", help="Activate age-1 exclusion branch")  # NEW
args, unknown = parser.parse_known_args()

# Pass additional CLI args to the target script
sys.argv = [args.year + ".py"] + unknown
if args.verbose:
    sys.argv.append("--verbose")
if args.compare:
    sys.argv.append("--compare")
if args.remove_age1:                          # NEW
    sys.argv.append("--remove-age1")          # NEW

# Get the script path
script_path = os.path.join(os.path.dirname(__file__), f"{args.year}.py")
runpy.run_path(script_path, run_name="__main__")

The only change from the original dispatcher is three lines: the add_argument declaration for --remove-age1 and the conditional sys.argv.append block that forwards it when the flag is present. All other routing and execution logic is unchanged.

Shared hooks and workflow integration#

Year-specific scripts use shared utility hooks from cli_utils.py to read CLI state at runtime. Adding get_remove_age1() follows the same pattern as get_verbose() and get_compare(), keeping hook semantics consistent across all flags.

cli_utils.py — updated with get_remove_age1()
import argparse

def get_verbose():
    parser = argparse.ArgumentParser()
    parser.add_argument("--verbose", action="store_true", help="Enable verbose logging")
    args, unknown = parser.parse_known_args()
    return args.verbose

def get_compare():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--compare", action="store_false", help="Compare generated reports to EchoPro"
    )
    args, unknown = parser.parse_known_args()
    return args.compare

def get_remove_age1():                                  # NEW
    parser = argparse.ArgumentParser()                  # NEW
    parser.add_argument(                                # NEW
        "--remove-age1",                                # NEW
        action="store_true",                            # NEW
        help="Activate age-1 exclusion branch",         # NEW
    )                                                   # NEW
    args, unknown = parser.parse_known_args()           # NEW
    return args.remove_age1                             # NEW

This new hook is incorporated into each year-specific script by replacing the hard-coded REMOVE_AGE1 = True or REMOVE_AGE1 = False assignment in the parameter block with a try/except pattern that mirrors how VERBOSE and COMPARE are already handled:

hake_<year>.py — replacing REMOVE_AGE1 with CLI hook
from echopop.example_workflows import cli_utils

# PRINT CONSOLE LOGGING MESSAGES
try:
    VERBOSE = cli_utils.get_verbose()
except Exception:
    VERBOSE = True

# ...other parameter block entries...

# REMOVE AGE-1 (I.E., AGE-2+ ONLY)?
# ---- When True (or when --remove-age1 is passed via CLI), the age-1 signal will be
# ---- removed from NASC, abundance, and biomass estimates.
try:
    REMOVE_AGE1 = cli_utils.get_remove_age1()   # FOR CLI USE
except Exception:
    REMOVE_AGE1 = False                          # FOR INTERACTIVE REPL USE

# BIODATA PROCESSING: AGE-1 DOMINATED HAULS
# ---- This is a list of age-1 dominated haul numbers designated for removal. If no
# ---- hauls should be removed, set AGE1_DOMINATED_HAULS to [].
AGE1_DOMINATED_HAULS = []

The try/except fallback allows the same year script to run cleanly from the CLI (where --remove-age1 may or may not be present), from a direct Python invocation, and from an interactive REPL session, while still defaulting to a predictable behavior when the flag is absent.

Decision points in the script#

With REMOVE_AGE1 resolved from the CLI hook, the age-1 logic appears in four specific places in the workflow. Every decision point — including the haul filter — must be gated on REMOVE_AGE1 so that an all-ages run (no --remove-age1) leaves the data completely untouched even if AGE1_DOMINATED_HAULS contains values. The snippets below show the correct conditional form for each placement.

1 — Optional haul removal (AGE1_DOMINATED_HAULS) (after ingestion, before stratification)

This block runs immediately after NASC and biodata are ingested and before any stratification joins are applied. The outer if REMOVE_AGE1 guard ensures the haul filter is skipped entirely on an all-ages run. Hauls removed here never appear in UID construction, binning tables, or proportion calculations.

# AGE-1 DOMINATED HAUL REMOVAL
if REMOVE_AGE1 and len(AGE1_DOMINATED_HAULS) > 0:
    logging.info(
        f"The following age-1 dominated haul numbers have been designated for removal:\n"
        f"{', '.join(map(str, AGE1_DOMINATED_HAULS))}."
    )
    dict_df_bio = {
        key: utils.apply_filters(dataset, exclude_filter={"haul_num": AGE1_DOMINATED_HAULS})
        for key, dataset in dict_df_bio.items()
    }
    logging.info(
        f"Age-1 dominated hauls successfully removed:\n"
        f"{', '.join(map(str, AGE1_DOMINATED_HAULS))}."
    )
2 — Age-1 proportion extraction (after binning and count tables, before inversion)

This block runs after length-weight regression, binned weight computation, and the number/weight proportion tables are fully constructed. Those tables supply the stratum-by-age slices that the removal step needs. The entire block is wrapped in if REMOVE_AGE1 so on an all-ages run no slice arrays are computed and the variable names da_age1_nasc_proportions, da_age1_number_proportions, and da_age1_weight_proportions do not exist.

if REMOVE_AGE1:
    # NASC proportions attributed to age-1
    da_age1_nasc_proportions = proportions.get_nasc_proportions_slice(
        number_proportions=dict_ds_number_proportion["aged"],
        group_columns=["stratum_ks"],
        ts_length_regression_parameters={"slope": 20.0, "intercept": -68.0},
        include_filter={"age_bin": [1]},
    )

    # Number proportions attributed to age-1
    da_age1_number_proportions = proportions.get_number_proportions_slice(
        number_proportions=dict_ds_number_proportion["aged"],
        stratum_dim="stratum_ks",
        include_filter={"age_bin": [1]},
    )

    # Weight proportions attributed to age-1
    da_age1_weight_proportions = proportions.get_weight_proportions_slice(
        weight_proportions=dict_da_weight_proportion["aged"],
        stratum_dim="stratum_ks",
        include_filter={"age_bin": [1]},
        number_proportions=dict_ds_number_proportion,
        length_threshold_min=10.0,
        weight_proportion_threshold=1e-10,
    )
3 — Remove age-1 signal from transect estimates (after inversion and abundance/biomass computation)

This block runs immediately after abundance and biomass are computed from the inverted NASC. When REMOVE_AGE1 is True, the proportions extracted above are used to subtract the age-1 signal and produce df_nasc_noage1. When it is False, df_nasc_noage1 is simply a copy of df_nasc so all downstream stages can reference the same variable name regardless of which branch ran.

if REMOVE_AGE1:
    df_nasc_noage1 = apportionment.remove_group_from_estimates(
        transect_data=df_nasc,
        group_proportions=xr.Dataset({
            "nasc": da_age1_nasc_proportions,
            "abundance": da_age1_number_proportions,
            "biomass": da_age1_weight_proportions,
        }),
    )
else:
    df_nasc_noage1 = df_nasc.copy()
4 — Redistribute kriged age-1 estimates (after kriging, before reporting)

After kriging, the consolidated abundance and biomass tables still carry an age-1 bin because the age-distribution proportions are applied to the age-1-removed NASC. When REMOVE_AGE1 is True, the redistribution step moves any residual age-1 counts into the age-2+ bins before final reporting. When it is False, the tables are passed through unchanged.

if REMOVE_AGE1:
    da_kriged_abundance_table_noage1 = apportionment.reallocate_excluded_estimates(
        population_table=da_kriged_abundance_table,
        exclusion_filter={"age_bin": [1]},
        group_columns=["sex"],
    )

    da_kriged_biomass_table_noage1 = apportionment.reallocate_excluded_estimates(
        population_table=da_kriged_biomass_table,
        exclusion_filter={"age_bin": [1]},
        group_columns=["sex"],
    )
else:
    da_kriged_abundance_table_noage1 = da_kriged_abundance_table
    da_kriged_biomass_table_noage1 = da_kriged_biomass_table

CLI usage examples by platform#

In each platform variant below, --remove-age1 is added after the year target. All other flags behave identically to the year-specific workflow contract. Omitting --remove-age1 produces a full all-ages run from the same year script.

REM Age-2+ only run
python year_specific_workflow.py --year hake_2019 --remove-age1

REM Age-2+ only with verbose logging
python year_specific_workflow.py --year hake_2019 --verbose --remove-age1

REM Age-2+ only with verbose logging and EchoPro comparison
python year_specific_workflow.py --year hake_2019 --verbose --compare --remove-age1

REM Standard all-ages run (no flag needed)
python year_specific_workflow.py --year hake_2019 --verbose
# Age-2+ only run
python year_specific_workflow.py --year hake_2019 --remove-age1

# Age-2+ only with verbose logging
python year_specific_workflow.py --year hake_2019 --verbose --remove-age1

# Age-2+ only with verbose logging and EchoPro comparison
python year_specific_workflow.py --year hake_2019 --verbose --compare --remove-age1

# Standard all-ages run (no flag needed)
python year_specific_workflow.py --year hake_2019 --verbose
# Age-2+ only run
python3 year_specific_workflow.py --year hake_2019 --remove-age1

# Age-2+ only with verbose logging
python3 year_specific_workflow.py --year hake_2019 --verbose --remove-age1

# Age-2+ only with verbose logging and EchoPro comparison
python3 year_specific_workflow.py --year hake_2019 --verbose --compare --remove-age1

# Standard all-ages run (no flag needed)
python3 year_specific_workflow.py --year hake_2019 --verbose

Notebook-driven orchestration#

A notebook can serve as a lightweight orchestration surface for launching age-1 exclusion runs when live logs, incremental diagnostics, and reproducible command history are needed in one place. The pattern below adds --remove-age1 to the dispatcher command and streams output in real time so stage progress is visible as it occurs.

The shell escape (!) is the most direct method and is suitable for one-off runs where streaming output is not critical.

# Set year
year = 2019

# Age-2+ only run
!python year_specific_workflow.py --year hake_{year} --verbose --remove-age1

The subprocess module streams log output while the process runs, provides reliable termination on kernel interrupt, and exposes the exit code so failures are immediately visible.

import subprocess

# Parameterize the execution
year = 2019
cmd = [
    "python", "year_specific_workflow.py",
    "--year", f"hake_{year}",
    "--verbose",
    "--remove-age1",
]

print(f"Starting age-2+ workflow for {year}...\n")

# Launch the process with piped streams
process = subprocess.Popen(
    cmd,
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,  # Redirect errors to the standard output stream
    text=True,                 # Decode bytes to strings automatically
    bufsize=1                  # Line-buffered for immediate printing
)

# Iterate over the output stream until the process terminates
try:
    for line in process.stdout:
        print(line, end="")
except KeyboardInterrupt:
    process.kill()
    print("\n[Workflow interrupted by user; process terminated]")

process.wait()
if process.returncode == 0:
    print(f"\nAge-2+ workflow for {year} completed successfully.")
else:
    print(f"\nWorkflow failed with exit code {process.returncode}.")

Batch orchestration across multiple years#

When age-1 exclusion needs to be applied across a series of survey years, wrapper scripts provide a practical control layer that reuses the same dispatcher contract and adds --remove-age1 to every invocation.

To run: Open Command Prompt, navigate to the folder, and type run_hake_age2plus.bat.

@echo off
setlocal enabledelayedexpansion

set YEARS=2015 2017 2019 2021 2023

for %%Y in (%YEARS%) do (
    echo [INFO] Running age-2+ workflow for hake_%%Y ...
    python year_specific_workflow.py --year hake_%%Y --verbose --remove-age1

    if errorlevel 1 (
        echo ❌ Error encountered for year %%Y
    ) else (
        echo ✅ Completed year %%Y
    )
)

To run: Open PowerShell, navigate to the folder, and type .\run_hake_age2plus.ps1.

$years = @(2015, 2017, 2019, 2021, 2023)
$failedYears = @()

foreach ($year in $years) {
    Write-Host "Running age-2+ workflow for year $year..."
    python year_specific_workflow.py --year "hake_$year" --verbose --remove-age1

    if ($LASTEXITCODE -eq 0) {
        Write-Host "✅ Completed year $year" -ForegroundColor Green
    } else {
        Write-Host "❌ Error encountered for year $year" -ForegroundColor Red
        $failedYears += $year
    }
}

if ($failedYears.Count -gt 0) {
    Write-Host "`nSummary of Failures: $($failedYears -join ', ')"
}

To run: Open Terminal, navigate to the folder, and type ./run_hake_age2plus.sh.

#!/usr/bin/env bash
set -u

years=(2015 2017 2019 2021 2023)
failed_years=()

for year in "${years[@]}"; do
  echo "Running age-2+ workflow for year ${year}..."

  if python3 year_specific_workflow.py --year "hake_${year}" --verbose --remove-age1; then
    echo "✅ Completed year ${year}"
  else
    echo "❌ Error encountered for year ${year}"
    failed_years+=("$year")
  fi
done

if [ ${#failed_years[@]} -gt 0 ]; then
  echo -e "\nSummary of Failures: ${failed_years[*]}"
  exit 1
fi

Practical notes#

Choosing between REMOVE_AGE1 and AGE1_DOMINATED_HAULS#

These two parameters address different levels of the exclusion problem. REMOVE_AGE1 removes the age-1 signal from the population estimates by scaling NASC, abundance, and biomass proportionally, and it operates at the stratum level across all transect intervals. AGE1_DOMINATED_HAULS is a haul-level filter that removes specific trawl hauls from both the NASC and biodata tables before any processing begins. The haul filter is appropriate when certain trawls are so heavily dominated by age-1 fish that their presence would distort the proportion tables even after signal removal. The two mechanisms are independent and can be used together, in isolation, or not at all.

Implementing age-1 exclusion in a new workflow#

To add age-1 exclusion to a new year script, add get_remove_age1() to cli_utils.py, add --remove-age1 to the dispatcher, and then add four blocks to the year script: a try/except block in the parameter section to set REMOVE_AGE1 from the CLI hook; the haul-removal guard after ingestion; the age-1 proportion extraction after binning and count tables; and the remove_group_from_estimates call after inversion along with the reallocate_excluded_estimates call after kriging.

Reproducibility note

Always document the AGE1_DOMINATED_HAULS list and whether --remove-age1 was passed alongside your run outputs. Because REMOVE_AGE1 defaults to False in interactive mode, an undocumented run may produce all-ages results even when age-1 exclusion was intended.