LGES-OC5-PtoP-Welder/docs/작업지시서/2026-08-04_Laser_Recipe_Save_진단계측.md
2026-08-06 11:29:00 +09:00

36 KiB
Raw Blame History

작업지시서 — Laser Recipe Save 교차 오염 진단 계측 (2026-08-04)

항목 내용
대상 LGES-OC5-PtoP-Welder / ILSOS_PrismaticRD_PTOP
분류 진단 전용 계측 (기능 수정 아님)
선행 문서 docs/작업지시서/작업지시서_2026-08-03_Laser_Recipe_Save_교차오염.txt
원본 분석 docs/분석/2026/08/03/레시피저장_활성vs선택_교차오염_원인분석.md
상태 적용 완료 + 빌드 성공 (2026-08-04, 브랜치 feature/ubisam-pptx-integration, 미커밋). 현장 검증 대기.
원복 문서 docs/작업지시서/2026-08-04_Laser_Recipe_Save_진단계측_원복.md

적용 시 실제 소스에는 원복용 마커가 추가되었습니다. 본 문서의 [변경안] 코드 블록에는 마커가 없으나, 소스에는 삽입 구간마다 아래 형태의 시작/끝 주석이 들어가 있습니다. 검색 한 번으로 전부 찾을 수 있습니다.

[진단계측 2026-08-04]

각 블록 머리 주석에 원본 코드가 그대로 적혀 있어 그것만 보고도 되돌릴 수 있습니다. RecipeHelper.cs 에 3쌍, WeldingCommon.csproj 에 주석 1줄이 있습니다.

이 작업의 목적

저장을 막지 않습니다. 레시피 신원이 언제 어긋나는지, Save 로 무엇이 무엇으로 바뀌는지를 데이터로 확정하기 위한 계측만 넣습니다. 원인 경로가 확정되면 선행 문서의 2~4단계 중 필요한 것만 적용하고, 본 계측은 통째로 제거합니다.

[!] 경고 1 — WeldingComponents 하위를 수정합니다. 프로젝트 규약상 사전 승인 대상입니다. [!] 경고 2 — 한글 포함 파일은 UTF-8 인코딩을 유지하고 편집하십시오. 새 인코딩으로 저장 금지. [!] 경고 3 — 작업 전 D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\_config_recipe 폴더를 통째로 백업하십시오. 본 계측은 저장을 막지 않으므로 검증 중에도 레시피가 오염될 수 있습니다.

수정 요약

# 파일 위치 작업
1 WeldingComponents\WeldingCommon\Utils\RecipeTrace.cs 신규 파일 생성 (전문 아래)
2 WeldingComponents\WeldingCommon\WeldingCommon.csproj 272행 뒤 <Compile Include> 1줄 추가
3 WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs 18행 필드 → 프로퍼티
4 WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs 36~39행 SaveRecipe 전/후 계측
5 WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs 64~69행 ReloadRecipe 성공/실패/무동작 계측
6 WeldingComponents\LASA\GUI\Page\MainPage.cs 535행 Page(FieldHelper.json) 기록 훅 (작업 6)
7 WeldingComponents\WeldingCommon\Utils\CommonHelper.cs 13~17행 위 훅 연결 (작업 7)

ILSOS_PrismaticRD_PTOP 소스는 수정하지 않습니다. 배포용 파일 목록은 docs/작업지시서/2026-08-04_Laser_Recipe_Save_진단계측_변경파일목록.md 참조.

3~5는 같은 파일입니다. 위에서부터 고치면 줄 번호가 밀리므로, 줄 번호보다 [현재 코드] 문자열을 기준으로 찾아서 바꾸십시오.

왜 이 3곳인가 (근거)

저장소 전체를 조사한 결과, 레시피 신원이 바뀌는 지점은 정확히 3종류뿐입니다.

(가) 저장 "대상"이 바뀌는 곳 — RecipeHelper.SelectedRecipe 대입, 전 저장소에 4곳

위치 경로
UCScannerObjectTeachingViewModel.cs:114 신규 레시피 생성
UCScannerObjectTeachingViewModel.cs:165 OnChanged
UCScannerObjectTeachingViewModel.cs:172 Reloaded
RecipeHelper.cs:76 OnActiveReloaded

나머지는 전부 .name / .id 읽기뿐이고, 튜플 멤버에 직접 대입하는 코드는 없습니다. → 필드를 프로퍼티로 바꿔도 컴파일이 깨지지 않으며, setter 한 곳에서 4개 지점을 호출자까지 전부 자동으로 잡습니다.

(나) 저장 "내용"이 바뀌는 곳 — MainPage.ReloadRecipe 하나뿐

WeldingManager.ReloadWeldingRecipe(WeldingManager.cs:43)라는 우회 경로가 있으나 호출부가 전부 주석(CommonHelper.cs:22, UCScannerObjectTeaching.xaml.cs:37)이라 죽어 있습니다. 무시해도 됩니다.

(다) 사고가 디스크에 확정되는 곳 — RecipeHelper.SaveRecipe

MainPage.SaveRecipe(MainPage.cs:425-434)는 메모리를 지정 경로에 덤프할 뿐, 이 시점에 바뀌는 것은 파일뿐입니다. 메모리도 대상도 여기서 바뀌지 않습니다. 즉 사고는 Save 이전에 이미 결정되어 있고, Save 는 그것을 파일에 굳힐 뿐입니다. 그래서 전(원인)과 후(피해)를 모두 찍습니다.

참조 가능성 사전 확인 (완료)

선행 문서 §3 "구현 시 확인 사항"과 §9 미확정 항목 중 아래는 확인 완료입니다. 다시 검증할 필요 없습니다.

항목 결과
DB.Dao.SystemRecipe.WorkRecipeFile 를 WeldingCommon 에서 참조 가능한가 가능. WeldingCommon.csproj:581 이 LASAGUI 만 참조하므로 DB.Dao 는 MainPage.cs:466 이 쓰는 바로 그 정적과 동일 인스턴스. 선례 RecipeLaserInterlock.cs:25
UtilUnit.logUnit WeldingCommon 에서 사용 선례 0건. → 로그는 파일 append 로 대체함
MessageBox WeldingCommon 에서 이미 다수 사용 중 (UCPowerCalibrationViewModel.cs:263 등)
Auto/Manual 판별 WeldingCommon.Status.EquipmentStatus 정적, WeldingCommon.Utils 에서 그대로 해석됨
getRecipeInfo 호출이 안전한가 안전. LoadFileReadOnly(RecipeWeldingLines.cs:232)는 자기 인스턴스에만 로드하고 전역을 건드리지 않음. OnSaved:209 가 이미 동일하게 사용 중
RecipeExIds namespace DB (LASARecipeHelper.cs:10)
eEqpStatus namespace WeldingCommon (EquipDefCommon.cs:20)

추가 발견 — 신원 마커가 2개입니다.

마커 기록 지점
DB.Dao.SystemRecipe.WorkRecipeFile MainPage.ReloadRecipe (MainPage.cs:466)
DB.Recipe.Recipe.SelectedRecipeFile Recipe.Load 내부 (Recipe.cs:75). Recipe.Save 는 건드리지 않음

정상이면 항상 같습니다. 다르면 MainPage 를 거치지 않은 Load 경로가 있었다는 뜻이라, 그 자체로 원인을 좁혀 줍니다.


작업 1. 신규 파일 생성

경로: WeldingComponents\WeldingCommon\Utils\RecipeTrace.cs 인코딩: UTF-8 (한글 주석 포함)

아래 전문을 그대로 새 파일로 저장하십시오.

// ─────────────────────────────────────────────────────────────────────────────
// [진단 전용 2026-08-04] Laser Recipe Save 교차 오염 — "언제 어긋나는지" 추적.
//   레시피 신원(대상/내용)이 바뀌는 지점마다 흔적을 남기고,
//   Save 전/후를 비교해 무엇이 무엇으로 바뀌었는지 보여준다.
//   저장을 막지 않는다. 원인 확정 후 이 파일과 호출부를 통째로 제거할 것.
// ─────────────────────────────────────────────────────────────────────────────
using Aurora;
using CoreUnit.Control.UserControl.Canvas;
using CoreUnit.Scanner.UserControl.ScannerCanvas;
using DB;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Windows;

namespace WeldingCommon.Utils
{
    public static class RecipeTrace
    {
        // 흐름을 눈으로 다 보고 싶으면 true (저장할 때마다 팝업).
        // 평소엔 false 로 두고 불일치가 감지될 때만 뜨게 한다.
        public const bool ALWAYS_SHOW_ON_SAVE = false;

        // Auto 중 모달은 시퀀스를 세운다. 기본은 파일에만 기록.
        public const bool SHOW_IN_AUTO = false;

        const int KEEP = 40;
        static readonly List<string> _trail = new List<string>();
        static readonly object _lock = new object();

        // ── 흔적 남기기 ──────────────────────────────────────────────────────
        public static void Mark(string evt, string detail = "")
        {
            string line;
            try {
                string actName; int actId;
                GetActive(out actName, out actId);

                line = $"{DateTime.Now:HH:mm:ss.fff} T{System.Threading.Thread.CurrentThread.ManagedThreadId,-3} "
                     + $"{evt,-24} 선택={Sel()} 메모리={Short(Work())} LASA={Short(Lasa())} "
                     + $"활성=[{actId}][{actName}] {Status.EquipmentStatus} | {detail} | ← {Caller()}";
            } catch (Exception ex) {
                line = $"{DateTime.Now:HH:mm:ss.fff} {evt} (기록실패 {ex.Message})";
            }

            lock (_lock) {
                _trail.Add(line);
                if (_trail.Count > KEEP) _trail.RemoveAt(0);
            }
            AppendFile(line);
        }

        // ── Save 전/후 스냅샷 ────────────────────────────────────────────────
        public class SaveSnap
        {
            public string RecipeName, TargetPath, ActivePath, ActiveName;
            public string Selected, Work, Lasa, Eqp;
            public string MemFp, TgtFp, ActFp;
            public string TgtHash, ActHash, TgtField, ActField;
            public bool BadArg, BadPath, BadMarker;
        }

        // 저장 직전 — "왜 어긋났나"
        public static SaveSnap BeforeSave(string recipeName, string targetPath)
        {
            var s = new SaveSnap { RecipeName = recipeName, TargetPath = targetPath };
            try {
                string actName; int actId;
                GetActive(out actName, out actId);
                s.ActiveName = actName;
                s.ActivePath = string.IsNullOrWhiteSpace(actName) ? "" : TeachingPath(actName);

                s.Selected = Sel();
                s.Work     = Work();
                s.Lasa     = Lasa();
                s.Eqp      = Status.EquipmentStatus.ToString();

                s.MemFp = MemFingerprint();                  // 메모리 = 저장될 내용
                s.TgtFp = FileFingerprint(targetPath);       // 저장될 자리에 원래 있던 내용
                s.ActFp = FileFingerprint(s.ActivePath);     // 활성 레시피 내용

                s.TgtHash  = Hash(targetPath);
                s.ActHash  = Hash(s.ActivePath);
                s.TgtField = Hash(FieldPath(targetPath));    // Page 데이터(FieldHelper.json)
                s.ActField = Hash(FieldPath(s.ActivePath));

                // 호출 인자와 전역 대상이 다르다 (OnSaved 가 인자를 버리는 경로)
                s.BadArg    = !string.Equals(recipeName, RecipeHelper.SelectedRecipe.name,
                                             StringComparison.OrdinalIgnoreCase);
                // 저장 대상과 메모리에 로드된 파일이 다르다
                s.BadPath   = !string.IsNullOrWhiteSpace(s.Work) && !SamePath(s.Work, targetPath);
                // 두 신원 마커가 서로 다르다 (MainPage 를 안 거친 Load 존재)
                s.BadMarker = !string.IsNullOrWhiteSpace(s.Work) && !string.IsNullOrWhiteSpace(s.Lasa)
                                                                 && !SamePath(s.Work, s.Lasa);

                Mark("SAVE 직전",
                     $"요청=[{recipeName}] arg={s.BadArg} path={s.BadPath} marker={s.BadMarker} "
                   + $"메모리={s.MemFp} 대상파일={s.TgtFp}");
            } catch { }
            return s;
        }

        // 저장 직후 — "무엇이 무엇으로 바뀌었나"
        public static void AfterSave(SaveSnap s)
        {
            try {
                if (s == null) return;

                string tgtFpAfter = FileFingerprint(s.TargetPath);
                string actFpAfter = FileFingerprint(s.ActivePath);
                string tgtHashA   = Hash(s.TargetPath);
                string actHashA   = Hash(s.ActivePath);
                string tgtFieldA  = Hash(FieldPath(s.TargetPath));
                string actFieldA  = Hash(FieldPath(s.ActivePath));

                bool targetIsActive = SamePath(s.TargetPath, s.ActivePath);
                bool tgtChanged  = s.TgtHash  != tgtHashA;
                bool actChanged  = s.ActHash  != actHashA;
                bool tgtFieldChg = s.TgtField != tgtFieldA;
                bool actFieldChg = s.ActField != actFieldA;

                // 핵심 : 대상 파일이 활성 레시피 내용으로 바뀌었다 = 신고된 증상
                bool becameActive = !targetIsActive && tgtChanged
                                    && tgtFpAfter == actFpAfter && s.TgtFp != s.ActFp;
                // 역방향 : 엉뚱하게 활성 파일이 바뀌었다 (선행문서 원인 2)
                bool activeHit = !targetIsActive && (actChanged || actFieldChg);

                bool bad = becameActive || activeHit || s.BadArg || s.BadPath || s.BadMarker;

                string conclusion =
                    becameActive ? $"★ 대상 [{s.RecipeName}] 이 활성 레시피 [{s.ActiveName}] 내용으로 덮어써짐"
                  : activeHit    ? $"★ 대상은 [{s.RecipeName}] 인데 활성 파일 [{s.ActiveName}] 이 함께 변경됨"
                  : (s.BadArg || s.BadPath || s.BadMarker)
                                 ? "△ 저장은 대상 파일에만 됐지만 신원이 어긋난 상태였음"
                                 : "정상 (대상 파일만 변경)";

                Mark(bad ? "*** SAVE 직후 (의심) ***" : "SAVE 직후",
                     $"{conclusion} | 대상변경={tgtChanged} 활성변경={actChanged} "
                   + $"페이지변경(대상)={tgtFieldChg} 페이지변경(활성)={actFieldChg}");

                if (!bad && !ALWAYS_SHOW_ON_SAVE) return;
                if (s.Eqp != eEqpStatus.Manual.ToString() && !SHOW_IN_AUTO) return;

                var sb = new StringBuilder();
                sb.AppendLine("[Recipe Save 진단] 이 화면을 촬영해 주세요. 저장은 그대로 진행되었습니다.");
                sb.AppendLine();
                sb.AppendLine($"결론 : {conclusion}");
                sb.AppendLine();
                sb.AppendLine($"저장 요청       : [{s.RecipeName}]");
                sb.AppendLine($"SelectedRecipe  : {s.Selected}");
                sb.AppendLine($"활성 레시피     : [{s.ActiveName}]");
                sb.AppendLine($"설비상태        : {s.Eqp}");
                sb.AppendLine($"신원 어긋남     : arg={s.BadArg} path={s.BadPath} marker={s.BadMarker}");
                sb.AppendLine($"  메모리 WorkRecipeFile     : {s.Work}");
                sb.AppendLine($"  메모리 SelectedRecipeFile : {s.Lasa}");
                sb.AppendLine($"  저장대상 target           : {s.TargetPath}");
                sb.AppendLine();
                sb.AppendLine("──────── 무엇이 무엇으로 바뀌었나 ────────");
                sb.AppendLine($"저장된 내용(메모리) : {s.MemFp}");
                sb.AppendLine();
                sb.AppendLine($"대상파일 전 : {s.TgtFp}");
                sb.AppendLine($"대상파일 후 : {tgtFpAfter}   변경={tgtChanged}");
                sb.AppendLine($"활성파일 전 : {s.ActFp}");
                sb.AppendLine($"활성파일 후 : {actFpAfter}   변경={actChanged}");
                sb.AppendLine();
                sb.AppendLine($"Page(FieldHelper) 대상 변경 : {tgtFieldChg}   활성 변경 : {actFieldChg}");
                sb.AppendLine();
                sb.AppendLine("──────── 직전 이력 (오래된 것 → 최근) ────────");
                lock (_lock) { foreach (var l in _trail) sb.AppendLine(l); }

                MessageBox.Show(sb.ToString(), "Recipe Save 진단",
                                MessageBoxButton.OK, MessageBoxImage.Warning);
            } catch { }
        }

        // ── 지문 ────────────────────────────────────────────────────────────
        // 메모리의 실제 용접 파라미터. ScannerCanvasUserCtrl.ScannerParams 가 실체다.
        public static string MemFingerprint()
        {
            try { return Fp(ScannerCanvasUserCtrl.ScannerParams); } catch { return "?"; }
        }

        public static string FileFingerprint(string fullPath)
        {
            try {
                if (string.IsNullOrWhiteSpace(fullPath) || !File.Exists(fullPath)) return "(파일없음)";
                return Fp(CommonHelper.GetMainPage().getRecipeInfo(fullPath).ReadOnlyScanParams);
            } catch (Exception ex) { return "(읽기실패)" + ex.Message; }
        }

        // OnSaved 가 비교하는 필드와 동일한 6개, 앞 3개 파라미터
        static string Fp(List<ScannerParam> p)
        {
            if (p == null) return "null";
            var sb = new StringBuilder($"n={p.Count}");
            for (int i = 0; i < 3 && i < p.Count; i++)
                sb.Append($"|{p[i].MarkDelay},{p[i].JumpDelay},{p[i].LaserOnDelay},"
                        + $"{p[i].LaserOffDelay},{p[i].LaserFrequency},{p[i].NumberOfCount}");
            return sb.ToString();
        }

        // 파일↔파일 비교는 파싱 없이 해시로 (빠름)
        static string Hash(string path)
        {
            try {
                if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return "(없음)";
                using (var md5 = System.Security.Cryptography.MD5.Create())
                using (var fs = File.OpenRead(path)) {
                    var h = BitConverter.ToString(md5.ComputeHash(fs)).Replace("-", "");
                    return h.Substring(0, 8) + "/" + new FileInfo(path).Length;
                }
            } catch { return "(해시실패)"; }
        }

        // ── 잡동사니 ────────────────────────────────────────────────────────
        static string Sel()  { var s = RecipeHelper.SelectedRecipe; return $"[{s.id}][{s.name}]"; }
        static string Work() { try { return DB.Dao.SystemRecipe.WorkRecipeFile; } catch { return ""; } }
        static string Lasa() { try { return DB.Recipe.Recipe.SelectedRecipeFile; } catch { return ""; } }

        // Aurora 바이너리 타입명을 쓰지 않기 위해 out 으로 받는다
        static void GetActive(out string name, out int id)
        {
            name = ""; id = -1;
            try {
                var a = Application.Current.AuroraEnv().RecipeManager.ActivatedRecipe();
                if (a != null) { name = a.Name; id = a.Id; }
            } catch { }
        }

        // 같은 폴더의 Page 데이터
        static string FieldPath(string recipeFullPath)
        {
            try {
                return string.IsNullOrWhiteSpace(recipeFullPath) ? ""
                     : recipeFullPath.Replace(Path.GetFileName(recipeFullPath), "FieldHelper.json");
            } catch { return ""; }
        }

        static string TeachingPath(string recipeName)
        {
            var root = ConfigurationManager.AppSettings.Get("DATA_PATH");
            return Path.Combine(root, RecipeExIds.RECIPE_DIR, recipeName, RecipeExIds.TEACHING_EDITOR_RECIPE);
        }

        // 전체 경로 대신 레시피 폴더명만 (…/_config_recipe/<레시피명>/TeachingEditor…)
        static string Short(string p)
        {
            try {
                return string.IsNullOrWhiteSpace(p) ? "(비어있음)"
                                                    : Path.GetFileName(Path.GetDirectoryName(p));
            } catch { return p; }
        }

        static bool SamePath(string a, string b)
        {
            if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false;
            try {
                return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b),
                                     StringComparison.OrdinalIgnoreCase);
            } catch { return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); }
        }

        // RecipeTrace 프레임을 건너뛰고 실제 호출자 2단계
        static string Caller()
        {
            try {
                var st = new StackTrace(true);
                var sb = new StringBuilder();
                int found = 0;
                for (int i = 1; i < st.FrameCount && found < 2; i++) {
                    var m = st.GetFrame(i).GetMethod();
                    var t = m.DeclaringType == null ? "?" : m.DeclaringType.Name;
                    if (t == "RecipeTrace") continue;
                    if (found > 0) sb.Append(" ← ");
                    sb.Append($"{t}.{m.Name}:{st.GetFrame(i).GetFileLineNumber()}");
                    found++;
                }
                return sb.ToString();
            } catch { return "?"; }
        }

        static void AppendFile(string line)
        {
            try {
                var root = ConfigurationManager.AppSettings.Get("DATA_PATH");
                File.AppendAllText(Path.Combine(root, "RecipeTrace.txt"), line + "\r\n", Encoding.UTF8);
            } catch { }
        }
    }
}

작업 2. csproj 에 파일 등록

파일: WeldingComponents\WeldingCommon\WeldingCommon.csproj

구식 csproj 라 파일만 만들면 빌드에 포함되지 않습니다. Visual Studio 에서 [기존 항목 추가]로 넣으면 자동 처리되고, 직접 편집한다면 아래와 같이 합니다.

[현재 272~273행]

    <Compile Include="Utils\RecipeHelper.cs" />
    <Compile Include="Utils\RegistrySerializer.cs" />

[변경안]

    <Compile Include="Utils\RecipeHelper.cs" />
    <Compile Include="Utils\RecipeTrace.cs" />
    <Compile Include="Utils\RegistrySerializer.cs" />

작업 3. SelectedRecipe 필드 → 프로퍼티

파일: WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs 위치: 18행

[현재 코드]

        public static (int id, string name) SelectedRecipe;

[변경안]

        // [진단 2026-08-04] 대상이 바뀌는 순간을 잡기 위해 필드 → 프로퍼티.
        //   외부에서의 읽기/쓰기 방식은 동일하다(대입 4곳, 나머지는 .name/.id 읽기뿐).
        static (int id, string name) _SelectedRecipe;
        public static (int id, string name) SelectedRecipe {
            get { return _SelectedRecipe; }
            set {
                var old = _SelectedRecipe;
                _SelectedRecipe = value;
                RecipeTrace.Mark("대상변경 SELECTED",
                    $"[{old.id}][{old.name}] → [{value.id}][{value.name}]");
            }
        }

확인 사항 — 빌드 시 SelectedRecipe 관련 에러가 나면 튜플 멤버에 직접 대입하는 코드(SelectedRecipe.name = ...)가 어딘가 있다는 뜻입니다. 조사 시점에는 0건이었습니다.


작업 4. SaveRecipe 전/후 계측

파일: WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs 위치: 36~39행

[현재 코드]

        public static void SaveRecipe(string recipeName, bool includeModel = true)
        {
            CommonHelper.GetMainPage().SaveRecipe(GetTeachingEditorFullPath(recipeName), includeModel);
        }

[변경안]

        public static void SaveRecipe(string recipeName, bool includeModel = true)
        {
            var targetPath = GetTeachingEditorFullPath(recipeName);

            // [진단 2026-08-04] 저장 전/후를 모두 찍어 무엇이 무엇으로 바뀌었는지 확인한다.
            //   저장 자체는 막지 않는다.
            var snap = RecipeTrace.BeforeSave(recipeName, targetPath);

            CommonHelper.GetMainPage().SaveRecipe(targetPath, includeModel);

            RecipeTrace.AfterSave(snap);
        }

작업 5. ReloadRecipe 성공/실패/무동작 계측

파일: WeldingComponents\WeldingCommon\Utils\RecipeHelper.cs 위치: 64~69행

[현재 코드]

        public static void ReloadRecipe(string recipeName)
        {
            var camConfig = VisionEnv.AuroraEnv.DeviceManager.GetDeviceConfig(eCamId.CAM1.ToString()) as CamConfig;
            CommonHelper.GetMainPage().UpdateDefaultField(camConfig.ChildImage.ChildBufRowCols);
            CommonHelper.GetMainPage().ReloadRecipe(GetTeachingEditorFullPath(recipeName));
        }

[변경안]

        public static void ReloadRecipe(string recipeName)
        {
            // [진단 2026-08-04] 내용 전환의 성공/실패/무동작을 구분해 기록. 동작은 그대로.
            var before = RecipeTrace.MemFingerprint();
            RecipeTrace.Mark("내용변경 RELOAD 진입", $"요청=[{recipeName}] 지문(전)={before}");
            try {
                var camConfig = VisionEnv.AuroraEnv.DeviceManager.GetDeviceConfig(eCamId.CAM1.ToString()) as CamConfig;
                CommonHelper.GetMainPage().UpdateDefaultField(camConfig.ChildImage.ChildBufRowCols);
                CommonHelper.GetMainPage().ReloadRecipe(GetTeachingEditorFullPath(recipeName));

                var after = RecipeTrace.MemFingerprint();
                RecipeTrace.Mark(before == after ? "RELOAD 완료(내용무변화!)" : "RELOAD 완료",
                    $"요청=[{recipeName}] 지문(후)={after}");
            } catch (System.Exception ex) {
                RecipeTrace.Mark("*** RELOAD 실패 ***",
                    $"요청=[{recipeName}] {ex.GetType().Name}: {ex.Message}");
                throw;      // 기존 동작 보존 — 예외 전파는 그대로
            }
        }

throw;(파라미터 없음)이므로 스택 트레이스와 예외 전파 동작이 기존과 동일합니다. throw ex; 로 쓰지 마십시오.

이 두 문구가 원인 확정의 핵심입니다.

문구 의미
*** RELOAD 실패 *** 선행문서 원인 (1) 확정. 대상만 바뀌고 내용은 직전 레시피에 남음
RELOAD 완료(내용무변화!) Recipe.cs:64 무동작 가설 확정. 경로 비교로는 절대 못 잡는 케이스

같은 레시피를 다시 로드하면 정상적으로도 내용무변화 가 뜹니다. 요청 레시피명이 직전과 다른데 무변화면 그때가 사고입니다.



작업 6. Page(FieldHelper.json) 기록 훅 — MainPage.cs

파일: WeldingComponents\LASA\GUI\Page\MainPage.cs 위치: SaveAndLoadFieldScanFromLasaDb (535행)

왜 필요한가

FieldHelper.json(Page 데이터)을 실제로 쓰는 건 SaveAndLoadFieldScanFromLasaDb()(내부 :539 SaveFieldHelper)인데, 호출처 4곳 중 SaveRecipe 안에 있는 건 1곳뿐입니다.

호출처 Save 경로 안인가
MainPage.cs:433 — SaveRecipe ✓
MainPage.cs:448,452 — ReloadSetting() ✗
MainPage.cs:502,505 — UpdateScoutField() (Scout 필드 조작) ✗
MainPage.cs:1433 — 콤보박스 SelectedIndexChanged (페이지 바꿀 때마다) ✗

게다가 경로를 WorkRecipeFile 기준으로 정합니다(:537). WorkRecipeFile 이 어긋난 상태에서 페이지를 넘기기만 해도 엉뚱한 레시피 폴더의 FieldHelper.json 이 덮어써질 수 있습니다. _suppressPageFieldSync 가드(:1422)는 ReloadRecipe 도중의 발화만 막고 평상시 페이지 전환은 그대로 씁니다.

함수 한 곳에 훅을 넣으면 호출처 4곳이 전부 잡힙니다.

6-1. Tracer 필드 추가

[현재 코드]

        void SaveAndLoadFieldScanFromLasaDb(string inputStr = null)

[변경안]

        // ═══ [진단계측 2026-08-04] 시작 ═══════════════════════════════════════
        //   WeldingCommon(RecipeTrace)이 LASAGUI 를 참조하므로 역방향 호출이 불가능하다.
        //   델리게이트로 우회한다. CommonHelper.SetMainPage 에서 RecipeTrace.Mark 가 꽂힌다.
        //   원복 : 이 블록과 SaveAndLoadFieldScanFromLasaDb 안의 Tracer?.Invoke 블록을 삭제.
        public static Action<string, string> Tracer;
        // ═══ [진단계측 2026-08-04] 끝 ═════════════════════════════════════════
        void SaveAndLoadFieldScanFromLasaDb(string inputStr = null)

6-2. 기록 호출 추가

[현재 코드]

            string newpath = fullpath.Replace(Path.GetFileName(fullpath), "FieldHelper.json");
            DB.RecipeFieldScan.Instance.SaveFieldHelper(newpath);

[변경안]

            string newpath = fullpath.Replace(Path.GetFileName(fullpath), "FieldHelper.json");
            // ═══ [진단계측 2026-08-04] 시작 — 원복 시 이 블록 삭제 ═══
            Tracer?.Invoke("PAGE 저장 FieldHelper",
                $"경로={newpath} 기준={(inputStr == null ? "WorkRecipeFile" : "인자지정")}");
            // ═══ [진단계측 2026-08-04] 끝 ═══
            DB.RecipeFieldScan.Instance.SaveFieldHelper(newpath);

기준=WorkRecipeFile 이면 그 시점 WorkRecipeFile 값이 곧 기록 대상이라는 뜻입니다. 어긋나 있으면 그대로 오염입니다.

Tracer 는 Action 이라 LASAGUI → WeldingCommon 역참조가 생기지 않습니다. 연결이 안 돼 있으면 ?. 로 조용히 무시되므로 계측이 없어도 원래대로 동작합니다.


작업 7. 훅 연결 — CommonHelper.cs

파일: WeldingComponents\WeldingCommon\Utils\CommonHelper.cs 위치: SetMainPage() (13~17행)

[현재 코드]

        public static void SetMainPage(MainPage mainPage)
        {
            MainPage = mainPage;
            //If Using WeldingManager, This Method Not Necessary.
        }

[변경안]

        public static void SetMainPage(MainPage mainPage)
        {
            MainPage = mainPage;
            //If Using WeldingManager, This Method Not Necessary.

            // ═══ [진단계측 2026-08-04] 시작 — 원복 시 이 블록 삭제 ═══
            RecipeTrace.Attach();   // MainPage.Tracer 에 RecipeTrace.Mark 를 연결(참조 방향 우회)
            // ═══ [진단계측 2026-08-04] 끝 ═══
        }

SetMainPage 는 기동 시 MainPage 가 준비되는 시점에 호출되므로, 사용자가 페이지를 조작하기 전에 훅이 걸립니다.

RecipeTrace.Attach() 는 작업 1의 RecipeTrace.cs 에 포함되어 있습니다. 별도 추가 불필요.


빌드

  1. ILSOS_All.sln 을 Visual Studio 2022 로 열고 Release | x64 빌드.
  2. 빌드 실패 시 진행 중단. 특히 아래를 확인:
    • RecipeTrace.cs 가 csproj 에 등록됐는지 (작업 2)
    • SelectedRecipe 프로퍼티 전환 관련 에러 (작업 3 확인 사항)

현장 확인 절차

  1. _config_recipe 폴더 백업 완료 확인.
  2. 기동 후 Manual 상태에서 레시피 A 선택 → 파라미터 수정 → Save.
    • 정상이면 팝업이 뜨지 않습니다.
    • D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\RecipeTrace.txt 에 SAVE 직전 / SAVE 직후 줄이 남았는지 확인.
  3. 레시피 전환(A→B→A)을 10회 반복하며 팝업이 뜨는지 관찰.
  4. AutoRun 기동 → PLC 레시피 전환 발생 → Manual 복귀 → 레시피 선택 후 Save.
  5. 팝업이 뜨면 화면 전체를 촬영하고 RecipeTrace.txt 를 함께 회수.

흐름을 눈으로 직접 보고 싶으면 RecipeTrace.cs 의 ALWAYS_SHOW_ON_SAVE 를 true 로 바꾸면 저장할 때마다 팝업이 뜹니다. 확인이 끝나면 false 로 되돌리십시오.

결과 읽는 법

팝업 맨 위 결론 한 줄이 판정입니다.

결론 : ★ 대상 [RCP_B] 이 활성 레시피 [RCP_A] 내용으로 덮어써짐

──────── 무엇이 무엇으로 바뀌었나 ────────
저장된 내용(메모리) : n=128|300,100,50,50,2000,1

대상파일 전 : n=128|500,200,80,80,3000,2
대상파일 후 : n=128|300,100,50,50,2000,1   변경=True    ← 활성 것과 같아짐
활성파일 전 : n=128|300,100,50,50,2000,1
활성파일 후 : n=128|300,100,50,50,2000,1   변경=False
Page(FieldHelper) 대상 변경 : True   활성 변경 : False

──────── 직전 이력 (오래된 것 → 최근) ────────
14:22:07.113 T1   대상변경 SELECTED    선택=[3][RCP_B] 메모리=RCP_A … | [1][RCP_A] → [3][RCP_B] | ← UCScannerObjectTeachingViewModel.OnChanged:165
14:22:07.115 T1   내용변경 RELOAD 진입 선택=[3][RCP_B] 메모리=RCP_A … | 요청=[RCP_B] 지문(전)=n=128|300,…
14:22:07.140 T1   *** RELOAD 실패 ***  선택=[3][RCP_B] 메모리=RCP_A … | 요청=[RCP_B] NullReferenceException: …
14:23:51.902 T1   *** SAVE 직후 (의심) *** …
  • 대상파일 전 → 후 가 곧 무엇이 무엇으로 바뀌었나.
  • 그 값이 활성파일 과 같아지면 교차 오염 확정.
  • 그 아래 이력에서 언제 어긋났는지(RELOAD 실패 또는 대상변경 SELECTED 뒤에 대응하는 RELOAD 완료 가 없음)가 보입니다.
  • ← 호출자 컬럼으로 어느 화면 조작이었는지까지 특정됩니다.

원인별 후속 조치 (확정 후)

팝업/이력에서 관찰된 것 적용할 것
*** RELOAD 실패 *** 직후의 Save 선행문서 2단계 (대상·내용 갱신 순서 역전)
RELOAD 완료(내용무변화!) 인데 요청 레시피가 바뀐 경우 Recipe.cs:64 무동작. 별도 분석 필요 (선행문서 §9-5)
SAVE 직전 의 호출자가 OnActivatedRecipe:156 선행문서 3단계 (A안 권장)
SAVE 직전 의 호출자가 OnRecipeEntryChanged:115 선행문서 4단계
arg=True (인자 ≠ SelectedRecipe) OnSaved 의 저장 대상을 recipeName 으로 통일
marker=True (두 마커 불일치) MainPage 를 거치지 않은 Load 경로 존재. 재분석 필요

알아두실 점 / 한계

  • 저장을 막지 않습니다. 오염이 일어나면 팝업은 뜨지만 파일은 이미 덮어써진 뒤입니다. 데이터를 지키는 게 목적이면 선행문서 1단계 가드(저장 거부)를 넣어야 하지만, 그러면 원인 경로 데이터를 모을 수 없습니다. 백업을 전제로 진단을 먼저 도는 순서입니다.
  • Save 가 느려질 수 있습니다. 저장 1회당 레시피 파일 파싱이 4회(대상·활성 × 전·후) 늘어납니다. TeachingEditor 에 Model 까지 들어 있어 레시피가 크면 체감될 수 있습니다. 느리면 BeforeSave/AfterSave 에서 활성 파일 지문(ActFp/actFpAfter) 계산만 제거하십시오. becameActive 판정만 약해지고 나머지는 그대로 동작합니다.
  • Auto 중에는 팝업이 뜨지 않습니다 (SHOW_IN_AUTO = false). OnActivatedRecipe(VM:156)의 Save 는 PLC 레시피 전환 중 디스패처에서 돌기 때문에, 여기서 모달이 뜨면 확인을 누를 때까지 전환이 멈춥니다. 파일에는 그대로 남으므로 양산 중이면 이대로 두십시오.
  • 지문은 ScanParam 앞 3개 기준입니다. 두 레시피의 파라미터가 완전히 동일하면 becameActive 가 구분하지 못합니다. 이 경우 Page(FieldHelper) 변경 과 해시로 판단하십시오.
  • MainPage.SaveRecipe 가 예외를 던지면 AfterSave 는 실행되지 않습니다. 이때는 SAVE 직전 만 남습니다 — 그 자체가 저장 실패의 증거입니다.

원복 방법

  1. RecipeTrace.cs 삭제
  2. csproj 의 <Compile Include="Utils\RecipeTrace.cs" /> 삭제
  3. RecipeHelper.cs 의 작업 3·4·5 를 원래 코드로 되돌림 (본 문서의 [현재 코드] 블록)
  4. D:\ILSOSConfig\ILSOS_PrismaticRD_PTOP\RecipeTrace.txt 삭제

이번 작업에서 하지 않는 것

  • 저장 차단 가드 (선행문서 1단계) — 원인 데이터 수집 후 판단
  • OnActiveReloaded / OnChanged / Reloaded 의 순서 교정 (2단계)
  • OnActivatedRecipe 의 SaveRecipe 제거 (3단계)
  • RMSHandlerForPTOP 수정 — 죽은 코드 (선행문서 §7)
  • EquipmentSetup.cs:282 RollbackRecipe 주석 해제 — 의미 없는 동작 (선행문서 §7)