Menu

runParameterSweep.sh

runParameterSweep.sh

#!/bin/bash
# runParameterSweep.sh
#
# Run a bretherton-drops-bubbles parameter sweep from the repository root.
# The script reads SWEEP_* variables from a sweep config file, generates
# case-specific parameter files with incrementing CaseNo, then runs each case
# using runSimulation.sh (sequentially by default, optionally in parallel).

set -euo pipefail

SCRIPT_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)"

usage() {
  cat <<'EOF'
Usage: bash runParameterSweep.sh [sweep_file] [OPTIONS]

Arguments:
  sweep_file     Sweep config path (default: sweep.params)

Options:
  --exec FILE    C source in simulationCases/ (default: bretherton.c)
  --threads N    OpenMP thread count per case (default: 1)
  --parallel N   Maximum concurrent cases in the sweep (default: 1)
  -n, --dry-run  Show generated parameter combinations only
  -v, --verbose  Print expanded per-case parameter details
  -h, --help     Show this help message

Environment:
  OUTPUT_ROOT    Directory holding per-case output directories, passed
                 through to runSimulation.sh (default: simulationCases/
                 inside the repository). Export it to keep campaign data on
                 a registered volume rather than in the source checkout.
EOF
}

trim() {
  local s="\$1"
  s="\${s#"\${s%%[![:space:]]*}"}"
  s="\${s%"\${s##*[![:space:]]}"}"
  printf '%s' "\$s"
}

get_param_value() {
  local key="\$1"
  local file="\$2"
  awk -F '=' -v key="\$key" '
    /^[[:space:]]*#/ { next }
    {
      k = \$1
      gsub(/^[[:space:]]+|[[:space:]]+\$/, "", k)
      if (k == key) {
        v = \$2
        sub(/[[:space:]]*#.*/, "", v)
        gsub(/^[[:space:]]+|[[:space:]]+\$/, "", v)
        print v
        exit
      }
    }
  ' "\$file"
}

set_param_in_file() {
  local key="\$1"
  local value="\$2"
  local file="\$3"

  if grep -q "^\${key}=" "\$file"; then
    sed -i'.bak' "s|^\${key}=.*|\${key}=\${value}|" "\$file"
  else
    printf '%s=%s\n' "\$key" "\$value" >> "\$file"
  fi
  rm -f "\${file}.bak"
}

parse_sweep_variables() {
  local file="\$1"
  local raw_key raw_value key value

  SWEEP_VARS=()
  SWEEP_VALUES_RAW=()

  while IFS='=' read -r raw_key raw_value || [[ -n "\${raw_key:-}" ]]; do
    key="\$(trim "\${raw_key:-}")"
    [[ -z "\$key" ]] && continue
    [[ "\$key" == \#* ]] && continue

    if [[ "\$key" =~ ^SWEEP_([A-Za-z0-9_]+)\$ ]]; then
      value="\${raw_value:-}"
      value="\${value%%#*}"
      value="\$(trim "\$value")"
      if [[ -z "\$value" ]]; then
        echo "ERROR: Empty value list for \${key} in \$file" >&2
        exit 1
      fi
      SWEEP_VARS+=("\${BASH_REMATCH[1]}")
      SWEEP_VALUES_RAW+=("\$value")
    fi
  done < "\$file"

  if [[ \${#SWEEP_VARS[@]} -eq 0 ]]; then
    echo "ERROR: No SWEEP_* variables found in \$file" >&2
    exit 1
  fi
}

generate_combinations() {
  local depth="\$1"
  shift || true
  local current_values=("\$@")

  if [[ "\$depth" -eq "\${#SWEEP_VARS[@]}" ]]; then
    local case_no="\$CURRENT_CASE_NO"
    local case_file="\${TEMP_DIR}/case_\$(printf '%04d' "\$case_no").params"
    local i

    cp "\$BASE_CONFIG" "\$case_file"
    set_param_in_file "CaseNo" "\$case_no" "\$case_file"

    for i in "\${!SWEEP_VARS[@]}"; do
      set_param_in_file "\${SWEEP_VARS[\$i]}" "\${current_values[\$i]}" "\$case_file"
    done

    PARAM_FILES+=("\$case_file")
    ((COMBINATION_COUNT += 1))
    ((CURRENT_CASE_NO += 1))

    if [[ \$DRY_RUN -eq 1 || \$VERBOSE -eq 1 ]]; then
      echo "Case \${case_no}:"
      for i in "\${!SWEEP_VARS[@]}"; do
        echo "  \${SWEEP_VARS[\$i]}=\${current_values[\$i]}"
      done
      echo ""
    fi
    return
  fi

  local values="\${SWEEP_VALUES_RAW[\$depth]}"
  local value_array=()
  local value trimmed_value

  IFS=',' read -r -a value_array <<< "\$values"
  for value in "\${value_array[@]}"; do
    trimmed_value="\$(trim "\$value")"
    [[ -z "\$trimmed_value" ]] && continue
    if [[ \${#current_values[@]} -gt 0 ]]; then
      generate_combinations \$((depth + 1)) "\${current_values[@]}" "\$trimmed_value"
    else
      generate_combinations \$((depth + 1)) "\$trimmed_value"
    fi
  done
}

# Defaults
EXEC_CODE="bretherton.c"
SWEEP_FILE="sweep.params"
SWEEP_FILE_SET=0
DRY_RUN=0
VERBOSE=0
OMP_THREADS=1
MAX_PARALLEL=1

while [[ \$# -gt 0 ]]; do
  case "\$1" in
    -h|--help)
      usage
      exit 0
      ;;
    --exec)
      if [[ -z "\${2:-}" ]]; then
        echo "ERROR: --exec requires a file name." >&2
        usage
        exit 1
      fi
      EXEC_CODE="\$2"
      shift 2
      ;;
    --exec=*)
      EXEC_CODE="\${1#*=}"
      shift
      ;;
    --threads)
      if [[ -z "\${2:-}" ]]; then
        echo "ERROR: \$1 requires a positive integer value." >&2
        usage
        exit 1
      fi
      OMP_THREADS="\$2"
      shift 2
      ;;
    --threads=*)
      OMP_THREADS="\${1#*=}"
      shift
      ;;
    --parallel)
      if [[ -z "\${2:-}" ]]; then
        echo "ERROR: \$1 requires a positive integer value." >&2
        usage
        exit 1
      fi
      MAX_PARALLEL="\$2"
      shift 2
      ;;
    --parallel=*)
      MAX_PARALLEL="\${1#*=}"
      shift
      ;;
    -n|--dry-run)
      DRY_RUN=1
      shift
      ;;
    -v|--verbose)
      VERBOSE=1
      shift
      ;;
    --)
      shift
      break
      ;;
    -*)
      echo "ERROR: Unknown option: \$1" >&2
      usage
      exit 1
      ;;
    *)
      if [[ \$SWEEP_FILE_SET -eq 0 ]]; then
        SWEEP_FILE="\$1"
        SWEEP_FILE_SET=1
        shift
      else
        echo "ERROR: Unexpected argument: \$1" >&2
        usage
        exit 1
      fi
      ;;
  esac
done

if [[ \$# -gt 0 ]]; then
  echo "ERROR: Unexpected trailing arguments: \$*" >&2
  usage
  exit 1
fi

if [[ ! "\$OMP_THREADS" =~ ^[1-9][0-9]*\$ ]]; then
  echo "ERROR: --threads must be a positive integer, got: \$OMP_THREADS" >&2
  exit 1
fi

if [[ ! "\$MAX_PARALLEL" =~ ^[1-9][0-9]*\$ ]]; then
  echo "ERROR: --parallel must be a positive integer, got: \$MAX_PARALLEL" >&2
  exit 1
fi

if [[ "\$EXEC_CODE" != *.c ]]; then
  EXEC_CODE="\${EXEC_CODE}.c"
fi

if [[ ! "\$SWEEP_FILE" = /* ]]; then
  SWEEP_FILE="\${SCRIPT_DIR}/\${SWEEP_FILE}"
fi

if [[ ! -f "\$SWEEP_FILE" ]]; then
  echo "ERROR: Sweep file not found: \$SWEEP_FILE" >&2
  exit 1
fi

if [[ -f "\${SCRIPT_DIR}/.project_config" ]]; then
  # shellcheck disable=SC1091
  source "\${SCRIPT_DIR}/.project_config"
fi

if ! command -v qcc >/dev/null 2>&1; then
  echo "ERROR: qcc not found in PATH." >&2
  echo "Hint: source your Basilisk environment or provide .project_config." >&2
  exit 1
fi

RUN_SIM_SCRIPT="\${SCRIPT_DIR}/runSimulation.sh"
if [[ ! -f "\$RUN_SIM_SCRIPT" ]]; then
  echo "ERROR: runSimulation.sh not found at \${RUN_SIM_SCRIPT}" >&2
  exit 1
fi

SRC_FILE_ORIG="\${SCRIPT_DIR}/simulationCases/\${EXEC_CODE}"
if [[ ! -f "\$SRC_FILE_ORIG" ]]; then
  echo "ERROR: Source file not found: \$SRC_FILE_ORIG" >&2
  exit 1
fi

CONFIG_DIR="\$(cd "\$(dirname "\$SWEEP_FILE")" && pwd)"

# Read the sweep header with the same key=value parser as the runner
# (never source the file: config is data, not shell code).
BASE_CONFIG="\$(get_param_value "BASE_CONFIG" "\$SWEEP_FILE")"
CASE_START="\$(get_param_value "CASE_START" "\$SWEEP_FILE")"
CASE_END="\$(get_param_value "CASE_END" "\$SWEEP_FILE")"

BASE_CONFIG="\${BASE_CONFIG:-default.params}"
CASE_START="\${CASE_START:-1000}"

if [[ "\$BASE_CONFIG" != /* ]]; then
  BASE_CONFIG="\${CONFIG_DIR}/\${BASE_CONFIG}"
fi

if [[ ! -f "\$BASE_CONFIG" ]]; then
  echo "ERROR: BASE_CONFIG file not found: \$BASE_CONFIG" >&2
  exit 1
fi

if [[ ! "\$CASE_START" =~ ^[0-9]+\$ ]]; then
  echo "ERROR: CASE_START must be numeric, got: \$CASE_START" >&2
  exit 1
fi

if [[ "\$CASE_START" -lt 1000 ]]; then
  echo "ERROR: CASE_START must be >= 1000 for consistent sorting, got: \$CASE_START" >&2
  exit 1
fi

if [[ -n "\${CASE_END:-}" ]] && [[ ! "\$CASE_END" =~ ^[0-9]+\$ ]]; then
  echo "ERROR: CASE_END must be numeric when provided, got: \$CASE_END" >&2
  exit 1
fi

if [[ -n "\${CASE_END:-}" ]] && [[ "\$CASE_END" -lt 1000 ]]; then
  echo "ERROR: CASE_END must be >= 1000 when provided, got: \$CASE_END" >&2
  exit 1
fi

parse_sweep_variables "\$SWEEP_FILE"

TEMP_DIR="\$(mktemp -d "\${TMPDIR:-/tmp}/bretherton-sweep.XXXXXX")"
trap 'rm -rf "\$TEMP_DIR"' EXIT

PARAM_FILES=()
COMBINATION_COUNT=0
CURRENT_CASE_NO="\$CASE_START"
generate_combinations 0

if [[ "\$COMBINATION_COUNT" -le 0 ]]; then
  echo "ERROR: No parameter combinations generated." >&2
  exit 1
fi

if [[ -n "\${CASE_END:-}" ]]; then
  EXPECTED_COUNT=\$((CASE_END - CASE_START + 1))
  if [[ "\$EXPECTED_COUNT" -ne "\$COMBINATION_COUNT" ]]; then
    echo "ERROR: CASE_START/CASE_END imply \${EXPECTED_COUNT} cases, but generated \${COMBINATION_COUNT} combinations." >&2
    exit 1
  fi
else
  CASE_END=\$((CASE_START + COMBINATION_COUNT - 1))
fi

echo "========================================="
echo "bretherton-drops-bubbles - Parameter Sweep"
echo "========================================="
echo "Sweep file: \${SWEEP_FILE}"
echo "Source file: \${EXEC_CODE}"
echo "Base config: \${BASE_CONFIG}"
echo "Sweep variables: \${#SWEEP_VARS[@]}"
echo "Cases: \${CASE_START}..\${CASE_END} (\${COMBINATION_COUNT})"
if [[ \$OMP_THREADS -gt 1 ]]; then
  echo "Run mode: OpenMP (threads per case=\${OMP_THREADS})"
else
  echo "Run mode: Serial"
fi
if [[ \$MAX_PARALLEL -gt 1 ]]; then
  echo "Sweep execution: Parallel (max concurrent cases=\${MAX_PARALLEL})"
else
  echo "Sweep execution: Sequential"
fi
if [[ \$DRY_RUN -eq 1 ]]; then
  echo "Mode: Dry run"
fi
echo "========================================="
echo ""

if [[ \$DRY_RUN -eq 1 ]]; then
  echo "Dry run complete. No simulations executed."
  exit 0
fi

SUCCESSFUL=0
FAILED=0
RUN_PIDS=()
RUN_CASE_NOS=()

wait_for_pid_index() {
  local idx="\$1"
  local pid="\${RUN_PIDS[\$idx]}"
  local case_no="\${RUN_CASE_NOS[\$idx]}"

  if wait "\$pid"; then
    ((SUCCESSFUL += 1))
    echo "Case \${case_no} completed."
  else
    ((FAILED += 1))
    echo "ERROR: Case \${case_no} failed." >&2
  fi

  unset 'RUN_PIDS[idx]'
  unset 'RUN_CASE_NOS[idx]'
  RUN_PIDS=("\${RUN_PIDS[@]}")
  RUN_CASE_NOS=("\${RUN_CASE_NOS[@]}")
}

wait_for_one_active_case() {
  local idx
  while true; do
    for idx in "\${!RUN_PIDS[@]}"; do
      if ! kill -0 "\${RUN_PIDS[\$idx]}" 2>/dev/null; then
        wait_for_pid_index "\$idx"
        return
      fi
    done
    sleep 1
  done
}

for param_file in "\${PARAM_FILES[@]}"; do
  case_no="\$(get_param_value "CaseNo" "\$param_file")"
  ca_value="\$(get_param_value "Ca" "\$param_file")"
  mur_value="\$(get_param_value "muR" "\$param_file")"
  tmax_value="\$(get_param_value "tmax" "\$param_file")"

  echo "-----------------------------------------"
  echo "Launching Case \${case_no}"
  echo "Ca=\${ca_value:-NA}, muR=\${mur_value:-NA}, tmax=\${tmax_value:-NA}"
  echo "Expected log file: c\${case_no}-log"
  echo "-----------------------------------------"

  run_cmd=(bash "\$RUN_SIM_SCRIPT" "\$param_file" --exec "\$EXEC_CODE" --threads "\$OMP_THREADS")
  "\${run_cmd[@]}" &
  RUN_PIDS+=("\$!")
  RUN_CASE_NOS+=("\$case_no")

  if [[ \${#RUN_PIDS[@]} -ge \$MAX_PARALLEL ]]; then
    wait_for_one_active_case
  fi
  echo ""
done

while [[ \${#RUN_PIDS[@]} -gt 0 ]]; do
  wait_for_one_active_case
done

echo "========================================="
echo "Parameter Sweep Complete"
echo "========================================="
echo "Total cases: \${COMBINATION_COUNT}"
echo "Successful: \${SUCCESSFUL}"
echo "Failed: \${FAILED}"
echo "Outputs: \${OUTPUT_ROOT:-simulationCases/}"
echo "========================================="

if [[ "\$FAILED" -gt 0 ]]; then
  exit 1
fi

exit 0