first commit
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Data Augmentation Example
|
||||
|
||||
Demonstrates how to create a custom data augmentation function
|
||||
following the architecture design pattern.
|
||||
"""
|
||||
|
||||
import torch
|
||||
from typing import Dict
|
||||
from src.data_module.augmentation import register_augmentation
|
||||
|
||||
|
||||
@register_augmentation("time_shift")
|
||||
def time_shift(signal: torch.Tensor, max_shift: int = 10) -> torch.Tensor:
|
||||
"""Randomly shift signal in time.
|
||||
|
||||
Args:
|
||||
signal: Input signal tensor of shape (channels, time_steps)
|
||||
max_shift: Maximum number of steps to shift
|
||||
|
||||
Returns:
|
||||
Shifted signal tensor
|
||||
"""
|
||||
shift = torch.randint(-max_shift, max_shift + 1, (1,)).item()
|
||||
return torch.roll(signal, shifts=shift, dims=-1)
|
||||
|
||||
|
||||
@register_augmentation("amplitude_scale")
|
||||
def amplitude_scale(
|
||||
signal: torch.Tensor,
|
||||
min_scale: float = 0.8,
|
||||
max_scale: float = 1.2
|
||||
) -> torch.Tensor:
|
||||
"""Randomly scale signal amplitude.
|
||||
|
||||
Args:
|
||||
signal: Input signal tensor of shape (channels, time_steps)
|
||||
min_scale: Minimum scaling factor
|
||||
max_scale: Maximum scaling factor
|
||||
|
||||
Returns:
|
||||
Scaled signal tensor
|
||||
"""
|
||||
scale = torch.empty(1).uniform_(min_scale, max_scale).item()
|
||||
return signal * scale
|
||||
|
||||
|
||||
@register_augmentation("gaussian_noise")
|
||||
def add_gaussian_noise(
|
||||
signal: torch.Tensor,
|
||||
mean: float = 0.0,
|
||||
std: float = 0.1
|
||||
) -> torch.Tensor:
|
||||
"""Add Gaussian noise to signal.
|
||||
|
||||
Args:
|
||||
signal: Input signal tensor of shape (channels, time_steps)
|
||||
mean: Mean of Gaussian noise
|
||||
std: Standard deviation of Gaussian noise
|
||||
|
||||
Returns:
|
||||
Signal with added noise
|
||||
"""
|
||||
noise = torch.randn_like(signal) * std + mean
|
||||
return signal + noise
|
||||
|
||||
|
||||
# Example: Composed augmentation
|
||||
@register_augmentation("composed")
|
||||
def composed_augmentation(signal: torch.Tensor, cfg) -> torch.Tensor:
|
||||
"""Apply multiple augmentations in sequence.
|
||||
|
||||
Args:
|
||||
signal: Input signal tensor
|
||||
cfg: Configuration object with augmentation parameters
|
||||
|
||||
Returns:
|
||||
Augmented signal tensor
|
||||
"""
|
||||
# Apply each augmentation based on config
|
||||
if cfg.augmentation.time_shift:
|
||||
signal = time_shift(signal, cfg.augmentation.max_shift)
|
||||
|
||||
if cfg.augmentation.amplitude_scale:
|
||||
signal = amplitude_scale(
|
||||
signal,
|
||||
cfg.augmentation.min_scale,
|
||||
cfg.augmentation.max_scale
|
||||
)
|
||||
|
||||
if cfg.augmentation.gaussian_noise:
|
||||
signal = add_gaussian_noise(
|
||||
signal,
|
||||
cfg.augmentation.noise_mean,
|
||||
cfg.augmentation.noise_std
|
||||
)
|
||||
|
||||
return signal
|
||||
|
||||
|
||||
# Usage in dataset class
|
||||
class AugmentedDataset:
|
||||
"""Example dataset with augmentation support."""
|
||||
|
||||
def __init__(self, cfg):
|
||||
self.cfg = cfg
|
||||
self.augmentation_fn = AugmentationFactory(cfg.augmentation.name)
|
||||
|
||||
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
|
||||
# Load signal
|
||||
signal = self.load_signal(idx)
|
||||
|
||||
# Apply augmentation (training mode only)
|
||||
if self.training and self.augmentation_fn:
|
||||
signal = self.augmentation_fn(signal, self.cfg)
|
||||
|
||||
return {"signal": signal, "label": self.labels[idx]}
|
||||
@@ -0,0 +1,131 @@
|
||||
# Hydra Configuration Example
|
||||
# This demonstrates the config structure for training pipeline
|
||||
|
||||
# Run with: python train.py --config-name=config_example
|
||||
|
||||
defaults:
|
||||
- training: default
|
||||
- dataset: brain_decoder
|
||||
- model: transformer
|
||||
- override hydra/launcher: submitit_local
|
||||
|
||||
# Project settings
|
||||
project_name: brain_decoder
|
||||
experiment_name: transformer_baseline
|
||||
|
||||
# Random seed
|
||||
seed: 42
|
||||
|
||||
# Device settings
|
||||
device: cuda
|
||||
num_workers: 4
|
||||
pin_memory: true
|
||||
|
||||
# Training configuration
|
||||
training:
|
||||
epochs: 100
|
||||
batch_size: 32
|
||||
learning_rate: 0.001
|
||||
weight_decay: 0.0001
|
||||
gradient_clip: 1.0
|
||||
early_stopping:
|
||||
patience: 10
|
||||
min_delta: 0.001
|
||||
|
||||
# Optimizer settings
|
||||
optimizer: adamw
|
||||
optimizer_params:
|
||||
betas: [0.9, 0.999]
|
||||
eps: 1.0e-08
|
||||
|
||||
# Scheduler settings
|
||||
scheduler: cosine
|
||||
scheduler_params:
|
||||
warmup_epochs: 10
|
||||
min_lr: 1.0e-06
|
||||
|
||||
# Checkpoint settings
|
||||
checkpoint:
|
||||
save_every: 5
|
||||
save_best: true
|
||||
monitor: val_loss
|
||||
mode: min
|
||||
|
||||
# Dataset configuration
|
||||
dataset:
|
||||
name: brain_decoder
|
||||
task: movement_classification
|
||||
target_size:
|
||||
movement_classification: 5
|
||||
reconstruction: [64, 64]
|
||||
|
||||
# Data paths
|
||||
data_dir: ${dir.data_dir}/processed
|
||||
train_split: train
|
||||
val_split: val
|
||||
test_split: test
|
||||
|
||||
# Data loading
|
||||
num_channels: 64
|
||||
sampling_rate: 1000
|
||||
sequence_length: 1000
|
||||
|
||||
# Augmentation
|
||||
augmentation:
|
||||
name: composed
|
||||
time_shift: true
|
||||
amplitude_scale: true
|
||||
gaussian_noise: true
|
||||
max_shift: 10
|
||||
min_scale: 0.8
|
||||
max_scale: 1.2
|
||||
noise_mean: 0.0
|
||||
noise_std: 0.1
|
||||
|
||||
# Model configuration
|
||||
model:
|
||||
name: Transformer
|
||||
hidden_dim: 256
|
||||
num_heads: 8
|
||||
num_layers: 6
|
||||
dropout: 0.1
|
||||
activation: gelu
|
||||
|
||||
# Architecture specific
|
||||
encoder:
|
||||
input_dim: 64
|
||||
embedding_dim: 256
|
||||
positional_encoding: true
|
||||
|
||||
decoder:
|
||||
output_dim: ${dataset.target_size.${dataset.task}}
|
||||
pooling: avg
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
logger: wandb
|
||||
log_every: 10
|
||||
log_grads: false
|
||||
|
||||
# TensorBoard
|
||||
tensorboard: true
|
||||
histogram: true
|
||||
|
||||
# W&B
|
||||
wandb:
|
||||
project: ${project_name}
|
||||
entity: null
|
||||
tags: ["baseline", "transformer"]
|
||||
|
||||
# Output directories
|
||||
dir:
|
||||
data_dir: ./data
|
||||
output_dir: ./outputs
|
||||
log_dir: ${dir.output_dir}/logs
|
||||
checkpoint_dir: ${dir.output_dir}/checkpoints
|
||||
figure_dir: ${dir.output_dir}/figures
|
||||
table_dir: ${dir.output_dir}/tables
|
||||
|
||||
# Debug settings
|
||||
debug: false
|
||||
fast_dev_run: false
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Example: Creating a Custom Dataset
|
||||
|
||||
This example shows how to add a new dataset following the project architecture.
|
||||
"""
|
||||
|
||||
from torch.utils.data import Dataset
|
||||
from typing import Dict
|
||||
import torch
|
||||
from src.data_module.dataset import register_dataset
|
||||
|
||||
|
||||
@register_dataset("time_series")
|
||||
class TimeSeriesDataset(Dataset):
|
||||
"""
|
||||
Time series dataset for sequence modeling.
|
||||
|
||||
Args:
|
||||
sequences: List of time series sequences
|
||||
seq_length: Fixed sequence length (pad or truncate if needed)
|
||||
"""
|
||||
|
||||
def __init__(self, sequences: list, seq_length: int = 100):
|
||||
self.sequences = sequences
|
||||
self.seq_length = seq_length
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.sequences)
|
||||
|
||||
def __getitem__(self, i: int) -> Dict[str, torch.Tensor]:
|
||||
sequence = self.sequences[i]
|
||||
|
||||
# Pad or truncate to fixed length
|
||||
if len(sequence) < self.seq_length:
|
||||
padding = torch.zeros(self.seq_length - len(sequence))
|
||||
sequence = torch.cat([sequence, padding])
|
||||
else:
|
||||
sequence = sequence[:self.seq_length]
|
||||
|
||||
return {
|
||||
"input": sequence,
|
||||
"label": sequence, # For autoencoder, etc.
|
||||
"length": torch.tensor(min(len(self.sequences[i]), self.seq_length))
|
||||
}
|
||||
|
||||
|
||||
# Usage in training:
|
||||
# from src.data_module.dataset import DatasetFactory
|
||||
# dataset = DatasetFactory("time_series")(sequences=training_data, seq_length=128)
|
||||
# dataloader = DataLoader(dataset, batch_size=32, shuffle=True)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Example: Creating a Custom Model
|
||||
|
||||
This example shows how to add a new model following the project architecture.
|
||||
IMPORTANT: Models use a config-driven pattern where __init__ only accepts cfg.
|
||||
|
||||
Key Requirements:
|
||||
- Use @register_model('ModelName') decorator
|
||||
- __init__ accepts ONLY cfg parameter
|
||||
- All hyperparameters come from cfg (cfg.model.*, cfg.dataset.*, etc.)
|
||||
- forward() returns dict: {"loss": loss, "labels": labels, "logits": logits}
|
||||
"""
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from typing import Dict, Optional
|
||||
|
||||
# Import the register_model decorator
|
||||
# Location may vary: src.model_module.brain_decoder or src.model_module.model
|
||||
from src.model_module.brain_decoder import register_model
|
||||
|
||||
|
||||
@register_model('SimpleMLP')
|
||||
class SimpleMLP(nn.Module):
|
||||
"""
|
||||
Simple Multi-Layer Perceptron for classification tasks.
|
||||
|
||||
Config structure ( Hydra YAML ):
|
||||
model:
|
||||
input_dim: 100
|
||||
hidden_dim: 256
|
||||
output_dim: 10
|
||||
num_layers: 3
|
||||
dropout: 0.1
|
||||
dataset:
|
||||
task: classification # Used to get target_size
|
||||
target_size:
|
||||
classification: 10
|
||||
"""
|
||||
|
||||
def __init__(self, cfg):
|
||||
super().__init__()
|
||||
|
||||
# Store config
|
||||
self.cfg = cfg
|
||||
|
||||
# Get task info from config
|
||||
self.task = cfg.dataset.task
|
||||
|
||||
# Build model - ALL parameters from cfg
|
||||
self.input_dim = cfg.model.input_dim
|
||||
self.hidden_dim = cfg.model.get('hidden_dim', 256)
|
||||
self.output_dim = cfg.dataset.target_size[cfg.dataset.task]
|
||||
self.num_layers = cfg.model.get('num_layers', 3)
|
||||
self.dropout = cfg.model.get('dropout', 0.1)
|
||||
|
||||
# Build layers
|
||||
layers = []
|
||||
in_dim = self.input_dim
|
||||
|
||||
for i in range(self.num_layers):
|
||||
layers.extend([
|
||||
nn.Linear(in_dim, self.hidden_dim),
|
||||
nn.ReLU(),
|
||||
nn.Dropout(self.dropout)
|
||||
])
|
||||
in_dim = self.hidden_dim
|
||||
|
||||
# Output layer
|
||||
layers.append(nn.Linear(self.hidden_dim, self.output_dim))
|
||||
self.network = nn.Sequential(*layers)
|
||||
|
||||
# Loss function
|
||||
self.loss_fn = nn.CrossEntropyLoss()
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
labels: Optional[torch.Tensor] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Optional[torch.Tensor]]:
|
||||
"""
|
||||
Forward pass.
|
||||
|
||||
Args:
|
||||
x: Input tensor of shape (batch_size, input_dim)
|
||||
labels: Ground truth labels (optional, for training)
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- loss: Computed loss (None if labels not provided)
|
||||
- labels: Ground truth labels
|
||||
- logits: Model predictions
|
||||
"""
|
||||
logits = self.network(x)
|
||||
|
||||
loss = None
|
||||
if labels is not None:
|
||||
# Convert labels to long type if needed
|
||||
if labels.dtype != torch.long:
|
||||
labels = labels.long()
|
||||
loss = self.loss_fn(logits, labels)
|
||||
|
||||
return {
|
||||
"loss": loss,
|
||||
"labels": labels,
|
||||
"logits": logits
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Example with Training/Inference Modes
|
||||
# ============================================
|
||||
|
||||
@register_model('SimpleMLPWithModes')
|
||||
class SimpleMLPWithModes(nn.Module):
|
||||
"""
|
||||
MLP with separate training and inference logic.
|
||||
Shows how to handle different modes using self.training.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg):
|
||||
super().__init__()
|
||||
self.cfg = cfg
|
||||
self.task = cfg.dataset.task
|
||||
|
||||
self.input_dim = cfg.model.input_dim
|
||||
self.hidden_dim = cfg.model.get('hidden_dim', 256)
|
||||
self.output_dim = cfg.dataset.target_size[cfg.dataset.task]
|
||||
|
||||
self.fc_in = nn.Linear(self.input_dim, self.hidden_dim)
|
||||
self.ln = nn.LayerNorm(self.hidden_dim)
|
||||
self.fc_out = nn.Linear(self.hidden_dim, self.output_dim)
|
||||
self.loss_fn = nn.CrossEntropyLoss()
|
||||
|
||||
# Test-time augmentation config
|
||||
self.tta_times = cfg.model.get('tta_times', 1)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
labels: Optional[torch.Tensor] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Optional[torch.Tensor]]:
|
||||
"""
|
||||
Forward pass with training/inference modes.
|
||||
"""
|
||||
if self.training:
|
||||
# Training mode
|
||||
x = x.float()
|
||||
x = self.fc_in(x)
|
||||
x = self.ln(x)
|
||||
x = F.relu(x)
|
||||
logits = self.fc_out(x)
|
||||
|
||||
loss = None
|
||||
if labels is not None:
|
||||
if labels.dtype != torch.long:
|
||||
labels = labels.long()
|
||||
loss = self.loss_fn(logits, labels)
|
||||
|
||||
return {
|
||||
"loss": loss,
|
||||
"labels": labels,
|
||||
"logits": logits
|
||||
}
|
||||
else:
|
||||
# Inference mode with TTA
|
||||
all_logits = []
|
||||
with torch.no_grad():
|
||||
x = x.float()
|
||||
for _ in range(self.tta_times):
|
||||
x_aug = x.clone()
|
||||
# Apply TTA transformations here if needed
|
||||
|
||||
x_aug = self.fc_in(x_aug)
|
||||
x_aug = self.ln(x_aug)
|
||||
x_aug = F.relu(x_aug)
|
||||
logits = self.fc_out(x_aug)
|
||||
all_logits.append(logits)
|
||||
|
||||
# Average predictions
|
||||
avg_logits = torch.mean(torch.stack(all_logits), dim=0)
|
||||
|
||||
loss = None
|
||||
if labels is not None:
|
||||
if labels.dtype != torch.long:
|
||||
labels = labels.long()
|
||||
loss = self.loss_fn(avg_logits, labels)
|
||||
|
||||
return {
|
||||
"loss": loss,
|
||||
"labels": labels,
|
||||
"logits": avg_logits
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Config Example (Hydra YAML)
|
||||
# ============================================
|
||||
"""
|
||||
# run/conf/model/simple_mlp.yaml
|
||||
|
||||
model:
|
||||
name: SimpleMLP
|
||||
input_dim: 100
|
||||
hidden_dim: 256
|
||||
output_dim: 10
|
||||
num_layers: 3
|
||||
dropout: 0.1
|
||||
tta_times: 1
|
||||
|
||||
# Then in training pipeline:
|
||||
# from src.model_module.brain_decoder import ModelFactory
|
||||
# model = ModelFactory(cfg.model.name)(cfg)
|
||||
"""
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
###############################################################################
|
||||
# Training Pipeline Script
|
||||
#
|
||||
# This script demonstrates the standard training pipeline execution pattern.
|
||||
# It handles environment setup, configuration, and execution with proper
|
||||
# error handling and logging.
|
||||
#
|
||||
# Usage:
|
||||
# ./run/pipeline/training/train.sh --config-name=config_example
|
||||
###############################################################################
|
||||
|
||||
set -e # Exit on error
|
||||
set -o pipefail # Exit on pipe failure
|
||||
|
||||
# Script configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
EXPERIMENT_NAME="baseline_experiment"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
###############################################################################
|
||||
# Helper Functions
|
||||
###############################################################################
|
||||
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
log_info "Cleaning up..."
|
||||
# Add cleanup logic here (e.g., kill background processes)
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
###############################################################################
|
||||
# Environment Setup
|
||||
###############################################################################
|
||||
|
||||
setup_environment() {
|
||||
log_info "Setting up environment..."
|
||||
|
||||
# Activate virtual environment if it exists
|
||||
if [ -f "${PROJECT_ROOT}/.venv/bin/activate" ]; then
|
||||
source "${PROJECT_ROOT}/.venv/bin/activate"
|
||||
log_info "Activated virtual environment"
|
||||
fi
|
||||
|
||||
# Check required commands
|
||||
command -v python >/dev/null 2>&1 || { log_error "Python not found"; exit 1; }
|
||||
|
||||
# Set Python path
|
||||
export PYTHONPATH="${PROJECT_ROOT}/src:${PYTHONPATH}"
|
||||
log_info "PYTHONPATH set to: ${PYTHONPATH}"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Configuration
|
||||
###############################################################################
|
||||
|
||||
parse_arguments() {
|
||||
# Default values
|
||||
CONFIG="default"
|
||||
GPUS=0
|
||||
SEED=42
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--config-name|-c)
|
||||
CONFIG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--gpus|-g)
|
||||
GPUS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--seed|-s)
|
||||
SEED="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
log_warn "Unknown argument: $1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
log_info "Configuration: ${CONFIG}"
|
||||
log_info "GPUs: ${GPUS}"
|
||||
log_info "Seed: ${SEED}"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Main Training Function
|
||||
###############################################################################
|
||||
|
||||
run_training() {
|
||||
log_info "Starting training..."
|
||||
|
||||
# Output directory for this run
|
||||
OUTPUT_DIR="${PROJECT_ROOT}/outputs/${EXPERIMENT_NAME}/${TIMESTAMP}"
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
|
||||
log_info "Output directory: ${OUTPUT_DIR}"
|
||||
|
||||
# Training command with Hydra
|
||||
python "${PROJECT_ROOT}/train.py" \
|
||||
--config-name="${CONFIG}" \
|
||||
seed=${SEED} \
|
||||
dir.output_dir="${OUTPUT_DIR}" \
|
||||
training.device=cuda \
|
||||
hydra.output_dir="${OUTPUT_DIR}/hydra" \
|
||||
hydra.run.dir="${OUTPUT_DIR}/hydra" || {
|
||||
log_error "Training failed!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_info "Training completed successfully!"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Post-Processing
|
||||
###############################################################################
|
||||
|
||||
post_process() {
|
||||
log_info "Post-processing results..."
|
||||
|
||||
# Copy logs to output directory
|
||||
if [ -f "${OUTPUT_DIR}/hydra/*.log" ]; then
|
||||
cp "${OUTPUT_DIR}/hydra/"*.log "${OUTPUT_DIR}/"
|
||||
fi
|
||||
|
||||
# Generate summary
|
||||
log_info "Run summary:"
|
||||
log_info " Config: ${CONFIG}"
|
||||
log_info " Seed: ${SEED}"
|
||||
log_info " Output: ${OUTPUT_DIR}"
|
||||
|
||||
# Print path to best checkpoint
|
||||
BEST_CHECKPOINT=$(find "${OUTPUT_DIR}" -name "best*.pt" | head -n 1)
|
||||
if [ -n "${BEST_CHECKPOINT}" ]; then
|
||||
log_info " Best checkpoint: ${BEST_CHECKPOINT}"
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# Main Execution
|
||||
###############################################################################
|
||||
|
||||
main() {
|
||||
log_info "=========================================="
|
||||
log_info "Training Pipeline"
|
||||
log_info "=========================================="
|
||||
|
||||
# Setup
|
||||
setup_environment
|
||||
|
||||
# Parse arguments
|
||||
parse_arguments "$@"
|
||||
|
||||
# Run training
|
||||
run_training
|
||||
|
||||
# Post-process
|
||||
post_process
|
||||
|
||||
log_info "=========================================="
|
||||
log_info "Pipeline completed successfully!"
|
||||
log_info "=========================================="
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user