diff --git a/Cs_HMI/AGVLogic/AGVNavigationCore/Models/VirtualAGV.cs b/Cs_HMI/AGVLogic/AGVNavigationCore/Models/VirtualAGV.cs index 4f518ba..f200ce6 100644 --- a/Cs_HMI/AGVLogic/AGVNavigationCore/Models/VirtualAGV.cs +++ b/Cs_HMI/AGVLogic/AGVNavigationCore/Models/VirtualAGV.cs @@ -559,253 +559,7 @@ namespace AGVNavigationCore.Models #endregion - #region Directional Navigation - - /// - /// 현재 이전/현재 위치와 이동 방향을 기반으로 다음 노드 ID를 반환 - /// - /// 사용 예시: - /// - 001에서 002로 이동 후, Forward 선택 → 003 반환 - /// - 003에서 004로 이동 후, Right 선택 → 030 반환 - /// - 004에서 003으로 Backward 이동 중, GetNextNodeId(Backward) → 002 반환 - /// - /// 전제조건: SetPosition이 최소 2번 이상 호출되어 _prevPosition이 설정되어야 함 - /// - /// 이동 방향 (Forward/Backward/Left/Right) - /// 맵의 모든 노드 - /// 다음 노드 ID (또는 null) - public MapNode GetNextNodeId(AgvDirection direction, List allNodes) - { - // 전제조건 검증: 2개 위치 히스토리 필요 - if ( _prevNode == null) - { - return null; - } - - if (_currentNode == null || allNodes == null || allNodes.Count == 0) - { - return null; - } - - // 현재 노드에 연결된 노드들 가져오기 - var connectedNodeIds = _currentNode.ConnectedNodes; - if (connectedNodeIds == null || connectedNodeIds.Count == 0) - { - return null; - } - - // 연결된 노드 중 현재 노드가 아닌 것들만 필터링 - var candidateNodes = allNodes.Where(n => - connectedNodeIds.Contains(n.NodeId) && n.NodeId != _currentNode.NodeId - ).ToList(); - - if (candidateNodes.Count == 0) - { - return null; - } - - // 이전→현재 벡터 계산 (진행 방향 벡터) - var movementVector = new PointF( - _currentPosition.X - _prevPosition.X, - _currentPosition.Y - _prevPosition.Y - ); - - // 벡터 정규화 - var movementLength = (float)Math.Sqrt( - movementVector.X * movementVector.X + - movementVector.Y * movementVector.Y - ); - - if (movementLength < 0.001f) // 거의 이동하지 않음 - { - return candidateNodes[0]; // 첫 번째 연결 노드 반환 - } - - var normalizedMovement = new PointF( - movementVector.X / movementLength, - movementVector.Y / movementLength - ); - - // 각 후보 노드에 대해 방향 점수 계산 - var bestCandidate = (node: (MapNode)null, score: 0.0f); - - foreach (var candidate in candidateNodes) - { - var toNextVector = new PointF( - candidate.Position.X - _currentPosition.X, - candidate.Position.Y - _currentPosition.Y - ); - - var toNextLength = (float)Math.Sqrt( - toNextVector.X * toNextVector.X + - toNextVector.Y * toNextVector.Y - ); - - if (toNextLength < 0.001f) - { - continue; - } - - var normalizedToNext = new PointF( - toNextVector.X / toNextLength, - toNextVector.Y / toNextLength - ); - - // 진행 방향 기반 점수 계산 - float score = CalculateDirectionalScore( - normalizedMovement, - normalizedToNext, - direction - ); - - if (score > bestCandidate.score) - { - bestCandidate = (candidate, score); - } - } - - return bestCandidate.node; - } - - /// - /// 이동 방향을 기반으로 방향 점수를 계산 - /// 높은 점수 = 더 나은 선택지 - /// - private float CalculateDirectionalScore( - PointF movementDirection, // 정규화된 이전→현재 벡터 - PointF nextDirection, // 정규화된 현재→다음 벡터 - AgvDirection requestedDir) // 요청된 이동 방향 - { - // 벡터 간 내적 계산 (유사도: -1 ~ 1) - float dotProduct = (movementDirection.X * nextDirection.X) + - (movementDirection.Y * nextDirection.Y); - - // 벡터 간 외적 계산 (좌우 판별: Z 성분) - // 양수 = 좌측, 음수 = 우측 - float crossProduct = (movementDirection.X * nextDirection.Y) - - (movementDirection.Y * nextDirection.X); - - float baseScore = 0; - - switch (requestedDir) - { - case AgvDirection.Forward: - // Forward: 현재 모터 상태에 따라 다름 - // 1) 현재 Forward 모터 상태라면 → 같은 경로 (계속 전진) - // 2) 현재 Backward 모터 상태라면 → 반대 경로 (모터 방향 전환) - if (_currentDirection == AgvDirection.Forward) - { - // 이미 Forward 상태, 계속 Forward → 같은 경로 - if (dotProduct > 0.9f) - baseScore = 100.0f; - else if (dotProduct > 0.5f) - baseScore = 80.0f; - else if (dotProduct > 0.0f) - baseScore = 50.0f; - else if (dotProduct > -0.5f) - baseScore = 20.0f; - } - else - { - // Backward 상태에서 Forward로 → 반대 경로 - if (dotProduct < -0.9f) - baseScore = 100.0f; - else if (dotProduct < -0.5f) - baseScore = 80.0f; - else if (dotProduct < 0.0f) - baseScore = 50.0f; - else if (dotProduct < 0.5f) - baseScore = 20.0f; - } - break; - - case AgvDirection.Backward: - // Backward: 현재 모터 상태에 따라 다름 - // 1) 현재 Backward 모터 상태라면 → 같은 경로 (Forward와 동일) - // 2) 현재 Forward 모터 상태라면 → 반대 경로 (현재의 Backward와 반대) - if (_currentDirection == AgvDirection.Backward) - { - // 이미 Backward 상태, 계속 Backward → 같은 경로 - if (dotProduct > 0.9f) - baseScore = 100.0f; - else if (dotProduct > 0.5f) - baseScore = 80.0f; - else if (dotProduct > 0.0f) - baseScore = 50.0f; - else if (dotProduct > -0.5f) - baseScore = 20.0f; - } - else - { - // Forward 상태에서 Backward로 → 반대 경로 - if (dotProduct < -0.9f) - baseScore = 100.0f; - else if (dotProduct < -0.5f) - baseScore = 80.0f; - else if (dotProduct < 0.0f) - baseScore = 50.0f; - else if (dotProduct < 0.5f) - baseScore = 20.0f; - } - break; - - case AgvDirection.Left: - // Left: 좌측 방향 선호 - if (dotProduct > 0.0f) // Forward 상태 - { - if (crossProduct > 0.5f) - baseScore = 100.0f; - else if (crossProduct > 0.0f) - baseScore = 70.0f; - else if (crossProduct > -0.5f) - baseScore = 50.0f; - else - baseScore = 30.0f; - } - else // Backward 상태 - 좌우 반전 - { - if (crossProduct < -0.5f) - baseScore = 100.0f; - else if (crossProduct < 0.0f) - baseScore = 70.0f; - else if (crossProduct < 0.5f) - baseScore = 50.0f; - else - baseScore = 30.0f; - } - break; - - case AgvDirection.Right: - // Right: 우측 방향 선호 - if (dotProduct > 0.0f) // Forward 상태 - { - if (crossProduct < -0.5f) - baseScore = 100.0f; - else if (crossProduct < 0.0f) - baseScore = 70.0f; - else if (crossProduct < 0.5f) - baseScore = 50.0f; - else - baseScore = 30.0f; - } - else // Backward 상태 - 좌우 반전 - { - if (crossProduct > 0.5f) - baseScore = 100.0f; - else if (crossProduct > 0.0f) - baseScore = 70.0f; - else if (crossProduct > -0.5f) - baseScore = 50.0f; - else - baseScore = 30.0f; - } - break; - } - - return baseScore; - } - - #endregion + #region Cleanup diff --git a/Cs_HMI/AGVLogic/AGVNavigationCore/PathFinding/Core/AStarPathfinder.cs b/Cs_HMI/AGVLogic/AGVNavigationCore/PathFinding/Core/AStarPathfinder.cs index e7adebf..ed8105e 100644 --- a/Cs_HMI/AGVLogic/AGVNavigationCore/PathFinding/Core/AStarPathfinder.cs +++ b/Cs_HMI/AGVLogic/AGVNavigationCore/PathFinding/Core/AStarPathfinder.cs @@ -66,17 +66,17 @@ namespace AGVNavigationCore.PathFinding.Core { var pathNode = _nodeMap[mapNode.NodeId]; - foreach (var connectedNodeId in mapNode.ConnectedNodes) + foreach (var connectedNode in mapNode.ConnectedNodes) { - if (_nodeMap.ContainsKey(connectedNodeId)) + if (_nodeMap.ContainsKey(connectedNode)) { // 양방향 연결 생성 (단일 연결이 양방향을 의미) - if (!pathNode.ConnectedNodes.Contains(connectedNodeId)) + if (!pathNode.ConnectedNodes.Contains(connectedNode)) { - pathNode.ConnectedNodes.Add(connectedNodeId); + pathNode.ConnectedNodes.Add(connectedNode); } - var connectedPathNode = _nodeMap[connectedNodeId]; + var connectedPathNode = _nodeMap[connectedNode]; if (!connectedPathNode.ConnectedNodes.Contains(mapNode.NodeId)) { connectedPathNode.ConnectedNodes.Add(mapNode.NodeId); @@ -594,13 +594,13 @@ namespace AGVNavigationCore.PathFinding.Core foreach (var connectedNodeId in junctionNode.ConnectedNodes) { + if (connectedNodeId == null) continue; + // 조건 1: 왔던 길이 아님 - if (connectedNodeId == previousNodeId) - continue; + if (connectedNodeId == previousNodeId) continue; // 조건 2: 갈 길이 아님 - if (connectedNodeId == targetNodeId) - continue; + if (connectedNodeId == targetNodeId) continue; // 조건 3, 4, 5: 존재하고, 활성 상태이고, 네비게이션 가능 var connectedNode = _mapNodes.FirstOrDefault(n => n.NodeId == connectedNodeId); diff --git a/Cs_HMI/AGVLogic/AGVNavigationCore/PathFinding/Planning/AGVPathfinder.cs b/Cs_HMI/AGVLogic/AGVNavigationCore/PathFinding/Planning/AGVPathfinder.cs index afb699c..aebb94c 100644 --- a/Cs_HMI/AGVLogic/AGVNavigationCore/PathFinding/Planning/AGVPathfinder.cs +++ b/Cs_HMI/AGVLogic/AGVNavigationCore/PathFinding/Planning/AGVPathfinder.cs @@ -22,6 +22,8 @@ namespace AGVNavigationCore.PathFinding.Planning private readonly JunctionAnalyzer _junctionAnalyzer; private readonly DirectionChangePlanner _directionChangePlanner; + + public AGVPathfinder(List mapNodes) { _mapNodes = mapNodes ?? new List(); @@ -114,7 +116,7 @@ namespace AGVNavigationCore.PathFinding.Planning return AGVPathResult.CreateFailure("목적지 노드가 null입니다.", 0, 0); if (prevNode == null) return AGVPathResult.CreateFailure("이전위치 노드가 null입니다.", 0, 0); - if (startNode == targetNode) + if (startNode.NodeId == targetNode.NodeId && targetNode.DockDirection.MatchAGVDirection(prevDirection)) return AGVPathResult.CreateFailure("목적지와 현재위치가 동일합니다.", 0, 0); var ReverseDirection = (currentDirection == AgvDirection.Forward ? AgvDirection.Backward : AgvDirection.Forward); @@ -258,77 +260,101 @@ namespace AGVNavigationCore.PathFinding.Planning MakeDetailData(path2, currentDirection); } + MapNode tempNode = null; + + //3.방향전환을 위환 대체 노드찾기 - var tempNode = _basicPathfinder.FindAlternateNodeForDirectionChange(JunctionInPath.NodeId, - path1.Path[path1.Path.Count - 2].NodeId, - path2.Path[1].NodeId); + tempNode = _basicPathfinder.FindAlternateNodeForDirectionChange(JunctionInPath.NodeId, + path1.Path[path1.Path.Count - 2].NodeId, + path2.Path[1].NodeId); //4. path1 + tempnode + path2 가 최종 위치가 된다. if (tempNode == null) return AGVPathResult.CreateFailure("방향 전환을 위한 대체 노드를 찾을 수 없습니다.", 0, 0); + // path1 (시작 → 교차로) var combinedResult = path1; - // 교차로 → 대체노드 경로 계산 - var pathToTemp = _basicPathfinder.FindPath(JunctionInPath.NodeId, tempNode.NodeId); - pathToTemp.PrevNode = JunctionInPath; - pathToTemp.PrevDirection = (ReverseCheck ? ReverseDirection : currentDirection); - if (!pathToTemp.Success) - return AGVPathResult.CreateFailure("교차로에서 대체 노드까지의 경로를 찾을 수 없습니다.", 0, 0); - if (ReverseCheck) MakeDetailData(pathToTemp, ReverseDirection); - else MakeDetailData(pathToTemp, currentDirection); - - //교차로찍고 원래방향으로 돌어가야한다. - if (pathToTemp.DetailedPath.Count > 1) - pathToTemp.DetailedPath[pathToTemp.DetailedPath.Count - 1].MotorDirection = currentDirection; - - // path1 + pathToTemp 합치기 - combinedResult = _basicPathfinder.CombineResults(combinedResult, pathToTemp); - - // 대체노드 → 교차로 경로 계산 (역방향) - var pathFromTemp = _basicPathfinder.FindPath(tempNode.NodeId, JunctionInPath.NodeId); - pathFromTemp.PrevNode = JunctionInPath; - pathFromTemp.PrevDirection = (ReverseCheck ? ReverseDirection : currentDirection); - if (!pathFromTemp.Success) - return AGVPathResult.CreateFailure("대체 노드에서 교차로까지의 경로를 찾을 수 없습니다.", 0, 0); - - if (ReverseCheck) MakeDetailData(pathFromTemp, currentDirection); - else MakeDetailData(pathFromTemp, ReverseDirection); - - // (path1 + pathToTemp) + pathFromTemp 합치기 - combinedResult = _basicPathfinder.CombineResults(combinedResult, pathFromTemp); - - //대체노드에서 최종 목적지를 다시 확인한다. - if ((currentDirection == AgvDirection.Forward && targetNode.DockDirection != DockingDirection.Forward) || - (currentDirection == AgvDirection.Backward && targetNode.DockDirection != DockingDirection.Backward)) + //교차로 대체노드를 사용한 경우 + //if (tempNode != null) { - //목적지와 방향이 맞지 않다. 그러므로 대체노드를 추가로 더 찾아야한다. - var tempNode2 = _basicPathfinder.FindAlternateNodeForDirectionChange(JunctionInPath.NodeId, - combinedResult.Path[combinedResult.Path.Count - 2].NodeId, - path2.Path[1].NodeId); - - var pathToTemp2 = _basicPathfinder.FindPath(JunctionInPath.NodeId, tempNode2.NodeId); - if (ReverseCheck) MakeDetailData(pathToTemp2, currentDirection); - else MakeDetailData(pathToTemp2, ReverseDirection); - - combinedResult = _basicPathfinder.CombineResults(combinedResult, pathToTemp2); + // 교차로 → 대체노드 경로 계산 + var pathToTemp = _basicPathfinder.FindPath(JunctionInPath.NodeId, tempNode.NodeId); + pathToTemp.PrevNode = JunctionInPath; + pathToTemp.PrevDirection = (ReverseCheck ? ReverseDirection : currentDirection); + if (!pathToTemp.Success) + return AGVPathResult.CreateFailure("교차로에서 대체 노드까지의 경로를 찾을 수 없습니다.", 0, 0); + if (ReverseCheck) MakeDetailData(pathToTemp, ReverseDirection); + else MakeDetailData(pathToTemp, currentDirection); //교차로찍고 원래방향으로 돌어가야한다. - if (combinedResult.DetailedPath.Count > 1) + if (pathToTemp.DetailedPath.Count > 1) + pathToTemp.DetailedPath[pathToTemp.DetailedPath.Count - 1].MotorDirection = currentDirection; + + // path1 + pathToTemp 합치기 + combinedResult = _basicPathfinder.CombineResults(combinedResult, pathToTemp); + + // 대체노드 → 교차로 경로 계산 (역방향) + var pathFromTemp = _basicPathfinder.FindPath(tempNode.NodeId, JunctionInPath.NodeId); + pathFromTemp.PrevNode = JunctionInPath; + pathFromTemp.PrevDirection = (ReverseCheck ? ReverseDirection : currentDirection); + if (!pathFromTemp.Success) + return AGVPathResult.CreateFailure("대체 노드에서 교차로까지의 경로를 찾을 수 없습니다.", 0, 0); + + if (ReverseCheck) MakeDetailData(pathFromTemp, currentDirection); + else MakeDetailData(pathFromTemp, ReverseDirection); + + // (path1 + pathToTemp) + pathFromTemp 합치기 + combinedResult = _basicPathfinder.CombineResults(combinedResult, pathFromTemp); + + //현재까지 노드에서 목적지까지의 방향이 일치하면 그대로 사용한다. + bool temp3ok = false; + var TempCheck3 = _basicPathfinder.FindPath(combinedResult.Path.Last().NodeId, targetNode.NodeId); + if (TempCheck3.Path.First().NodeId.Equals(combinedResult.Path.Last().NodeId)) { - if (ReverseCheck) - combinedResult.DetailedPath[combinedResult.DetailedPath.Count - 1].MotorDirection = ReverseDirection; - else - combinedResult.DetailedPath[combinedResult.DetailedPath.Count - 1].MotorDirection = currentDirection; + if (targetNode.DockDirection == DockingDirection.Forward && combinedResult.DetailedPath.Last().MotorDirection == AgvDirection.Forward) + { + temp3ok = true; + } + else if (targetNode.DockDirection == DockingDirection.Backward && combinedResult.DetailedPath.Last().MotorDirection == AgvDirection.Backward) + { + temp3ok = true; + } } - var pathToTemp3 = _basicPathfinder.FindPath(tempNode2.NodeId, JunctionInPath.NodeId); - if (ReverseCheck) MakeDetailData(pathToTemp3, ReverseDirection); - else MakeDetailData(pathToTemp3, currentDirection); - combinedResult = _basicPathfinder.CombineResults(combinedResult, pathToTemp3); + + //대체노드에서 최종 목적지를 다시 확인한다. + if (temp3ok == false) + { + //목적지와 방향이 맞지 않다. 그러므로 대체노드를 추가로 더 찾아야한다. + var tempNode2 = _basicPathfinder.FindAlternateNodeForDirectionChange(JunctionInPath.NodeId, + combinedResult.Path[combinedResult.Path.Count - 2].NodeId, + path2.Path[1].NodeId); + + var pathToTemp2 = _basicPathfinder.FindPath(JunctionInPath.NodeId, tempNode2.NodeId); + if (ReverseCheck) MakeDetailData(pathToTemp2, currentDirection); + else MakeDetailData(pathToTemp2, ReverseDirection); + + combinedResult = _basicPathfinder.CombineResults(combinedResult, pathToTemp2); + + //교차로찍고 원래방향으로 돌어가야한다. + if (combinedResult.DetailedPath.Count > 1) + { + if (ReverseCheck) + combinedResult.DetailedPath[combinedResult.DetailedPath.Count - 1].MotorDirection = ReverseDirection; + else + combinedResult.DetailedPath[combinedResult.DetailedPath.Count - 1].MotorDirection = currentDirection; + } + + var pathToTemp3 = _basicPathfinder.FindPath(tempNode2.NodeId, JunctionInPath.NodeId); + if (ReverseCheck) MakeDetailData(pathToTemp3, ReverseDirection); + else MakeDetailData(pathToTemp3, currentDirection); + + combinedResult = _basicPathfinder.CombineResults(combinedResult, pathToTemp3); + } } // (path1 + pathToTemp + pathFromTemp) + path2 합치기 diff --git a/Cs_HMI/AGVLogic/AGVNavigationCore/Utils/DirectionalHelper.cs b/Cs_HMI/AGVLogic/AGVNavigationCore/Utils/DirectionalHelper.cs index 7ee251e..389b94e 100644 --- a/Cs_HMI/AGVLogic/AGVNavigationCore/Utils/DirectionalHelper.cs +++ b/Cs_HMI/AGVLogic/AGVNavigationCore/Utils/DirectionalHelper.cs @@ -15,6 +15,21 @@ namespace AGVNavigationCore.Utils /// public static class DirectionalHelper { + + /// + /// AGV방향과 일치하는지 확인한다. 단 원본위치에서 dock 위치가 Don't Care 라면 true가 반환 됩니다. + /// + /// + /// + /// + public static bool MatchAGVDirection(this DockingDirection dock, AgvDirection agvdirection) + { + if (dock == DockingDirection.DontCare) return true; + if (dock == DockingDirection.Forward && agvdirection == AgvDirection.Forward) return true; + if (dock == DockingDirection.Backward && agvdirection == AgvDirection.Backward) return true; + return false; + } + private static JunctionAnalyzer _junctionAnalyzer; /// diff --git a/Cs_HMI/AGVLogic/AGVSimulator/AGVSimulator.csproj b/Cs_HMI/AGVLogic/AGVSimulator/AGVSimulator.csproj index 2b0e79b..9088fe2 100644 --- a/Cs_HMI/AGVLogic/AGVSimulator/AGVSimulator.csproj +++ b/Cs_HMI/AGVLogic/AGVSimulator/AGVSimulator.csproj @@ -45,6 +45,7 @@ + diff --git a/Cs_HMI/AGVLogic/AGVSimulator/Forms/ProgressLogForm.cs b/Cs_HMI/AGVLogic/AGVSimulator/Forms/ProgressLogForm.cs new file mode 100644 index 0000000..c06eac7 --- /dev/null +++ b/Cs_HMI/AGVLogic/AGVSimulator/Forms/ProgressLogForm.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Text; +using System.Windows.Forms; + +namespace AGVSimulator.Forms +{ + /// + /// 경로 예측 테스트 결과 로그 항목 + /// + public class PathTestLogItem + { + public string PreviousPosition { get; set; } + public string MotorDirection { get; set; } // 정방향 or 역방향 + public string CurrentPosition { get; set; } + public string TargetPosition { get; set; } + public string DockingPosition { get; set; } // 도킹위치 + public bool Success { get; set; } + public string Message { get; set; } + public string DetailedPath { get; set; } + public DateTime Timestamp { get; set; } + + public PathTestLogItem() + { + Timestamp = DateTime.Now; + } + } + + /// + /// 경로 예측 테스트 진행 상황 로그 표시 폼 + /// + public partial class ProgressLogForm : Form + { + private ListView _logListView; + private Button _closeButton; + private Button _cancelButton; + private Button _saveCSVButton; + private ProgressBar _progressBar; + private Label _statusLabel; + private List _logItems; + + /// + /// 취소 요청 여부 + /// + public bool CancelRequested { get; private set; } + + public ProgressLogForm() + { + InitializeComponent(); + CancelRequested = false; + _logItems = new List(); + } + + private void InitializeComponent() + { + this.Text = "경로 예측 테스트 진행 상황"; + this.Size = new Size(1200, 600); + this.StartPosition = FormStartPosition.CenterParent; + this.FormBorderStyle = FormBorderStyle.Sizable; + this.MinimumSize = new Size(800, 400); + + // 상태 레이블 + _statusLabel = new Label + { + Text = "준비 중...", + Dock = DockStyle.Top, + Height = 30, + TextAlign = ContentAlignment.MiddleLeft, + Padding = new Padding(10, 5, 10, 5), + Font = new Font("맑은 고딕", 10F, FontStyle.Bold) + }; + + // 프로그레스바 + _progressBar = new ProgressBar + { + Dock = DockStyle.Top, + Height = 25, + Style = ProgressBarStyle.Continuous, + Minimum = 0, + Maximum = 100 + }; + + // 로그 리스트뷰 + _logListView = new ListView + { + Dock = DockStyle.Fill, + View = View.Details, + FullRowSelect = true, + GridLines = true, + Font = new Font("맑은 고딕", 9F), + BackColor = Color.White + }; + + // 컬럼 추가 + _logListView.Columns.Add("이전위치", 80); + _logListView.Columns.Add("모터방향", 80); + _logListView.Columns.Add("현재위치", 80); + _logListView.Columns.Add("대상위치", 80); + _logListView.Columns.Add("도킹위치", 80); + _logListView.Columns.Add("성공", 50); + _logListView.Columns.Add("메세지", 180); + _logListView.Columns.Add("상세경로", 350); + _logListView.Columns.Add("시간", 90); + + // 버튼 패널 + var buttonPanel = new Panel + { + Dock = DockStyle.Bottom, + Height = 50 + }; + + _cancelButton = new Button + { + Text = "취소", + Size = new Size(100, 30), + Location = new Point(10, 10), + Enabled = true + }; + _cancelButton.Click += OnCancel_Click; + + _closeButton = new Button + { + Text = "닫기", + Size = new Size(100, 30), + Location = new Point(120, 10), + Enabled = false + }; + _closeButton.Click += OnClose_Click; + + _saveCSVButton = new Button + { + Text = "CSV 저장", + Size = new Size(100, 30), + Location = new Point(230, 10), + Enabled = true + }; + _saveCSVButton.Click += OnSaveCSV_Click; + + buttonPanel.Controls.Add(_cancelButton); + buttonPanel.Controls.Add(_closeButton); + buttonPanel.Controls.Add(_saveCSVButton); + + this.Controls.Add(_logListView); + this.Controls.Add(_progressBar); + this.Controls.Add(_statusLabel); + this.Controls.Add(buttonPanel); + } + + /// + /// 로그 추가 (PathTestLogItem) + /// + public void AddLogItem(PathTestLogItem item) + { + if (InvokeRequired) + { + Invoke(new Action(AddLogItem), item); + return; + } + + _logItems.Add(item); + + var listItem = new ListViewItem(item.PreviousPosition ?? "-"); + listItem.SubItems.Add(item.MotorDirection ?? "-"); + listItem.SubItems.Add(item.CurrentPosition ?? "-"); + listItem.SubItems.Add(item.TargetPosition ?? "-"); + listItem.SubItems.Add(item.DockingPosition ?? "-"); + listItem.SubItems.Add(item.Success ? "O" : "X"); + listItem.SubItems.Add(item.Message ?? "-"); + listItem.SubItems.Add(item.DetailedPath ?? "-"); + listItem.SubItems.Add(item.Timestamp.ToString("HH:mm:ss")); + + // 성공 여부에 따라 색상 설정 + if (!item.Success) + { + listItem.BackColor = Color.LightPink; + } + + _logListView.Items.Add(listItem); + _logListView.EnsureVisible(_logListView.Items.Count - 1); + } + + /// + /// 간단한 텍스트 로그 추가 (상태 메시지용) + /// + public void AppendLog(string message) + { + var item = new PathTestLogItem + { + Message = message, + Success = true + }; + AddLogItem(item); + } + + /// + /// 상태 메시지 업데이트 + /// + public void UpdateStatus(string status) + { + if (InvokeRequired) + { + Invoke(new Action(UpdateStatus), status); + return; + } + + _statusLabel.Text = status; + } + + /// + /// 프로그레스바 업데이트 + /// + public void UpdateProgress(int value, int maximum) + { + if (InvokeRequired) + { + Invoke(new Action(UpdateProgress), value, maximum); + return; + } + + _progressBar.Maximum = maximum; + _progressBar.Value = Math.Min(value, maximum); + } + + /// + /// 작업 완료 시 호출 + /// + public void SetCompleted() + { + if (InvokeRequired) + { + Invoke(new Action(SetCompleted)); + return; + } + + _cancelButton.Enabled = false; + _closeButton.Enabled = true; + UpdateStatus("작업 완료"); + } + + /// + /// 작업 취소 시 호출 + /// + public void SetCancelled() + { + if (InvokeRequired) + { + Invoke(new Action(SetCancelled)); + return; + } + + _cancelButton.Enabled = false; + _closeButton.Enabled = true; + UpdateStatus("작업 취소됨"); + } + + private void OnCancel_Click(object sender, EventArgs e) + { + var result = MessageBox.Show( + "진행 중인 작업을 취소하시겠습니까?", + "취소 확인", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (result == DialogResult.Yes) + { + CancelRequested = true; + _cancelButton.Enabled = false; + UpdateStatus("취소 요청됨..."); + AppendLog("사용자가 취소를 요청했습니다."); + } + } + + private void OnSaveCSV_Click(object sender, EventArgs e) + { + if (_logItems.Count == 0) + { + MessageBox.Show("저장할 데이터가 없습니다.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + using (var saveDialog = new SaveFileDialog()) + { + saveDialog.Filter = "CSV 파일 (*.csv)|*.csv|모든 파일 (*.*)|*.*"; + saveDialog.DefaultExt = "csv"; + saveDialog.FileName = $"경로예측테스트_{DateTime.Now:yyyyMMdd_HHmmss}.csv"; + + if (saveDialog.ShowDialog() == DialogResult.OK) + { + try + { + SaveToCSV(saveDialog.FileName); + MessageBox.Show($"CSV 파일이 저장되었습니다.\n{saveDialog.FileName}", + "저장 완료", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show($"CSV 저장 중 오류가 발생했습니다:\n{ex.Message}", + "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + } + + /// + /// CSV 파일로 저장 + /// + private void SaveToCSV(string filePath) + { + using (var writer = new StreamWriter(filePath, false, Encoding.UTF8)) + { + // 헤더 작성 + writer.WriteLine("이전위치,모터방향,현재위치,대상위치,도킹위치,성공,메세지,상세경로,시간"); + + // 데이터 작성 + foreach (var item in _logItems) + { + var line = $"{EscapeCSV(item.PreviousPosition)}," + + $"{EscapeCSV(item.MotorDirection)}," + + $"{EscapeCSV(item.CurrentPosition)}," + + $"{EscapeCSV(item.TargetPosition)}," + + $"{EscapeCSV(item.DockingPosition)}," + + $"{(item.Success ? "O" : "X")}," + + $"{EscapeCSV(item.Message)}," + + $"{EscapeCSV(item.DetailedPath)}," + + $"{item.Timestamp:yyyy-MM-dd HH:mm:ss}"; + + writer.WriteLine(line); + } + } + } + + /// + /// CSV 셀 데이터 이스케이프 처리 + /// + private string EscapeCSV(string value) + { + if (string.IsNullOrEmpty(value)) + return ""; + + // 쉼표, 큰따옴표, 줄바꿈이 있으면 큰따옴표로 감싸고 내부 큰따옴표는 두 개로 + if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r")) + { + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + + return value; + } + + private void OnClose_Click(object sender, EventArgs e) + { + this.Close(); + } + } +} diff --git a/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.Designer.cs b/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.Designer.cs index 20f5d69..0ebf626 100644 --- a/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.Designer.cs +++ b/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.Designer.cs @@ -45,6 +45,7 @@ namespace AGVSimulator.Forms /// private void InitializeComponent() { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(SimulatorForm)); this._menuStrip = new System.Windows.Forms.MenuStrip(); this.fileToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.openMapToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -74,9 +75,12 @@ namespace AGVSimulator.Forms this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); this.fitToMapToolStripButton = new System.Windows.Forms.ToolStripButton(); this.resetZoomToolStripButton = new System.Windows.Forms.ToolStripButton(); + this.toolStripSeparator5 = new System.Windows.Forms.ToolStripSeparator(); + this.toolStripButton1 = new System.Windows.Forms.ToolStripButton(); this._statusStrip = new System.Windows.Forms.StatusStrip(); this._statusLabel = new System.Windows.Forms.ToolStripStatusLabel(); this._coordLabel = new System.Windows.Forms.ToolStripStatusLabel(); + this.prb1 = new System.Windows.Forms.ToolStripProgressBar(); this._controlPanel = new System.Windows.Forms.Panel(); this._statusGroup = new System.Windows.Forms.GroupBox(); this._pathLengthLabel = new System.Windows.Forms.Label(); @@ -92,7 +96,6 @@ namespace AGVSimulator.Forms this._startNodeCombo = new System.Windows.Forms.ComboBox(); this.startNodeLabel = new System.Windows.Forms.Label(); this._agvControlGroup = new System.Windows.Forms.GroupBox(); - this.btNextNode = new System.Windows.Forms.Button(); this._setPositionButton = new System.Windows.Forms.Button(); this._rfidTextBox = new System.Windows.Forms.TextBox(); this._rfidLabel = new System.Windows.Forms.Label(); @@ -105,10 +108,10 @@ namespace AGVSimulator.Forms this._agvListCombo = new System.Windows.Forms.ComboBox(); this._canvasPanel = new System.Windows.Forms.Panel(); this._agvInfoPanel = new System.Windows.Forms.Panel(); + this._pathDebugLabel = new System.Windows.Forms.TextBox(); this._agvInfoTitleLabel = new System.Windows.Forms.Label(); this._liftDirectionLabel = new System.Windows.Forms.Label(); this._motorDirectionLabel = new System.Windows.Forms.Label(); - this._pathDebugLabel = new System.Windows.Forms.TextBox(); this._menuStrip.SuspendLayout(); this._toolStrip.SuspendLayout(); this._statusStrip.SuspendLayout(); @@ -274,7 +277,9 @@ namespace AGVSimulator.Forms this.btAllReset, this.toolStripSeparator3, this.fitToMapToolStripButton, - this.resetZoomToolStripButton}); + this.resetZoomToolStripButton, + this.toolStripSeparator5, + this.toolStripButton1}); this._toolStrip.Location = new System.Drawing.Point(0, 24); this._toolStrip.Name = "_toolStrip"; this._toolStrip.Size = new System.Drawing.Size(1200, 25); @@ -372,11 +377,26 @@ namespace AGVSimulator.Forms this.resetZoomToolStripButton.ToolTipText = "줌을 초기화합니다"; this.resetZoomToolStripButton.Click += new System.EventHandler(this.OnResetZoom_Click); // + // toolStripSeparator5 + // + this.toolStripSeparator5.Name = "toolStripSeparator5"; + this.toolStripSeparator5.Size = new System.Drawing.Size(6, 25); + // + // toolStripButton1 + // + this.toolStripButton1.Image = ((System.Drawing.Image)(resources.GetObject("toolStripButton1.Image"))); + this.toolStripButton1.ImageTransparentColor = System.Drawing.Color.Magenta; + this.toolStripButton1.Name = "toolStripButton1"; + this.toolStripButton1.Size = new System.Drawing.Size(75, 22); + this.toolStripButton1.Text = "경로예측"; + this.toolStripButton1.Click += new System.EventHandler(this.toolStripButton1_Click); + // // _statusStrip // this._statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { this._statusLabel, - this._coordLabel}); + this._coordLabel, + this.prb1}); this._statusStrip.Location = new System.Drawing.Point(0, 778); this._statusStrip.Name = "_statusStrip"; this._statusStrip.Size = new System.Drawing.Size(1200, 22); @@ -394,6 +414,11 @@ namespace AGVSimulator.Forms this._coordLabel.Name = "_coordLabel"; this._coordLabel.Size = new System.Drawing.Size(0, 17); // + // prb1 + // + this.prb1.Name = "prb1"; + this.prb1.Size = new System.Drawing.Size(200, 16); + // // _controlPanel // this._controlPanel.BackColor = System.Drawing.SystemColors.Control; @@ -540,7 +565,6 @@ namespace AGVSimulator.Forms // // _agvControlGroup // - this._agvControlGroup.Controls.Add(this.btNextNode); this._agvControlGroup.Controls.Add(this._setPositionButton); this._agvControlGroup.Controls.Add(this._rfidTextBox); this._agvControlGroup.Controls.Add(this._rfidLabel); @@ -559,16 +583,6 @@ namespace AGVSimulator.Forms this._agvControlGroup.TabStop = false; this._agvControlGroup.Text = "AGV 제어"; // - // btNextNode - // - this.btNextNode.Location = new System.Drawing.Point(160, 183); - this.btNextNode.Name = "btNextNode"; - this.btNextNode.Size = new System.Drawing.Size(60, 21); - this.btNextNode.TabIndex = 10; - this.btNextNode.Text = "다음"; - this.btNextNode.UseVisualStyleBackColor = true; - this.btNextNode.Click += new System.EventHandler(this.btNextNode_Click); - // // _setPositionButton // this._setPositionButton.Location = new System.Drawing.Point(160, 138); @@ -685,6 +699,18 @@ namespace AGVSimulator.Forms this._agvInfoPanel.Size = new System.Drawing.Size(967, 80); this._agvInfoPanel.TabIndex = 5; // + // _pathDebugLabel + // + this._pathDebugLabel.BackColor = System.Drawing.Color.LightBlue; + this._pathDebugLabel.BorderStyle = System.Windows.Forms.BorderStyle.None; + this._pathDebugLabel.Font = new System.Drawing.Font("굴림", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(129))); + this._pathDebugLabel.Location = new System.Drawing.Point(10, 30); + this._pathDebugLabel.Multiline = true; + this._pathDebugLabel.Name = "_pathDebugLabel"; + this._pathDebugLabel.Size = new System.Drawing.Size(947, 43); + this._pathDebugLabel.TabIndex = 4; + this._pathDebugLabel.Text = "경로: 설정되지 않음"; + // // _agvInfoTitleLabel // this._agvInfoTitleLabel.AutoSize = true; @@ -715,18 +741,6 @@ namespace AGVSimulator.Forms this._motorDirectionLabel.TabIndex = 2; this._motorDirectionLabel.Text = "모터 방향: -"; // - // _pathDebugLabel - // - this._pathDebugLabel.BackColor = System.Drawing.Color.LightBlue; - this._pathDebugLabel.BorderStyle = System.Windows.Forms.BorderStyle.None; - this._pathDebugLabel.Font = new System.Drawing.Font("굴림", 8.25F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(129))); - this._pathDebugLabel.Location = new System.Drawing.Point(10, 30); - this._pathDebugLabel.Multiline = true; - this._pathDebugLabel.Name = "_pathDebugLabel"; - this._pathDebugLabel.Size = new System.Drawing.Size(947, 43); - this._pathDebugLabel.TabIndex = 4; - this._pathDebugLabel.Text = "경로: 설정되지 않음"; - // // SimulatorForm // this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F); @@ -827,7 +841,9 @@ namespace AGVSimulator.Forms private System.Windows.Forms.Label _liftDirectionLabel; private System.Windows.Forms.Label _motorDirectionLabel; private System.Windows.Forms.Label _agvInfoTitleLabel; - private System.Windows.Forms.Button btNextNode; private System.Windows.Forms.TextBox _pathDebugLabel; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator5; + private System.Windows.Forms.ToolStripButton toolStripButton1; + private System.Windows.Forms.ToolStripProgressBar prb1; } } \ No newline at end of file diff --git a/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.cs b/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.cs index 899c527..adb7be7 100644 --- a/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.cs +++ b/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.cs @@ -4,6 +4,8 @@ using System.Diagnostics; using System.Drawing; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using System.Windows.Forms; using AGVNavigationCore.Models; using AGVNavigationCore.Controls; @@ -28,7 +30,7 @@ namespace AGVSimulator.Forms private AGVPathfinder _advancedPathfinder; private List _agvList; private SimulationState _simulationState; - private Timer _simulationTimer; + private System.Windows.Forms.Timer _simulationTimer; private SimulatorConfig _config; private string _currentMapFilePath; private bool _isTargetCalcMode; // 타겟계산 모드 상태 @@ -76,7 +78,7 @@ namespace AGVSimulator.Forms CreateSimulatorCanvas(); // 타이머 초기화 - _simulationTimer = new Timer(); + _simulationTimer = new System.Windows.Forms.Timer(); _simulationTimer.Interval = 100; // 100ms 간격 _simulationTimer.Tick += OnSimulationTimer_Tick; @@ -268,6 +270,12 @@ namespace AGVSimulator.Forms } private void OnCalculatePath_Click(object sender, EventArgs e) + { + var rlt = CalcPath(); + if (rlt.result == false) MessageBox.Show(rlt.message, "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + (bool result, string message) CalcPath() { // 시작 RFID가 없으면 AGV 현재 위치로 설정 if (_startNodeCombo.SelectedItem == null || _startNodeCombo.Text == "선택하세요") @@ -277,19 +285,17 @@ namespace AGVSimulator.Forms if (_startNodeCombo.SelectedItem == null || _targetNodeCombo.SelectedItem == null) { - MessageBox.Show("시작 RFID와 목표 RFID를 선택해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); - return; + return (false, "시작 RFID와 목표 RFID를 선택해주세요."); } var startItem = _startNodeCombo.SelectedItem as ComboBoxItem; var targetItem = _targetNodeCombo.SelectedItem as ComboBoxItem; var startNode = startItem?.Value; var targetNode = targetItem?.Value; - + if (startNode == null || targetNode == null) { - MessageBox.Show("선택한 노드 정보가 올바르지 않습니다.", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; + return (false, "선택한 노드 정보가 올바르지 않습니다."); } @@ -300,10 +306,9 @@ namespace AGVSimulator.Forms // 현재 AGV 방향 가져오기 var selectedAGV = _agvListCombo.SelectedItem as VirtualAGV; - if(selectedAGV == null) + if (selectedAGV == null) { - MessageBox.Show("Virtual AGV 가 없습니다", "오류", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; + return (false, "Virtual AGV 가 없습니다"); } var currentDirection = selectedAGV.CurrentDirection; @@ -331,14 +336,14 @@ namespace AGVSimulator.Forms // 고급 경로 디버깅 정보 표시 UpdateAdvancedPathDebugInfo(advancedResult); + return (true, string.Empty); } else { // 경로 실패시 디버깅 정보 초기화 _pathDebugLabel.Text = $"경로: 실패 - {advancedResult.ErrorMessage}"; - MessageBox.Show($"경로를 찾을 수 없습니다:\n{advancedResult.ErrorMessage}", "경로 계산 실패", - MessageBoxButtons.OK, MessageBoxIcon.Warning); + return (false, $"경로를 찾을 수 없습니다:\n{advancedResult.ErrorMessage}"); } } @@ -559,7 +564,7 @@ namespace AGVSimulator.Forms var selectedDirection = selectedDirectionItem?.Direction ?? AgvDirection.Forward; //이전위치와 동일한지 체크한다. - if(selectedAGV.CurrentNodeId == targetNode.NodeId && selectedAGV.CurrentDirection == selectedDirection) + if (selectedAGV.CurrentNodeId == targetNode.NodeId && selectedAGV.CurrentDirection == selectedDirection) { Program.WriteLine($"이전 노드위치와 모터의 방향이 동일하여 현재 위치 변경이 취소됩니다(NODE:{targetNode.NodeId},RFID:{targetNode.RfidId},DIR:{selectedDirection})"); return; @@ -741,7 +746,7 @@ namespace AGVSimulator.Forms var nodeNamePart = !string.IsNullOrEmpty(node.Name) ? $" {node.Name}" : ""; var displayText = $"{node.RfidId} - [{node.NodeId}]{nodeNamePart}"; var item = new ComboBoxItem(node, displayText); - + _startNodeCombo.Items.Add(item); _targetNodeCombo.Items.Add(item); } @@ -1106,7 +1111,7 @@ namespace AGVSimulator.Forms _pathDebugLabel.Text = $"고급경로: {pathString} (총 {advancedResult.DetailedPath.Count}개 노드, {advancedResult.TotalDistance:F1}px, {stats})"; } - + private void OnReloadMap_Click(object sender, EventArgs e) { @@ -1218,26 +1223,355 @@ namespace AGVSimulator.Forms _statusLabel.Text = "초기화 완료"; } - private void btNextNode_Click(object sender, EventArgs e) + + private async void toolStripButton1_Click(object sender, EventArgs e) { - //get next node - - // 선택된 AGV 확인 - var selectedAGV = _agvListCombo.SelectedItem as VirtualAGV; - if (selectedAGV == null) + // 맵과 AGV 확인 + if (_mapNodes == null || _mapNodes.Count == 0) { - MessageBox.Show("먼저 AGV를 선택해주세요.", "알림", MessageBoxButtons.OK, MessageBoxIcon.Information); + MessageBox.Show("맵 데이터가 없습니다. 먼저 맵을 로드해주세요.", "알림", + MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } - // 선택된 방향 확인 - var selectedDirectionItem = _directionCombo.SelectedItem as DirectionItem; - var selectedDirection = selectedDirectionItem?.Direction ?? AgvDirection.Forward; + var selectedAGV = _agvListCombo.SelectedItem as VirtualAGV; + if (selectedAGV == null) + { + MessageBox.Show("테스트할 AGV를 선택해주세요.", "알림", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } - var nextNode = selectedAGV.GetNextNodeId(selectedDirection, this._mapNodes); - MessageBox.Show($"Node:{nextNode.NodeId},RFID:{nextNode.RfidId}"); + // 도킹 타겟 노드 찾기 + var dockingTargets = _mapNodes.Where(n => + n.Type == NodeType.Charging || n.Type == NodeType.Docking).ToList(); + if (dockingTargets.Count == 0) + { + MessageBox.Show("도킹 타겟(충전기 또는 장비)이 없습니다.", "알림", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + // 연결된 노드 쌍 찾기 (사전 계산) + var nodePairs = GetConnectedNodePairs(); + var testCount = nodePairs.Count * dockingTargets.Count * 2; // 노드 쌍 x 도킹 타겟 x 방향(2) + + // 테스트 시작 확인 + var result = MessageBox.Show( + $"경로 예측 테스트를 시작합니다.\n\n" + + $"※ 실제 사용자 시나리오 재현 방식:\n" + + $" 연결된 노드 쌍을 따라 AGV를 2번 이동시켜\n" + + $" 방향을 확정한 후 각 도킹 타겟으로 경로 계산\n\n" + + $"• 연결된 노드 쌍: {nodePairs.Count}개\n" + + $"• 도킹 타겟: {dockingTargets.Count}개 (충전기/장비)\n" + + $"• AGV 방향: 2가지 (정방향/역방향)\n" + + $"• 총 테스트 케이스: {testCount}개\n\n" + + $"※ UI가 실시간으로 업데이트됩니다.\n" + + $"계속하시겠습니까?", + "경로 예측 테스트 확인", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (result != DialogResult.Yes) + return; + + // 로그 폼 생성 및 표시 + var logForm = new ProgressLogForm(); + logForm.Show(this); + logForm.UpdateProgress(0, testCount); + logForm.UpdateStatus("테스트 준비 중..."); + + // 비동기 테스트 시작 + await Task.Run(() => RunPathPredictionTest(selectedAGV, dockingTargets, logForm)); + + // 완료 + if (logForm.CancelRequested) + { + logForm.SetCancelled(); + MessageBox.Show("테스트가 취소되었습니다.", "알림", + MessageBoxButtons.OK, MessageBoxIcon.Information); + } + else + { + logForm.SetCompleted(); + MessageBox.Show("경로 예측 테스트가 완료되었습니다.", "완료", + MessageBoxButtons.OK, MessageBoxIcon.Information); + } } + + /// + /// 노드의 표시 이름 가져오기 (RFID 우선, 없으면 (NodeId)) + /// + private string GetNodeDisplayName(MapNode node) + { + if (node == null) + return "-"; + + if (!string.IsNullOrEmpty(node.RfidId)) + return node.RfidId; + + return $"({node.NodeId})"; + } + + /// + /// 방향 콤보박스 선택 (테스트용) + /// + private void SetDirectionComboBox(AgvDirection direction) + { + for (int i = 0; i < _directionCombo.Items.Count; i++) + { + var item = _directionCombo.Items[i] as DirectionItem; + if (item != null && item.Direction == direction) + { + _directionCombo.SelectedIndex = i; + return; + } + } + } + + /// + /// 목표 노드 콤보박스 선택 (테스트용) + /// + private void SetTargetNodeComboBox(string nodeId) + { + for (int i = 0; i < _targetNodeCombo.Items.Count; i++) + { + var item = _targetNodeCombo.Items[i] as ComboBoxItem; + if (item?.Value?.NodeId == nodeId) + { + _targetNodeCombo.SelectedIndex = i; + return; + } + } + } + + /// + /// UI 상태로부터 테스트 결과 생성 (테스트용) + /// + private PathTestLogItem CreateTestResultFromUI(MapNode prevNode, MapNode targetNode, + string directionName, (bool result, string message) calcResult) + { + var currentNode = _mapNodes.FirstOrDefault(n => n.NodeId == + (_agvListCombo.SelectedItem as VirtualAGV)?.CurrentNodeId); + + var logItem = new PathTestLogItem + { + PreviousPosition = GetNodeDisplayName(prevNode), + MotorDirection = directionName, + CurrentPosition = GetNodeDisplayName(currentNode), + TargetPosition = GetNodeDisplayName(targetNode), + DockingPosition = targetNode.Type == NodeType.Charging ? "충전기" : "장비" + }; + + if (calcResult.result) + { + // 경로 계산 성공 - 현재 화면에 표시된 경로 정보 사용 + var currentPath = _simulatorCanvas.CurrentPath; + if (currentPath != null && currentPath.Success) + { + // 도킹 검증 + var dockingValidation = DockingValidator.ValidateDockingDirection(currentPath, _mapNodes); + + if (dockingValidation.IsValid) + { + logItem.Success = true; + logItem.Message = "성공"; + logItem.DetailedPath = string.Join(" → ", currentPath.GetDetailedInfo()); + } + else + { + logItem.Success = false; + logItem.Message = $"도킹 검증 실패: {dockingValidation.ValidationError}"; + logItem.DetailedPath = string.Join(" → ", currentPath.GetDetailedInfo()); + } + } + else + { + logItem.Success = true; + logItem.Message = "경로 계산 성공"; + logItem.DetailedPath = "-"; + } + } + else + { + // 경로 계산 실패 + logItem.Success = false; + logItem.Message = calcResult.message; + logItem.DetailedPath = "-"; + } + + return logItem; + } + + /// + /// 연결된 노드 쌍 찾기 (A→B 형태) + /// + private List<(MapNode nodeA, MapNode nodeB)> GetConnectedNodePairs() + { + var pairs = new List<(MapNode, MapNode)>(); + var processedPairs = new HashSet(); + + foreach (var nodeA in _mapNodes) + { + if (nodeA.ConnectedNodes == null || nodeA.ConnectedNodes.Count == 0) + continue; + + // 연결된 노드 ID를 실제 MapNode 객체로 변환 + foreach (var connectedNodeId in nodeA.ConnectedNodes) + { + var nodeB = _mapNodes.FirstOrDefault(n => n.NodeId == connectedNodeId); + if (nodeB == null) + continue; + + // 중복 방지 (A→B와 B→A를 같은 것으로 간주) + var pairKey1 = $"{nodeA.NodeId}→{nodeB.NodeId}"; + var pairKey2 = $"{nodeB.NodeId}→{nodeA.NodeId}"; + + if (!processedPairs.Contains(pairKey1) && !processedPairs.Contains(pairKey2)) + { + pairs.Add((nodeA, nodeB)); + processedPairs.Add(pairKey1); + processedPairs.Add(pairKey2); + } + } + } + + return pairs; + } + + /// + /// 경로 예측 테스트 실행 (실제 사용자 시나리오 재현) + /// + private void RunPathPredictionTest(VirtualAGV agv, List dockingTargets, ProgressLogForm logForm) + { + var directions = new[] + { + (AgvDirection.Forward, "정방향"), + (AgvDirection.Backward, "역방향") + }; + + // 연결된 노드 쌍 찾기 + var nodePairs = GetConnectedNodePairs(); + + int totalTests = nodePairs.Count * dockingTargets.Count * 2; + int currentTest = 0; + int successCount = 0; + int failCount = 0; + + logForm.UpdateStatus("경로 예측 테스트 진행 중..."); + logForm.AppendLog($"테스트 시작: 총 {totalTests}개 케이스"); + logForm.AppendLog($"연결된 노드 쌍: {nodePairs.Count}개"); + logForm.AppendLog($"도킹 타겟: {dockingTargets.Count}개"); + logForm.AppendLog("---"); + + // 각 연결된 노드 쌍에 대해 테스트 + foreach (var (nodeA, nodeB) in nodePairs) + { + foreach (var (direction, directionName) in directions) + { + // 취소 확인 + if (logForm.CancelRequested) + { + logForm.AppendLog($"테스트 취소됨 - {currentTest}/{totalTests} 완료"); + return; + } + + // === 실제 사용자 워크플로우 재현 === + // 1단계: AGV를 nodeA로 이동 (실제 UI 조작) + this.Invoke((MethodInvoker)delegate + { + // RFID 텍스트박스에 값 입력 + _rfidTextBox.Text = nodeA.RfidId; + + // 방향 콤보박스 선택 + SetDirectionComboBox(direction); + + // 위치설정 버튼 클릭 (실제 사용자 동작) + SetAGVPositionByRfid(); + + Application.DoEvents(); // UI 업데이트 + }); + Thread.Sleep(100); // 시각적 효과 + + // 2단계: AGV를 nodeB로 이동 (방향 확정됨) + this.Invoke((MethodInvoker)delegate + { + // RFID 텍스트박스에 값 입력 + _rfidTextBox.Text = nodeB.RfidId; + + // 방향 콤보박스 선택 + SetDirectionComboBox(direction); + + // 위치설정 버튼 클릭 (실제 사용자 동작) + SetAGVPositionByRfid(); + + Application.DoEvents(); // UI 업데이트 + }); + Thread.Sleep(100); // 시각적 효과 + + // 3단계: nodeB 위치에서 모든 도킹 타겟으로 경로 예측 + foreach (var dockingTarget in dockingTargets) + { + // 취소 확인 + if (logForm.CancelRequested) + { + logForm.AppendLog($"테스트 취소됨 - {currentTest}/{totalTests} 완료"); + return; + } + + currentTest++; + + // UI 스레드에서 경로 계산 및 테스트 (실제 UI 사용) + PathTestLogItem testResult = null; + this.Invoke((MethodInvoker)delegate + { + // 진행상황 업데이트 + logForm.UpdateProgress(currentTest, totalTests); + logForm.UpdateStatus($"테스트 진행 중... ({currentTest}/{totalTests}) [{GetNodeDisplayName(nodeA)}→{GetNodeDisplayName(nodeB)}→{GetNodeDisplayName(dockingTarget)}]"); + prb1.Value = (int)((double)currentTest / totalTests * 100); + + // 목표 노드 콤보박스 선택 + SetTargetNodeComboBox(dockingTarget.NodeId); + + // 경로 계산 버튼 클릭 (실제 사용자 동작) + var calcResult = CalcPath(); + + // 테스트 결과 생성 + testResult = CreateTestResultFromUI(nodeA, dockingTarget, directionName, calcResult); + + // 로그 추가 + logForm.AddLogItem(testResult); + + // 실패한 경우에만 경로를 화면에 표시 (시각적 확인) + if (!testResult.Success && _simulatorCanvas.CurrentPath != null) + { + _simulatorCanvas.Invalidate(); + } + + Application.DoEvents(); + }); + + if (testResult.Success) + successCount++; + else + failCount++; + + // UI 반응성을 위한 짧은 대기 + Thread.Sleep(50); + } + } + } + + // 최종 결과 + logForm.AppendLog($""); + logForm.AppendLog($"=== 테스트 완료 ==="); + logForm.AppendLog($"총 테스트: {totalTests}"); + logForm.AppendLog($"성공: {successCount}"); + logForm.AppendLog($"실패: {failCount}"); + logForm.AppendLog($"성공률: {(double)successCount / totalTests * 100:F1}%"); + } + } /// diff --git a/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.resx b/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.resx index 35cb4b7..3d64678 100644 --- a/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.resx +++ b/Cs_HMI/AGVLogic/AGVSimulator/Forms/SimulatorForm.resx @@ -123,6 +123,22 @@ 132, 17 + + + + iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8 + YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIFSURBVDhPpZLtS1NhGMbPPxJmmlYSgqHiKzGU1EDxg4iK + YKyG2WBogqMYJQOtCEVRFBGdTBCJfRnkS4VaaWNT5sqx1BUxRXxDHYxAJLvkusEeBaPAB+5z4Jzn+t3X + /aLhnEfjo8m+dCoa+7/C3O2Hqe0zDC+8KG+cRZHZhdzaaWTVTCLDMIY0vfM04Nfh77/G/sEhwpEDbO3t + I7TxE8urEVy99fT/AL5gWDLrTB/hnF4XsW0khCu5ln8DmJliT2AXrcNBsU1gj/MH4nMeKwBrPktM28xM + cX79DFKrHHD5d9D26hvicx4pABt2lpg10zYzU0zr7+e3xXGcrkEB2O2TNec9nJFwB3alZn5jZorfeDZh + 6Q3g8s06BeCoKF4MRURoH1+BY2oNCbeb0TIclIYxOhzf8frTOuo7FxCbbVIAzpni0iceEc8vhzEwGkJD + lx83ymxifejdKjRNk/8PWnyIyTQqAJek0jqHwfEVscu31baIu8+90sTE4nY025dQ2/5FIPpnXlzKuK8A + HBUzHot52djqQ6HZhfR7IwK4mKpHtvEDMqvfCiQ6zaAAXM8x94aIWTNrLLG4kVUzgaTSPlzLtyJOZxbb + 1wtfyg4Q+AfA3aZlButjSfxGcUJBk4g5tuP3haQKRKXcUQDOmbvNTpPOJeFFjordZmbWTNvMTHFUcpUC + nOccAdABIDXXE1nzAAAAAElFTkSuQmCC + + 237, 17