feat: Add AGV Map Editor and Simulator tools
- Add AGVMapEditor: Visual map editing with drag-and-drop node placement * RFID mapping separation (physical ID ↔ logical node mapping) * A* pathfinding algorithm with AGV directional constraints * JSON map data persistence with structured format * Interactive map canvas with zoom/pan functionality - Add AGVSimulator: Real-time AGV movement simulation * Virtual AGV with state machine (Idle, Moving, Rotating, Docking, Charging, Error) * Path execution and visualization from calculated routes * Real-time position tracking and battery simulation * Integration with map editor data format - Update solution structure and build configuration - Add comprehensive documentation in CLAUDE.md - Implement AGV-specific constraints (forward/backward docking, rotation limits) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
64
Cs_HMI/AGVMapEditor/Models/Enums.cs
Normal file
64
Cs_HMI/AGVMapEditor/Models/Enums.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
|
||||
namespace AGVMapEditor.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// 노드 타입 열거형
|
||||
/// </summary>
|
||||
public enum NodeType
|
||||
{
|
||||
/// <summary>일반 경로 노드</summary>
|
||||
Normal,
|
||||
/// <summary>회전 가능 지점</summary>
|
||||
Rotation,
|
||||
/// <summary>도킹 스테이션</summary>
|
||||
Docking,
|
||||
/// <summary>충전 스테이션</summary>
|
||||
Charging
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 도킹 방향 열거형
|
||||
/// </summary>
|
||||
public enum DockingDirection
|
||||
{
|
||||
/// <summary>전진 도킹 (충전기)</summary>
|
||||
Forward,
|
||||
/// <summary>후진 도킹 (로더, 클리너, 오프로더, 버퍼)</summary>
|
||||
Backward
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AGV 이동 방향 열거형
|
||||
/// </summary>
|
||||
public enum AgvDirection
|
||||
{
|
||||
/// <summary>전진 (모니터 방향)</summary>
|
||||
Forward,
|
||||
/// <summary>후진 (리프트 방향)</summary>
|
||||
Backward,
|
||||
/// <summary>좌회전</summary>
|
||||
Left,
|
||||
/// <summary>우회전</summary>
|
||||
Right,
|
||||
/// <summary>정지</summary>
|
||||
Stop
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 장비 타입 열거형
|
||||
/// </summary>
|
||||
public enum StationType
|
||||
{
|
||||
/// <summary>로더</summary>
|
||||
Loader,
|
||||
/// <summary>클리너</summary>
|
||||
Cleaner,
|
||||
/// <summary>오프로더</summary>
|
||||
Offloader,
|
||||
/// <summary>버퍼</summary>
|
||||
Buffer,
|
||||
/// <summary>충전기</summary>
|
||||
Charger
|
||||
}
|
||||
}
|
||||
225
Cs_HMI/AGVMapEditor/Models/MapNode.cs
Normal file
225
Cs_HMI/AGVMapEditor/Models/MapNode.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
|
||||
namespace AGVMapEditor.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// 맵 노드 정보를 관리하는 클래스
|
||||
/// 논리적 노드로서 실제 맵의 위치와 속성을 정의
|
||||
/// </summary>
|
||||
public class MapNode
|
||||
{
|
||||
/// <summary>
|
||||
/// 논리적 노드 ID (맵 에디터에서 관리하는 고유 ID)
|
||||
/// 예: "N001", "N002", "LOADER1", "CHARGER1"
|
||||
/// </summary>
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 표시 이름 (사용자 친화적)
|
||||
/// 예: "로더1", "충전기1", "교차점A", "회전지점1"
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 맵 상의 위치 좌표 (픽셀 단위)
|
||||
/// </summary>
|
||||
public Point Position { get; set; } = Point.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 타입
|
||||
/// </summary>
|
||||
public NodeType Type { get; set; } = NodeType.Normal;
|
||||
|
||||
/// <summary>
|
||||
/// 도킹 방향 (도킹/충전 노드인 경우만 사용)
|
||||
/// </summary>
|
||||
public DockingDirection? DockDirection { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// 연결된 노드 ID 목록 (경로 정보)
|
||||
/// </summary>
|
||||
public List<string> ConnectedNodes { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 회전 가능 여부 (180도 회전 가능한 지점)
|
||||
/// </summary>
|
||||
public bool CanRotate { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 장비 ID (도킹/충전 스테이션인 경우)
|
||||
/// 예: "LOADER1", "CLEANER1", "BUFFER1", "CHARGER1"
|
||||
/// </summary>
|
||||
public string StationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 장비 타입 (도킹/충전 스테이션인 경우)
|
||||
/// </summary>
|
||||
public StationType? StationType { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 생성 일자
|
||||
/// </summary>
|
||||
public DateTime CreatedDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 수정 일자
|
||||
/// </summary>
|
||||
public DateTime ModifiedDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 설명 (추가 정보)
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 활성화 여부
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 노드 색상 (맵 에디터 표시용)
|
||||
/// </summary>
|
||||
public Color DisplayColor { get; set; } = Color.Blue;
|
||||
|
||||
/// <summary>
|
||||
/// 기본 생성자
|
||||
/// </summary>
|
||||
public MapNode()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 매개변수 생성자
|
||||
/// </summary>
|
||||
/// <param name="nodeId">노드 ID</param>
|
||||
/// <param name="name">노드 이름</param>
|
||||
/// <param name="position">위치</param>
|
||||
/// <param name="type">노드 타입</param>
|
||||
public MapNode(string nodeId, string name, Point position, NodeType type)
|
||||
{
|
||||
NodeId = nodeId;
|
||||
Name = name;
|
||||
Position = position;
|
||||
Type = type;
|
||||
CreatedDate = DateTime.Now;
|
||||
ModifiedDate = DateTime.Now;
|
||||
|
||||
// 타입별 기본 색상 설정
|
||||
SetDefaultColorByType(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 노드 타입에 따른 기본 색상 설정
|
||||
/// </summary>
|
||||
/// <param name="type">노드 타입</param>
|
||||
public void SetDefaultColorByType(NodeType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case NodeType.Normal:
|
||||
DisplayColor = Color.Blue;
|
||||
break;
|
||||
case NodeType.Rotation:
|
||||
DisplayColor = Color.Orange;
|
||||
break;
|
||||
case NodeType.Docking:
|
||||
DisplayColor = Color.Green;
|
||||
break;
|
||||
case NodeType.Charging:
|
||||
DisplayColor = Color.Red;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다른 노드와의 연결 추가
|
||||
/// </summary>
|
||||
/// <param name="nodeId">연결할 노드 ID</param>
|
||||
public void AddConnection(string nodeId)
|
||||
{
|
||||
if (!ConnectedNodes.Contains(nodeId))
|
||||
{
|
||||
ConnectedNodes.Add(nodeId);
|
||||
ModifiedDate = DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다른 노드와의 연결 제거
|
||||
/// </summary>
|
||||
/// <param name="nodeId">연결 해제할 노드 ID</param>
|
||||
public void RemoveConnection(string nodeId)
|
||||
{
|
||||
if (ConnectedNodes.Remove(nodeId))
|
||||
{
|
||||
ModifiedDate = DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 도킹 스테이션 설정
|
||||
/// </summary>
|
||||
/// <param name="stationId">장비 ID</param>
|
||||
/// <param name="stationType">장비 타입</param>
|
||||
/// <param name="dockDirection">도킹 방향</param>
|
||||
public void SetDockingStation(string stationId, StationType stationType, DockingDirection dockDirection)
|
||||
{
|
||||
Type = NodeType.Docking;
|
||||
StationId = stationId;
|
||||
StationType = stationType;
|
||||
DockDirection = dockDirection;
|
||||
SetDefaultColorByType(NodeType.Docking);
|
||||
ModifiedDate = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 충전 스테이션 설정
|
||||
/// </summary>
|
||||
/// <param name="stationId">충전기 ID</param>
|
||||
public void SetChargingStation(string stationId)
|
||||
{
|
||||
Type = NodeType.Charging;
|
||||
StationId = stationId;
|
||||
StationType = Models.StationType.Charger;
|
||||
DockDirection = DockingDirection.Forward; // 충전기는 항상 전진 도킹
|
||||
SetDefaultColorByType(NodeType.Charging);
|
||||
ModifiedDate = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 문자열 표현
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{NodeId}: {Name} ({Type}) at ({Position.X}, {Position.Y})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 노드 복사
|
||||
/// </summary>
|
||||
/// <returns>복사된 노드</returns>
|
||||
public MapNode Clone()
|
||||
{
|
||||
var clone = new MapNode
|
||||
{
|
||||
NodeId = NodeId,
|
||||
Name = Name,
|
||||
Position = Position,
|
||||
Type = Type,
|
||||
DockDirection = DockDirection,
|
||||
ConnectedNodes = new List<string>(ConnectedNodes),
|
||||
CanRotate = CanRotate,
|
||||
StationId = StationId,
|
||||
StationType = StationType,
|
||||
CreatedDate = CreatedDate,
|
||||
ModifiedDate = ModifiedDate,
|
||||
Description = Description,
|
||||
IsActive = IsActive,
|
||||
DisplayColor = DisplayColor
|
||||
};
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
}
|
||||
308
Cs_HMI/AGVMapEditor/Models/NodeResolver.cs
Normal file
308
Cs_HMI/AGVMapEditor/Models/NodeResolver.cs
Normal file
@@ -0,0 +1,308 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AGVMapEditor.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// RFID 값을 논리적 노드로 변환하는 클래스
|
||||
/// 실제 AGV 시스템에서 RFID 리더가 읽은 값을 맵 노드 정보로 변환
|
||||
/// </summary>
|
||||
public class NodeResolver
|
||||
{
|
||||
private List<RfidMapping> _rfidMappings;
|
||||
private List<MapNode> _mapNodes;
|
||||
|
||||
/// <summary>
|
||||
/// 기본 생성자
|
||||
/// </summary>
|
||||
public NodeResolver()
|
||||
{
|
||||
_rfidMappings = new List<RfidMapping>();
|
||||
_mapNodes = new List<MapNode>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 매개변수 생성자
|
||||
/// </summary>
|
||||
/// <param name="rfidMappings">RFID 매핑 목록</param>
|
||||
/// <param name="mapNodes">맵 노드 목록</param>
|
||||
public NodeResolver(List<RfidMapping> rfidMappings, List<MapNode> mapNodes)
|
||||
{
|
||||
_rfidMappings = rfidMappings ?? new List<RfidMapping>();
|
||||
_mapNodes = mapNodes ?? new List<MapNode>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFID 값으로 맵 노드 검색
|
||||
/// </summary>
|
||||
/// <param name="rfidValue">RFID 리더에서 읽은 값</param>
|
||||
/// <returns>해당하는 맵 노드, 없으면 null</returns>
|
||||
public MapNode GetNodeByRfid(string rfidValue)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rfidValue))
|
||||
return null;
|
||||
|
||||
// 1. RFID 매핑에서 논리적 노드 ID 찾기
|
||||
var mapping = _rfidMappings.FirstOrDefault(m =>
|
||||
m.RfidId.Equals(rfidValue, StringComparison.OrdinalIgnoreCase) && m.IsActive);
|
||||
|
||||
if (mapping == null)
|
||||
return null;
|
||||
|
||||
// 2. 논리적 노드 ID로 실제 맵 노드 찾기
|
||||
var mapNode = _mapNodes.FirstOrDefault(n =>
|
||||
n.NodeId.Equals(mapping.LogicalNodeId, StringComparison.OrdinalIgnoreCase) && n.IsActive);
|
||||
|
||||
return mapNode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 논리적 노드 ID로 맵 노드 검색
|
||||
/// </summary>
|
||||
/// <param name="nodeId">논리적 노드 ID</param>
|
||||
/// <returns>해당하는 맵 노드, 없으면 null</returns>
|
||||
public MapNode GetNodeById(string nodeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nodeId))
|
||||
return null;
|
||||
|
||||
return _mapNodes.FirstOrDefault(n =>
|
||||
n.NodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase) && n.IsActive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 맵 노드로 연결된 RFID 값 검색
|
||||
/// </summary>
|
||||
/// <param name="nodeId">논리적 노드 ID</param>
|
||||
/// <returns>연결된 RFID 값, 없으면 null</returns>
|
||||
public string GetRfidByNodeId(string nodeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nodeId))
|
||||
return null;
|
||||
|
||||
var mapping = _rfidMappings.FirstOrDefault(m =>
|
||||
m.LogicalNodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase) && m.IsActive);
|
||||
|
||||
return mapping?.RfidId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFID 매핑 정보 검색
|
||||
/// </summary>
|
||||
/// <param name="rfidValue">RFID 값</param>
|
||||
/// <returns>매핑 정보, 없으면 null</returns>
|
||||
public RfidMapping GetRfidMapping(string rfidValue)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rfidValue))
|
||||
return null;
|
||||
|
||||
return _rfidMappings.FirstOrDefault(m =>
|
||||
m.RfidId.Equals(rfidValue, StringComparison.OrdinalIgnoreCase) && m.IsActive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 새로운 RFID 매핑 추가
|
||||
/// </summary>
|
||||
/// <param name="rfidId">RFID 값</param>
|
||||
/// <param name="nodeId">논리적 노드 ID</param>
|
||||
/// <param name="description">설명</param>
|
||||
/// <returns>추가 성공 여부</returns>
|
||||
public bool AddRfidMapping(string rfidId, string nodeId, string description = "")
|
||||
{
|
||||
if (string.IsNullOrEmpty(rfidId) || string.IsNullOrEmpty(nodeId))
|
||||
return false;
|
||||
|
||||
// 중복 RFID 체크
|
||||
if (_rfidMappings.Any(m => m.RfidId.Equals(rfidId, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
// 해당 노드 존재 체크
|
||||
if (!_mapNodes.Any(n => n.NodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
var mapping = new RfidMapping(rfidId, nodeId, description);
|
||||
_rfidMappings.Add(mapping);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFID 매핑 제거
|
||||
/// </summary>
|
||||
/// <param name="rfidId">제거할 RFID 값</param>
|
||||
/// <returns>제거 성공 여부</returns>
|
||||
public bool RemoveRfidMapping(string rfidId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rfidId))
|
||||
return false;
|
||||
|
||||
var mapping = _rfidMappings.FirstOrDefault(m =>
|
||||
m.RfidId.Equals(rfidId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (mapping != null)
|
||||
{
|
||||
_rfidMappings.Remove(mapping);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 맵 노드 추가
|
||||
/// </summary>
|
||||
/// <param name="node">추가할 맵 노드</param>
|
||||
/// <returns>추가 성공 여부</returns>
|
||||
public bool AddMapNode(MapNode node)
|
||||
{
|
||||
if (node == null || string.IsNullOrEmpty(node.NodeId))
|
||||
return false;
|
||||
|
||||
// 중복 노드 ID 체크
|
||||
if (_mapNodes.Any(n => n.NodeId.Equals(node.NodeId, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
_mapNodes.Add(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 맵 노드 제거
|
||||
/// </summary>
|
||||
/// <param name="nodeId">제거할 노드 ID</param>
|
||||
/// <returns>제거 성공 여부</returns>
|
||||
public bool RemoveMapNode(string nodeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(nodeId))
|
||||
return false;
|
||||
|
||||
var node = _mapNodes.FirstOrDefault(n =>
|
||||
n.NodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (node != null)
|
||||
{
|
||||
// 연관된 RFID 매핑도 함께 제거
|
||||
var associatedMappings = _rfidMappings.Where(m =>
|
||||
m.LogicalNodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
foreach (var mapping in associatedMappings)
|
||||
{
|
||||
_rfidMappings.Remove(mapping);
|
||||
}
|
||||
|
||||
// 다른 노드의 연결 정보에서도 제거
|
||||
foreach (var otherNode in _mapNodes.Where(n => n.ConnectedNodes.Contains(nodeId)))
|
||||
{
|
||||
otherNode.RemoveConnection(nodeId);
|
||||
}
|
||||
|
||||
_mapNodes.Remove(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 타입의 노드들 검색
|
||||
/// </summary>
|
||||
/// <param name="nodeType">노드 타입</param>
|
||||
/// <returns>해당 타입의 노드 목록</returns>
|
||||
public List<MapNode> GetNodesByType(NodeType nodeType)
|
||||
{
|
||||
return _mapNodes.Where(n => n.Type == nodeType && n.IsActive).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 장비 ID로 노드 검색
|
||||
/// </summary>
|
||||
/// <param name="stationId">장비 ID</param>
|
||||
/// <returns>해당 장비의 노드, 없으면 null</returns>
|
||||
public MapNode GetNodeByStationId(string stationId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stationId))
|
||||
return null;
|
||||
|
||||
return _mapNodes.FirstOrDefault(n =>
|
||||
n.StationId.Equals(stationId, StringComparison.OrdinalIgnoreCase) && n.IsActive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 매핑되지 않은 노드들 검색 (RFID가 연결되지 않은 노드)
|
||||
/// </summary>
|
||||
/// <returns>매핑되지 않은 노드 목록</returns>
|
||||
public List<MapNode> GetUnmappedNodes()
|
||||
{
|
||||
var mappedNodeIds = _rfidMappings.Where(m => m.IsActive)
|
||||
.Select(m => m.LogicalNodeId)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return _mapNodes.Where(n => n.IsActive && !mappedNodeIds.Contains(n.NodeId)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용되지 않는 RFID 매핑들 검색 (노드가 삭제된 매핑)
|
||||
/// </summary>
|
||||
/// <returns>사용되지 않는 매핑 목록</returns>
|
||||
public List<RfidMapping> GetOrphanedMappings()
|
||||
{
|
||||
var activeNodeIds = _mapNodes.Where(n => n.IsActive)
|
||||
.Select(n => n.NodeId)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return _rfidMappings.Where(m => m.IsActive && !activeNodeIds.Contains(m.LogicalNodeId)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터 초기화
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_rfidMappings.Clear();
|
||||
_mapNodes.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터 유효성 검증
|
||||
/// </summary>
|
||||
/// <returns>검증 결과 메시지 목록</returns>
|
||||
public List<string> ValidateData()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// 중복 RFID 체크
|
||||
var duplicateRfids = _rfidMappings.GroupBy(m => m.RfidId.ToLower())
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key);
|
||||
foreach (var rfid in duplicateRfids)
|
||||
{
|
||||
errors.Add($"중복된 RFID: {rfid}");
|
||||
}
|
||||
|
||||
// 중복 노드 ID 체크
|
||||
var duplicateNodeIds = _mapNodes.GroupBy(n => n.NodeId.ToLower())
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key);
|
||||
foreach (var nodeId in duplicateNodeIds)
|
||||
{
|
||||
errors.Add($"중복된 노드 ID: {nodeId}");
|
||||
}
|
||||
|
||||
// 고아 매핑 체크
|
||||
var orphanedMappings = GetOrphanedMappings();
|
||||
foreach (var mapping in orphanedMappings)
|
||||
{
|
||||
errors.Add($"존재하지 않는 노드를 참조하는 RFID 매핑: {mapping.RfidId} → {mapping.LogicalNodeId}");
|
||||
}
|
||||
|
||||
// 매핑되지 않은 노드 경고 (에러는 아님)
|
||||
var unmappedNodes = GetUnmappedNodes();
|
||||
foreach (var node in unmappedNodes)
|
||||
{
|
||||
errors.Add($"RFID가 매핑되지 않은 노드: {node.NodeId} ({node.Name})");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
469
Cs_HMI/AGVMapEditor/Models/PathCalculator.cs
Normal file
469
Cs_HMI/AGVMapEditor/Models/PathCalculator.cs
Normal file
@@ -0,0 +1,469 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
|
||||
namespace AGVMapEditor.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// AGV 전용 경로 계산기 (A* 알고리즘 기반)
|
||||
/// AGV의 방향성, 도킹 제약, 회전 제약을 고려한 경로 계산
|
||||
/// </summary>
|
||||
public class PathCalculator
|
||||
{
|
||||
#region Constants
|
||||
|
||||
private const float BASE_MOVE_COST = 1.0f; // 기본 이동 비용
|
||||
private const float ROTATION_COST = 0.5f; // 회전 비용
|
||||
private const float DOCKING_APPROACH_COST = 0.2f; // 도킹 접근 추가 비용
|
||||
private const float HEURISTIC_WEIGHT = 1.0f; // 휴리스틱 가중치
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private List<MapNode> _mapNodes;
|
||||
private NodeResolver _nodeResolver;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
|
||||
/// <summary>
|
||||
/// 생성자
|
||||
/// </summary>
|
||||
/// <param name="mapNodes">맵 노드 목록</param>
|
||||
/// <param name="nodeResolver">노드 해결기</param>
|
||||
public PathCalculator(List<MapNode> mapNodes, NodeResolver nodeResolver)
|
||||
{
|
||||
_mapNodes = mapNodes ?? throw new ArgumentNullException(nameof(mapNodes));
|
||||
_nodeResolver = nodeResolver ?? throw new ArgumentNullException(nameof(nodeResolver));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
|
||||
/// <summary>
|
||||
/// 경로 계산 (메인 메서드)
|
||||
/// </summary>
|
||||
/// <param name="startNodeId">시작 노드 ID</param>
|
||||
/// <param name="targetNodeId">목표 노드 ID</param>
|
||||
/// <param name="currentDirection">현재 AGV 방향</param>
|
||||
/// <returns>경로 계산 결과</returns>
|
||||
public PathResult CalculatePath(string startNodeId, string targetNodeId, AgvDirection currentDirection)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// 입력 검증
|
||||
var validationResult = ValidateInput(startNodeId, targetNodeId, currentDirection);
|
||||
if (!validationResult.Success)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
validationResult.CalculationTime = stopwatch.ElapsedMilliseconds;
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
// A* 알고리즘 실행
|
||||
var result = ExecuteAStar(startNodeId, targetNodeId, currentDirection);
|
||||
|
||||
stopwatch.Stop();
|
||||
result.CalculationTime = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new PathResult($"경로 계산 중 오류 발생: {ex.Message}")
|
||||
{
|
||||
CalculationTime = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 경로 유효성 검증 (RFID 이탈 감지시 사용)
|
||||
/// </summary>
|
||||
/// <param name="currentPath">현재 경로</param>
|
||||
/// <param name="currentRfidId">현재 감지된 RFID</param>
|
||||
/// <returns>경로 유효성 여부</returns>
|
||||
public bool ValidateCurrentPath(PathResult currentPath, string currentRfidId)
|
||||
{
|
||||
if (currentPath == null || !currentPath.Success)
|
||||
return false;
|
||||
|
||||
var currentNode = _nodeResolver.GetNodeByRfid(currentRfidId);
|
||||
if (currentNode == null)
|
||||
return false;
|
||||
|
||||
// 현재 노드가 계획된 경로에 포함되어 있는지 확인
|
||||
return currentPath.NodeSequence.Contains(currentNode.NodeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 동적 경로 재계산 (경로 이탈시 사용)
|
||||
/// </summary>
|
||||
/// <param name="currentRfidId">현재 RFID 위치</param>
|
||||
/// <param name="targetNodeId">목표 노드 ID</param>
|
||||
/// <param name="currentDirection">현재 방향</param>
|
||||
/// <param name="originalPath">원래 경로 (참고용)</param>
|
||||
/// <returns>새로운 경로</returns>
|
||||
public PathResult RecalculatePath(string currentRfidId, string targetNodeId,
|
||||
AgvDirection currentDirection, PathResult originalPath = null)
|
||||
{
|
||||
var currentNode = _nodeResolver.GetNodeByRfid(currentRfidId);
|
||||
if (currentNode == null)
|
||||
{
|
||||
return new PathResult("현재 위치를 확인할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 새로운 경로 계산
|
||||
var result = CalculatePath(currentNode.NodeId, targetNodeId, currentDirection);
|
||||
|
||||
// 원래 경로와 비교 (로그용)
|
||||
if (originalPath != null && result.Success)
|
||||
{
|
||||
// TODO: 경로 변경 로그 기록
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Methods - Input Validation
|
||||
|
||||
/// <summary>
|
||||
/// 입력 값 검증
|
||||
/// </summary>
|
||||
private PathResult ValidateInput(string startNodeId, string targetNodeId, AgvDirection currentDirection)
|
||||
{
|
||||
if (string.IsNullOrEmpty(startNodeId))
|
||||
return new PathResult("시작 노드가 지정되지 않았습니다.");
|
||||
|
||||
if (string.IsNullOrEmpty(targetNodeId))
|
||||
return new PathResult("목표 노드가 지정되지 않았습니다.");
|
||||
|
||||
var startNode = _mapNodes.FirstOrDefault(n => n.NodeId == startNodeId);
|
||||
if (startNode == null)
|
||||
return new PathResult($"시작 노드를 찾을 수 없습니다: {startNodeId}");
|
||||
|
||||
var targetNode = _mapNodes.FirstOrDefault(n => n.NodeId == targetNodeId);
|
||||
if (targetNode == null)
|
||||
return new PathResult($"목표 노드를 찾을 수 없습니다: {targetNodeId}");
|
||||
|
||||
if (startNodeId == targetNodeId)
|
||||
return new PathResult("시작점과 목표점이 동일합니다.");
|
||||
|
||||
return new PathResult { Success = true };
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Methods - A* Algorithm
|
||||
|
||||
/// <summary>
|
||||
/// A* 알고리즘 실행
|
||||
/// </summary>
|
||||
private PathResult ExecuteAStar(string startNodeId, string targetNodeId, AgvDirection currentDirection)
|
||||
{
|
||||
var openSet = new SortedSet<PathNode>();
|
||||
var closedSet = new HashSet<string>();
|
||||
var gScore = new Dictionary<string, float>();
|
||||
|
||||
// 시작 노드 설정
|
||||
var startPathNode = new PathNode(startNodeId, currentDirection)
|
||||
{
|
||||
GCost = 0,
|
||||
HCost = CalculateHeuristic(startNodeId, targetNodeId)
|
||||
};
|
||||
|
||||
openSet.Add(startPathNode);
|
||||
gScore[startPathNode.GetKey()] = 0;
|
||||
|
||||
while (openSet.Count > 0)
|
||||
{
|
||||
// 가장 낮은 F 비용을 가진 노드 선택
|
||||
var current = openSet.Min;
|
||||
openSet.Remove(current);
|
||||
|
||||
// 목표 도달 확인
|
||||
if (current.NodeId == targetNodeId)
|
||||
{
|
||||
// 도킹 방향 검증
|
||||
if (IsValidDockingApproach(targetNodeId, current.Direction))
|
||||
{
|
||||
return ReconstructPath(current, startNodeId, targetNodeId, currentDirection);
|
||||
}
|
||||
// 도킹 방향이 맞지 않으면 계속 탐색
|
||||
}
|
||||
|
||||
closedSet.Add(current.GetKey());
|
||||
|
||||
// 인접 노드들 처리
|
||||
ProcessNeighbors(current, targetNodeId, openSet, closedSet, gScore);
|
||||
}
|
||||
|
||||
return new PathResult("경로를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 인접 노드들 처리
|
||||
/// </summary>
|
||||
private void ProcessNeighbors(PathNode current, string targetNodeId,
|
||||
SortedSet<PathNode> openSet, HashSet<string> closedSet,
|
||||
Dictionary<string, float> gScore)
|
||||
{
|
||||
var currentMapNode = _mapNodes.FirstOrDefault(n => n.NodeId == current.NodeId);
|
||||
if (currentMapNode == null) return;
|
||||
|
||||
foreach (var neighborId in currentMapNode.ConnectedNodes)
|
||||
{
|
||||
var neighborMapNode = _mapNodes.FirstOrDefault(n => n.NodeId == neighborId);
|
||||
if (neighborMapNode == null) continue;
|
||||
|
||||
// 가능한 모든 방향으로 이웃 노드 방문
|
||||
foreach (var direction in GetPossibleDirections(current, neighborMapNode))
|
||||
{
|
||||
var neighborPathNode = new PathNode(neighborId, direction);
|
||||
var neighborKey = neighborPathNode.GetKey();
|
||||
|
||||
if (closedSet.Contains(neighborKey))
|
||||
continue;
|
||||
|
||||
// 이동 비용 계산
|
||||
var moveCost = CalculateMoveCost(current, neighborPathNode, neighborMapNode);
|
||||
var tentativeGScore = current.GCost + moveCost;
|
||||
|
||||
// 더 좋은 경로인지 확인
|
||||
if (!gScore.ContainsKey(neighborKey) || tentativeGScore < gScore[neighborKey])
|
||||
{
|
||||
// 경로 정보 업데이트
|
||||
neighborPathNode.Parent = current;
|
||||
neighborPathNode.GCost = tentativeGScore;
|
||||
neighborPathNode.HCost = CalculateHeuristic(neighborId, targetNodeId);
|
||||
neighborPathNode.RotationCount = current.RotationCount +
|
||||
(current.Direction != direction ? 1 : 0);
|
||||
|
||||
// 이동 명령 시퀀스 구성
|
||||
neighborPathNode.MovementSequence = GenerateMovementSequence(current, neighborPathNode);
|
||||
|
||||
gScore[neighborKey] = tentativeGScore;
|
||||
|
||||
// openSet에 추가 (중복 제거)
|
||||
openSet.RemoveWhere(n => n.GetKey() == neighborKey);
|
||||
openSet.Add(neighborPathNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 가능한 방향들 계산
|
||||
/// </summary>
|
||||
private List<AgvDirection> GetPossibleDirections(PathNode current, MapNode neighborNode)
|
||||
{
|
||||
var directions = new List<AgvDirection>();
|
||||
|
||||
// 기본적으로 전진/후진 가능
|
||||
directions.Add(AgvDirection.Forward);
|
||||
directions.Add(AgvDirection.Backward);
|
||||
|
||||
// 회전 가능한 노드에서만 방향 전환 가능
|
||||
if (CanRotateAt(current.NodeId))
|
||||
{
|
||||
// 현재 방향과 다른 방향도 고려
|
||||
if (current.Direction == AgvDirection.Forward)
|
||||
directions.Add(AgvDirection.Backward);
|
||||
else if (current.Direction == AgvDirection.Backward)
|
||||
directions.Add(AgvDirection.Forward);
|
||||
}
|
||||
|
||||
return directions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이동 비용 계산
|
||||
/// </summary>
|
||||
private float CalculateMoveCost(PathNode from, PathNode to, MapNode toMapNode)
|
||||
{
|
||||
float cost = BASE_MOVE_COST;
|
||||
|
||||
// 방향 전환 비용
|
||||
if (from.Direction != to.Direction)
|
||||
{
|
||||
cost += ROTATION_COST;
|
||||
}
|
||||
|
||||
// 도킹 스테이션 접근 비용
|
||||
if (toMapNode.Type == NodeType.Docking || toMapNode.Type == NodeType.Charging)
|
||||
{
|
||||
cost += DOCKING_APPROACH_COST;
|
||||
}
|
||||
|
||||
// 실제 거리 기반 비용 (좌표가 있는 경우)
|
||||
var fromMapNode = _mapNodes.FirstOrDefault(n => n.NodeId == from.NodeId);
|
||||
if (fromMapNode != null && toMapNode != null)
|
||||
{
|
||||
var distance = CalculateDistance(fromMapNode.Position, toMapNode.Position);
|
||||
cost *= (distance / 100.0f); // 좌표 단위를 거리 단위로 조정
|
||||
}
|
||||
|
||||
return cost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 휴리스틱 함수 (목표까지의 추정 거리)
|
||||
/// </summary>
|
||||
private float CalculateHeuristic(string fromNodeId, string toNodeId)
|
||||
{
|
||||
var fromNode = _mapNodes.FirstOrDefault(n => n.NodeId == fromNodeId);
|
||||
var toNode = _mapNodes.FirstOrDefault(n => n.NodeId == toNodeId);
|
||||
|
||||
if (fromNode == null || toNode == null)
|
||||
return 0;
|
||||
|
||||
// 유클리드 거리 계산
|
||||
var distance = CalculateDistance(fromNode.Position, toNode.Position);
|
||||
return distance * HEURISTIC_WEIGHT / 100.0f; // 좌표 단위 조정
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 두 점 사이의 거리 계산
|
||||
/// </summary>
|
||||
private float CalculateDistance(Point from, Point to)
|
||||
{
|
||||
var dx = to.X - from.X;
|
||||
var dy = to.Y - from.Y;
|
||||
return (float)Math.Sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 회전 가능한 위치인지 확인
|
||||
/// </summary>
|
||||
private bool CanRotateAt(string nodeId)
|
||||
{
|
||||
var node = _mapNodes.FirstOrDefault(n => n.NodeId == nodeId);
|
||||
return node != null && (node.CanRotate || node.Type == NodeType.Rotation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 도킹 접근 방향이 유효한지 확인
|
||||
/// </summary>
|
||||
private bool IsValidDockingApproach(string nodeId, AgvDirection approachDirection)
|
||||
{
|
||||
var node = _mapNodes.FirstOrDefault(n => n.NodeId == nodeId);
|
||||
if (node == null) return true;
|
||||
|
||||
// 도킹/충전 스테이션이 아니면 방향 제약 없음
|
||||
if (node.Type != NodeType.Docking && node.Type != NodeType.Charging)
|
||||
return true;
|
||||
|
||||
// 도킹 방향 확인
|
||||
if (node.DockDirection == null)
|
||||
return true;
|
||||
|
||||
// 충전기는 전진으로만, 다른 장비는 후진으로만
|
||||
if (node.Type == NodeType.Charging)
|
||||
return approachDirection == AgvDirection.Forward;
|
||||
else
|
||||
return approachDirection == AgvDirection.Backward;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이동 명령 시퀀스 생성
|
||||
/// </summary>
|
||||
private List<AgvDirection> GenerateMovementSequence(PathNode from, PathNode to)
|
||||
{
|
||||
var sequence = new List<AgvDirection>();
|
||||
|
||||
// 방향 전환이 필요한 경우
|
||||
if (from.Direction != to.Direction)
|
||||
{
|
||||
if (from.Direction == AgvDirection.Forward && to.Direction == AgvDirection.Backward)
|
||||
{
|
||||
sequence.Add(AgvDirection.Right); // 180도 회전 (마크센서까지)
|
||||
}
|
||||
else if (from.Direction == AgvDirection.Backward && to.Direction == AgvDirection.Forward)
|
||||
{
|
||||
sequence.Add(AgvDirection.Right); // 180도 회전 (마크센서까지)
|
||||
}
|
||||
}
|
||||
|
||||
// 이동 명령
|
||||
sequence.Add(to.Direction);
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 경로 재구성
|
||||
/// </summary>
|
||||
private PathResult ReconstructPath(PathNode goalNode, string startNodeId, string targetNodeId, AgvDirection startDirection)
|
||||
{
|
||||
var path = new List<PathNode>();
|
||||
var current = goalNode;
|
||||
|
||||
// 역순으로 경로 구성
|
||||
while (current != null)
|
||||
{
|
||||
path.Insert(0, current);
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return new PathResult(path, startNodeId, targetNodeId, startDirection);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public Utility Methods
|
||||
|
||||
/// <summary>
|
||||
/// 노드 간 직선 거리 계산
|
||||
/// </summary>
|
||||
public float GetDistance(string fromNodeId, string toNodeId)
|
||||
{
|
||||
var fromNode = _mapNodes.FirstOrDefault(n => n.NodeId == fromNodeId);
|
||||
var toNode = _mapNodes.FirstOrDefault(n => n.NodeId == toNodeId);
|
||||
|
||||
if (fromNode == null || toNode == null)
|
||||
return float.MaxValue;
|
||||
|
||||
return CalculateDistance(fromNode.Position, toNode.Position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 노드에서 가능한 다음 노드들 조회
|
||||
/// </summary>
|
||||
public List<string> GetPossibleNextNodes(string currentNodeId, AgvDirection currentDirection)
|
||||
{
|
||||
var currentNode = _mapNodes.FirstOrDefault(n => n.NodeId == currentNodeId);
|
||||
if (currentNode == null)
|
||||
return new List<string>();
|
||||
|
||||
return currentNode.ConnectedNodes.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 경로 최적화 (선택적 기능)
|
||||
/// </summary>
|
||||
public PathResult OptimizePath(PathResult originalPath)
|
||||
{
|
||||
if (originalPath == null || !originalPath.Success)
|
||||
return originalPath;
|
||||
|
||||
// TODO: 경로 최적화 로직 구현
|
||||
// - 불필요한 중간 정지점 제거
|
||||
// - 회전 최소화
|
||||
// - 경로 단순화
|
||||
|
||||
return originalPath;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
144
Cs_HMI/AGVMapEditor/Models/PathNode.cs
Normal file
144
Cs_HMI/AGVMapEditor/Models/PathNode.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AGVMapEditor.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// A* 알고리즘에서 사용되는 경로 노드
|
||||
/// </summary>
|
||||
public class PathNode : IComparable<PathNode>
|
||||
{
|
||||
/// <summary>
|
||||
/// 맵 노드 ID
|
||||
/// </summary>
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// AGV의 현재 방향 (이 노드에 도달했을 때의 방향)
|
||||
/// </summary>
|
||||
public AgvDirection Direction { get; set; } = AgvDirection.Forward;
|
||||
|
||||
/// <summary>
|
||||
/// 시작점에서 이 노드까지의 실제 비용 (G)
|
||||
/// </summary>
|
||||
public float GCost { get; set; } = float.MaxValue;
|
||||
|
||||
/// <summary>
|
||||
/// 이 노드에서 목표까지의 추정 비용 (H)
|
||||
/// </summary>
|
||||
public float HCost { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 총 비용 (F = G + H)
|
||||
/// </summary>
|
||||
public float FCost => GCost + HCost;
|
||||
|
||||
/// <summary>
|
||||
/// 이전 노드 (경로 추적용)
|
||||
/// </summary>
|
||||
public PathNode Parent { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// 회전 횟수 (방향 전환 비용 계산용)
|
||||
/// </summary>
|
||||
public int RotationCount { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 이 노드에 도달하기 위한 이동 명령 시퀀스
|
||||
/// </summary>
|
||||
public List<AgvDirection> MovementSequence { get; set; } = new List<AgvDirection>();
|
||||
|
||||
/// <summary>
|
||||
/// 기본 생성자
|
||||
/// </summary>
|
||||
public PathNode()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 매개변수 생성자
|
||||
/// </summary>
|
||||
/// <param name="nodeId">노드 ID</param>
|
||||
/// <param name="direction">AGV 방향</param>
|
||||
public PathNode(string nodeId, AgvDirection direction)
|
||||
{
|
||||
NodeId = nodeId;
|
||||
Direction = direction;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 우선순위 큐를 위한 비교 (FCost 기준)
|
||||
/// </summary>
|
||||
public int CompareTo(PathNode other)
|
||||
{
|
||||
if (other == null) return 1;
|
||||
|
||||
int compare = FCost.CompareTo(other.FCost);
|
||||
if (compare == 0)
|
||||
{
|
||||
// FCost가 같으면 HCost가 낮은 것을 우선
|
||||
compare = HCost.CompareTo(other.HCost);
|
||||
}
|
||||
if (compare == 0)
|
||||
{
|
||||
// 그것도 같으면 회전 횟수가 적은 것을 우선
|
||||
compare = RotationCount.CompareTo(other.RotationCount);
|
||||
}
|
||||
|
||||
return compare;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 노드 상태 복사
|
||||
/// </summary>
|
||||
public PathNode Clone()
|
||||
{
|
||||
return new PathNode
|
||||
{
|
||||
NodeId = NodeId,
|
||||
Direction = Direction,
|
||||
GCost = GCost,
|
||||
HCost = HCost,
|
||||
Parent = Parent,
|
||||
RotationCount = RotationCount,
|
||||
MovementSequence = new List<AgvDirection>(MovementSequence)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 고유 키 생성 (노드ID + 방향)
|
||||
/// </summary>
|
||||
public string GetKey()
|
||||
{
|
||||
return $"{NodeId}_{Direction}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 문자열 표현
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{NodeId}({Direction}) F:{FCost:F1} G:{GCost:F1} H:{HCost:F1} R:{RotationCount}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 해시코드 (딕셔너리 키용)
|
||||
/// </summary>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return GetKey().GetHashCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 동등성 비교
|
||||
/// </summary>
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
if (obj is PathNode other)
|
||||
{
|
||||
return NodeId == other.NodeId && Direction == other.Direction;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
277
Cs_HMI/AGVMapEditor/Models/PathResult.cs
Normal file
277
Cs_HMI/AGVMapEditor/Models/PathResult.cs
Normal file
@@ -0,0 +1,277 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AGVMapEditor.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// 경로 계산 결과
|
||||
/// </summary>
|
||||
public class PathResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 경로 계산 성공 여부
|
||||
/// </summary>
|
||||
public bool Success { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 경로상의 노드 ID 시퀀스
|
||||
/// </summary>
|
||||
public List<string> NodeSequence { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// AGV 이동 명령 시퀀스
|
||||
/// </summary>
|
||||
public List<AgvDirection> MovementSequence { get; set; } = new List<AgvDirection>();
|
||||
|
||||
/// <summary>
|
||||
/// 총 이동 거리 (비용)
|
||||
/// </summary>
|
||||
public float TotalDistance { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 총 회전 횟수
|
||||
/// </summary>
|
||||
public int TotalRotations { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 예상 소요 시간 (초)
|
||||
/// </summary>
|
||||
public float EstimatedTime { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 시작 노드 ID
|
||||
/// </summary>
|
||||
public string StartNodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 목표 노드 ID
|
||||
/// </summary>
|
||||
public string TargetNodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 시작시 AGV 방향
|
||||
/// </summary>
|
||||
public AgvDirection StartDirection { get; set; } = AgvDirection.Forward;
|
||||
|
||||
/// <summary>
|
||||
/// 도착시 AGV 방향
|
||||
/// </summary>
|
||||
public AgvDirection EndDirection { get; set; } = AgvDirection.Forward;
|
||||
|
||||
/// <summary>
|
||||
/// 경로 계산에 걸린 시간 (밀리초)
|
||||
/// </summary>
|
||||
public long CalculationTime { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 오류 메시지 (실패시)
|
||||
/// </summary>
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 경로상의 상세 정보 (디버깅용)
|
||||
/// </summary>
|
||||
public List<PathNode> DetailedPath { get; set; } = new List<PathNode>();
|
||||
|
||||
/// <summary>
|
||||
/// 회전이 발생하는 노드들
|
||||
/// </summary>
|
||||
public List<string> RotationNodes { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 기본 생성자
|
||||
/// </summary>
|
||||
public PathResult()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 성공 결과 생성자
|
||||
/// </summary>
|
||||
public PathResult(List<PathNode> path, string startNodeId, string targetNodeId, AgvDirection startDirection)
|
||||
{
|
||||
if (path == null || path.Count == 0)
|
||||
{
|
||||
Success = false;
|
||||
ErrorMessage = "빈 경로입니다.";
|
||||
return;
|
||||
}
|
||||
|
||||
Success = true;
|
||||
StartNodeId = startNodeId;
|
||||
TargetNodeId = targetNodeId;
|
||||
StartDirection = startDirection;
|
||||
DetailedPath = new List<PathNode>(path);
|
||||
|
||||
// 노드 시퀀스 구성
|
||||
NodeSequence = path.Select(p => p.NodeId).ToList();
|
||||
|
||||
// 이동 명령 시퀀스 구성
|
||||
MovementSequence = new List<AgvDirection>();
|
||||
for (int i = 0; i < path.Count; i++)
|
||||
{
|
||||
MovementSequence.AddRange(path[i].MovementSequence);
|
||||
}
|
||||
|
||||
// 통계 계산
|
||||
if (path.Count > 0)
|
||||
{
|
||||
TotalDistance = path[path.Count - 1].GCost;
|
||||
EndDirection = path[path.Count - 1].Direction;
|
||||
}
|
||||
|
||||
TotalRotations = MovementSequence.Count(cmd =>
|
||||
cmd == AgvDirection.Left || cmd == AgvDirection.Right);
|
||||
|
||||
// 회전 노드 추출
|
||||
var previousDirection = startDirection;
|
||||
for (int i = 0; i < path.Count; i++)
|
||||
{
|
||||
if (path[i].Direction != previousDirection)
|
||||
{
|
||||
RotationNodes.Add(path[i].NodeId);
|
||||
}
|
||||
previousDirection = path[i].Direction;
|
||||
}
|
||||
|
||||
// 예상 소요 시간 계산 (단순 추정)
|
||||
EstimatedTime = CalculateEstimatedTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실패 결과 생성자
|
||||
/// </summary>
|
||||
public PathResult(string errorMessage)
|
||||
{
|
||||
Success = false;
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 예상 소요 시간 계산
|
||||
/// </summary>
|
||||
private float CalculateEstimatedTime()
|
||||
{
|
||||
// 기본 이동 속도 및 회전 시간 가정
|
||||
const float MOVE_SPEED = 1.0f; // 단위/초
|
||||
const float ROTATION_TIME = 2.0f; // 초/회전
|
||||
|
||||
float moveTime = TotalDistance / MOVE_SPEED;
|
||||
float rotationTime = TotalRotations * ROTATION_TIME;
|
||||
|
||||
return moveTime + rotationTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 경로 요약 정보
|
||||
/// </summary>
|
||||
public string GetSummary()
|
||||
{
|
||||
if (!Success)
|
||||
{
|
||||
return $"경로 계산 실패: {ErrorMessage}";
|
||||
}
|
||||
|
||||
return $"경로: {NodeSequence.Count}개 노드, " +
|
||||
$"거리: {TotalDistance:F1}, " +
|
||||
$"회전: {TotalRotations}회, " +
|
||||
$"예상시간: {EstimatedTime:F1}초";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 상세 경로 정보
|
||||
/// </summary>
|
||||
public List<string> GetDetailedSteps()
|
||||
{
|
||||
var steps = new List<string>();
|
||||
|
||||
if (!Success)
|
||||
{
|
||||
steps.Add($"경로 계산 실패: {ErrorMessage}");
|
||||
return steps;
|
||||
}
|
||||
|
||||
steps.Add($"시작: {StartNodeId} (방향: {StartDirection})");
|
||||
|
||||
for (int i = 0; i < DetailedPath.Count; i++)
|
||||
{
|
||||
var node = DetailedPath[i];
|
||||
var step = $"{i + 1}. {node.NodeId}";
|
||||
|
||||
if (node.MovementSequence.Count > 0)
|
||||
{
|
||||
step += $" [명령: {string.Join(",", node.MovementSequence)}]";
|
||||
}
|
||||
|
||||
step += $" (F:{node.FCost:F1}, 방향:{node.Direction})";
|
||||
steps.Add(step);
|
||||
}
|
||||
|
||||
steps.Add($"도착: {TargetNodeId} (최종 방향: {EndDirection})");
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFID 시퀀스 추출 (실제 AGV 제어용)
|
||||
/// </summary>
|
||||
public List<string> GetRfidSequence(NodeResolver nodeResolver)
|
||||
{
|
||||
var rfidSequence = new List<string>();
|
||||
|
||||
foreach (var nodeId in NodeSequence)
|
||||
{
|
||||
var rfidId = nodeResolver.GetRfidByNodeId(nodeId);
|
||||
if (!string.IsNullOrEmpty(rfidId))
|
||||
{
|
||||
rfidSequence.Add(rfidId);
|
||||
}
|
||||
}
|
||||
|
||||
return rfidSequence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 경로 유효성 검증
|
||||
/// </summary>
|
||||
public bool ValidatePath(List<MapNode> mapNodes)
|
||||
{
|
||||
if (!Success || NodeSequence.Count == 0)
|
||||
return false;
|
||||
|
||||
// 모든 노드가 존재하는지 확인
|
||||
foreach (var nodeId in NodeSequence)
|
||||
{
|
||||
if (!mapNodes.Any(n => n.NodeId == nodeId))
|
||||
{
|
||||
ErrorMessage = $"존재하지 않는 노드: {nodeId}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 연결성 확인
|
||||
for (int i = 0; i < NodeSequence.Count - 1; i++)
|
||||
{
|
||||
var currentNode = mapNodes.FirstOrDefault(n => n.NodeId == NodeSequence[i]);
|
||||
var nextNodeId = NodeSequence[i + 1];
|
||||
|
||||
if (currentNode != null && !currentNode.ConnectedNodes.Contains(nextNodeId))
|
||||
{
|
||||
ErrorMessage = $"연결되지 않은 노드: {currentNode.NodeId} → {nextNodeId}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON 직렬화를 위한 문자열 변환
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return GetSummary();
|
||||
}
|
||||
}
|
||||
}
|
||||
79
Cs_HMI/AGVMapEditor/Models/RfidMapping.cs
Normal file
79
Cs_HMI/AGVMapEditor/Models/RfidMapping.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
|
||||
namespace AGVMapEditor.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// RFID와 논리적 노드 ID를 매핑하는 클래스
|
||||
/// 물리적 RFID는 의미없는 고유값, 논리적 노드는 맵 에디터에서 관리
|
||||
/// </summary>
|
||||
public class RfidMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 물리적 RFID 값 (의미 없는 고유 식별자)
|
||||
/// 예: "1234567890", "ABCDEF1234" 등
|
||||
/// </summary>
|
||||
public string RfidId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 논리적 노드 ID (맵 에디터에서 관리)
|
||||
/// 예: "N001", "N002", "LOADER1", "CHARGER1" 등
|
||||
/// </summary>
|
||||
public string LogicalNodeId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 매핑 생성 일자
|
||||
/// </summary>
|
||||
public DateTime CreatedDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 마지막 수정 일자
|
||||
/// </summary>
|
||||
public DateTime ModifiedDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 설치 위치 설명 (현장 작업자용)
|
||||
/// 예: "로더1번 앞", "충전기2번 입구", "복도 교차점" 등
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// RFID 상태 (정상, 손상, 교체예정 등)
|
||||
/// </summary>
|
||||
public string Status { get; set; } = "정상";
|
||||
|
||||
/// <summary>
|
||||
/// 매핑 활성화 여부
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 기본 생성자
|
||||
/// </summary>
|
||||
public RfidMapping()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 매개변수 생성자
|
||||
/// </summary>
|
||||
/// <param name="rfidId">물리적 RFID ID</param>
|
||||
/// <param name="logicalNodeId">논리적 노드 ID</param>
|
||||
/// <param name="description">설치 위치 설명</param>
|
||||
public RfidMapping(string rfidId, string logicalNodeId, string description = "")
|
||||
{
|
||||
RfidId = rfidId;
|
||||
LogicalNodeId = logicalNodeId;
|
||||
Description = description;
|
||||
CreatedDate = DateTime.Now;
|
||||
ModifiedDate = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 문자열 표현
|
||||
/// </summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{RfidId} → {LogicalNodeId} ({Description})";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user