diff --git a/src/main/java/com/kamco/cd/training/train/TrainApiController.java b/src/main/java/com/kamco/cd/training/train/TrainApiController.java index 8dd3ed9..eb6fea7 100644 --- a/src/main/java/com/kamco/cd/training/train/TrainApiController.java +++ b/src/main/java/com/kamco/cd/training/train/TrainApiController.java @@ -2,6 +2,7 @@ package com.kamco.cd.training.train; import com.kamco.cd.training.config.api.ApiResponseDto; import com.kamco.cd.training.train.dto.TrainingMetricsDto; +import com.kamco.cd.training.train.dto.TrainingProgressDto; import com.kamco.cd.training.train.service.DataSetCountersService; import com.kamco.cd.training.train.service.TestJobService; import com.kamco.cd.training.train.service.TrainJobService; @@ -298,4 +299,26 @@ public class TrainApiController { trainingMetricsService.getTrainingMetricsByModelUuid(modelUuid); return ApiResponseDto.ok(response); } + + @Operation( + summary = "학습 진행률 조회", + description = "UUID로 학습 진행률을 실시간 조회합니다. 기존 DB 구조를 활용하여 진행률을 계산합니다.") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = TrainingProgressDto.class))), + @ApiResponse(responseCode = "404", description = "모델을 찾을 수 없음", content = @Content), + @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) + }) + @GetMapping("/progress/{uuid}") + public ApiResponseDto getTrainingProgress( + @Parameter(description = "모델 UUID", required = true) @PathVariable UUID uuid) { + TrainingProgressDto progress = trainJobService.getTrainingProgress(uuid); + return ApiResponseDto.ok(progress); + } } diff --git a/src/main/java/com/kamco/cd/training/train/dto/TrainingProgressDto.java b/src/main/java/com/kamco/cd/training/train/dto/TrainingProgressDto.java new file mode 100644 index 0000000..8a0c095 --- /dev/null +++ b/src/main/java/com/kamco/cd/training/train/dto/TrainingProgressDto.java @@ -0,0 +1,44 @@ +package com.kamco.cd.training.train.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.ZonedDateTime; +import java.util.UUID; +import lombok.Builder; +import lombok.Getter; + +/** 학습 진행률 조회 응답 DTO */ +@Getter +@Builder +public class TrainingProgressDto { + + // 기본 정보 + private Long jobId; + private UUID modelUuid; + private String statusCd; // QUEUED/RUNNING/SUCCESS/FAILED + private String currentPhase; // 현재 단계 (계산된 값) + private Double progressPercent; // 진행률 (0.00 ~ 100.00, 계산된 값) + + // Epoch 정보 (기존 DB 컬럼) + private Integer currentEpoch; // 현재 Epoch + private Integer totalEpoch; // 전체 Epoch + + // 시간 정보 (기존 DB 컬럼) - ISO 8601 형식으로 직렬화 + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + private ZonedDateTime queuedDttm; // 큐 등록 시각 + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + private ZonedDateTime startedDttm; // 시작 시각 + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + private ZonedDateTime finishedDttm; // 종료 시각 (완료/실패 시) + + // 계산된 시간 정보 + private Long elapsedSeconds; // 경과 시간 (초) + private Long estimatedRemainingSeconds; // 예상 남은 시간 (초) + + // 상태 메시지 + private String message; // 현재 상태 메시지 (계산된 값) + + // 에러 정보 (실패 시) + private String errorMessage; // 에러 메시지 (기존 DB 컬럼) +} diff --git a/src/main/java/com/kamco/cd/training/train/service/DockerTrainService.java b/src/main/java/com/kamco/cd/training/train/service/DockerTrainService.java index 93783c6..36d0931 100644 --- a/src/main/java/com/kamco/cd/training/train/service/DockerTrainService.java +++ b/src/main/java/com/kamco/cd/training/train/service/DockerTrainService.java @@ -158,12 +158,42 @@ public class DockerTrainService { lastEpoch.set(epoch); lastIter.set(iter); + // Step 구분 (컨테이너 이름으로 판별) + boolean isStep1 = containerName.startsWith("train-"); + boolean isStep2 = containerName.startsWith("eval-"); + + // 진행률 계산 (Step별 구분) + double progress = 0.0; + if (maxEpochs > 0) { + // Epoch + Iteration 기반 정밀 진행률 계산 + double epochProgress = + ((double) (epoch - 1) + ((double) iter / totalIter)) / maxEpochs; + + // Step1 (학습): 7.5% ~ 42.5% (0% ~ 50% 중 15% ~ 85%) + // Step2 (테스트): 57.5% ~ 92.5% (50% ~ 100% 중 15% ~ 85%) + if (isStep1) { + // Step1: 0% ~ 50% 범위, 그 중 15% ~ 85% = 7.5% ~ 42.5% + progress = 7.5 + (epochProgress * 35.0); + } else if (isStep2) { + // Step2: 50% ~ 100% 범위, 그 중 15% ~ 85% = 57.5% ~ 92.5% + progress = 57.5 + (epochProgress * 35.0); + } else { + // 기본값 (하위 호환) + progress = 15.0 + (epochProgress * 70.0); + } + } + + String stepLabel = isStep1 ? "STEP1" : isStep2 ? "STEP2" : "UNKNOWN"; + log.info( - "[TRAIN] container={} epoch={} iter={}/{}", + "[TRAIN] {} container={} epoch={}/{} iter={}/{} | Progress: {}%", + stepLabel, containerName, epoch, + maxEpochs, iter, - totalIter); + totalIter, + String.format("%.2f", progress)); modelTrainJobCoreService.updateEpoch(containerName, epoch); } diff --git a/src/main/java/com/kamco/cd/training/train/service/ModelTestMetricsJobService.java b/src/main/java/com/kamco/cd/training/train/service/ModelTestMetricsJobService.java index 17c8a72..6c204eb 100644 --- a/src/main/java/com/kamco/cd/training/train/service/ModelTestMetricsJobService.java +++ b/src/main/java/com/kamco/cd/training/train/service/ModelTestMetricsJobService.java @@ -300,7 +300,7 @@ public class ModelTestMetricsJobService { zipFiles(zipFileList, individualZipPath); log.info( - "✅ 개별 ZIP 생성 완료: fileName={}, pthFile={}, size={} bytes", + "개별 ZIP 생성 완료: fileName={}, pthFile={}, size={} bytes", individualZipName, pthFileName, Files.size(individualZipPath)); diff --git a/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java b/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java index 2fc5585..e05d302 100644 --- a/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java +++ b/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java @@ -13,6 +13,8 @@ import com.kamco.cd.training.train.dto.ModelTrainJobQueuedEvent; import com.kamco.cd.training.train.dto.ModelTrainLinkDto; import com.kamco.cd.training.train.dto.OutputResult; import com.kamco.cd.training.train.dto.TrainRunRequest; +import com.kamco.cd.training.train.dto.TrainingProgressDto; +import com.kamco.cd.training.train.util.TrainingProgressCalculator; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -466,4 +468,136 @@ public class TrainJobService { modelTrainMngCoreService.markStep1Stop(job.getModelId(), msg); } } + + /** + * UUID로 학습 진행률 조회 기존 DB 컬럼만을 활용하여 실시간으로 진행률 계산 + * + * @param uuid 모델 UUID + * @return 학습 진행률 정보 + */ + @Transactional(readOnly = true) + public TrainingProgressDto getTrainingProgress(UUID uuid) { + // 1. UUID로 모델 조회 + Long modelId = getModelIdByUuid(uuid); + ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelById(modelId); + + // 2. Step 상태 확인 (step1Status, step2Status) + String step1Status = model.getStep1Status(); // READY/IN_PROGRESS/COMPLETED/STOPPED/ERROR + String step2Status = model.getStep2Status(); // READY/IN_PROGRESS/COMPLETED/STOPPED/ERROR + + // 3. 현재 실행중인 Job ID 확인 + Long jobId = model.getCurrentAttemptId(); + + if (jobId == null) { + // Job이 없는 경우 모델 상태만 반환 + // Step1 완료 여부에 따라 진행률 결정 + double progressPercent = 0.0; + String currentPhase = "NOT_STARTED"; + + if ("COMPLETED".equals(step1Status) && "COMPLETED".equals(step2Status)) { + progressPercent = 100.0; + currentPhase = "COMPLETED"; + } else if ("COMPLETED".equals(step1Status)) { + progressPercent = 50.0; + currentPhase = "STEP1_COMPLETED"; + } + + return TrainingProgressDto.builder() + .modelUuid(uuid) + .statusCd(model.getStatusCd()) + .currentPhase(currentPhase) + .progressPercent(progressPercent) + .message(getMessageForPhase(currentPhase, null, null)) + .build(); + } + + // 4. Job 정보 조회 (기존 컬럼만 사용) + ModelTrainJobDto job = + modelTrainJobCoreService + .findById(jobId) + .orElseThrow(() -> new IllegalStateException("Job을 찾을 수 없습니다: " + jobId)); + + // 5. totalEpoch 추출 (DB 컬럼 우선, 없으면 params_json에서 추출) + Integer totalEpoch = job.getTotalEpoch(); + if (totalEpoch == null && job.getParamsJson() != null) { + Object totalEpochObj = job.getParamsJson().get("totalEpoch"); + if (totalEpochObj != null) { + totalEpoch = Integer.valueOf(String.valueOf(totalEpochObj)); + } + } + + // 6. jobType 추출 (TRAIN/EVAL) + String jobType = "TRAIN"; // 기본값 + if (job.getParamsJson() != null) { + Object jobTypeObj = job.getParamsJson().get("jobType"); + if (jobTypeObj != null) { + jobType = String.valueOf(jobTypeObj); + } + } + + // 7. 진행률 계산 (Step 구분하여 계산) + double progressPercent = + TrainingProgressCalculator.calculateProgress( + job.getStatusCd(), + job.getCurrentEpoch(), + totalEpoch, + job.getStartedDttm(), + job.getFinishedDttm(), + jobType, + step1Status, + step2Status); + + // 8. 현재 Phase 추정 + String currentPhase = + TrainingProgressCalculator.estimateCurrentPhase( + job.getStatusCd(), job.getCurrentEpoch(), totalEpoch, job.getStartedDttm(), jobType); + + // 9. 경과 시간 계산 + Long elapsedSeconds = TrainingProgressCalculator.calculateElapsedSeconds(job.getStartedDttm()); + + // 10. 예상 남은 시간 계산 + Long estimatedRemaining = + TrainingProgressCalculator.estimateRemainingSeconds(progressPercent, elapsedSeconds); + + // 11. 상태 메시지 생성 + String message = + TrainingProgressCalculator.generateProgressMessage( + currentPhase, job.getCurrentEpoch(), totalEpoch); + + // 12. DTO 생성 및 반환 + return TrainingProgressDto.builder() + .jobId(job.getId()) + .modelUuid(uuid) + .statusCd(job.getStatusCd()) + .currentPhase(currentPhase) + .progressPercent(progressPercent) + .currentEpoch(job.getCurrentEpoch()) + .totalEpoch(totalEpoch) + .queuedDttm(job.getQueuedDttm()) + .startedDttm(job.getStartedDttm()) + .finishedDttm(job.getFinishedDttm()) + .elapsedSeconds(elapsedSeconds) + .estimatedRemainingSeconds(estimatedRemaining) + .message(message) + .errorMessage(job.getErrorMessage()) + .build(); + } + + /** + * Phase에 맞는 메시지 반환 (Job이 없는 경우) + * + * @param phase 현재 Phase + * @param currentEpoch 현재 Epoch + * @param totalEpoch 전체 Epoch + * @return 상태 메시지 + */ + private String getMessageForPhase(String phase, Integer currentEpoch, Integer totalEpoch) { + if ("COMPLETED".equals(phase)) { + return "모든 학습 및 테스트가 완료되었습니다."; + } else if ("STEP1_COMPLETED".equals(phase)) { + return "학습이 완료되었습니다. 테스트를 시작할 수 있습니다."; + } else { + return "학습 작업이 시작되지 않았습니다."; + } + } } diff --git a/src/main/java/com/kamco/cd/training/train/service/TrainJobWorker.java b/src/main/java/com/kamco/cd/training/train/service/TrainJobWorker.java index 0b3bf17..6a646b2 100644 --- a/src/main/java/com/kamco/cd/training/train/service/TrainJobWorker.java +++ b/src/main/java/com/kamco/cd/training/train/service/TrainJobWorker.java @@ -58,6 +58,7 @@ public class TrainJobWorker { (isEval ? "eval-" : "train-") + jobId + "-" + params.get("uuid").toString().substring(0, 8); String type = isEval ? "TEST" : "TRAIN"; + String step = isEval ? "STEP2" : "STEP1"; Integer totalEpoch = null; if (params.containsKey("totalEpoch")) { @@ -65,6 +66,21 @@ public class TrainJobWorker { totalEpoch = Integer.parseInt(params.get("totalEpoch").toString()); } } + + // Phase 1: 준비 단계 + // Step1: 0% ~ 2.5%, Step2: 50% ~ 52.5% + double preparingProgress = isEval ? 50.0 : 0.0; + log.info( + "[JOB] {} jobId={} | Phase: PREPARING | Progress: {}%", + step, jobId, String.format("%.1f", preparingProgress + 2.5)); + + // Phase 2: 컨테이너 시작 + // Step1: 2.5% ~ 5%, Step2: 52.5% ~ 55% + double containerStartProgress = isEval ? 52.5 : 2.5; + log.info( + "[JOB] {} jobId={} | Phase: CONTAINER_STARTING | Progress: {}%", + step, jobId, String.format("%.1f", containerStartProgress + 2.5)); + log.info("[JOB] markRunning start jobId={}, containerName={}", jobId, containerName); // 실행 시작 처리 modelTrainJobCoreService.markRunning( @@ -90,12 +106,21 @@ public class TrainJobWorker { evalReq.setReqTmpYn((Boolean) params.get("reqTmpYn")); log.info("[JOB] selected test epoch={}", epoch); + // Phase 3: 테스트 시작 + // Step2: 55% ~ 57.5% + log.info("[JOB] STEP2 jobId={} | Phase: EVAL_STARTED | Progress: 57.5%", jobId); + // 도커 실행 후 로그 수집 result = dockerTrainService.runEvalSync(containerName, evalReq); } else { // step1 진행중 처리 modelTrainMngCoreService.markStep1InProgress(modelId, jobId); TrainRunRequest trainReq = toTrainRunRequest(params); + + // Phase 3: 학습 시작 + // Step1: 5% ~ 7.5% + log.info("[JOB] STEP1 jobId={} | Phase: TRAINING_STARTED | Progress: 7.5%", jobId); + // 도커 실행 후 로그 수집 result = dockerTrainService.runTrainSync(trainReq, containerName); } @@ -109,11 +134,25 @@ public class TrainJobWorker { return; } + // Phase 4: 학습/테스트 완료 + // Step1: 42.5%, Step2: 92.5% + double completedProgress = isEval ? 92.5 : 42.5; + log.info( + "[JOB] {} jobId={} | Phase: TRAINING_COMPLETED | Progress: {}%", + step, jobId, String.format("%.1f", completedProgress)); + /** * 0 정상 종료 SUCCESS 1~125 학습 코드 에러 FAILED 137 OOMKill FAILED 143 SIGTERM (stop) STOP -1 우리 내부 * 강제 중단 STOP */ if (result.getExitCode() == 0) { + // Phase 5: 결과 처리 중 + // Step1: 45%, Step2: 95% + double processingProgress = isEval ? 95.0 : 45.0; + log.info( + "[JOB] {} jobId={} | Phase: PROCESSING_RESULTS | Progress: {}%", + step, jobId, String.format("%.1f", processingProgress)); + // 성공 처리 modelTrainJobCoreService.markSuccess(jobId, result.getExitCode()); @@ -128,6 +167,13 @@ public class TrainJobWorker { modelTrainMetricsJobService.findTrainValidMetricCsvFiles(); } + // Phase 6: 완료 + // Step1: 50%, Step2: 100% + double finalProgress = isEval ? 100.0 : 50.0; + log.info( + "[JOB] {} jobId={} | Phase: COMPLETED | Progress: {}%", + step, jobId, String.format("%.1f", finalProgress)); + } else { String failMsg = result.getStatus() + "\n" + result.getLogs(); diff --git a/src/main/java/com/kamco/cd/training/train/util/TrainingProgressCalculator.java b/src/main/java/com/kamco/cd/training/train/util/TrainingProgressCalculator.java new file mode 100644 index 0000000..4f58dde --- /dev/null +++ b/src/main/java/com/kamco/cd/training/train/util/TrainingProgressCalculator.java @@ -0,0 +1,295 @@ +package com.kamco.cd.training.train.util; + +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import lombok.experimental.UtilityClass; + +/** + * 학습 진행률 계산 유틸리티 기존 DB 컬럼만을 활용하여 진행률을 실시간 계산 + * + *

상태전의 STATUS 가이드 기준: - Step1 (학습): TRAIN jobType → 0% ~ 50% - Step2 (테스트): EVAL jobType → 50% ~ + * 100% + */ +@UtilityClass +public class TrainingProgressCalculator { + + /** + * 학습 진행률 계산 (Step 구분) + * + * @param statusCd 작업 상태 (QUEUED/RUNNING/SUCCESS/FAILED/STOPPED/CANCELED) + * @param currentEpoch 현재 Epoch + * @param totalEpoch 전체 Epoch + * @param startedDttm 시작 시각 + * @param finishedDttm 종료 시각 + * @param jobType 작업 타입 (TRAIN/EVAL) + * @param step1State Step1 상태 (READY/IN_PROGRESS/COMPLETED/STOPPED/ERROR) + * @param step2State Step2 상태 (READY/IN_PROGRESS/COMPLETED/STOPPED/ERROR) + * @return 진행률 (0.00 ~ 100.00) + */ + public static double calculateProgress( + String statusCd, + Integer currentEpoch, + Integer totalEpoch, + ZonedDateTime startedDttm, + ZonedDateTime finishedDttm, + String jobType, + String step1State, + String step2State) { + if (statusCd == null) { + return 0.0; + } + + // Step별 진행률 계산 + boolean isStep1 = "TRAIN".equals(jobType); + boolean isStep2 = "EVAL".equals(jobType) || "TEST".equals(jobType); + + // Step1 완료, Step2 완료 여부 확인 + boolean isStep1Completed = "COMPLETED".equals(step1State); + boolean isStep2Completed = "COMPLETED".equals(step2State); + + switch (statusCd) { + case "QUEUED": + // Step1 대기 중: 0%, Step2 대기 중: 50% + return isStep2 ? 50.0 : 0.0; + + case "RUNNING": + return calculateRunningProgress( + currentEpoch, totalEpoch, startedDttm, isStep1, isStep2, isStep1Completed); + + case "SUCCESS": + // Step1 성공: 50%, Step2 성공: 100% + if (isStep2 || isStep2Completed) { + return 100.0; + } else if (isStep1 || isStep1Completed) { + return 50.0; + } + return 100.0; // 기본값 + + case "FAILED": + case "STOPPED": + case "CANCELED": + // 실패/취소된 경우 마지막 진행률 반환 + return calculateRunningProgress( + currentEpoch, totalEpoch, startedDttm, isStep1, isStep2, isStep1Completed); + + default: + return 0.0; + } + } + + /** + * 실행 중인 학습의 진행률 계산 (Step 구분) + * + *

Step1 (학습): 0% ~ 50% - PREPARING: 0% ~ 2.5% - CONTAINER_STARTING: 2.5% ~ 5% - DATA_LOADING: + * 5% ~ 7.5% - TRAINING: 7.5% ~ 42.5% (35% 비중) - PROCESSING: 42.5% ~ 50% + * + *

Step2 (테스트): 50% ~ 100% - PREPARING: 50% ~ 52.5% - CONTAINER_STARTING: 52.5% ~ 55% - + * DATA_LOADING: 55% ~ 57.5% - TESTING: 57.5% ~ 92.5% (35% 비중) - PROCESSING: 92.5% ~ 100% + */ + private static double calculateRunningProgress( + Integer currentEpoch, + Integer totalEpoch, + ZonedDateTime startedDttm, + boolean isStep1, + boolean isStep2, + boolean isStep1Completed) { + + // Step 기준 계산 + double baseProgress = 0.0; + double maxProgress = 50.0; + + if (isStep2 || isStep1Completed) { + // Step2 진행 중 또는 Step1 완료 후 + baseProgress = 50.0; + maxProgress = 100.0; + } else if (isStep1) { + // Step1 진행 중 + baseProgress = 0.0; + maxProgress = 50.0; + } + + double stepRange = maxProgress - baseProgress; // 50% + + // 시작 직후 (Epoch 정보 없음) + if (currentEpoch == null || totalEpoch == null || totalEpoch == 0) { + return baseProgress + estimateInitialPhaseProgress(startedDttm, stepRange); + } + + // Epoch 0 또는 1 (학습 시작 단계) + if (currentEpoch <= 1) { + return baseProgress + (stepRange * 0.15); // 7.5% (Step1) 또는 57.5% (Step2) + } + + // 학습 진행 중 (15% ~ 85% of step range) + double epochProgress = (double) currentEpoch / totalEpoch; + double trainingProgress = 0.15 + (epochProgress * 0.70); // 15% ~ 85% + + // 학습 완료 (마지막 Epoch) + if (currentEpoch >= totalEpoch) { + // 85% ~ 100% 사이로 추정 + return baseProgress + (stepRange * 0.90); + } + + return baseProgress + (stepRange * trainingProgress); + } + + /** + * 초기 단계 진행률 추정 (시작 시간 기반) + * + * @param startedDttm 시작 시각 + * @param stepRange Step 범위 (50%) + * @return 초기 단계 진행률 (0 ~ 0.15 비율) + */ + private static double estimateInitialPhaseProgress(ZonedDateTime startedDttm, double stepRange) { + if (startedDttm == null) { + return stepRange * 0.05; // 5% of step range + } + + long elapsedSeconds = ChronoUnit.SECONDS.between(startedDttm, ZonedDateTime.now()); + + // 시작 후 30초 이내: PREPARING (0% ~ 5% of step) + if (elapsedSeconds < 30) { + return Math.min(stepRange * 0.05, (elapsedSeconds / 30.0) * stepRange * 0.05); + } + // 30초 ~ 60초: CONTAINER_STARTING (5% ~ 10% of step) + else if (elapsedSeconds < 60) { + return stepRange * 0.05 + ((elapsedSeconds - 30) / 30.0) * stepRange * 0.05; + } + // 60초 이상: DATA_LOADING (10% ~ 15% of step) + else { + return Math.min( + stepRange * 0.15, stepRange * 0.10 + ((elapsedSeconds - 60) / 60.0) * stepRange * 0.05); + } + } + + /** + * 현재 Phase 추정 + * + * @param statusCd 작업 상태 + * @param currentEpoch 현재 Epoch + * @param totalEpoch 전체 Epoch + * @param startedDttm 시작 시각 + * @param jobType 작업 타입 (TRAIN/EVAL) + * @return 현재 Phase + */ + public static String estimateCurrentPhase( + String statusCd, + Integer currentEpoch, + Integer totalEpoch, + ZonedDateTime startedDttm, + String jobType) { + if ("SUCCESS".equals(statusCd)) { + return "COMPLETED"; + } + if ("FAILED".equals(statusCd)) { + return "FAILED"; + } + if ("STOPPED".equals(statusCd) || "CANCELED".equals(statusCd)) { + return "CANCELED"; + } + if ("QUEUED".equals(statusCd)) { + return "QUEUED"; + } + + // RUNNING 상태 + boolean isStep2 = "EVAL".equals(jobType) || "TEST".equals(jobType); + String prefix = isStep2 ? "STEP2_" : "STEP1_"; + + if (currentEpoch == null || totalEpoch == null) { + if (startedDttm == null) { + return prefix + "PREPARING"; + } + long elapsed = ChronoUnit.SECONDS.between(startedDttm, ZonedDateTime.now()); + if (elapsed < 30) return prefix + "PREPARING"; + if (elapsed < 60) return prefix + "CONTAINER_STARTING"; + return prefix + "DATA_LOADING"; + } + + if (currentEpoch >= totalEpoch) { + return prefix + "PROCESSING_RESULTS"; + } + + return prefix + "TRAINING"; + } + + /** 경과 시간 계산 (초) */ + public static Long calculateElapsedSeconds(ZonedDateTime startedDttm) { + if (startedDttm == null) { + return null; + } + return ChronoUnit.SECONDS.between(startedDttm, ZonedDateTime.now()); + } + + /** 예상 남은 시간 계산 (초) */ + public static Long estimateRemainingSeconds(double progressPercent, Long elapsedSeconds) { + if (elapsedSeconds == null || progressPercent <= 0.0) { + return null; + } + + double remaining = (100.0 - progressPercent) / progressPercent; + return (long) (elapsedSeconds * remaining); + } + + /** + * 상태 메시지 생성 + * + * @param phase 현재 Phase + * @param currentEpoch 현재 Epoch + * @param totalEpoch 전체 Epoch + * @return 상태 메시지 + */ + public static String generateProgressMessage( + String phase, Integer currentEpoch, Integer totalEpoch) { + if (phase == null) { + return "대기중"; + } + + // Step 구분 + boolean isStep1 = phase.startsWith("STEP1_"); + boolean isStep2 = phase.startsWith("STEP2_"); + String stepName = isStep1 ? "학습" : isStep2 ? "테스트" : ""; + + switch (phase) { + case "QUEUED": + return "학습 작업이 큐에 등록되었습니다."; + + case "STEP1_PREPARING": + return "학습 준비 중입니다."; + case "STEP1_CONTAINER_STARTING": + return "학습용 Docker 컨테이너를 시작하는 중입니다."; + case "STEP1_DATA_LOADING": + return "학습 데이터를 로딩하는 중입니다."; + case "STEP1_TRAINING": + if (currentEpoch != null && totalEpoch != null) { + return String.format("학습 진행 중 (Epoch %d/%d)", currentEpoch, totalEpoch); + } + return "학습 진행 중"; + case "STEP1_PROCESSING_RESULTS": + return "학습 결과를 처리하는 중입니다."; + + case "STEP2_PREPARING": + return "테스트 준비 중입니다."; + case "STEP2_CONTAINER_STARTING": + return "테스트용 Docker 컨테이너를 시작하는 중입니다."; + case "STEP2_DATA_LOADING": + return "테스트 데이터를 로딩하는 중입니다."; + case "STEP2_TRAINING": + if (currentEpoch != null && totalEpoch != null) { + return String.format("테스트 진행 중 (Epoch %d/%d)", currentEpoch, totalEpoch); + } + return "테스트 진행 중"; + case "STEP2_PROCESSING_RESULTS": + return "테스트 결과를 처리하는 중입니다."; + + case "COMPLETED": + return "학습이 성공적으로 완료되었습니다."; + case "FAILED": + return "학습이 실패했습니다."; + case "CANCELED": + return "학습이 취소되었습니다."; + + default: + return stepName + " 진행 중"; + } + } +}