-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathReportParser.cs
More file actions
183 lines (157 loc) · 6.81 KB
/
Copy pathReportParser.cs
File metadata and controls
183 lines (157 loc) · 6.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
using System.Globalization;
namespace Instrument.Core;
/// <summary>
/// 儀器報表解析器。
///
/// 真實痛點不在「解析文字檔」,而在**同一個物理量,各廠商叫不同名字**:
/// Agilent 寫 "Calibration ID"、Waters 寫 "Calib. File"、舊機台寫 "CalID"。
/// 所以欄位對映表才是這個元件的核心,解析邏輯本身很單純。
/// 這張表就是「來源端資料規格」的具體形式。
/// </summary>
public sealed class ReportParser
{
/// <summary>欄位別名 → 正規欄位名。跨廠商整合的核心資產。</summary>
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
["instrument s/n"] = "serial",
["instrument sn"] = "serial",
["serial number"] = "serial",
["device id"] = "serial",
["sample id"] = "sample",
["sample name"] = "sample",
["sample"] = "sample",
["acquired"] = "measured_at",
["acquisition date"] = "measured_at",
["run date"] = "measured_at",
["date/time"] = "measured_at",
["calibration id"] = "cal_id",
["calib. file"] = "cal_id",
["calid"] = "cal_id",
["calibrated"] = "cal_date",
["calibration date"] = "cal_date",
["standard"] = "cal_standard",
["calibration standard"] = "cal_standard",
["column"] = "column",
["solvent"] = "solvent",
["mobile phase"] = "solvent",
["mn"] = "mn",
["mw"] = "mw",
["pdi"] = "pdi",
["polydispersity"] = "pdi",
["mw/mn"] = "pdi",
["tg (c)"] = "tg",
["tg"] = "tg",
["glass transition"] = "tg",
["tm (c)"] = "tm",
["tm"] = "tm",
["melting point"] = "tm",
["heating rate"] = "scan_rate",
["scan rate"] = "scan_rate",
["ramp"] = "scan_rate",
["sample mass (mg)"] = "mass",
["sample weight"] = "mass",
["mass"] = "mass",
};
/// <summary>從檔案解析。內部委派給 <see cref="ParseContent"/>,讓通訊來源共用同一套解析邏輯。</summary>
public ParseResult Parse(string filePath)
{
string content;
try
{
content = File.ReadAllText(filePath);
}
catch (Exception ex)
{
return ParseResult.Failed(filePath, $"讀檔失敗:{ex.Message}");
}
return ParseContent(content, Path.GetFileName(filePath));
}
/// <summary>
/// 從純文字內容解析。通訊層(TCP/串列埠/SDK)收到的是位元組串流而非檔案,
/// 所以解析必須與「怎麼拿到資料」解耦。
/// </summary>
/// <param name="origin">來源識別(檔名/連線端點/NodeId),出問題時用來追溯。</param>
public ParseResult ParseContent(string content, string origin)
{
var lines = content.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var unknownKeys = new List<string>();
foreach (var raw in lines)
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("---")) continue;
var idx = line.IndexOf(':');
if (idx <= 0) continue;
var key = line[..idx].Trim();
var value = line[(idx + 1)..].Trim();
if (value.Length == 0) continue;
if (Aliases.TryGetValue(key, out var canonical))
fields[canonical] = value;
else
unknownKeys.Add(key); // 未知欄位要浮出來,不能靜默吞掉
}
var type = DetectType(fields);
if (type is null)
return ParseResult.Failed(origin, "無法判定儀器種類(既無 GPC 也無 DSC 的特徵欄位)");
var measurement = new Measurement
{
SampleId = fields.GetValueOrDefault("sample", ""),
InstrumentSerial = fields.GetValueOrDefault("serial", ""),
Instrument = type.Value,
MeasuredAt = ParseDate(fields.GetValueOrDefault("measured_at")) ?? default,
SourceFile = origin,
Mn = ParseNum(fields.GetValueOrDefault("mn")),
Mw = ParseNum(fields.GetValueOrDefault("mw")),
ReportedPdi = ParseNum(fields.GetValueOrDefault("pdi")),
CalibrationId = fields.GetValueOrDefault("cal_id"),
CalibratedAt = ParseDate(fields.GetValueOrDefault("cal_date")),
CalibrationStandard = fields.GetValueOrDefault("cal_standard"),
Column = fields.GetValueOrDefault("column"),
Solvent = fields.GetValueOrDefault("solvent"),
TgCelsius = ParseNum(fields.GetValueOrDefault("tg")),
TmCelsius = ParseNum(fields.GetValueOrDefault("tm")),
ScanRateCPerMin = ParseNum(fields.GetValueOrDefault("scan_rate")),
SampleMassMg = ParseNum(fields.GetValueOrDefault("mass")),
};
return ParseResult.Ok(measurement, unknownKeys);
}
private static InstrumentType? DetectType(Dictionary<string, string> f)
{
if (f.ContainsKey("mn") || f.ContainsKey("mw") || f.ContainsKey("cal_id"))
return InstrumentType.Gpc;
if (f.ContainsKey("tg") || f.ContainsKey("tm") || f.ContainsKey("scan_rate"))
return InstrumentType.Dsc;
return null;
}
/// <summary>數值可能帶千分位、單位後綴或前後空白,一律容忍。</summary>
private static double? ParseNum(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
var cleaned = new string(s.Where(c => char.IsDigit(c) || c is '.' or '-' or '+' or 'e' or 'E').ToArray());
return double.TryParse(cleaned, NumberStyles.Float, CultureInfo.InvariantCulture, out var v)
? v : null;
}
private static DateTime? ParseDate(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
string[] formats =
[
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd",
"yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd", "dd/MM/yyyy HH:mm", "MM/dd/yyyy HH:mm",
];
return DateTime.TryParseExact(s, formats, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var d)
? d
: DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var g)
? g : null;
}
}
public sealed record ParseResult(
bool Success, Measurement? Measurement, string? Error,
IReadOnlyList<string> UnknownKeys, string? FilePath = null)
{
public static ParseResult Ok(Measurement m, IReadOnlyList<string> unknown) =>
new(true, m, null, unknown);
public static ParseResult Failed(string filePath, string error) =>
new(false, null, error, [], filePath);
}