feat: 레시피별 Front/Down 레이저 인터락 추가

Use Interlock ON 시 ① 안전구역 게이트(기존) 유지 + ② 레시피별 방향 차단(신규). Recipe 폴더의 Recipe_Laser_Interlock.json(Front/Down 0/1)에 저장하고, 경로는 FieldHelper.json과 동일 소스(DB.Dao.SystemRecipe.WorkRecipeFile) 사용. Auto/Manual 양쪽 적용(Manual은 ①+② 신규), VM 체크박스는 레시피 전환 시 Background 우선순위 재로드로 stale 방지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
to3796zw 2026-06-25 22:30:57 +09:00
parent 48befb60b2
commit 041c15550c
6 changed files with 192 additions and 1 deletions

View File

@ -15,6 +15,7 @@ using CoreUnit.Scanner.UserControl.ScannerCanvas;
using DB; using DB;
using GUI; using GUI;
using Hermes; using Hermes;
using ILSOS_PrismaticRD_PTOP.Alarm;
using ILSOS_PrismaticRD_PTOP.Device; using ILSOS_PrismaticRD_PTOP.Device;
using ILSOS_PrismaticRD_PTOP.Device.IO; using ILSOS_PrismaticRD_PTOP.Device.IO;
using ILSOS_PrismaticRD_PTOP.Settings; using ILSOS_PrismaticRD_PTOP.Settings;
@ -567,6 +568,50 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
Status.SetWorkingPosition(eWorkingPosition.EXECUTING_WELD); Status.SetWorkingPosition(eWorkingPosition.EXECUTING_WELD);
Log.I($"Welding List Space : {_Scanner.GetListSpace()}"); Log.I($"Welding List Space : {_Scanner.GetListSpace()}");
// === Head Theta 인터락 (① 안전 게이트 + ② 레시피별 방향 차단) — Manual 경로 신규 추가 ===
var thetaSetting = context.GetSetting<LaserInterlockSetting>(SettingIds.LaserInterlock);
if (thetaSetting != null && thetaSetting.HeadThetaInterlockEnabled)
{
bool blocked = false;
if (_plc == null || !_plc.IsActivated)
{
Log.W("[DoComponentWelding] LaserInterlock - PLC 미연결/미활성 → 발진 차단(fail-closed)");
blocked = true;
}
else
{
short thetaRaw = _plc.ReadWord(eWordInput.HEAD_THETA_POSITION).data;
double scale = thetaSetting.HeadThetaPlcScale != 0 ? thetaSetting.HeadThetaPlcScale : 1; // 0 가드
double thetaDeg = thetaRaw / scale;
bool okFront = Math.Abs(thetaDeg - thetaSetting.HeadThetaFront) <= thetaSetting.HeadThetaTolerance;
bool okDown = Math.Abs(thetaDeg - thetaSetting.HeadThetaDown) <= thetaSetting.HeadThetaTolerance;
Log.I($"[DoComponentWelding] HeadTheta raw={thetaRaw}, deg={thetaDeg:F1} (Front={thetaSetting.HeadThetaFront}/Down={thetaSetting.HeadThetaDown}/±{thetaSetting.HeadThetaTolerance})");
// ① 안전 게이트: 안전 구역(Front/Down 부근) 아니면 차단
if (!okFront && !okDown)
{
Log.W($"[DoComponentWelding] ① HeadTheta 인터락 → Front/Down ±{thetaSetting.HeadThetaTolerance}° 벗어남(deg={thetaDeg:F1}) → 발진 차단");
blocked = true;
}
else
{
// ② 레시피별 방향 차단: 체크된 방향의 구역이면 차단
var rl = RecipeLaserInterlock.Load();
if ((rl.Front && okFront) || (rl.Down && okDown))
{
Log.W($"[DoComponentWelding] ② 레시피 차단 deg={thetaDeg:F1} Front?{rl.Front}/{okFront} Down?{rl.Down}/{okDown} → 발진 차단");
blocked = true;
}
}
}
if (blocked)
{
try { AlarmManager.Alarm(AlarmCode_PTOP.ALARM_LASER_HEAD_THETA_INTERLOCK); }
catch (Exception ex) { Log.E($"[DoComponentWelding] 알람 표시 실패: {ex.Message}"); }
return null;
}
}
DateTime weldingTime = DateTime.Now; DateTime weldingTime = DateTime.Now;
_Scanner.ExecuteList(_WeldingScannerListNo); _Scanner.ExecuteList(_WeldingScannerListNo);
_Scanner.WaitForListTaskCompleted(1000);//(_SystemParameter.WeldLinesTaskTimeOut); _Scanner.WaitForListTaskCompleted(1000);//(_SystemParameter.WeldLinesTaskTimeOut);

View File

@ -767,11 +767,22 @@ namespace ILSOS_PrismaticRD_PTOP.Sequences
bool okFront = Math.Abs(thetaDeg - thetaSetting.HeadThetaFront) <= thetaSetting.HeadThetaTolerance; bool okFront = Math.Abs(thetaDeg - thetaSetting.HeadThetaFront) <= thetaSetting.HeadThetaTolerance;
bool okDown = Math.Abs(thetaDeg - thetaSetting.HeadThetaDown) <= thetaSetting.HeadThetaTolerance; bool okDown = Math.Abs(thetaDeg - thetaSetting.HeadThetaDown) <= thetaSetting.HeadThetaTolerance;
Log.I($"[ExecuteWelding] HeadTheta raw={thetaRaw}, deg={thetaDeg:F1} (Front={thetaSetting.HeadThetaFront}/Down={thetaSetting.HeadThetaDown}/±{thetaSetting.HeadThetaTolerance})"); Log.I($"[ExecuteWelding] HeadTheta raw={thetaRaw}, deg={thetaDeg:F1} (Front={thetaSetting.HeadThetaFront}/Down={thetaSetting.HeadThetaDown}/±{thetaSetting.HeadThetaTolerance})");
// ① 안전 게이트(기존): 안전 구역(Front/Down 부근) 아니면 차단
if (!okFront && !okDown) if (!okFront && !okDown)
{ {
Log.W($"[ExecuteWelding] HeadTheta 인터락 → Front/Down ±{thetaSetting.HeadThetaTolerance}° 벗어남(deg={thetaDeg:F1}) → 발진 차단"); Log.W($"[ExecuteWelding] ① HeadTheta 인터락 → Front/Down ±{thetaSetting.HeadThetaTolerance}° 벗어남(deg={thetaDeg:F1}) → 발진 차단");
blocked = true; blocked = true;
} }
else
{
// ② 레시피별 방향 차단(신규): 체크된 방향의 구역이면 차단
var rl = WeldingCommon.Settings.RecipeLaserInterlock.Load();
if ((rl.Front && okFront) || (rl.Down && okDown))
{
Log.W($"[ExecuteWelding] ② 레시피 차단 deg={thetaDeg:F1} Front?{rl.Front}/{okFront} Down?{rl.Down}/{okDown} → 발진 차단");
blocked = true;
}
}
} }
if (blocked) if (blocked)
{ {

View File

@ -0,0 +1,79 @@
using System;
using System.IO;
using DB;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace WeldingCommon.Settings
{
// 레시피별 Laser 방향(Front/Down) 발진 차단 체크값.
// 활성 레시피 폴더의 Recipe_Laser_Interlock.json 에 저장/로드한다.
// 경로 소스는 FieldHelper.json 과 동일한 DB.Dao.SystemRecipe.WorkRecipeFile 을 사용해
// 같은 레시피 폴더에 떨어지도록 보장한다.
public class RecipeLaserInterlock
{
public bool Front { get; set; } // json 1=true(차단) / 0=false
public bool Down { get; set; }
private const string FileName = "Recipe_Laser_Interlock.json";
private const string WrapperKey = "Laser Interlock";
// 활성 레시피 폴더의 Recipe_Laser_Interlock.json 전체 경로.
// (FieldHelper.json 과 동일 소스/폴더. 파일명만 치환)
public static string GetActiveFilePath()
{
string recipeFile = DB.Dao.SystemRecipe.WorkRecipeFile;
if (string.IsNullOrEmpty(recipeFile))
{
return null;
}
return recipeFile.Replace(Path.GetFileName(recipeFile), FileName);
}
// 파일이 없거나 파싱 실패 시 기본값(Front=false, Down=false) = 차단 없음.
public static RecipeLaserInterlock Load()
{
var result = new RecipeLaserInterlock();
try
{
string path = GetActiveFilePath();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
return result;
}
var root = JObject.Parse(File.ReadAllText(path));
var node = root[WrapperKey];
if (node != null)
{
result.Front = (node["Front"]?.Value<int>() ?? 0) == 1;
result.Down = (node["Down"]?.Value<int>() ?? 0) == 1;
}
}
catch
{
// 손상/파싱 실패 → 기본값(차단 없음) 반환
}
return result;
}
public static void Save(RecipeLaserInterlock value)
{
string path = GetActiveFilePath();
if (string.IsNullOrEmpty(path))
{
return;
}
var root = new JObject
{
[WrapperKey] = new JObject
{
["Front"] = value.Front ? 1 : 0,
["Down"] = value.Down ? 1 : 0,
}
};
File.WriteAllText(path, root.ToString(Formatting.Indented));
}
}
}

View File

@ -98,6 +98,7 @@
<RowDefinition Height="auto"></RowDefinition> <RowDefinition Height="auto"></RowDefinition>
<RowDefinition Height="auto"></RowDefinition> <RowDefinition Height="auto"></RowDefinition>
<RowDefinition Height="auto"></RowDefinition> <RowDefinition Height="auto"></RowDefinition>
<RowDefinition Height="auto"></RowDefinition>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"></ColumnDefinition> <ColumnDefinition Width="1*"></ColumnDefinition>
@ -158,6 +159,15 @@
<Button Grid.Column="1" Margin="3" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Height="auto" FontWeight="Bold" Style="{StaticResource StButtonAccentPurple}" Command="{Binding CMD_StopTeachingRun}" Content="Stop" IsEnabled="{Binding IsSequenceRunning_TeachingRun}"/> <Button Grid.Column="1" Margin="3" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Height="auto" FontWeight="Bold" Style="{StaticResource StButtonAccentPurple}" Command="{Binding CMD_StopTeachingRun}" Content="Stop" IsEnabled="{Binding IsSequenceRunning_TeachingRun}"/>
</Grid> </Grid>
<GroupBox Header="Laser Interlock" Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="2" Margin="0 5 0 0" Style="{StaticResource StGroupBox4th}">
<StackPanel>
<CheckBox Margin="6 4" FontSize="12" FontWeight="Bold" Foreground="#FFBBBBBB"
Content="FRONT INTERLOCK" IsChecked="{Binding IsFrontInterlock}"/>
<CheckBox Margin="6 4" FontSize="12" FontWeight="Bold" Foreground="#FFBBBBBB"
Content="DOWN INTERLOCK" IsChecked="{Binding IsDownInterlock}"/>
</StackPanel>
</GroupBox>
</Grid> </Grid>
</GroupBox> </GroupBox>

View File

@ -82,6 +82,40 @@ namespace WeldingCommon.Views
private bool _IsObjectSelectAll; private bool _IsObjectSelectAll;
public bool IsObjectSelectAll { get => _IsObjectSelectAll; set => SetProperty(ref _IsObjectSelectAll, value); } public bool IsObjectSelectAll { get => _IsObjectSelectAll; set => SetProperty(ref _IsObjectSelectAll, value); }
// 레시피별 Laser 방향 발진 차단 체크 (Recipe_Laser_Interlock.json) — 변경 시 즉시 활성 레시피에 저장
private bool _IsFrontInterlock;
public bool IsFrontInterlock
{
get => _IsFrontInterlock;
set { SetProperty(ref _IsFrontInterlock, value); SaveLaserInterlock(); }
}
private bool _IsDownInterlock;
public bool IsDownInterlock
{
get => _IsDownInterlock;
set { SetProperty(ref _IsDownInterlock, value); SaveLaserInterlock(); }
}
// 로드로 인한 set 시 저장 억제(레시피 전환 시 stale 덮어쓰기 방지)
private bool _suppressInterlockSave;
private void SaveLaserInterlock()
{
if (_suppressInterlockSave) {
return;
}
RecipeLaserInterlock.Save(new RecipeLaserInterlock { Front = _IsFrontInterlock, Down = _IsDownInterlock });
}
private void LoadLaserInterlock()
{
var rl = RecipeLaserInterlock.Load();
_suppressInterlockSave = true;
try {
IsFrontInterlock = rl.Front;
IsDownInterlock = rl.Down;
} finally {
_suppressInterlockSave = false;
}
}
// ScoutNormal 행 전환 sleep 분리 (같은 행 / 행 전환) // ScoutNormal 행 전환 sleep 분리 (같은 행 / 행 전환)
private int _ScoutSameRowSleepMs; private int _ScoutSameRowSleepMs;
public int ScoutSameRowSleepMs public int ScoutSameRowSleepMs
@ -135,6 +169,8 @@ namespace WeldingCommon.Views
InitVariable(); InitVariable();
LoadLaserInterlock();
CMD_VisibleChanged = new DelegateCommand(VisibleChanged); CMD_VisibleChanged = new DelegateCommand(VisibleChanged);
CMD_RunScoutNormal = new DelegateCommand(PerformCmdRunScoutNormal); CMD_RunScoutNormal = new DelegateCommand(PerformCmdRunScoutNormal);
@ -154,6 +190,14 @@ namespace WeldingCommon.Views
.ObserveOnDispatcher() .ObserveOnDispatcher()
.Subscribe(OnSettingReloaded); .Subscribe(OnSettingReloaded);
// 레시피 전환 시 체크 상태를 새 레시피의 json 값으로 갱신.
// SYS_RECIPE_ACTIVATED(1-홉)에서 WorkRecipeFile은 아직 미갱신(ReloadRecipe는 2-홉 뒤) →
// Background 우선순위로 지연 실행해 ReloadRecipe(Normal) 완료 후 새 경로로 로드.
_Aurora.EventObservable.AsObservable(AuroraSysEvent.SYS_RECIPE_ACTIVATED)
.ObserveOnDispatcher()
.Subscribe(_ => Application.Current.Dispatcher.BeginInvoke(
new Action(LoadLaserInterlock), DispatcherPriority.Background));
_MonitorTimer = new DispatcherTimer(); _MonitorTimer = new DispatcherTimer();
_MonitorTimer.Tick += OnMonitoring; _MonitorTimer.Tick += OnMonitoring;
_MonitorTimer.Interval = TimeSpan.FromMilliseconds(100); _MonitorTimer.Interval = TimeSpan.FromMilliseconds(100);
@ -182,6 +226,7 @@ namespace WeldingCommon.Views
{ {
bool visible = (bool)obj; bool visible = (bool)obj;
if (visible) { if (visible) {
LoadLaserInterlock(); // 패널 표시 시 현재 활성 레시피의 체크값 반영
_MonitorTimer.Start(); _MonitorTimer.Start();
} else { } else {
_MonitorTimer.Stop(); _MonitorTimer.Stop();

View File

@ -253,6 +253,7 @@
<DependentUpon>SequenceDialog.xaml</DependentUpon> <DependentUpon>SequenceDialog.xaml</DependentUpon>
</Compile> </Compile>
<Compile Include="Settings\LaserPowerCalParam.cs" /> <Compile Include="Settings\LaserPowerCalParam.cs" />
<Compile Include="Settings\RecipeLaserInterlock.cs" />
<Compile Include="Settings\SequenceSetting.cs" /> <Compile Include="Settings\SequenceSetting.cs" />
<Compile Include="Settings\SettingRmsDataList.cs" /> <Compile Include="Settings\SettingRmsDataList.cs" />
<Compile Include="Settings\SettingUploadDataList.cs" /> <Compile Include="Settings\SettingUploadDataList.cs" />