feat: Add comprehensive path prediction test with ProgressLogForm

- Add ProgressLogForm.cs for test result logging with ListView
- Implement real UI workflow simulation in path prediction test
- Test all connected node pairs to all docking targets
- Support CSV export for test results
- Keep List<string> ConnectedNodes structure (reverted List<MapNode> changes)
- Display RFID values in log for better readability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
backuppc
2025-10-29 14:14:33 +09:00
parent 24a14fbd48
commit 0b59479d34
9 changed files with 884 additions and 366 deletions

View File

@@ -559,253 +559,7 @@ namespace AGVNavigationCore.Models
#endregion
#region Directional Navigation
/// <summary>
/// 현재 이전/현재 위치와 이동 방향을 기반으로 다음 노드 ID를 반환
///
/// 사용 예시:
/// - 001에서 002로 이동 후, Forward 선택 → 003 반환
/// - 003에서 004로 이동 후, Right 선택 → 030 반환
/// - 004에서 003으로 Backward 이동 중, GetNextNodeId(Backward) → 002 반환
///
/// 전제조건: SetPosition이 최소 2번 이상 호출되어 _prevPosition이 설정되어야 함
/// </summary>
/// <param name="direction">이동 방향 (Forward/Backward/Left/Right)</param>
/// <param name="allNodes">맵의 모든 노드</param>
/// <returns>다음 노드 ID (또는 null)</returns>
public MapNode GetNextNodeId(AgvDirection direction, List<MapNode> 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;
}
/// <summary>
/// 이동 방향을 기반으로 방향 점수를 계산
/// 높은 점수 = 더 나은 선택지
/// </summary>
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

View File

@@ -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);

View File

@@ -22,6 +22,8 @@ namespace AGVNavigationCore.PathFinding.Planning
private readonly JunctionAnalyzer _junctionAnalyzer;
private readonly DirectionChangePlanner _directionChangePlanner;
public AGVPathfinder(List<MapNode> mapNodes)
{
_mapNodes = mapNodes ?? new List<MapNode>();
@@ -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 합치기

View File

@@ -15,6 +15,21 @@ namespace AGVNavigationCore.Utils
/// </summary>
public static class DirectionalHelper
{
/// <summary>
/// AGV방향과 일치하는지 확인한다. 단 원본위치에서 dock 위치가 Don't Care 라면 true가 반환 됩니다.
/// </summary>
/// <param name="dock"></param>
/// <param name="agvdirection"></param>
/// <returns></returns>
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;
/// <summary>