Compare commits

...

2 Commits

Author SHA1 Message Date
seuuung a0f3bdf571 4251 센서 보드 복구 처리 및 오류 분석 보고서 추가 1 week ago
seuuung 78af1a184a 오버로드 개별 처리 및 debug 진단 로그 추가 1 week ago
  1. 20
      docs/knowledge_base.md
  2. 158
      docs/reports/계측기_저항_오버플로우_오류_분석_보고서.md
  3. 72
      marking_gui/MainWindow.xaml.cs
  4. 27
      marking_gui/Models/EquipmentMeasurementResult.cs
  5. 119
      marking_gui/Services/Board4251Service.cs
  6. 27
      marking_gui/Services/CommunicationBase.cs
  7. 39
      marking_gui/Services/DatabaseService.cs
  8. 90
      marking_gui/Services/EquipmentMeasurementService.cs
  9. 62
      marking_gui/Services/LoggerService.cs
  10. 6
      sql/database_setup.sql
  11. 19
      tests/DiagnosticsRegression/DiagnosticsRegression.csproj
  12. 268
      tests/DiagnosticsRegression/Program.cs
  13. 11
      tests/DiagnosticsRegression/README.md

20
docs/knowledge_base.md

@ -44,6 +44,14 @@
```
- **ADO.NET StoredProcedure 연동:** `SqlCommand.CommandType = CommandType.StoredProcedure` 설정을 사용하여 `@Operator`, `@Password`, 그리고 선택적으로 `@ProcessName` 파라미터를 매핑하여 계정 검증 프로시저를 안전하게 가동합니다.
### 2.3. 항목별 Overload 처리 및 NG 미저장
- 전압·전류·저항 응답의 `+9.90000000E+37` 등 절댓값 `9.0e37` 이상인 유한 숫자와 `OL`, `O.L`, `OVERLOAD`, `OVLD` 문자열은 해당 항목의 Overload로 처리합니다.
- 해당 항목만 표시·저장 단위의 `999`로 대체하고 다른 항목의 측정과 화면 갱신을 계속합니다. `999`는 전류/저항 단위 변환으로 확대·축소하지 않습니다. 정상 응답 `999 Ohm`은 기존대로 `0.999 kOhm`으로 처리합니다.
- `EquipmentMeasurementResult`의 항목별 Overload 플래그로 NG를 확정합니다. Overload가 없는 정상 측정값은 기존 범위 판정을 적용합니다. 빈 응답·잘못된 문자열·실제 통신 오류는 별도 오류로 남기며 측정값 전체를 0으로 대체하지 않습니다.
- Overload NG도 일반 성능 NG와 동일한 경로로 처리합니다. 화면에는 해당 항목 `999`와 나머지 실측값을 유지하고, 각인·QR 스캔·DB 저장을 생략한 뒤 다음 검사 대기로 복귀합니다. 새 각인 순번을 소비하지 않습니다.
- DB 저장은 QR 검증까지 합격한 OK에만 허용합니다. DatabaseService도 NG를 SQL 연결·변환 전에 차단하므로 기존 OK 레코드는 NG 재검사로 덮어쓰지 않습니다. 현장 스키마의 세 계측 컬럼/SQL 매개변수는 `DECIMAL(5,2)`를 유지합니다.
- Overload 원문과 전체 계측 순서는 `debug` 폴더의 파일에 기록합니다. 이 진단 로그는 DB 결과 저장과 별개로 유지합니다.
---
## 3. 유지보수 및 릴리즈 가이드 (Release & Maintenance)
@ -91,8 +99,17 @@
- `Sensor.ini` (4251 센서 통신 보드 COM 포트/속도/채널)
- `libs/` (LMC 레이저 각인 드라이버 격리 디렉터리: `MarkEzdWrapper.dll` 등)
- `logs/` (런타임 가동 시 일자별 자동 생성되는 시스템/각인/통신 이력 로그 폴더)
- `debug/` (오류 발생 시 자동 생성되는 오류별 진단 파일 폴더)
- `publish_backup/` (빌드 배포 시 이전 릴리즈 자동 백업 폴더)
### 3.4. 오류 원인 추적용 진단 파일
- `LoggerService.Error` 호출 시 실행 파일 폴더 아래 `debug/error_yyyyMMdd_HHmmss_fff_<id>.log`를 UTF-8로 생성합니다. 폴더명과 파일명은 영문·숫자를 사용하고 로그 본문의 한글은 유지합니다. 일별 로그에는 해당 파일 경로를 기록합니다.
- 파일에는 발생 시각과 시간대, 프로그램 버전, 실행 환경, 예외 종류·내부 예외·스택 추적, 직전 로그 최대 100건을 기록합니다. 설정 파일 전체나 DB 비밀번호는 진단 정보로 수집하지 않습니다.
- 계측 오류에는 장비 주소·포트, 타임아웃·안정화 시간, 송신 명령, 수신 응답 원문, 전원 차단 명령 전송 결과를 추가합니다. 메인 화면의 계측 오류 기록에는 IC_SN·Lot_No·도안 경로도 포함됩니다.
- 일별 로그는 기존 30일 보관 정책을 유지하며, 오류별 진단 파일은 자동 삭제하지 않습니다. 폴더가 삭제되면 다음 기록 시 재생성합니다. 한쪽 저장에 실패해도 다른 쪽 기록을 시도하며, 진단 파일 저장 실패는 일별 로그에 남깁니다.
- 현장 오류 분석 시 발생 시각에 해당하는 진단 파일과 일별 로그를 함께 확인합니다. 이 기능은 원인 추적용이며 계측 판정·마킹·DB 저장 정책을 변경하지 않습니다.
- 실장비 없는 회귀 검증: `dotnet run --project tests/DiagnosticsRegression/DiagnosticsRegression.csproj`
---
## 4. 데이터베이스 구성 가이드 (Database Configuration)
@ -110,7 +127,8 @@
- 고유 QR 코드 문자열 `Marking_QR` (NVARCHAR(60)): `IC_SN;도면품번;HKMC품번;각인번호;` 포맷으로 구성되며, 60자 초과 시 절삭합니다.
- 공정 정보(`Maker`, `Model`, `Variant_1`, `Variant_2`, `Operator`, `Production_Date`, `Line`, `Lot_No`, `Jig_No`) 및 최종 판정(`Result`)이 통합 적재됩니다. 각 문자열 컬럼은 최대 10자로 초과 시 절삭 처리됩니다.
- 3단계에 걸친 전압/전류/저항 컬럼(`PT_Vol_1~3`, `PT_Current_1~3`, `PT_Resistance_1~3`)이 존재하며, 현재는 1번 컬럼들에 최종 계측 수치(소수점 2~3자리 반올림)를 저장하고 2, 3번 컬럼은 `0.00`으로 기본값을 보정하여 저장합니다.
- `Result` 컬럼에는 최종 합격된 **`OK`** 레코드만 저장됩니다. (계측 불합격 또는 QR 검증 실패 등의 **`NG`**인 경우, 각인 순번이 불필요하게 카운트되는 것을 방지하기 위해 DB에 적재하지 않고 저장을 건너뜁니다.)
- `PT_Vol_1`, `PT_Current_1`, `PT_Resistance_1`의 현장 컬럼 규격은 모두 `DECIMAL(5,2)`이며 Overload 대체값 `999.00`을 수용합니다.
- `Result` 컬럼에는 QR 검증까지 최종 합격된 **`OK`** 레코드만 저장합니다. Overload NG, 일반 규격 이탈 NG, QR 검증 실패는 저장하지 않으며 기존 레코드도 변경하지 않습니다. 과거에 생성된 `NG:<IC_SN>` QR은 순번 계산에서 제외합니다.
- `Lot_No` 컬럼은 `YYMMDDLL` 형식(예: `26080401`)으로 입력되며 최대 **10자**까지 허용됩니다.
### 4.2. 로그인 검증 저장 프로시저 ([CheckOperator])

158
docs/reports/계측기_저항_오버플로우_오류_분석_보고서.md

@ -0,0 +1,158 @@
# [기술 보고서] 계측기 저항 측정 수치 변환 오류 분석 및 개선 방안
---
## 1. 개요 (Overview)
| 항목 | 내용 |
| :--- | :--- |
| **보고서 제목** | 계측기 저항 측정 시 수치 변환 실패(`+9.90000000E+37`) 오류 원인 분석 및 개선안 |
| **발생 시스템** | 양산형 레이저 마킹 & 성능 검사 모니터링 시스템 (`marking_gui`) |
| **관련 장비** | Digital Multimeter (Keysight/Agilent 34465A 등) - 저항 측정 계측기 |
| **심각도** | **높음 (Major)** — 단선 제품 또는 프로브 미접촉 시 검사 프로세스 중단 및 시스템 오류 발생 |
| **작성 일자** | 2026년 9월 21일 |
---
## 2. 현상 및 에러 상세 (Issue Description)
### 2.1. 에러 메시지
```text
[시스템 오류]
계측기 오류: [RESISTANCE] 수치 변환 실패 (값: '+9.90000000E+37'). 원본 응답: [+9.90000000E+37]
```
### 2.2. 발생 상황
- 생산 라인 검사 시퀀스 실행 중, 전압 측정 후 저항 측정 단계(`MEAS:RES?`)에서 발생
- 오류 발생 즉시 검사 시퀀스가 비정상 중단되고 시스템 오류 팝업 및 에러 배너가 출력됨
- 검사 결과 판정(PASS/NG) 및 각인, 데이터베이스 저장이 진행되지 않고 정지됨
---
## 3. 근본 원인 분석 (Root Cause Analysis)
본 장애는 **계측기 하드웨어 통신 규격(SCPI)**과 **애플리케이션 데이터 타입(`decimal`) 한계**의 불일치로 인해 발생한 문제입니다.
```mermaid
flowchart TD
A["저항 측정 지그 접촉 불량 또는 제품 단선 (Open)"] --> B["계측기(DMM): Overload (OL) 감지"]
B --> C["SCPI 규격 응답 전송: '+9.90000000E+37'"]
C --> D["애플리케이션: Regex로 '+9.90000000E+37' 추출"]
D --> E{"decimal.TryParse() 시도"}
E -- "decimal 최대치(~7.9e28) 초과" --> F["Overflow / 파싱 실패 (false)"]
F --> G["InvalidOperationException 발생"]
G --> H["검사 중단 및 '시스템 오류' 팝업 표출"]
```
### 3.1. 계측기 하드웨어/통신 관점 (SCPI 규격 특성)
* **표준 SCPI Overload 응답:** Keysight 및 IEEE 488.2 호환 DMM 장비는 저항 측정 시 프로브가 분리되어 있거나, 제품 내부가 단선(Open Circuit)되어 저항값이 계측 가능 범위를 초과하는 경우 **`+9.90000000E+37`** ($9.9 \times 10^{37}$)을 반환하도록 규격화되어 있습니다.
* 즉, 이 값은 **통신 장애나 계측기 고장이 아니라 제품의 단선(Open) 또는 지그 미접촉(Overload) 상태**를 나타내는 정상적인 계측 응답입니다.
### 3.2. 애플리케이션 코드 관점 (자료형 오버플로우)
* 문제 발생 코드 위치: `marking_gui/Services/EquipmentMeasurementService.cs` 내부 `ExtractFirstDecimal` 메서드
```csharp
// EquipmentMeasurementService.cs (Line 398~411)
private static decimal ExtractFirstDecimal(string response, string label)
{
if (string.IsNullOrWhiteSpace(response))
throw new InvalidOperationException($"[{label}] 계측기 응답이 비어있습니다 (응답 수신 실패).");
var match = Regex.Match(response, @"[-+]?\d+(?:\.\d+)?(?:[Ee][-+]?\d+)?");
if (!match.Success)
throw new InvalidOperationException($"[{label}] 수치 파싱 실패. 원본 응답: [{response.Trim()}]");
// [문제 지점] decimal 타입으로 파싱 시도
if (!decimal.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal val))
throw new InvalidOperationException($"[{label}] 수치 변환 실패 (값: '{match.Value}'). 원본 응답: [{response.Trim()}]");
return val;
}
```
* **자료형 한계 비교:**
| 자료형 | 최소값 ~ 최대값 범위 | 9.9e37 수용 여부 |
| :--- | :--- | :---: |
| **`decimal`** (현재 적용) | 약 $-7.9 \times 10^{28} \sim +7.9 \times 10^{28}$ | **불가 (Overflow)** |
| **`double`** (권장) | 약 $\pm 5.0 \times 10^{-324} \sim \pm 1.7 \times 10^{308}$ | **가능 (정상 수용)** |
* 계측기 응답값 $9.9 \times 10^{37}$은 `decimal` 최대 표현 한계($7.9 \times 10^{28}$)를 초과하므로 `decimal.TryParse()`가 `false`를 반환하며 `InvalidOperationException`을 발생시킵니다.
### 3.3. 비즈니스 로직 관점의 문제점
* 제품이 단선된 불량품인 경우, 시스템은 이를 **불합격(NG)**으로 정상 판정하고 시퀀스를 마무리해야 합니다.
* 현재 구현은 불량 제품이 인입되었을 때 계측기 통신 에러로 인식하여 전체 공정이 정지되는 구조적 결함을 안고 있습니다.
---
## 4. 영향도 및 리스크 평가 (Impact & Risk)
1. **생산 라인 지연 및 정지 (Line Stoppage):**
- 접촉이 불안정하거나 단선 불량품이 유입될 때마다 작업자가 에러 창을 확인하고 수동 리셋해야 하므로 공정 지연이 발생합니다.
2. **불량품 판정 및 이력 누락:**
- 예외가 발생하여 `SaveAndFinalizeAsync`가 호출되지 못하므로, 해당 불량 시료의 검사 데이터가 데이터베이스에 남지 않습니다.
---
## 5. 개선 및 해결 방안 (Action Plan)
### 5.1. 수치 파싱 로직 개선 (`double` 기반 전환)
- `ExtractFirstDecimal`의 반환 타입 및 파싱 로직을 `decimal`에서 `double`로 변경하여 $10^{37}$ 스케일의 부동소수점 값을 에러 없이 파싱합니다.
```csharp
private static double ExtractFirstNumber(string response, string label)
{
if (string.IsNullOrWhiteSpace(response))
throw new InvalidOperationException($"[{label}] 계측기 응답이 비어있습니다 (응답 수신 실패).");
var match = Regex.Match(response, @"[-+]?\d+(?:\.\d+)?(?:[Ee][-+]?\d+)?");
if (!match.Success)
throw new InvalidOperationException($"[{label}] 수치 파싱 실패. 원본 응답: [{response.Trim()}]");
if (!double.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double val))
throw new InvalidOperationException($"[{label}] 수치 변환 실패 (값: '{match.Value}'). 원본 응답: [{response.Trim()}]");
return val;
}
```
### 5.2. Overload (OL / 단선) 상태 식별 및 비즈니스 로직 연계
- 계측기 반환값이 $9.0 \times 10^{37}$ 이상일 경우 오버로드(OL, 단선) 상태로 감지합니다.
- **판정 연계:**
- 저항값을 규격 상한(예: 13 k$\Omega$)을 크게 상회하는 값(예: `99999.0` k$\Omega$)으로 처리하여 자연스러운 **저항 불합격(NG)** 판정을 유도합니다.
- **UI 표출 연계:**
- GUI 저항 표시창(`txtResistance`)에 지수 형태 대신 직관적인 **`O.L`** 또는 규격 상한 초과 수치를 표시합니다.
```csharp
// 저항 측정 처리 예시
double rawResistance = ExtractFirstNumber(rResponse, "RESISTANCE");
double resistance;
if (rawResistance >= 9.0e37)
{
// 오버로드 (단선 / 오픈) 상태 감지
resistance = 99999.0; // 상한 초과 값 부여 -> 자동 NG 판정 유도
LoggerService.Warn("[Equipment] 저항 계측 Overload 감지 (단선/프로브 미접촉 상태)");
}
else
{
resistance = rawResistance / 1000.0; // Ω -> kΩ 정상 변환
}
```
---
## 6. 검증 계획 (Verification Plan)
| 검증 단계 | 검증 시나리오 | 예상 결과 |
| :--- | :--- | :--- |
| **1. 파서 단위 검증** | `+9.90000000E+37` 문자열을 파서에 입력 | 예외 없이 OL 값(99999.0)으로 안전하게 변환 확인 |
| **2. 오픈 상태 검증** | 지그에 제품을 장착하지 않고(프로브 개방) 검사 시작 버튼 클릭 | 시스템 오류 팝업 없이 저항 화면에 `O.L` 표시 및 최종 **`NG`** 판정 확인 |
| **3. 정상 상태 검증** | 정상 저항 규격(8.0 ~ 13.0 k$\Omega$) 제품 장착 후 검사 시작 | 기존과 동일하게 실제 측정 수치 표시 및 **`OK`** 판정 확인 |
| **4. DB 적재 검증** | 단선(NG) 발생 시 시퀀스 종료 후 DB 및 UI 상태 점검 | 시스템 멈춤 없이 다음 검사 대기 상태로 정상 복귀 확인 |
---
## 7. 결론 및 건의사항
본 오류는 계측기의 하드웨어 고장이 아니라 **단선/오버로드 규격치 수신 시의 예외 처리 누락**에서 기인한 것입니다.\
제안된 파싱 타입 변경 및 오버로드 핸들링 로직을 적용하면 단선 불량 발생 시에도 시스템 멈춤 없이 안정적으로 불합격 판정 및 공정을 지속할 수 있습니다.

72
marking_gui/MainWindow.xaml.cs

@ -601,6 +601,23 @@ namespace marking_gui
}
});
}
// 3. 4251 제품 ID 센서 보드 채널 OFF 및 하드웨어 리셋
if (_sensorReader != null)
{
await System.Threading.Tasks.Task.Run(async () =>
{
try
{
await _sensorReader.ResetAsync();
LoggerService.Info("[장비 리셋] 4251 센서 보드 채널 OFF 및 하드웨어 리셋 완료");
}
catch (Exception ex)
{
LoggerService.Warn($"[장비 리셋] 4251 센서 복구 예외: {ex.Message}");
}
});
}
}
catch (Exception ex)
{
@ -859,6 +876,15 @@ namespace marking_gui
{
LoggerService.Warn("제품 ID(IC_SN)를 획득하지 못해 검사를 취소했습니다.");
// 센서 하드웨어 상태 리셋 보장 (다음 시료 측정 시 고착 방지)
if (_sensorReader != null)
{
_ = System.Threading.Tasks.Task.Run(async () =>
{
try { await _sensorReader.ResetAsync(); } catch { }
});
}
// IC_SN 읽기 실패 시 버튼 원복 후 종료
SetStartButtonReady();
@ -1120,7 +1146,7 @@ namespace marking_gui
LoggerService.Warn($"[재작업 감지] IC S/N ({serial})가 이미 DB에 존재합니다. 작업자 확인 팝업 표시");
MessageBoxResult reworkChoice = MessageBox.Show(
string.Format("입력된 시리얼 번호({0})는 이미 검사 및 각인 이력이 존재하는 재작업 제품입니다.\n\n재작업 각인 및 검사를 계속 진행하시겠습니까?", serial),
string.Format("입력된 시리얼 번호({0})는 이미 검사 이력이 존재하는 재작업 제품입니다.\n\n재검사 및 합격 시 각인을 계속 진행하시겠습니까?", serial),
"재작업 확인",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
@ -1183,11 +1209,12 @@ namespace marking_gui
double finalVoltage = 0.0;
double finalCurrent = 0.0;
double finalResistance = 0.0;
EquipmentMeasurementResult measurement = null;
try
{
token.ThrowIfCancellationRequested();
var measurement = await ReadMeasurementFromDeviceAsync(token);
measurement = await ReadMeasurementFromDeviceAsync(token);
token.ThrowIfCancellationRequested(); // 계측 완료 후에도 즉시 취소 확인
finalVoltage = measurement.Voltage;
finalCurrent = measurement.Current;
@ -1224,9 +1251,9 @@ namespace marking_gui
txtResistance.Text = finalResistance.ToString("00.000");
// 합/불 판정
bool isVoltageOk = finalVoltage >= _limits.MinVoltage && finalVoltage <= _limits.MaxVoltage;
bool isCurrentOk = finalCurrent >= _limits.MinCurrent && finalCurrent <= _limits.MaxCurrent;
bool isResistanceOk = finalResistance >= _limits.MinResistance && finalResistance <= _limits.MaxResistance;
bool isVoltageOk = !measurement.VoltageOverloaded && finalVoltage >= _limits.MinVoltage && finalVoltage <= _limits.MaxVoltage;
bool isCurrentOk = !measurement.CurrentOverloaded && finalCurrent >= _limits.MinCurrent && finalCurrent <= _limits.MaxCurrent;
bool isResistanceOk = !measurement.ResistanceOverloaded && finalResistance >= _limits.MinResistance && finalResistance <= _limits.MaxResistance;
bool isFinalPass = isVoltageOk && isCurrentOk && isResistanceOk;
// 측정값 색상 표시 (통과 시 검은색, 실패 시 빨간색)
@ -1534,24 +1561,25 @@ namespace marking_gui
}
else
{
// 불합격인 경우 각인 및 DB 저장 생략 (순번 카운트 제외)
// Overload를 포함한 모든 성능 NG는 각인 및 DB 저장을 생략합니다.
string markedSerial = GetCurrentMarkedSerial();
string drawingNo = _markingConfig.GetDrawingNoForEzd(_currentEzdFile);
string hkmcNo = _markingConfig.GetHkmcNoForEzd(_currentEzdFile);
await SaveAndFinalizeAsync(serial, finalVoltage, finalCurrent, finalResistance, isFinalPass, "NG", markedSerial, drawingNo, hkmcNo, bypassPrevSteps: bypassPrevSteps);
await SaveAndFinalizeAsync(serial, finalVoltage, finalCurrent, finalResistance, isFinalPass, "NG", markedSerial, drawingNo, hkmcNo,
bypassPrevSteps: bypassPrevSteps);
}
}
}
private async Task<(double Voltage, double Current, double Resistance)> ReadMeasurementFromDeviceAsync(CancellationToken token = default)
private async Task<EquipmentMeasurementResult> ReadMeasurementFromDeviceAsync(CancellationToken token = default)
{
try
{
token.ThrowIfCancellationRequested();
// EquipmentMeasurementService 를 통해 실시간 계측값을 획득하는 즉시 GUI에 반영
var (voltage, current, resistance) = await _equipment.MeasureAsync((v, c, r) =>
var measurement = await _equipment.MeasureAsync((v, c, r) =>
{
Dispatcher.Invoke(() =>
{
@ -1571,7 +1599,7 @@ namespace marking_gui
});
});
return (voltage, current, resistance);
return measurement;
}
catch (OperationCanceledException)
{
@ -1580,21 +1608,10 @@ namespace marking_gui
}
catch (Exception ex)
{
LoggerService.Error("[계측기] 측정 중 오류 발생", ex);
// 장비 오류 시 UI에 오류 메시지 표시 후 기본값 반환
Dispatcher.Invoke(() =>
{
txtSystemStatus.Text = "계측기 오류: " + ex.Message;
txtSystemStatus.Foreground = new SolidColorBrush(Color.FromRgb(239, 68, 68));
if (txtErrorMessage != null)
{
txtErrorMessage.Text = "계측기 오류: " + ex.Message;
if (borderErrorBanner != null)
borderErrorBanner.Visibility = System.Windows.Visibility.Visible;
}
});
return (0.00, 0.00, 0.00);
LoggerService.Error("[계측기] 측정 중 오류 발생", ex,
$"IC_SN={txtSerial.Text}, Lot_No={_loginInfo?.LotNo}, 도안={_currentEzdFile}");
// 실제 통신/파싱 오류는 호출자가 처리하고, 이미 표시한 정상 계측값은 유지합니다.
throw;
}
}
@ -1629,7 +1646,7 @@ namespace marking_gui
return string.Format("{0}{1:D4}", lotNo, seq);
}
private async Task SaveAndFinalizeAsync(string serial, double voltage, double current, double resistance, bool isFinalPass, string result, string markedSerial, string drawingNo = "", string hkmcNo = "", bool bypassPrevSteps = false)
private async Task SaveAndFinalizeAsync(string serial, double voltage, double current, double resistance, bool isFinalPass, string result, string markedSerial, string drawingNo = "", string hkmcNo = "", bool bypassPrevSteps = false)
{
// 종료 시간 기록 및 UI 갱신
_testEndTime = DateTime.Now;
@ -1639,7 +1656,6 @@ namespace marking_gui
if (!isOk)
{
// === [NG 결과 처리 - DB 미저장 (순번 카운트 제외)] ===
LoggerService.Warn($"[검사 NG] S/N: {serial}, 결과: NG (isFinalPass: {isFinalPass}, result: {result}) -> DB 저장 생략 (순번 카운트 제외)");
txtFinalResult.Text = "NG";
@ -1677,7 +1693,7 @@ namespace marking_gui
return;
}
// === [OK 결과 처리 - DB 저장 (순번 카운트)] ===
// QR 검증까지 합격한 OK 결과만 저장합니다.
if (txtProgressStatus != null)
{
txtProgressStatus.Text = "결과 데이터베이스 저장 중...";

27
marking_gui/Models/EquipmentMeasurementResult.cs

@ -0,0 +1,27 @@
namespace marking_gui.Models
{
public sealed class EquipmentMeasurementResult
{
// Overload is stored in display units, so A/mA and Ohm/kOhm conversion must not scale it.
public const double OverloadValue = 999.0;
public double Voltage { get; }
public double Current { get; }
public double Resistance { get; }
public bool VoltageOverloaded { get; }
public bool CurrentOverloaded { get; }
public bool ResistanceOverloaded { get; }
public bool HasOverload => VoltageOverloaded || CurrentOverloaded || ResistanceOverloaded;
public EquipmentMeasurementResult(double voltage, double current, double resistance,
bool voltageOverloaded = false, bool currentOverloaded = false, bool resistanceOverloaded = false)
{
Voltage = voltage;
Current = current;
Resistance = resistance;
VoltageOverloaded = voltageOverloaded;
CurrentOverloaded = currentOverloaded;
ResistanceOverloaded = resistanceOverloaded;
}
}
}

119
marking_gui/Services/Board4251Service.cs

@ -33,6 +33,7 @@ namespace marking_gui.Services
bool Connect();
void Disconnect();
Task<SensorIdData> ReadSensorAsync();
Task ResetAsync();
}
/// <summary>
@ -227,6 +228,14 @@ namespace marking_gui.Services
if (currentData.Contains("<end>"))
{
targetTcs.TrySetResult(currentData.Trim());
return;
}
// 실패 응답(Fail/Error) 감지 시 <end>가 없더라도 타임아웃 5초 대기 없이 즉시 완료 반환
if (currentData.Contains("Fail", StringComparison.OrdinalIgnoreCase) ||
currentData.Contains("Error", StringComparison.OrdinalIgnoreCase))
{
targetTcs.TrySetResult(currentData.Trim());
return;
}
return;
}
@ -238,6 +247,33 @@ namespace marking_gui.Services
}
}
/// <summary>
/// 4251 보드의 채널을 안전하게 OFF(x00o)하고 시리얼 포트 버퍼 및 통신 신호를 리셋합니다.
/// </summary>
public async Task ResetBoardAsync()
{
try
{
// 보드에 OFF 커맨드 전송 시도
await SendCommandAsync("x00o\r\n");
}
catch { }
try
{
// 시리얼 버퍼 플러시 및 하드웨어 DTR/RTS 리셋 펄스 인가
_communication.ResetConnection();
lock (_bufferLock)
{
_receiveBuffer.Clear();
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Board4251] ResetBoardAsync 예외: {ex.Message}");
}
}
public void Dispose()
{
try
@ -278,14 +314,34 @@ namespace marking_gui.Services
public bool Connect() => _service.Connect();
public void Disconnect() => _service.Disconnect();
public async Task<SensorIdData> ReadSensorAsync()
public async Task ResetAsync()
{
try
{
int channel = _sensorIndex + 1;
await _service.SendCommandAsync("x00o\r\n");
await Task.Delay(100);
string calCmd = $"x00c_00{channel}001:owt28006727ea97c7801\r\n";
await _service.SendCommandAsync(calCmd);
await Task.Delay(100);
await _service.ResetBoardAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[Board4251SensorService ResetAsync] {ex.Message}");
}
}
public async Task<SensorIdData> ReadSensorAsync()
{
int channel = _sensorIndex + 1;
string rawId = null;
try
{
ProgressMessage?.Invoke(this, "4251 보드 초기화 중...");
_ = _service.SendCommandAsync("x00o\r\n");
await Task.Delay(350);
await _service.SendCommandAsync("x00o\r\n");
await Task.Delay(200);
ProgressMessage?.Invoke(this, "4251 보드 데이터 읽는 중...");
int extendedTimeout = 15000;
@ -316,7 +372,6 @@ namespace marking_gui.Services
// ID 읽기 (최대 3회 시도)
string idCmd = $"x00c_00{channel}101:ow2800326003e";
string rawId = null;
int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
@ -356,39 +411,57 @@ namespace marking_gui.Services
if (attempt < maxAttempts)
{
ProgressMessage?.Invoke(this, $"ID 읽기 재시도 중... ({attempt}/{maxAttempts})");
await Task.Delay(350);
await Task.Delay(200);
continue;
}
}
break;
}
// Step 7: 보드 OFF/후처리 (x00o)
ProgressMessage?.Invoke(this, "4251 보드 OFF/후처리 중...");
await _service.SendCommandAsync("x00o\r\n");
await Task.Delay(350);
// Step 8: 보드 CAL 기본값 (x00c_00{channel}001:owt28006727ea97c7801)
ProgressMessage?.Invoke(this, "4251 보드 CAL 기본값 설정 중...");
string calCmd = $"x00c_00{channel}001:owt28006727ea97c7801\r\n";
await _service.SendCommandAsync(calCmd);
await Task.Delay(350);
var data = new SensorIdData
if (!string.IsNullOrEmpty(rawId))
{
ID = rawId,
Serial = "",
Item = "N/A"
};
var data = new SensorIdData
{
ID = rawId,
Serial = "",
Item = "N/A"
};
ProgressMessage?.Invoke(this, "ID 읽기 성공: " + rawId);
return data;
ProgressMessage?.Invoke(this, "ID 읽기 성공: " + rawId);
return data;
}
return null;
}
catch (Exception ex)
{
ErrorMessage?.Invoke(this, $"4251 읽기 예외: {ex.Message}");
return null;
}
finally
{
// [중요] 성공/실패/타임아웃/예외 불문하고 보드 채널 OFF 및 CAL 기본값 복원을 반드시 실행
try
{
ProgressMessage?.Invoke(this, "4251 보드 채널 OFF/대기 복귀 중...");
await _service.SendCommandAsync("x00o\r\n");
await Task.Delay(150);
string calCmd = $"x00c_00{channel}001:owt28006727ea97c7801\r\n";
await _service.SendCommandAsync(calCmd);
await Task.Delay(150);
}
catch (Exception cleanupEx)
{
System.Diagnostics.Debug.WriteLine($"[Board4251] 후처리 리셋 중 예외: {cleanupEx.Message}");
}
// ID 읽기 실패 시, 하드웨어 버퍼 플러시 및 포트 리셋 수행하여 다음 시료 측정 시 고착 방지
if (string.IsNullOrEmpty(rawId))
{
await _service.ResetBoardAsync();
}
}
}
public void Dispose()

27
marking_gui/Services/CommunicationBase.cs

@ -28,6 +28,9 @@ namespace marking_gui.Services
/// <summary> 수신 버퍼를 비워 잔류 데이터를 제거함 </summary>
void ClearBuffer();
/// <summary> 통신 하드웨어 및 버퍼를 강제 리셋함 (DTR/RTS 토글 및 플러시) </summary>
void ResetConnection();
/// <summary> 하드웨어로부터 데이터를 수신했을 때 발생하는 이벤트 </summary>
event System.EventHandler<string> DataReceived;
@ -128,6 +131,30 @@ namespace marking_gui.Services
}
}
public void ResetConnection()
{
if (_disposed) return;
try
{
ClearBuffer();
if (_serialPort != null && _serialPort.IsOpen)
{
// DTR/RTS 신호 토글을 통한 4251 센서 보드 하드웨어 리셋 펄스 인가
_serialPort.DtrEnable = false;
_serialPort.RtsEnable = false;
System.Threading.Thread.Sleep(50);
_serialPort.DtrEnable = true;
_serialPort.RtsEnable = true;
ClearBuffer();
System.Diagnostics.Debug.WriteLine($"[SerialProvider] {Name} 포트 하드웨어 리셋(DTR/RTS 토글) 완료");
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[SerialProvider ResetConnection Error] {ex.Message}");
}
}
public void Dispose()
{
if (!_disposed)

39
marking_gui/Services/DatabaseService.cs

@ -269,7 +269,7 @@ namespace marking_gui.Services
/// <summary>
/// 최종 성능 검출 값을 데이터베이스에 저장합니다.
/// 전압과 전류 수치는 3,2 포맷(DECIMAL(3, 2) 등)에 대응하기 위해 소수점 이하 2자리로 반올림하여 처리합니다.
/// 세 계측값은 현장 테이블의 DECIMAL(5, 2)에 맞춰 저장합니다 (Overload: 999.00).
/// </summary>
public bool SaveProductResult(
string serial,
@ -292,10 +292,17 @@ namespace marking_gui.Services
string drawingNo = "",
string hkmcNo = "")
{
// 전압과 전류, 저항을 소수점 이하 2자리(3,2 포맷 규격)로 반올림 처리
decimal roundedVoltage = Math.Round((decimal)voltage, 2);
decimal roundedCurrent = Math.Round((decimal)current, 2);
decimal roundedResistance = Math.Round((decimal)resistance, 3);
// Overload NG follows the same no-save rule as every other NG, even for direct callers.
if (result != "PASS" && result != "OK")
{
LoggerService.Info($"[DB 저장 생략] S/N: {serial}, 결과: {result} (NG는 저장하지 않음)");
return false;
}
var measurementParameters = CreateMeasurementParameters(voltage, current, resistance);
decimal roundedVoltage = (decimal)measurementParameters[0].Value;
decimal roundedCurrent = (decimal)measurementParameters[1].Value;
decimal roundedResistance = (decimal)measurementParameters[2].Value;
// IC_SN 및 PCB_Barcode 컬럼 규격(NVARCHAR(30), NOT NULL) 방어 처리
if (string.IsNullOrWhiteSpace(serial))
@ -306,7 +313,7 @@ namespace marking_gui.Services
{
serial = serial.Substring(0, 30);
}
string safeResult = (result == "PASS" || result == "OK") ? "OK" : "NG";
string safeResult = "OK";
// DB 컬럼 길이 한계에 맞춰서 문자열 안전하게 자르기 (Truncate)
string safeMaker = string.IsNullOrEmpty(maker) ? "" : (maker.Length > 10 ? maker.Substring(0, 10) : maker);
@ -329,7 +336,7 @@ namespace marking_gui.Services
// 오프라인 모드인 경우 실제 DB 저장을 생략하고 정상 통과 처리
if (_offlineMode)
{
if (!string.IsNullOrWhiteSpace(safeLotNo) && !string.IsNullOrWhiteSpace(markedSerial) && markedSerial.Length >= 4)
if (safeResult == "OK" && !string.IsNullOrWhiteSpace(safeLotNo) && !string.IsNullOrWhiteSpace(markedSerial) && markedSerial.Length >= 4)
{
string seqStr = markedSerial.Substring(markedSerial.Length - 4);
if (int.TryParse(seqStr, out int seqNum))
@ -396,9 +403,7 @@ namespace marking_gui.Services
cmd.Parameters.AddWithValue("@LotNo", safeLotNo);
cmd.Parameters.AddWithValue("@JigNo", safeJigNo);
cmd.Parameters.Add(new System.Data.SqlClient.SqlParameter("@Vol1", System.Data.SqlDbType.Decimal) { Precision = 4, Scale = 2, Value = roundedVoltage });
cmd.Parameters.Add(new System.Data.SqlClient.SqlParameter("@Cur1", System.Data.SqlDbType.Decimal) { Precision = 4, Scale = 2, Value = roundedCurrent });
cmd.Parameters.Add(new System.Data.SqlClient.SqlParameter("@Res1", System.Data.SqlDbType.Decimal) { Precision = 5, Scale = 3, Value = roundedResistance });
cmd.Parameters.AddRange(measurementParameters);
cmd.Parameters.AddWithValue("@Result", safeResult);
conn.Open();
@ -420,6 +425,17 @@ namespace marking_gui.Services
throw; // 예외 원인을 상위로 전파하여 UI 상태창에 구체적인 오류 원인(ex.Message)이 그대로 노출되도록 함
}
}
internal static System.Data.SqlClient.SqlParameter[] CreateMeasurementParameters(double voltage, double current, double resistance)
{
return new[]
{
new System.Data.SqlClient.SqlParameter("@Vol1", System.Data.SqlDbType.Decimal) { Precision = 5, Scale = 2, Value = Math.Round((decimal)voltage, 2) },
new System.Data.SqlClient.SqlParameter("@Cur1", System.Data.SqlDbType.Decimal) { Precision = 5, Scale = 2, Value = Math.Round((decimal)current, 2) },
new System.Data.SqlClient.SqlParameter("@Res1", System.Data.SqlDbType.Decimal) { Precision = 5, Scale = 2, Value = Math.Round((decimal)resistance, 2) }
};
}
/// <summary>
/// 지정한 LotNo로 기존에 저장된 제품들의 각인 순번 중 최대값을 구하여 다음 각인 일련번호(MAX + 1)를 구합니다.
/// 동일 시료 재검사 시 DB 행이 MERGE UPDATE 되어 레코드 수(COUNT)가 증가하지 않더라도 순번은 지속 증가합니다.
@ -452,7 +468,8 @@ namespace marking_gui.Services
), 0)
FROM [dbo].[Marking] WITH (NOLOCK)
WHERE [Lot_No] = @LotNo
AND [Marking_QR] IS NOT NULL
AND [Marking_QR] IS NOT NULL
AND [Marking_QR] NOT LIKE N'NG:%'
AND LEN(RTRIM([Marking_QR])) >= 4;";
using (var conn = new System.Data.SqlClient.SqlConnection(_connectionString))

90
marking_gui/Services/EquipmentMeasurementService.cs

@ -5,6 +5,7 @@ using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using marking_gui.Models;
namespace marking_gui.Services
{
@ -149,16 +150,22 @@ namespace marking_gui.Services
/// <summary>
/// 전압 DMM + 저항 DMM + 전류 DMM(또는 PSU) 독립 계측기 3-장비 측정 시퀀스.
/// </summary>
public async Task<(double Voltage, double Current, double Resistance)> MeasureAsync(Action<double?, double?, double?> onUpdate = null)
public async Task<EquipmentMeasurementResult> MeasureAsync(Action<double?, double?, double?> onUpdate = null)
{
var log = new StringBuilder();
IScpiClient psuClient = GetPsuClient();
IScpiClient voltageClient = GetVoltageClient();
IScpiClient resistanceClient = GetResistanceDmmClient();
IScpiClient currentDmmClient = GetCurrentDmmClient();
IScpiClient psuClient = null;
Exception measurementError = null;
bool hasOverload = false;
try
{
log.AppendLine($"측정 시작: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff zzz}, Timeout={_timeoutMs}ms, Settle={_settleMs}ms");
log.AppendLine($"PSU={_psu.Host}:{_psu.Port}, VoltageDMM={_voltageDmm.Host}:{_voltageDmm.Port}, ResistanceDMM={(_resistanceDmm.IsDisabled ? _voltageDmm.Host : _resistanceDmm.Host)}:{(_resistanceDmm.IsDisabled ? _voltageDmm.Port : _resistanceDmm.Port)}");
psuClient = GetPsuClient();
IScpiClient voltageClient = GetVoltageClient();
IScpiClient resistanceClient = GetResistanceDmmClient();
IScpiClient currentDmmClient = GetCurrentDmmClient();
if (psuClient == null)
throw new InvalidOperationException("PSU 파워서플라이 장치가 설정되지 않았거나 비활성화 상태입니다.");
if (voltageClient == null)
@ -184,8 +191,10 @@ namespace marking_gui.Services
string voltageReadCmd = !string.IsNullOrWhiteSpace(_voltageDmm.ReadCommand) ? _voltageDmm.ReadCommand : "MEAS:VOLT:DC?";
log.AppendLine($"> 전압 DMM SEND: {voltageReadCmd}");
string vResponse = await voltageClient.QueryAsync(voltageReadCmd);
log.AppendLine($" 응답: {vResponse.Trim()}");
double voltage = (double)ExtractFirstDecimal(vResponse, "VOLTAGE");
log.AppendLine($" 응답 원문: [{vResponse}]");
var voltageReading = ParseMeasurement(vResponse, "VOLTAGE");
double voltage = voltageReading.Value;
hasOverload |= voltageReading.Overloaded;
// 실시간 전압 업데이트
onUpdate?.Invoke(voltage, null, null);
@ -201,9 +210,10 @@ namespace marking_gui.Services
string resReadCmd = !string.IsNullOrWhiteSpace(_resistanceDmm.ReadCommand) ? _resistanceDmm.ReadCommand : _resistanceReadCommand;
log.AppendLine($"> 저항 DMM SEND: {resReadCmd}");
string rResponse = await (resistanceClient ?? voltageClient).QueryAsync(resReadCmd);
log.AppendLine($" 응답: {rResponse.Trim()}");
double rawResistance = (double)ExtractFirstDecimal(rResponse, "RESISTANCE");
double resistance = rawResistance / 1000.0; // Ohms -> kOhms 변환 (kΩ 단위 표출)
log.AppendLine($" 응답 원문: [{rResponse}]");
var resistanceReading = ParseMeasurement(rResponse, "RESISTANCE", 0.001); // Ohm -> kOhm
double resistance = resistanceReading.Value;
hasOverload |= resistanceReading.Overloaded;
// 실시간 저항 업데이트
onUpdate?.Invoke(voltage, null, resistance);
@ -213,14 +223,21 @@ namespace marking_gui.Services
string currentReadCmd = !string.IsNullOrWhiteSpace(_psu.ReadCommand) ? _psu.ReadCommand : "MEAS:CURR? (@1)";
log.AppendLine($"> PSU SEND: {currentReadCmd}");
string aResponse = await psuClient.QueryAsync(currentReadCmd);
log.AppendLine($" 응답: {aResponse.Trim()}");
double rawCurrent = (double)ExtractFirstDecimal(aResponse, "CURRENT");
double current = rawCurrent * 1000.0; // Amperes -> mA 변환
log.AppendLine($" 응답 원문: [{aResponse}]");
var currentReading = ParseMeasurement(aResponse, "CURRENT", 1000.0); // A -> mA
double current = currentReading.Value;
hasOverload |= currentReading.Overloaded;
// 실시간 전류 및 최종 업데이트
onUpdate?.Invoke(voltage, current, resistance);
return (voltage, current, resistance);
return new EquipmentMeasurementResult(voltage, current, resistance,
voltageReading.Overloaded, currentReading.Overloaded, resistanceReading.Overloaded);
}
catch (Exception ex)
{
measurementError = ex;
throw;
}
finally
{
@ -232,13 +249,20 @@ namespace marking_gui.Services
string psuCleanup = !string.IsNullOrWhiteSpace(_psu.CleanupCommand) ? _psu.CleanupCommand : "OUTP OFF, (@1)";
log.AppendLine($"> PSU CLEANUP: {psuCleanup}");
await SendCommandListAsync(psuClient, psuCleanup);
log.AppendLine("> PSU CLEANUP 명령 전송 완료");
}
catch (Exception ex)
{
log.AppendLine($"> PSU OUTP OFF FAILED: {ex.Message}");
LoggerService.Error("[Equipment] PSU 전원 차단 명령 전송 실패", ex, log.ToString());
}
}
LoggerService.Info("[Equipment] 독립 계측기 측정 시퀀스 로그:\n" + log);
if (measurementError != null)
LoggerService.Error("[Equipment] 계측 시퀀스 실패", measurementError, log.ToString());
else if (hasOverload)
LoggerService.Error("[Equipment] Overload 항목을 999로 기록하고 나머지 계측 완료", context: log.ToString());
else
LoggerService.Info("[Equipment] 독립 계측기 측정 시퀀스 로그:\n" + log);
}
}
@ -322,7 +346,8 @@ namespace marking_gui.Services
}
catch (Exception ex)
{
LoggerService.Warn($"[Equipment] ResetAsync 실패 (PSU 통신 오류): {ex.Message}");
LoggerService.Error("[Equipment] ResetAsync 실패 (PSU 통신 오류)", ex,
$"PSU={_psu.Host}:{_psu.Port}, CleanupCommand={_psu.CleanupCommand}");
}
}
@ -344,10 +369,10 @@ namespace marking_gui.Services
var client = label == "VOLTAGE" ? GetVoltageClient() : GetPsuClient();
if (client == null) throw new InvalidOperationException($"{label} 장치 클라이언트를 생성할 수 없습니다.");
return (double)await ReadChannelWithClientAsync(label, ch, client, log);
return await ReadChannelWithClientAsync(label, ch, client, log);
}
private static async Task<decimal> ReadChannelWithClientAsync(
private static async Task<double> ReadChannelWithClientAsync(
string label,
ScpiChannelSettings ch,
IScpiClient client,
@ -364,9 +389,9 @@ namespace marking_gui.Services
log.AppendLine($"> {label} READ: {ch.ReadCommand}");
string response = await client.QueryAsync(ch.ReadCommand);
log.AppendLine($" 응답: {response.Trim()}");
log.AppendLine($" 응답 원문: [{response}]");
return ExtractFirstDecimal(response, label);
return ParseMeasurement(response, label).Value;
}
private IScpiClient CreateClient(string label, ScpiChannelSettings ch)
@ -395,19 +420,38 @@ namespace marking_gui.Services
}
}
private static decimal ExtractFirstDecimal(string response, string label)
private static (double Value, bool Overloaded) ParseMeasurement(string response, string label, double scale = 1.0)
{
if (string.IsNullOrWhiteSpace(response))
throw new InvalidOperationException($"[{label}] 계측기 응답이 비어있습니다 (응답 수신 실패).");
string text = response.Trim();
if (text.Equals("OL", StringComparison.OrdinalIgnoreCase) ||
text.Equals("O.L", StringComparison.OrdinalIgnoreCase) ||
text.Equals("OVERLOAD", StringComparison.OrdinalIgnoreCase) ||
text.Equals("OVLD", StringComparison.OrdinalIgnoreCase))
{
LoggerService.Warn($"[Equipment] {label} Overload -> 999, 원본 응답: [{response}]");
return (EquipmentMeasurementResult.OverloadValue, true);
}
var match = Regex.Match(response, @"[-+]?\d+(?:\.\d+)?(?:[Ee][-+]?\d+)?");
if (!match.Success)
throw new InvalidOperationException($"[{label}] 수치 파싱 실패. 원본 응답: [{response.Trim()}]");
if (!decimal.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal val))
if (!double.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double val) || !double.IsFinite(val))
throw new InvalidOperationException($"[{label}] 수치 변환 실패 (값: '{match.Value}'). 원본 응답: [{response.Trim()}]");
return val;
if (Math.Abs(val) >= 9.0e37)
{
LoggerService.Warn($"[Equipment] {label} Overload -> 999, 원본 응답: [{response}]");
return (EquipmentMeasurementResult.OverloadValue, true);
}
double scaled = val * scale;
if (!double.IsFinite(scaled))
throw new InvalidOperationException($"[{label}] 단위 변환 실패. 원본 응답: [{response.Trim()}]");
return (scaled, false);
}
// ── INI 파서 ────────────────────────────────────────────────────

62
marking_gui/Services/LoggerService.cs

@ -1,12 +1,18 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
namespace marking_gui.Services
{
public static class LoggerService
{
private static readonly string LogDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
private static readonly string DiagnosticDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "debug");
private static readonly object LockObj = new object();
private static readonly Queue<string> RecentLogs = new Queue<string>();
private const int RecentLogLimit = 100;
static LoggerService()
{
@ -66,24 +72,68 @@ namespace marking_gui.Services
WriteLog("WARN", message);
}
public static void Error(string message, Exception ex = null)
public static void Error(string message, Exception ex = null, string context = null)
{
string detail = message;
if (ex != null)
{
detail += string.Format("\n[Exception] {0}\n[StackTrace]\n{1}", ex.Message, ex.StackTrace);
// ToString preserves exception types, inner exceptions and their stack traces.
detail += Environment.NewLine + ex;
}
WriteLog("ERROR", detail);
WriteLog("ERROR", detail, context);
}
private static void WriteLog(string level, string message)
private static void WriteLog(string level, string message, string context = null)
{
lock (LockObj)
{
DateTime now = DateTime.Now;
string logLine = string.Format("[{0}] [{1}] {2}", now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz"), level, message) + Environment.NewLine;
RecentLogs.Enqueue(logLine);
while (RecentLogs.Count > RecentLogLimit) RecentLogs.Dequeue();
// Keep diagnostic and daily writes independent so either can survive a failure in the other.
if (level == "ERROR")
{
try
{
Directory.CreateDirectory(DiagnosticDirectory);
string diagnosticFile = Path.Combine(DiagnosticDirectory,
$"error_{now:yyyyMMdd_HHmmss_fff}_{Guid.NewGuid():N}.log");
var report = new StringBuilder();
report.AppendLine($"발생 시각: {now:yyyy-MM-dd HH:mm:ss.fff zzz}");
report.AppendLine($"프로그램 버전: {typeof(LoggerService).Assembly.GetName().Version}");
report.AppendLine($"실행 경로: {AppDomain.CurrentDomain.BaseDirectory}");
report.AppendLine($"실행 환경: {RuntimeInformation.FrameworkDescription}, {RuntimeInformation.OSDescription}, {RuntimeInformation.ProcessArchitecture}");
report.AppendLine($"프로세스 ID: {Environment.ProcessId}, 스레드 ID: {Environment.CurrentManagedThreadId}");
report.AppendLine();
report.AppendLine("[오류 상세]");
report.AppendLine(message);
if (!string.IsNullOrWhiteSpace(context))
{
report.AppendLine();
report.AppendLine("[진단 정보]");
report.AppendLine(context);
}
report.AppendLine();
report.AppendLine($"[직전 로그 - 최대 {RecentLogLimit}건]");
foreach (string recent in RecentLogs) report.Append(recent);
File.WriteAllText(diagnosticFile, report.ToString(), Encoding.UTF8);
logLine += "[진단 파일] " + diagnosticFile + Environment.NewLine;
}
catch (Exception ex)
{
logLine += "[진단 파일 저장 실패] " + ex.Message + Environment.NewLine;
System.Diagnostics.Debug.WriteLine("Writing diagnostic failed: " + ex.Message);
}
}
try
{
string logFile = Path.Combine(LogDirectory, DateTime.Now.ToString("yyyy-MM-dd") + ".log");
string logLine = string.Format("[{0}] [{1}] {2}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"), level, message) + Environment.NewLine;
Directory.CreateDirectory(LogDirectory);
string logFile = Path.Combine(LogDirectory, now.ToString("yyyy-MM-dd") + ".log");
if (!string.IsNullOrWhiteSpace(context))
logLine += "[진단 정보] " + context + Environment.NewLine;
File.AppendAllText(logFile, logLine);
}
catch (Exception ex)

6
sql/database_setup.sql

@ -74,9 +74,9 @@ BEGIN
[Line] NVARCHAR(10) NOT NULL,
[Lot_No] NVARCHAR(8) NOT NULL,
[Jig_No] NVARCHAR(10) NOT NULL,
[PT_Vol_1] DECIMAL(4, 2) NOT NULL,
[PT_Current_1] DECIMAL(4, 2) NOT NULL,
[PT_Resistance_1] DECIMAL(5, 3) NOT NULL,
[PT_Vol_1] DECIMAL(5, 2) NOT NULL,
[PT_Current_1] DECIMAL(5, 2) NOT NULL,
[PT_Resistance_1] DECIMAL(5, 2) NOT NULL,
[Result] NVARCHAR(5) NOT NULL,
[Spare_1] NVARCHAR(30) NULL,
[Spare_2] NVARCHAR(30) NULL,

19
tests/DiagnosticsRegression/DiagnosticsRegression.csproj

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<LangVersion>10.0</LangVersion>
<EnableNETAnalyzers>false</EnableNETAnalyzers>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
<Compile Include="../../marking_gui/Models/EquipmentMeasurementResult.cs" Link="EquipmentMeasurementResult.cs" />
<Compile Include="../../marking_gui/Models/Product.cs" Link="Product.cs" />
<Compile Include="../../marking_gui/Services/DatabaseService.cs" Link="DatabaseService.cs" />
<Compile Include="../../marking_gui/Services/LoggerService.cs" Link="LoggerService.cs" />
<Compile Include="../../marking_gui/Services/EquipmentMeasurementService.cs" Link="EquipmentMeasurementService.cs" />
<Compile Include="../../marking_gui/Services/IScpiClient.cs" Link="IScpiClient.cs" />
<Compile Include="../../marking_gui/Services/TcpScpiClient.cs" Link="TcpScpiClient.cs" />
</ItemGroup>
</Project>

268
tests/DiagnosticsRegression/Program.cs

@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using marking_gui.Services;
using marking_gui.Models;
internal static class Program
{
private static readonly string DiagnosticPath = Path.Combine(AppContext.BaseDirectory, "debug");
private static readonly string DailyPath = Path.Combine(AppContext.BaseDirectory, "Logs");
private static readonly string FixturePath = Path.Combine(AppContext.BaseDirectory, "fixtures", Guid.NewGuid().ToString("N"));
private static async Task Main()
{
Directory.CreateDirectory(FixturePath);
TestErrorReport();
TestDirectoryRecreation();
TestIndependentWrites();
await TestConcurrentErrors();
TestBoundedHistory();
await TestMeasurementFailure(false);
await TestMeasurementFailure(true);
await TestNormalMeasurement();
await TestOverloadCombinations();
await TestOverloadFormats();
await TestInvalidResponses();
TestDatabaseValues();
TestOfflineNgSequence();
Console.WriteLine("PASS: diagnostics, 7 overload combinations, overload formats, invalid responses, DB parameters and NG no-save/sequence (no physical equipment/DB used).");
Console.WriteLine("Diagnostic samples: " + DiagnosticPath);
}
private static void TestErrorReport()
{
string token = Guid.NewGuid().ToString("N");
LoggerService.Info("검사 시작 " + token);
Exception failure;
try { throw new InvalidOperationException("outer " + token, new FormatException("inner 原因")); }
catch (Exception ex) { failure = ex; }
string report = CaptureReport(() => LoggerService.Error("측정 실패 " + token, failure, "원문=+9.90000000E+37"));
Require(report.Contains("검사 시작 " + token), "Recent context is missing.");
Require(report.Contains("FormatException: inner 原因"), "Inner exception/UTF-8 was lost.");
Require(report.Contains("Program.TestErrorReport"), "Stack trace is missing.");
Require(report.Contains("원문=+9.90000000E+37"), "Raw measurement context is missing.");
Require(File.ReadAllText(DailyFile()).Contains("[진단 파일]"), "Daily log must point to a diagnostic file.");
CaptureReport(() => LoggerService.Error("Exception 객체 없는 오류"));
}
private static void TestDirectoryRecreation()
{
Directory.Move(DiagnosticPath, Path.Combine(FixturePath, "previous-diagnostics"));
Directory.Move(DailyPath, Path.Combine(FixturePath, "previous-daily"));
string report = CaptureReport(() => LoggerService.Error("폴더 자동 재생성"));
Require(report.Contains("폴더 자동 재생성") && File.Exists(DailyFile()), "Deleted directories were not recreated.");
}
private static void TestIndependentWrites()
{
Directory.Move(DailyPath, Path.Combine(FixturePath, "daily-block-test"));
File.WriteAllText(DailyPath, "test-owned blocker");
try { CaptureReport(() => LoggerService.Error("일별 로그 저장 불가")); }
finally { File.Delete(DailyPath); }
Directory.Move(DiagnosticPath, Path.Combine(FixturePath, "diagnostic-block-test"));
File.WriteAllText(DiagnosticPath, "test-owned blocker");
try
{
LoggerService.Error("진단 폴더 저장 불가");
Require(File.ReadAllText(DailyFile()).Contains("[진단 파일 저장 실패]"), "Diagnostic failure was not recorded in the daily log.");
}
finally { File.Delete(DiagnosticPath); }
}
private static async Task TestConcurrentErrors()
{
Directory.CreateDirectory(DiagnosticPath);
int before = Directory.GetFiles(DiagnosticPath, "*.log").Length;
await Task.WhenAll(Enumerable.Range(0, 24).Select(i => Task.Run(() => LoggerService.Error("concurrent " + i))));
Require(Directory.GetFiles(DiagnosticPath, "*.log").Length == before + 24, "Concurrent reports were overwritten or lost.");
}
private static void TestBoundedHistory()
{
string oldToken = Guid.NewGuid().ToString("N");
LoggerService.Info(oldToken);
for (int i = 0; i < 105; i++) LoggerService.Info("history " + i);
string report = CaptureReport(() => LoggerService.Error("bounded history"));
Require(!report.Contains(oldToken) && report.Contains("history 104"), "Recent history is not bounded correctly.");
}
private static async Task TestMeasurementFailure(bool cleanupFails)
{
var psu = new FakeClient("0.008", cleanupFails);
using var service = CreateEquipment(psu, new FakeClient("4.5"), new FakeClient("invalid-response"));
var before = Reports();
Exception failure = null;
try { await service.MeasureAsync(); }
catch (Exception ex) { failure = ex; }
Require(failure is InvalidOperationException && failure.Message.Contains("RESISTANCE"), "Original parsing exception was changed/lost.");
Require(psu.Commands.Contains("OUTP OFF, (@1)"), "PSU cleanup was not attempted.");
string[] reports = Reports().Except(before).Select(File.ReadAllText).ToArray();
string report = reports.Single(r => r.Contains("[오류 상세]" + Environment.NewLine + "[Equipment] 계측 시퀀스 실패"));
Require(report.Contains("invalid-response") && report.Contains("MEAS:RES?") && report.Contains("test-resistance:5025"), "Report lost the endpoint, command or raw response.");
Require(report.Contains(cleanupFails ? "PSU OUTP OFF FAILED" : "PSU CLEANUP 명령 전송 완료"), "Cleanup outcome is missing.");
if (cleanupFails)
Require(reports.Any(r => r.Contains("IOException: simulated cleanup failure")), "Cleanup failure diagnostic is missing.");
}
private static async Task TestNormalMeasurement()
{
var psu = new FakeClient("0.008");
using var service = CreateEquipment(psu, new FakeClient("4.5"), new FakeClient("10000"));
var before = Reports();
var result = await service.MeasureAsync();
Require(result.Voltage == 4.5 && result.Current == 8 && result.Resistance == 10, "Normal measurement results changed.");
Require(Reports().Length == before.Length, "Successful measurement created an error report.");
Require(psu.Commands.Contains("OUTP OFF, (@1)"), "Normal PSU cleanup changed.");
}
private static async Task TestOverloadCombinations()
{
for (int mask = 1; mask < 8; mask++)
{
bool voltageOl = (mask & 1) != 0;
bool currentOl = (mask & 2) != 0;
bool resistanceOl = (mask & 4) != 0;
var psu = new FakeClient(currentOl ? "+9.90000000E+37" : "0.008");
var voltage = new FakeClient(voltageOl ? "+9.90000000E+37" : "4.5");
var resistance = new FakeClient(resistanceOl ? "+9.90000000E+37" : "10000");
using var service = CreateEquipment(psu, voltage, resistance);
var updates = new List<(double? Voltage, double? Current, double? Resistance)>();
var before = Reports();
var result = await service.MeasureAsync((v, c, r) => updates.Add((v, c, r)));
Require(result.Voltage == (voltageOl ? 999 : 4.5), "Voltage overload changed another reading.");
Require(result.Current == (currentOl ? 999 : 8), "Current overload sentinel was scaled or other current lost.");
Require(result.Resistance == (resistanceOl ? 999 : 10), "Resistance overload sentinel was scaled or normal resistance lost.");
Require(result.VoltageOverloaded == voltageOl && result.CurrentOverloaded == currentOl && result.ResistanceOverloaded == resistanceOl,
"Per-channel overload flags are incorrect.");
Require(result.HasOverload && updates.Count == 3, "Overload interrupted the remaining readings/UI updates.");
Require(updates[2].Voltage == result.Voltage && updates[2].Current == result.Current && updates[2].Resistance == result.Resistance,
"UI update does not preserve all final readings.");
Require(psu.Commands.Contains("MEAS:CURR? (@1)") && psu.Commands.Contains("OUTP OFF, (@1)"), "Overload skipped current measurement or cleanup.");
Require(resistance.Commands.Contains("MEAS:RES?"), "Voltage overload skipped resistance.");
string report = Reports().Except(before).Select(File.ReadAllText).Single();
Require(report.Contains("Overload 항목을 999") && report.Contains("+9.90000000E+37"), "Overload diagnostic is missing.");
var parameters = DatabaseService.CreateMeasurementParameters(result.Voltage, result.Current, result.Resistance);
Require((decimal)parameters[0].Value == (decimal)result.Voltage &&
(decimal)parameters[1].Value == (decimal)result.Current &&
(decimal)parameters[2].Value == (decimal)result.Resistance, "DB parameters changed the measured values.");
}
}
private static async Task TestOverloadFormats()
{
foreach (string response in new[] { "OL", " o.l\r\n", "OVERLOAD", "ovld", "-9.90000000E+37" })
{
using var service = CreateEquipment(new FakeClient(response), new FakeClient(response), new FakeClient(response));
var result = await service.MeasureAsync();
Require(result.Voltage == 999 && result.Current == 999 && result.Resistance == 999 && result.HasOverload,
"Overload response was not recognized: " + response);
}
using var normalService = CreateEquipment(new FakeClient("0"), new FakeClient("0"), new FakeClient("999"));
var normal = await normalService.MeasureAsync();
Require(normal.Voltage == 0 && normal.Current == 0 && normal.Resistance == 0.999 && !normal.HasOverload,
"Real zero/999 Ohm measurement was mistaken for overload.");
}
private static async Task TestInvalidResponses()
{
foreach (string response in new[] { "", "bad", "NaN", "INF", "1E309" })
{
var psu = new FakeClient("0.008");
using var service = CreateEquipment(psu, new FakeClient("4.5"), new FakeClient(response));
Exception failure = null;
try { await service.MeasureAsync(); }
catch (Exception ex) { failure = ex; }
Require(failure is InvalidOperationException, "Malformed/empty response must not be silently saved as overload.");
Require(psu.Commands.Contains("OUTP OFF, (@1)"), "Bad response bypassed PSU cleanup.");
}
}
private static void TestDatabaseValues()
{
var parameters = DatabaseService.CreateMeasurementParameters(999, 999, 999);
Require(parameters.All(p => p.Precision == 5 && p.Scale == 2 && (decimal)p.Value == 999m),
"999 does not fit the configured database parameters.");
var normal = DatabaseService.CreateMeasurementParameters(4.567, 8.126, 12.346);
Require((decimal)normal[0].Value == 4.57m && (decimal)normal[1].Value == 8.13m && (decimal)normal[2].Value == 12.35m,
"Normal measurements were not rounded for decimal(5,2).");
}
private static void TestOfflineNgSequence()
{
string settings = Path.Combine(AppContext.BaseDirectory, "Database.ini");
Require(!File.Exists(settings), "Refusing to overwrite an existing test Database.ini.");
File.WriteAllText(settings, "[Database]\nOfflineMode=true\n");
try
{
var database = new DatabaseService();
Require(database.OfflineMode, "Test database must be offline before saving.");
SaveOffline(database, "NG", "LOT0009");
Require(database.GetNextMarkingSequence("LOT") == 1, "Overload NG consumed a marking sequence.");
SaveOffline(database, "OK", "LOT0010");
SaveOffline(database, "NG", "LOT0020");
Require(database.GetNextMarkingSequence("LOT") == 11, "NG reinspection changed the issued sequence.");
}
finally { File.Delete(settings); }
}
private static void SaveOffline(DatabaseService database, string result, string serial)
{
bool saved = database.SaveProductResult("TEST_IC", "maker", "model", "", "", "operator", "line", "LOT", "jig",
result == "NG" ? 999 : 4.5, 8, 10, result, DateTime.Now, DateTime.Now, serial, "pcb");
Require(saved == (result == "OK"), "Only OK results may be saved; NG must be skipped.");
}
private static EquipmentMeasurementService CreateEquipment(FakeClient psu, FakeClient voltage, FakeClient resistance)
{
string settings = Path.Combine(FixturePath, "test-settings.ini");
File.WriteAllText(settings, "[Equipment]\nSettleMilliseconds=0\nTimeout=100\n[DMM]\nVoltageConnection=LAN\nVoltageHost=test-voltage\n[ResistanceDMM]\nResistanceDMMConnection=LAN\nResistanceDMMHost=test-resistance\n[PowerSupply]\nCurrentConnection=LAN\nCurrentHost=test-psu\n");
var service = new EquipmentMeasurementService(settings);
SetClient(service, "_psuClient", psu);
SetClient(service, "_voltageDmmClient", voltage);
SetClient(service, "_resistanceDmmClient", resistance);
return service;
}
private static void SetClient(EquipmentMeasurementService service, string field, IScpiClient client) =>
typeof(EquipmentMeasurementService).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(service, client);
private static string[] Reports() => Directory.Exists(DiagnosticPath) ? Directory.GetFiles(DiagnosticPath, "*.log") : Array.Empty<string>();
private static string DailyFile() => Path.Combine(DailyPath, DateTime.Now.ToString("yyyy-MM-dd") + ".log");
private static string CaptureReport(Action write)
{
string[] before = Reports();
write();
string reportPath = Reports().Except(before).Single();
string fileName = Path.GetFileName(reportPath);
Require(fileName.StartsWith("error_", StringComparison.Ordinal) && fileName.All(c => c <= 127),
"Diagnostic filenames must use the English prefix and ASCII characters only.");
return File.ReadAllText(reportPath);
}
private static void Require(bool condition, string message)
{
if (!condition) throw new Exception(message);
}
private sealed class FakeClient : IScpiClient
{
private readonly string _response;
private readonly bool _cleanupFails;
public List<string> Commands { get; } = new List<string>();
public FakeClient(string response, bool cleanupFails = false) { _response = response; _cleanupFails = cleanupFails; }
public Task SendAsync(string command)
{
Commands.Add(command);
return _cleanupFails && command.Contains("OFF")
? Task.FromException(new IOException("simulated cleanup failure")) : Task.CompletedTask;
}
public Task<string> QueryAsync(string command) { Commands.Add(command); return Task.FromResult(_response); }
public void Dispose() { }
}
}

11
tests/DiagnosticsRegression/README.md

@ -0,0 +1,11 @@
# 오류 진단 로그 회귀 검증
실장비·DB 연결 없이 운영 소스의 LoggerService, EquipmentMeasurementService, DatabaseService를 직접 컴파일해 검증합니다. 운영 프로젝트와 동일한 SQL 클라이언트와 Windows Desktop 프레임워크를 사용합니다.
```powershell
dotnet run --project tests/DiagnosticsRegression/DiagnosticsRegression.csproj
```
오류 파일 생성, UTF-8/내부 예외/스택 추적/직전 로그, 폴더 재생성, 두 저장 경로의 독립적인 실패 처리, 동시 오류, 로그 이력 상한, 파싱 실패와 전원 차단 실패를 확인합니다. 세 계측 항목의 Overload 7가지 조합과 문자/지수 응답, 정상 계측값·화면 콜백 보존, 999의 단위 변환 방지, DECIMAL(5,2) 저장 매개변수, NG 저장 차단 및 각인 순번 유지를 추가로 검증합니다.
테스트 로그와 보관한 테스트 폴더는 이 프로젝트의 `bin/Debug/net9.0-windows/` 아래에 생성됩니다. 진단 파일은 `debug/error_yyyyMMdd_HHmmss_fff_<id>.log` 형식이며 파일명은 ASCII 문자만 사용합니다. 테스트의 모든 장비 클라이언트는 가짜 구현이며 DB 테스트는 OfflineMode를 먼저 확인합니다.
Loading…
Cancel
Save