STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
PTCalibration.cpp
Go to the documentation of this file.
2
3#include <dirent.h>
4#include <sys/stat.h>
5
6#include <algorithm>
7#include <chrono>
8#include <cmath>
9#include <cstdlib>
10#include <cstring>
11#include <fstream>
12#include <iostream>
13#include <nlohmann/json.hpp>
14#include <regex>
15#include <sstream>
16
17#if __cplusplus >= 201703L
18#include <filesystem>
19namespace fs = std::filesystem;
20#else
21// Fallback for older C++ standards
22#include <dirent.h>
23#include <sys/stat.h>
24namespace fs {
25inline bool exists(const std::string& path) {
26 struct stat buffer;
27 return (stat(path.c_str(), &buffer) == 0);
28}
29inline bool is_directory(const std::string& path) {
30 struct stat buffer;
31 if (stat(path.c_str(), &buffer) != 0)
32 return false;
33 return S_ISDIR(buffer.st_mode);
34}
35struct path {
36 std::string p;
37 path(const std::string& s) : p(s) {
38 }
39 std::string string() const {
40 return p;
41 }
42 std::string extension() const {
43 size_t pos = p.find_last_of('.');
44 return (pos != std::string::npos) ? p.substr(pos) : "";
45 }
46};
48 DIR* dir;
49 struct dirent* entry;
50 directory_iterator(const std::string& dir_path) {
51 dir = opendir(dir_path.c_str());
52 entry = nullptr;
53 }
55 if (dir)
56 closedir(dir);
57 }
58 bool has_next() {
59 if (!dir)
60 return false;
61 entry = readdir(dir);
62 return entry != nullptr;
63 }
64 fs::path path() const {
65 return entry ? std::string(entry->d_name) : "";
66 }
67 bool is_regular_file() const {
68 if (!entry)
69 return false;
70 struct stat buffer;
71 std::string full_path = std::string(dir->d_name) + "/" + entry->d_name;
72 if (stat(full_path.c_str(), &buffer) != 0)
73 return false;
74 return S_ISREG(buffer.st_mode);
75 }
76};
77} // namespace fs
78#endif
79
80namespace fsw {
81namespace calibration {
82
83// Static member initialization
84std::string PTCalibrationManager::default_json_dir_ = "scripts/calibration/calibrations";
86 "external/DiabloAvionics/PT_Board/Calibration/PT Calibration Attempt 2026-02-04_test2.csv";
87
89 // Auto-load calibration on construction unless the caller opts out (physics-or-nothing).
90 if (auto_load)
92}
93
95 calibrations_.clear();
96
97 // Default: load Diablo factory CSV first (stable), then GUI JSON exports. Override with
98 // CAL_PT_JSON_FIRST=1 to prefer the newest JSON in scripts/calibration/calibrations/.
99 const char* jf = std::getenv("CAL_PT_JSON_FIRST");
100 const bool json_first =
101 jf && (jf[0] == '1' || jf[0] == 'y' || jf[0] == 'Y' || jf[0] == 't' || jf[0] == 'T');
102
103 auto try_csv = [this]() -> bool {
106 std::cout << "[PTCalibration] Loaded " << calibrations_.size()
107 << " calibrations from CSV (factory): " << default_csv_path_ << std::endl;
108 return true;
109 }
110 }
111 return false;
112 };
113
114 auto try_json = [this]() -> bool {
116 std::string json_file = find_latest_json_file(default_json_dir_);
117 if (!json_file.empty() && load_from_json(json_file, false)) {
118 std::cout << "[PTCalibration] Loaded " << calibrations_.size()
119 << " calibrations from JSON: " << json_file << std::endl;
120 return true;
121 }
122 }
123 return false;
124 };
125
126 if (json_first) {
127 if (try_json())
128 return true;
129 if (try_csv())
130 return true;
131 } else {
132 // CSV column labels (PT1, PT6, …) are bench IDs — they may not match vehicle logical
133 // connectors (e.g. "PT6" in CSV ≠ GN2 Reg on connector 6). Newest GUI JSON is keyed by
134 // logical channel; overlay it on CSV so ch 5/6/7 etc. get the right cubics.
135 try_csv();
136 const char* missing_only_env = std::getenv("CAL_PT_JSON_MISSING_ONLY");
137 const bool json_missing_only =
138 missing_only_env && (missing_only_env[0] == '1' || missing_only_env[0] == 'y' ||
139 missing_only_env[0] == 'Y' || missing_only_env[0] == 't' ||
140 missing_only_env[0] == 'T');
142 std::string json_file = find_latest_json_file(default_json_dir_);
143 if (!json_file.empty() && load_from_json(json_file, json_missing_only)) {
144 if (json_missing_only) {
145 std::cout << "[PTCalibration] Merged missing channels from JSON only: "
146 << json_file << " (total " << calibrations_.size()
147 << " logical channels)" << std::endl;
148 } else {
149 std::cout << "[PTCalibration] Overlaid GUI JSON on CSV: " << json_file
150 << " (logical channels present in JSON replace CSV; total keys "
151 << calibrations_.size() << ")" << std::endl;
152 }
153 }
154 }
155 if (!calibrations_.empty())
156 return true;
157 if (try_json())
158 return true;
159 }
160
161 std::cout << "[PTCalibration] ⚠️ No calibration files found - sensors will be uncalibrated"
162 << std::endl;
163 return false;
164}
165
166bool PTCalibrationManager::load_from_json(const std::string& json_path, bool merge_missing_only) {
167 std::ifstream file(json_path);
168 if (!file.is_open()) {
169 std::cerr << "[PTCalibration] Failed to open JSON file: " << json_path << std::endl;
170 return false;
171 }
172
173 // Format: {"calibration_polynomials": {"1": [A, B, C, D], "2": [A, B, C, D], ...}}
174 std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
175 file.close();
176
177 nlohmann::json root;
178 try {
179 root = nlohmann::json::parse(content);
180 } catch (const std::exception& e) {
181 std::cerr << "[PTCalibration] JSON parse error in " << json_path << ": " << e.what()
182 << std::endl;
183 return false;
184 }
185 if (!root.contains("calibration_polynomials") || !root["calibration_polynomials"].is_object())
186 return false;
187
188 size_t loaded = 0;
189 for (const auto& [ch_str, arr] : root["calibration_polynomials"].items()) {
190 if (!arr.is_array() || arr.size() < 4)
191 continue;
192 if (!arr[0].is_number() || !arr[1].is_number() || !arr[2].is_number() ||
193 !arr[3].is_number())
194 continue;
195 int ch = 0;
196 try {
197 ch = std::stoi(ch_str);
198 } catch (...) {
199 continue;
200 }
201 const uint8_t channel_id = static_cast<uint8_t>(ch);
202 if (merge_missing_only && calibrations_.find(channel_id) != calibrations_.end())
203 continue;
204 calibrations_[channel_id] = PTCalibrationCoeffs(arr[0].get<double>(), arr[1].get<double>(),
205 arr[2].get<double>(), arr[3].get<double>());
206 loaded++;
207 }
208
209 return loaded > 0;
210}
211
212bool PTCalibrationManager::load_from_csv(const std::string& csv_path) {
213 std::ifstream file(csv_path);
214 if (!file.is_open()) {
215 std::cerr << "[PTCalibration] Failed to open CSV file: " << csv_path << std::endl;
216 return false;
217 }
218
219 // Read header line
220 std::string header_line;
221 std::getline(file, header_line);
222
223 // Find PT columns: "PT{N} Coefficient 0", "PT{N} Coefficient 1", etc.
224 std::regex pt_col_regex(R"(PT(\d+)\s+Coefficient\s+(\d+))", std::regex_constants::icase);
225 std::sregex_iterator iter(header_line.begin(), header_line.end(), pt_col_regex);
226 std::sregex_iterator end;
227
228 std::map<uint8_t, std::array<int, 4>> pt_col_indices; // channel_id -> [col_idx for A, B, C, D]
229 std::vector<std::string> columns;
230
231 // Parse header to get column indices
232 std::istringstream header_stream(header_line);
233 std::string col;
234 int col_idx = 0;
235 while (std::getline(header_stream, col, ',')) {
236 columns.push_back(col);
237 std::smatch match;
238 if (std::regex_search(col, match, pt_col_regex)) {
239 uint8_t pt_num = static_cast<uint8_t>(std::stoi(match[1].str()));
240 int coeff_idx = std::stoi(match[2].str());
241 if (coeff_idx >= 0 && coeff_idx < 4) {
242 pt_col_indices[pt_num][coeff_idx] = col_idx;
243 }
244 }
245 col_idx++;
246 }
247
248 // Read all rows and use last row for coefficients
249 std::vector<std::vector<std::string>> rows;
250 std::string line;
251 while (std::getline(file, line)) {
252 if (line.empty())
253 continue;
254 std::vector<std::string> row;
255 std::istringstream line_stream(line);
256 std::string cell;
257 while (std::getline(line_stream, cell, ',')) {
258 row.push_back(cell);
259 }
260 rows.push_back(row);
261 }
262
263 if (rows.empty()) {
264 return false;
265 }
266
267 // Use last row for coefficients
268 const auto& last_row = rows.back();
269
270 // Extract coefficients for each PT
271 for (const auto& [pt_num, col_indices] : pt_col_indices) {
272 if (col_indices[0] < static_cast<int>(last_row.size()) &&
273 col_indices[1] < static_cast<int>(last_row.size()) &&
274 col_indices[2] < static_cast<int>(last_row.size()) &&
275 col_indices[3] < static_cast<int>(last_row.size())) {
276 try {
277 double A = std::stod(last_row[col_indices[0]]);
278 double B = std::stod(last_row[col_indices[1]]);
279 double C = std::stod(last_row[col_indices[2]]);
280 double D = std::stod(last_row[col_indices[3]]);
281
282 calibrations_[pt_num] = PTCalibrationCoeffs(A, B, C, D);
283 } catch (const std::exception& e) {
284 std::cerr << "[PTCalibration] Error parsing coefficients for PT "
285 << static_cast<int>(pt_num) << ": " << e.what() << std::endl;
286 }
287 }
288 }
289
290 return !calibrations_.empty();
291}
292
294 auto it = calibrations_.find(channel_id);
295 if (it != calibrations_.end()) {
296 return &it->second;
297 }
298 return nullptr;
299}
300
301bool PTCalibrationManager::is_calibrated(uint8_t channel_id) const {
302 return calibrations_.find(channel_id) != calibrations_.end();
303}
304
305void PTCalibrationManager::set_calibration(uint8_t channel_id, const PTCalibrationCoeffs& coeffs) {
306 calibrations_[channel_id] = coeffs;
307}
308
310 calibrations_.erase(channel_id);
311}
312
313double PTCalibrationManager::calculate_pressure(uint8_t channel_id, int32_t adc_code) const {
314 const auto* coeffs = get_calibration(channel_id);
315 if (coeffs) {
316 double p = coeffs->calculate_pressure(adc_code);
317 if (!std::isfinite(p))
318 return 0.0;
319 return std::clamp(p, -3000.0, 20000.0);
320 }
321 return 0.0; // Uncalibrated
322}
323
324std::optional<int32_t> PTCalibrationCoeffs::invert_to_adc(double target_psi) const {
325 auto eval = [this](double x) {
326 return (A * x * x * x) + (B * x * x) + (C * x) + D;
327 };
328 for (const auto& [lo, hi] : {std::pair<int64_t, int64_t>(0, 2147483647),
329 std::pair<int64_t, int64_t>(-2147483648, 0)}) {
330 double f_lo = eval(lo);
331 double f_hi = eval(hi);
332 if (!(std::min(f_lo, f_hi) <= target_psi && target_psi <= std::max(f_lo, f_hi)))
333 continue;
334 double left = lo, right = hi;
335 for (int i = 0; i < 64; ++i) {
336 double mid = std::round((left + right) / 2);
337 double f_mid = eval(mid);
338 if (std::abs(f_mid - target_psi) < 0.5)
339 return static_cast<int32_t>(mid);
340 if (f_lo < f_hi) {
341 if (f_mid < target_psi)
342 left = mid;
343 else
344 right = mid;
345 } else {
346 if (f_mid > target_psi)
347 left = mid;
348 else
349 right = mid;
350 }
351 }
352 return static_cast<int32_t>(std::round((left + right) / 2));
353 }
354 return std::nullopt;
355}
356
357void PTCalibrationManager::set_default_paths(const std::string& json_dir,
358 const std::string& csv_path) {
359 default_json_dir_ = json_dir;
360 default_csv_path_ = csv_path;
361}
362
363/** Match calibration_orchestrator._load_prior_from_polynomial_calibration: skip non-factory JSON.
364 */
365static bool skip_json_for_pt_polynomial_load(const std::string& filename) {
366 if (filename == "adjustments.json")
367 return true;
368 if (filename.find("learned_prior") != std::string::npos)
369 return true;
370 // Operator-built cubics are owned by CubicCalibrationStore, which applies them via
371 // set_calibration() after load. Excluding the file here keeps the factory overlay pure so the
372 // service can snapshot true factory coefficients (for revert-on-clear).
373 if (filename == "cubic_calibration.json")
374 return true;
375 return false;
376}
377
378std::string PTCalibrationManager::find_latest_json_file(const std::string& json_dir) const {
379 if (!fs::exists(json_dir) || !fs::is_directory(json_dir)) {
380 return "";
381 }
382
383 std::string latest_file;
384 std::time_t latest_time = 0;
385 // Seeding "newest so far" with 0 silently discarded every candidate: libstdc++'s
386 // fs::file_time_type is __file_clock, whose epoch is 2174-01-01, so a present-day file's
387 // time_since_epoch() is NEGATIVE (~-4.6e9 s) and `time_t > 0` is never true. This function
388 // therefore always returned "" and no JSON calibration has ever loaded — the service stat()ed
389 // the file and then skipped it, reporting "No calibration files found". Track the first
390 // candidate explicitly instead of relying on a sentinel that assumes a 1970 epoch.
391 bool have_candidate = false;
392
393#if __cplusplus >= 201703L
394 for (const auto& entry : fs::directory_iterator(json_dir)) {
395 if (!entry.is_regular_file() || entry.path().extension() != ".json")
396 continue;
397 const std::string fname = entry.path().filename().string();
399 continue;
400 auto file_time = fs::last_write_time(entry.path());
401 auto time_t =
402 std::chrono::duration_cast<std::chrono::seconds>(file_time.time_since_epoch()).count();
403
404 if (!have_candidate || time_t > latest_time) {
405 have_candidate = true;
406 latest_time = time_t;
407 latest_file = entry.path().string();
408 }
409 }
410#else
411 // Fallback for older C++ standards
412 DIR* dir = opendir(json_dir.c_str());
413 if (dir) {
414 struct dirent* entry;
415 while ((entry = readdir(dir)) != nullptr) {
416 std::string filename = entry->d_name;
417 if (filename.length() > 5 && filename.substr(filename.length() - 5) == ".json") {
419 continue;
420 std::string full_path = json_dir + "/" + filename;
421 struct stat file_stat;
422 if (stat(full_path.c_str(), &file_stat) == 0 && S_ISREG(file_stat.st_mode)) {
423 if (!have_candidate || file_stat.st_mtime > latest_time) {
424 have_candidate = true;
425 latest_time = file_stat.st_mtime;
426 latest_file = full_path;
427 }
428 }
429 }
430 }
431 closedir(dir);
432 }
433#endif
434
435 return latest_file;
436}
437
438} // namespace calibration
439} // namespace fsw
std::string find_latest_json_file(const std::string &json_dir) const
bool load_calibration()
Auto-load calibration from default paths Default: factory CSV first, then newest GUI JSON overlays ev...
bool load_from_json(const std::string &json_path, bool merge_missing_only=false)
Load calibration from JSON file (from calibration GUI).
void set_calibration(uint8_t channel_id, const PTCalibrationCoeffs &coeffs)
Set/replace the cubic coefficients for a logical channel at runtime.
std::map< uint8_t, PTCalibrationCoeffs > calibrations_
bool is_calibrated(uint8_t channel_id) const
Check if a channel has calibration.
double calculate_pressure(uint8_t channel_id, int32_t adc_code) const
Calculate calibrated pressure for a channel.
void clear_calibration(uint8_t channel_id)
Remove any cubic for a logical channel, so is_calibrated() returns false again.
bool load_from_csv(const std::string &csv_path)
Load calibration from CSV file (from DiabloAvionics).
const PTCalibrationCoeffs * get_calibration(uint8_t channel_id) const
Get calibration coefficients for a sensor channel.
static void set_default_paths(const std::string &json_dir, const std::string &csv_path)
Set default calibration file paths.
int32_t p
Definition flight_e2e.c:78
*brief create JSON pointer *sa reference_tokens end()
bool is_directory(const std::string &path)
bool exists(const std::string &path)
Output smoothing for calibrated PT pressure, per sensor uid (ESP32 and LabJack T7 alike).
static bool skip_json_for_pt_polynomial_load(const std::string &filename)
Match calibration_orchestrator._load_prior_from_polynomial_calibration: skip non-factory JSON.
Motorized ball valve position: raw ADC counts -> % open.
fs::path path() const
directory_iterator(const std::string &dir_path)
std::string string() const
std::string p
std::string extension() const
path(const std::string &s)
PT Calibration Coefficients.
std::optional< int32_t > invert_to_adc(double target_psi) const
Invert psi = A*x^3 + B*x^2 + C*x + D to find ADC code for target PSI.
static std::string ch(int ain, const std::string &kind, const std::string &extra="", const std::string &board="lj")
Definition test_t7.cpp:98
constexpr bool is_number
Definition toml.hpp:2244