1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13pub struct ReplicateSpec {
14 pub source_columns: Vec<String>,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub portal_mean_column: Option<String>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub portal_sd_column: Option<String>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub curve_ref_column: Option<String>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub calc: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub sd_estimator: Option<String>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
36pub struct ColumnAssignment {
37 pub column: String,
38 pub index: i16,
39 #[serde(default)]
42 pub retired: bool,
43}
44
45impl ColumnAssignment {
46 pub fn from_metadata(metadata: &serde_json::Value) -> Option<Vec<Self>> {
49 let spec = metadata.get("replicates")?;
50 let pinned: Vec<Self> = spec
51 .get("assignments")
52 .and_then(|v| serde_json::from_value(v.clone()).ok())
53 .unwrap_or_default();
54 let source_columns: Vec<String> = spec
55 .get("source_columns")
56 .and_then(|v| serde_json::from_value(v.clone()).ok())
57 .unwrap_or_default();
58 let resolved = Self::resolve(pinned, &source_columns);
59 (!resolved.is_empty()).then_some(resolved)
60 }
61
62 #[must_use]
68 pub fn resolve(pinned: Vec<Self>, source_columns: &[String]) -> Vec<Self> {
69 let mut resolved = if pinned.is_empty() {
70 source_columns
71 .iter()
72 .enumerate()
73 .map(|(i, column)| Self {
74 column: column.clone(),
75 index: i16::try_from(i).unwrap_or(i16::MAX),
76 retired: false,
77 })
78 .collect()
79 } else {
80 pinned
81 };
82 resolved.sort_by_key(|a| a.index);
83 resolved
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
90#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
91#[serde(deny_unknown_fields)]
92pub struct GroupAudit {
93 pub time: DateTime<Utc>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub expected_mean: Option<f64>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub expected_sd: Option<f64>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub expected_n: Option<i64>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
109#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
110pub struct StandardCurveUpsert {
111 pub source_key: String,
112 pub instrument_label: String,
115 pub slope: f64,
116 pub intercept: f64,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub r_squared: Option<f64>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub name: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub fitted_on: Option<chrono::NaiveDate>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub notes: Option<String>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
136#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
137#[serde(deny_unknown_fields)]
138pub struct SensorUpsert {
139 pub source_key: String,
141 pub name: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub serial_number: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub manufacturer: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub model: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub notes: Option<String>,
152 pub is_lab_instrument: bool,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub data_frequency: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub metadata: Option<serde_json::Value>,
161}
162
163#[derive(Debug, Clone)]
165pub struct SensorMapping {
166 pub source_key: String,
167 pub id: Uuid,
168 pub created: bool,
170 pub serial_claimed_by: Option<Uuid>,
172}
173
174#[derive(Debug, Clone)]
176pub struct CurveMapping {
177 pub source_key: String,
178 pub id: Uuid,
179 pub sensor_id: Uuid,
180 pub superseded: bool,
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
190 fn the_registered_wire_types_round_trip() {
191 let spec = ReplicateSpec {
192 source_columns: vec!["DOC_rep_1".into(), "DOC_rep_2".into()],
193 portal_mean_column: Some("DOC_avg".into()),
194 portal_sd_column: None,
195 curve_ref_column: None,
196 calc: Some("calcMean".into()),
197 sd_estimator: Some("population".into()),
198 };
199 let back: ReplicateSpec =
200 serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap();
201 assert_eq!(back.source_columns, spec.source_columns);
202 assert_eq!(back.sd_estimator.as_deref(), Some("population"));
203
204 let audit = GroupAudit {
205 time: Utc::now(),
206 expected_mean: Some(1.5),
207 expected_sd: Some(0.1),
208 expected_n: Some(3),
209 };
210 let back: GroupAudit =
211 serde_json::from_value(serde_json::to_value(&audit).unwrap()).unwrap();
212 assert_eq!(back.expected_n, Some(3));
213
214 let curve = StandardCurveUpsert {
215 source_key: "standard_curves:3".into(),
216 instrument_label: "DOC corr".into(),
217 slope: 1.0,
218 intercept: 0.0,
219 r_squared: None,
220 name: Some("DOC corr 2021-01-28".into()),
221 fitted_on: chrono::NaiveDate::from_ymd_opt(2021, 1, 28),
222 notes: None,
223 };
224 let back: StandardCurveUpsert =
225 serde_json::from_value(serde_json::to_value(&curve).unwrap()).unwrap();
226 assert_eq!(back.fitted_on, curve.fitted_on);
227 assert!(back.r_squared.is_none());
228
229 let sensor = SensorUpsert {
230 source_key: "sensor_inventory:62".into(),
231 name: "ANU TURB".into(),
232 serial_number: Some("919402".into()),
233 manufacturer: None,
234 model: Some("Cyclops-7".into()),
235 notes: None,
236 is_lab_instrument: false,
237 data_frequency: None,
238 metadata: None,
239 };
240 let back: SensorUpsert =
241 serde_json::from_value(serde_json::to_value(&sensor).unwrap()).unwrap();
242 assert_eq!(back.serial_number.as_deref(), Some("919402"));
243 assert!(!back.is_lab_instrument);
244
245 let assignment = ColumnAssignment {
246 column: "DOC_rep_2".into(),
247 index: 1,
248 retired: true,
249 };
250 let back: ColumnAssignment =
251 serde_json::from_value(serde_json::to_value(&assignment).unwrap()).unwrap();
252 assert_eq!(back, assignment);
253 }
254
255 #[test]
256 fn replicate_spec_skips_absent_fields() {
257 let spec = ReplicateSpec {
258 source_columns: vec!["DIC_A".into(), "DIC_B".into()],
259 portal_mean_column: Some("DIC_avg".into()),
260 portal_sd_column: None,
261 curve_ref_column: None,
262 calc: Some("calcMean".into()),
263 sd_estimator: None,
264 };
265 let json = serde_json::to_value(&spec).unwrap();
266 assert_eq!(json["source_columns"][1], "DIC_B");
267 assert_eq!(json["portal_mean_column"], "DIC_avg");
268 assert!(json.get("portal_sd_column").is_none());
269 assert!(json.get("curve_ref_column").is_none());
270 }
271
272 #[test]
273 fn sensor_upsert_skips_absent_fields() {
274 let up = SensorUpsert {
275 source_key: "sensor_inventory:62".into(),
276 name: "ANU TURB".into(),
277 serial_number: Some("919402".into()),
278 manufacturer: None,
279 model: Some("Cyclops-7".into()),
280 notes: None,
281 is_lab_instrument: false,
282 data_frequency: None,
283 metadata: None,
284 };
285 let json = serde_json::to_value(&up).unwrap();
286 assert_eq!(json["source_key"], "sensor_inventory:62");
287 assert_eq!(json["serial_number"], "919402");
288 assert_eq!(json["is_lab_instrument"], false);
289 assert!(json.get("manufacturer").is_none());
290 assert!(json.get("notes").is_none());
291 assert!(json.get("metadata").is_none());
292 }
293
294 #[test]
295 fn group_audit_skips_absent_fields() {
296 let audit = GroupAudit {
297 time: Utc::now(),
298 expected_mean: Some(1.5),
299 expected_sd: None,
300 expected_n: None,
301 };
302 let json = serde_json::to_value(&audit).unwrap();
303 assert_eq!(json["expected_mean"], 1.5);
304 assert!(json.get("expected_sd").is_none());
305 assert!(json.get("expected_n").is_none());
306 }
307
308 #[test]
309 fn group_audit_serializes_expected_n() {
310 let audit = GroupAudit {
311 time: Utc::now(),
312 expected_mean: Some(1.5),
313 expected_sd: Some(0.1),
314 expected_n: Some(2),
315 };
316 let json = serde_json::to_value(&audit).unwrap();
317 assert_eq!(json["expected_n"], 2);
318 }
319
320 #[test]
321 fn column_assignments_parse_from_metadata() {
322 let metadata = serde_json::json!({
323 "replicates": {
324 "source_columns": ["DOC_rep_1", "DOC_rep_3"],
325 "assignments": [
326 {"column": "DOC_rep_1", "index": 0},
327 {"column": "DOC_rep_2", "index": 1, "retired": true},
328 {"column": "DOC_rep_3", "index": 2, "retired": false},
329 ],
330 },
331 });
332 let assignments = ColumnAssignment::from_metadata(&metadata).unwrap();
333 assert_eq!(assignments.len(), 3);
334 assert_eq!(assignments[0].column, "DOC_rep_1");
335 assert!(!assignments[0].retired);
336 assert_eq!(assignments[1].index, 1);
337 assert!(assignments[1].retired);
338 }
339
340 #[test]
341 fn metadata_without_a_replicate_spec_yields_none() {
342 assert!(ColumnAssignment::from_metadata(&serde_json::json!({})).is_none());
343 let no_columns = serde_json::json!({ "replicates": {"source_columns": []} });
344 assert!(ColumnAssignment::from_metadata(&no_columns).is_none());
345 }
346
347 #[test]
353 fn an_unpinned_spec_resolves_to_column_positions() {
354 for spec in [
355 serde_json::json!({"source_columns": ["DOC_rep_1", "DOC_rep_2", "DOC_rep_3"]}),
356 serde_json::json!({
357 "source_columns": ["DOC_rep_1", "DOC_rep_2", "DOC_rep_3"],
358 "assignments": [],
359 }),
360 ] {
361 let metadata = serde_json::json!({ "replicates": spec });
362 let assignments = ColumnAssignment::from_metadata(&metadata).unwrap();
363 assert_eq!(
364 assignments,
365 vec![
366 ColumnAssignment {
367 column: "DOC_rep_1".into(),
368 index: 0,
369 retired: false,
370 },
371 ColumnAssignment {
372 column: "DOC_rep_2".into(),
373 index: 1,
374 retired: false,
375 },
376 ColumnAssignment {
377 column: "DOC_rep_3".into(),
378 index: 2,
379 retired: false,
380 },
381 ]
382 );
383 }
384 }
385
386 #[test]
387 fn pinned_assignments_win_over_column_order() {
388 let pinned = vec![
389 ColumnAssignment {
390 column: "DOC_rep_2".into(),
391 index: 1,
392 retired: false,
393 },
394 ColumnAssignment {
395 column: "DOC_rep_1".into(),
396 index: 0,
397 retired: false,
398 },
399 ];
400 let resolved = ColumnAssignment::resolve(
401 pinned,
402 &["DOC_rep_1".to_string(), "DOC_rep_2".to_string()],
403 );
404 assert_eq!(resolved[0].column, "DOC_rep_1");
405 assert_eq!(resolved[1].index, 1);
406 }
407
408 #[test]
412 fn a_retired_column_keeps_its_index_and_the_gap_it_leaves() {
413 let metadata = serde_json::json!({ "replicates": {
414 "source_columns": ["DOC_rep_A", "DOC_rep_C"],
415 "assignments": [
416 { "column": "DOC_rep_C", "index": 2 },
417 { "column": "DOC_rep_A", "index": 0 },
418 { "column": "DOC_rep_B", "index": 1, "retired": true },
419 ],
420 }});
421 let assignments = ColumnAssignment::from_metadata(&metadata).unwrap();
422 assert_eq!(
423 assignments,
424 vec![
425 ColumnAssignment {
426 column: "DOC_rep_A".into(),
427 index: 0,
428 retired: false,
429 },
430 ColumnAssignment {
431 column: "DOC_rep_B".into(),
432 index: 1,
433 retired: true,
434 },
435 ColumnAssignment {
436 column: "DOC_rep_C".into(),
437 index: 2,
438 retired: false,
439 },
440 ],
441 "the retired column is kept, in its place, and nothing is renumbered over its index"
442 );
443 }
444
445 #[test]
449 fn pinning_beats_the_declared_column_list() {
450 let metadata = serde_json::json!({ "replicates": {
451 "source_columns": ["DOC_rep_B", "DOC_rep_A"],
452 "assignments": [
453 { "column": "DOC_rep_A", "index": 0 },
454 { "column": "DOC_rep_B", "index": 1 },
455 ],
456 }});
457 let assignments = ColumnAssignment::from_metadata(&metadata).unwrap();
458 assert_eq!(
459 assignments.iter().map(|a| a.index).collect::<Vec<_>>(),
460 vec![0, 1]
461 );
462 assert_eq!(assignments[0].column, "DOC_rep_A");
463 }
464
465 #[test]
468 fn an_empty_spec_resolves_to_no_mapping() {
469 let metadata = serde_json::json!({ "replicates": { "source_columns": [] } });
470 assert!(ColumnAssignment::from_metadata(&metadata).is_none());
471 assert!(ColumnAssignment::resolve(Vec::new(), &[]).is_empty());
472 }
473
474 #[test]
475 fn curve_upsert_serialization() {
476 let up = StandardCurveUpsert {
477 source_key: "standard_curves:3".into(),
478 instrument_label: "DOC corr".into(),
479 slope: 1.0,
480 intercept: 0.0,
481 r_squared: None,
482 name: Some("DOC corr 2021-01-28".into()),
483 fitted_on: chrono::NaiveDate::from_ymd_opt(2021, 1, 28),
484 notes: None,
485 };
486 let json = serde_json::to_value(&up).unwrap();
487 assert_eq!(json["source_key"], "standard_curves:3");
488 assert_eq!(json["instrument_label"], "DOC corr");
489 assert_eq!(json["fitted_on"], "2021-01-28");
490 assert!(json.get("r_squared").is_none());
491 }
492}