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))]
91pub struct GroupAudit {
92 pub time: DateTime<Utc>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub expected_mean: Option<f64>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub expected_sd: Option<f64>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub expected_n: Option<i64>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
108#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
109pub struct StandardCurveUpsert {
110 pub source_key: String,
111 pub instrument_label: String,
114 pub slope: f64,
115 pub intercept: f64,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub r_squared: Option<f64>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub name: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub fitted_on: Option<chrono::NaiveDate>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
133pub struct SensorUpsert {
134 pub source_key: String,
136 pub name: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub serial_number: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub manufacturer: Option<String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub model: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub notes: Option<String>,
147 pub is_lab_instrument: bool,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub metadata: Option<serde_json::Value>,
152}
153
154#[derive(Debug, Clone)]
156pub struct SensorMapping {
157 pub source_key: String,
158 pub id: Uuid,
159 pub created: bool,
161 pub serial_claimed_by: Option<Uuid>,
163}
164
165#[derive(Debug, Clone)]
167pub struct CurveMapping {
168 pub source_key: String,
169 pub id: Uuid,
170 pub sensor_id: Uuid,
171 pub superseded: bool,
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
181 fn the_registered_wire_types_round_trip() {
182 let spec = ReplicateSpec {
183 source_columns: vec!["DOC_rep_1".into(), "DOC_rep_2".into()],
184 portal_mean_column: Some("DOC_avg".into()),
185 portal_sd_column: None,
186 curve_ref_column: None,
187 calc: Some("calcMean".into()),
188 sd_estimator: Some("population".into()),
189 };
190 let back: ReplicateSpec =
191 serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap();
192 assert_eq!(back.source_columns, spec.source_columns);
193 assert_eq!(back.sd_estimator.as_deref(), Some("population"));
194
195 let audit = GroupAudit {
196 time: Utc::now(),
197 expected_mean: Some(1.5),
198 expected_sd: Some(0.1),
199 expected_n: Some(3),
200 };
201 let back: GroupAudit =
202 serde_json::from_value(serde_json::to_value(&audit).unwrap()).unwrap();
203 assert_eq!(back.expected_n, Some(3));
204
205 let curve = StandardCurveUpsert {
206 source_key: "standard_curves:3".into(),
207 instrument_label: "DOC corr".into(),
208 slope: 1.0,
209 intercept: 0.0,
210 r_squared: None,
211 name: Some("DOC corr 2021-01-28".into()),
212 fitted_on: chrono::NaiveDate::from_ymd_opt(2021, 1, 28),
213 };
214 let back: StandardCurveUpsert =
215 serde_json::from_value(serde_json::to_value(&curve).unwrap()).unwrap();
216 assert_eq!(back.fitted_on, curve.fitted_on);
217 assert!(back.r_squared.is_none());
218
219 let sensor = SensorUpsert {
220 source_key: "sensor_inventory:62".into(),
221 name: "ANU TURB".into(),
222 serial_number: Some("919402".into()),
223 manufacturer: None,
224 model: Some("Cyclops-7".into()),
225 notes: None,
226 is_lab_instrument: false,
227 metadata: None,
228 };
229 let back: SensorUpsert =
230 serde_json::from_value(serde_json::to_value(&sensor).unwrap()).unwrap();
231 assert_eq!(back.serial_number.as_deref(), Some("919402"));
232 assert!(!back.is_lab_instrument);
233
234 let assignment = ColumnAssignment {
235 column: "DOC_rep_2".into(),
236 index: 1,
237 retired: true,
238 };
239 let back: ColumnAssignment =
240 serde_json::from_value(serde_json::to_value(&assignment).unwrap()).unwrap();
241 assert_eq!(back, assignment);
242 }
243
244 #[test]
245 fn replicate_spec_skips_absent_fields() {
246 let spec = ReplicateSpec {
247 source_columns: vec!["DIC_A".into(), "DIC_B".into()],
248 portal_mean_column: Some("DIC_avg".into()),
249 portal_sd_column: None,
250 curve_ref_column: None,
251 calc: Some("calcMean".into()),
252 sd_estimator: None,
253 };
254 let json = serde_json::to_value(&spec).unwrap();
255 assert_eq!(json["source_columns"][1], "DIC_B");
256 assert_eq!(json["portal_mean_column"], "DIC_avg");
257 assert!(json.get("portal_sd_column").is_none());
258 assert!(json.get("curve_ref_column").is_none());
259 }
260
261 #[test]
262 fn sensor_upsert_skips_absent_fields() {
263 let up = SensorUpsert {
264 source_key: "sensor_inventory:62".into(),
265 name: "ANU TURB".into(),
266 serial_number: Some("919402".into()),
267 manufacturer: None,
268 model: Some("Cyclops-7".into()),
269 notes: None,
270 is_lab_instrument: false,
271 metadata: None,
272 };
273 let json = serde_json::to_value(&up).unwrap();
274 assert_eq!(json["source_key"], "sensor_inventory:62");
275 assert_eq!(json["serial_number"], "919402");
276 assert_eq!(json["is_lab_instrument"], false);
277 assert!(json.get("manufacturer").is_none());
278 assert!(json.get("notes").is_none());
279 assert!(json.get("metadata").is_none());
280 }
281
282 #[test]
283 fn group_audit_skips_absent_fields() {
284 let audit = GroupAudit {
285 time: Utc::now(),
286 expected_mean: Some(1.5),
287 expected_sd: None,
288 expected_n: None,
289 };
290 let json = serde_json::to_value(&audit).unwrap();
291 assert_eq!(json["expected_mean"], 1.5);
292 assert!(json.get("expected_sd").is_none());
293 assert!(json.get("expected_n").is_none());
294 }
295
296 #[test]
297 fn group_audit_serializes_expected_n() {
298 let audit = GroupAudit {
299 time: Utc::now(),
300 expected_mean: Some(1.5),
301 expected_sd: Some(0.1),
302 expected_n: Some(2),
303 };
304 let json = serde_json::to_value(&audit).unwrap();
305 assert_eq!(json["expected_n"], 2);
306 }
307
308 #[test]
309 fn column_assignments_parse_from_metadata() {
310 let metadata = serde_json::json!({
311 "replicates": {
312 "source_columns": ["DOC_rep_1", "DOC_rep_3"],
313 "assignments": [
314 {"column": "DOC_rep_1", "index": 0},
315 {"column": "DOC_rep_2", "index": 1, "retired": true},
316 {"column": "DOC_rep_3", "index": 2, "retired": false},
317 ],
318 },
319 });
320 let assignments = ColumnAssignment::from_metadata(&metadata).unwrap();
321 assert_eq!(assignments.len(), 3);
322 assert_eq!(assignments[0].column, "DOC_rep_1");
323 assert!(!assignments[0].retired);
324 assert_eq!(assignments[1].index, 1);
325 assert!(assignments[1].retired);
326 }
327
328 #[test]
329 fn metadata_without_a_replicate_spec_yields_none() {
330 assert!(ColumnAssignment::from_metadata(&serde_json::json!({})).is_none());
331 let no_columns = serde_json::json!({ "replicates": {"source_columns": []} });
332 assert!(ColumnAssignment::from_metadata(&no_columns).is_none());
333 }
334
335 #[test]
341 fn an_unpinned_spec_resolves_to_column_positions() {
342 for spec in [
343 serde_json::json!({"source_columns": ["DOC_rep_1", "DOC_rep_2", "DOC_rep_3"]}),
344 serde_json::json!({
345 "source_columns": ["DOC_rep_1", "DOC_rep_2", "DOC_rep_3"],
346 "assignments": [],
347 }),
348 ] {
349 let metadata = serde_json::json!({ "replicates": spec });
350 let assignments = ColumnAssignment::from_metadata(&metadata).unwrap();
351 assert_eq!(
352 assignments,
353 vec![
354 ColumnAssignment {
355 column: "DOC_rep_1".into(),
356 index: 0,
357 retired: false,
358 },
359 ColumnAssignment {
360 column: "DOC_rep_2".into(),
361 index: 1,
362 retired: false,
363 },
364 ColumnAssignment {
365 column: "DOC_rep_3".into(),
366 index: 2,
367 retired: false,
368 },
369 ]
370 );
371 }
372 }
373
374 #[test]
375 fn pinned_assignments_win_over_column_order() {
376 let pinned = vec![
377 ColumnAssignment {
378 column: "DOC_rep_2".into(),
379 index: 1,
380 retired: false,
381 },
382 ColumnAssignment {
383 column: "DOC_rep_1".into(),
384 index: 0,
385 retired: false,
386 },
387 ];
388 let resolved = ColumnAssignment::resolve(
389 pinned,
390 &["DOC_rep_1".to_string(), "DOC_rep_2".to_string()],
391 );
392 assert_eq!(resolved[0].column, "DOC_rep_1");
393 assert_eq!(resolved[1].index, 1);
394 }
395
396 #[test]
400 fn a_retired_column_keeps_its_index_and_the_gap_it_leaves() {
401 let metadata = serde_json::json!({ "replicates": {
402 "source_columns": ["DOC_rep_A", "DOC_rep_C"],
403 "assignments": [
404 { "column": "DOC_rep_C", "index": 2 },
405 { "column": "DOC_rep_A", "index": 0 },
406 { "column": "DOC_rep_B", "index": 1, "retired": true },
407 ],
408 }});
409 let assignments = ColumnAssignment::from_metadata(&metadata).unwrap();
410 assert_eq!(
411 assignments,
412 vec![
413 ColumnAssignment {
414 column: "DOC_rep_A".into(),
415 index: 0,
416 retired: false,
417 },
418 ColumnAssignment {
419 column: "DOC_rep_B".into(),
420 index: 1,
421 retired: true,
422 },
423 ColumnAssignment {
424 column: "DOC_rep_C".into(),
425 index: 2,
426 retired: false,
427 },
428 ],
429 "the retired column is kept, in its place, and nothing is renumbered over its index"
430 );
431 }
432
433 #[test]
437 fn pinning_beats_the_declared_column_list() {
438 let metadata = serde_json::json!({ "replicates": {
439 "source_columns": ["DOC_rep_B", "DOC_rep_A"],
440 "assignments": [
441 { "column": "DOC_rep_A", "index": 0 },
442 { "column": "DOC_rep_B", "index": 1 },
443 ],
444 }});
445 let assignments = ColumnAssignment::from_metadata(&metadata).unwrap();
446 assert_eq!(
447 assignments.iter().map(|a| a.index).collect::<Vec<_>>(),
448 vec![0, 1]
449 );
450 assert_eq!(assignments[0].column, "DOC_rep_A");
451 }
452
453 #[test]
456 fn an_empty_spec_resolves_to_no_mapping() {
457 let metadata = serde_json::json!({ "replicates": { "source_columns": [] } });
458 assert!(ColumnAssignment::from_metadata(&metadata).is_none());
459 assert!(ColumnAssignment::resolve(Vec::new(), &[]).is_empty());
460 }
461
462 #[test]
463 fn curve_upsert_serialization() {
464 let up = StandardCurveUpsert {
465 source_key: "standard_curves:3".into(),
466 instrument_label: "DOC corr".into(),
467 slope: 1.0,
468 intercept: 0.0,
469 r_squared: None,
470 name: Some("DOC corr 2021-01-28".into()),
471 fitted_on: chrono::NaiveDate::from_ymd_opt(2021, 1, 28),
472 };
473 let json = serde_json::to_value(&up).unwrap();
474 assert_eq!(json["source_key"], "standard_curves:3");
475 assert_eq!(json["instrument_label"], "DOC corr");
476 assert_eq!(json["fitted_on"], "2021-01-28");
477 assert!(json.get("r_squared").is_none());
478 }
479}