Compare commits
2 Commits
d832f1f52c
...
a0f3bdf571
| Author | SHA1 | Date |
|---|---|---|
|
|
a0f3bdf571 | 1 week ago |
|
|
78af1a184a | 1 week ago |
13 changed files with 823 additions and 95 deletions
@ -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. 결론 및 건의사항 |
||||
|
|
||||
|
본 오류는 계측기의 하드웨어 고장이 아니라 **단선/오버로드 규격치 수신 시의 예외 처리 누락**에서 기인한 것입니다.\ |
||||
|
제안된 파싱 타입 변경 및 오버로드 핸들링 로직을 적용하면 단선 불량 발생 시에도 시스템 멈춤 없이 안정적으로 불합격 판정 및 공정을 지속할 수 있습니다. |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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() { } |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue