1use cobre_core::EntityId;
63use serde::Deserialize;
64use std::collections::HashSet;
65use std::path::Path;
66
67use crate::LoadError;
68
69#[derive(Debug, Clone, PartialEq)]
89pub struct ProductionModelConfig {
90 pub hydro_id: EntityId,
92 pub selection_mode: SelectionMode,
94}
95
96#[derive(Debug, Clone, PartialEq, Default)]
102pub struct ProductionModelFile {
103 pub configs: Vec<ProductionModelConfig>,
105 pub plane_reduction: Option<PlaneReductionConfig>,
108}
109
110#[derive(Debug, Clone, PartialEq)]
114pub enum SelectionMode {
115 StageRanges {
117 ranges: Vec<StageRange>,
119 },
120 Seasonal {
122 default_model: String,
124 seasons: Vec<SeasonConfig>,
126 },
127}
128
129#[derive(Debug, Clone, PartialEq)]
131pub struct StageRange {
132 pub start_stage_id: i32,
134 pub end_stage_id: Option<i32>,
136 pub model: String,
138 pub fpha_config: Option<FphaColumnLayout>,
140 pub reference_volume: Option<ReferenceVolume>,
143 pub productivity_mw_per_m3s: Option<f64>,
146}
147
148#[derive(Debug, Clone, PartialEq)]
150pub struct SeasonConfig {
151 pub season_id: i32,
153 pub model: String,
155 pub fpha_config: Option<FphaColumnLayout>,
157 pub reference_volume: Option<ReferenceVolume>,
160 pub productivity_mw_per_m3s: Option<f64>,
163}
164
165#[derive(Debug, Clone, PartialEq)]
167pub struct FphaColumnLayout {
168 pub source: String,
170 pub volume_discretization_points: Option<i32>,
172 pub turbine_discretization_points: Option<i32>,
174 pub spillage_discretization_points: Option<i32>,
176 pub max_planes_per_hydro: Option<i32>,
178 pub fitting_window: Option<FittingWindow>,
180}
181
182#[derive(Debug, Clone, PartialEq)]
189pub enum PlaneReductionConfig {
190 Angle {
193 tolerance_deg: f64,
196 },
197 Distance {
200 tolerance_pct: f64,
203 n_samples: u32,
205 },
206}
207
208#[derive(Debug, Clone, PartialEq)]
213pub struct FittingWindow {
214 pub volume_min_hm3: Option<f64>,
216 pub volume_max_hm3: Option<f64>,
218 pub volume_min_percentile: Option<f64>,
220 pub volume_max_percentile: Option<f64>,
222}
223
224#[derive(Debug, Clone, PartialEq)]
231pub enum ReferenceVolume {
232 AbsoluteHm3(f64),
234 Percentile(f64),
236}
237
238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
252#[derive(Deserialize)]
253#[serde(deny_unknown_fields)]
254pub(crate) struct RawProductionModelFile {
255 #[serde(rename = "$schema")]
257 _schema: Option<String>,
258
259 production_models: Vec<RawProductionModel>,
262
263 #[serde(default)]
267 fpha_plane_reduction: Option<RawPlaneReductionConfig>,
268}
269
270#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
276#[derive(Deserialize)]
277struct RawProductionModel {
278 hydro_id: i32,
280
281 #[serde(flatten)]
283 selection: RawSelectionMode,
284}
285
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
289#[derive(Deserialize)]
290#[serde(tag = "selection_mode", rename_all = "snake_case")]
291enum RawSelectionMode {
292 StageRanges {
295 stage_ranges: Vec<RawStageRange>,
297 },
298 Seasonal {
300 default_model: String,
303 seasons: Vec<RawSeasonConfig>,
305 },
306}
307
308#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
310#[derive(Deserialize)]
311#[serde(deny_unknown_fields)]
312struct RawStageRange {
313 start_stage_id: i32,
316 end_stage_id: Option<i32>,
319 model: String,
321 fpha_config: Option<RawFphaColumnLayout>,
324 reference_volume: Option<RawReferenceVolume>,
329 productivity_mw_per_m3s: Option<f64>,
334}
335
336#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
338#[derive(Deserialize)]
339#[serde(deny_unknown_fields)]
340struct RawSeasonConfig {
341 season_id: i32,
343 model: String,
345 fpha_config: Option<RawFphaColumnLayout>,
348 reference_volume: Option<RawReferenceVolume>,
353 productivity_mw_per_m3s: Option<f64>,
358}
359
360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
362#[derive(Deserialize)]
363#[serde(deny_unknown_fields)]
364struct RawFphaColumnLayout {
365 source: String,
368 volume_discretization_points: Option<i32>,
371 turbine_discretization_points: Option<i32>,
374 spillage_discretization_points: Option<i32>,
377 max_planes_per_hydro: Option<i32>,
380 fitting_window: Option<RawFittingWindow>,
383}
384
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
395#[derive(Deserialize)]
396#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
397enum RawPlaneReductionConfig {
398 Angle {
401 tolerance_deg: f64,
404 },
405 Distance {
408 tolerance_pct: f64,
411 n_samples: u32,
413 },
414}
415
416#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
423#[allow(clippy::struct_field_names)]
424#[derive(Deserialize)]
425#[serde(deny_unknown_fields)]
426struct RawFittingWindow {
427 volume_min_hm3: Option<f64>,
430 volume_max_hm3: Option<f64>,
433 volume_min_percentile: Option<f64>,
436 volume_max_percentile: Option<f64>,
439}
440
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
447#[derive(Deserialize)]
448#[serde(deny_unknown_fields)]
449struct RawReferenceVolume {
450 volume_hm3: Option<f64>,
453 percentile: Option<f64>,
457}
458
459pub fn parse_production_models(path: &Path) -> Result<ProductionModelFile, LoadError> {
485 let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
486
487 let raw: RawProductionModelFile = serde_json::from_str(&raw_text).map_err(|e| {
488 let msg = e.to_string();
489 if msg.contains("unknown variant") {
490 LoadError::SchemaError {
491 path: path.to_path_buf(),
492 field: "selection_mode".to_string(),
493 message: msg,
494 }
495 } else {
496 LoadError::parse(path, msg)
497 }
498 })?;
499
500 validate_production_models(
501 &raw.production_models,
502 raw.fpha_plane_reduction.as_ref(),
503 path,
504 )?;
505
506 let mut configs: Vec<ProductionModelConfig> = raw
507 .production_models
508 .into_iter()
509 .map(convert_production_model)
510 .collect();
511
512 configs.sort_by_key(|c| c.hydro_id.0);
513
514 let plane_reduction = raw
515 .fpha_plane_reduction
516 .as_ref()
517 .map(convert_plane_reduction);
518
519 Ok(ProductionModelFile {
520 configs,
521 plane_reduction,
522 })
523}
524
525fn validate_production_models(
529 models: &[RawProductionModel],
530 plane_reduction: Option<&RawPlaneReductionConfig>,
531 path: &Path,
532) -> Result<(), LoadError> {
533 let mut seen_ids: HashSet<i32> = HashSet::new();
534
535 for (entry_idx, model) in models.iter().enumerate() {
536 if !seen_ids.insert(model.hydro_id) {
537 return Err(LoadError::SchemaError {
538 path: path.to_path_buf(),
539 field: format!("production_models[{entry_idx}].hydro_id"),
540 message: format!(
541 "duplicate hydro_id {} — each hydro may appear at most once",
542 model.hydro_id
543 ),
544 });
545 }
546
547 match &model.selection {
548 RawSelectionMode::StageRanges { stage_ranges } => {
549 for (range_idx, range) in stage_ranges.iter().enumerate() {
550 validate_stage_range(range, entry_idx, range_idx, path)?;
551 }
552 }
553 RawSelectionMode::Seasonal { seasons, .. } => {
554 for (season_idx, season) in seasons.iter().enumerate() {
555 let field_base = format!(
556 "production_models[{entry_idx}].seasons[{season_idx}].productivity_mw_per_m3s"
557 );
558
559 if season.model == "fpha" && season.productivity_mw_per_m3s.is_some() {
560 return Err(LoadError::SchemaError {
561 path: path.to_path_buf(),
562 field: field_base,
563 message: "productivity_mw_per_m3s must not be set when model is 'fpha'"
564 .to_string(),
565 });
566 }
567
568 if season.model != "fpha"
570 && let Some(val) = season.productivity_mw_per_m3s
571 && (val < 0.0 || !val.is_finite())
572 {
573 return Err(LoadError::SchemaError {
574 path: path.to_path_buf(),
575 field: field_base,
576 message: format!(
577 "productivity_mw_per_m3s must be finite and non-negative, got {val}"
578 ),
579 });
580 }
581
582 if let Some(cfg) = &season.fpha_config {
583 validate_fitting_window(
584 cfg,
585 &format!(
586 "production_models[{entry_idx}].seasons[{season_idx}].fpha_config.fitting_window"
587 ),
588 path,
589 )?;
590 }
591
592 if let Some(rv) = &season.reference_volume {
593 validate_reference_volume(
594 rv,
595 &format!(
596 "production_models[{entry_idx}].seasons[{season_idx}].reference_volume"
597 ),
598 path,
599 )?;
600 }
601 }
602 }
603 }
604 }
605
606 if let Some(reduction) = plane_reduction {
607 validate_plane_reduction(reduction, path)?;
608 }
609
610 Ok(())
611}
612
613fn validate_stage_range(
615 range: &RawStageRange,
616 entry_idx: usize,
617 range_idx: usize,
618 path: &Path,
619) -> Result<(), LoadError> {
620 if let Some(end) = range.end_stage_id
621 && range.start_stage_id > end
622 {
623 return Err(LoadError::SchemaError {
624 path: path.to_path_buf(),
625 field: format!(
626 "production_models[{entry_idx}].stage_ranges[{range_idx}].start_stage_id"
627 ),
628 message: format!(
629 "stage_ranges entry has start_stage_id ({}) > end_stage_id ({}); \
630 start_stage_id must be <= end_stage_id",
631 range.start_stage_id, end
632 ),
633 });
634 }
635
636 let field_base =
637 format!("production_models[{entry_idx}].stage_ranges[{range_idx}].productivity_mw_per_m3s");
638
639 if range.model == "fpha" && range.productivity_mw_per_m3s.is_some() {
640 return Err(LoadError::SchemaError {
641 path: path.to_path_buf(),
642 field: field_base,
643 message: "productivity_mw_per_m3s must not be set when model is 'fpha'".to_string(),
644 });
645 }
646
647 if range.model != "fpha"
649 && let Some(val) = range.productivity_mw_per_m3s
650 && (val < 0.0 || !val.is_finite())
651 {
652 return Err(LoadError::SchemaError {
653 path: path.to_path_buf(),
654 field: field_base,
655 message: format!("productivity_mw_per_m3s must be finite and non-negative, got {val}"),
656 });
657 }
658
659 if let Some(cfg) = &range.fpha_config {
660 validate_fitting_window(
661 cfg,
662 &format!(
663 "production_models[{entry_idx}].stage_ranges[{range_idx}].fpha_config.fitting_window"
664 ),
665 path,
666 )?;
667 }
668
669 if let Some(rv) = &range.reference_volume {
670 validate_reference_volume(
671 rv,
672 &format!("production_models[{entry_idx}].stage_ranges[{range_idx}].reference_volume"),
673 path,
674 )?;
675 }
676
677 Ok(())
678}
679
680fn validate_fitting_window(
683 cfg: &RawFphaColumnLayout,
684 field_prefix: &str,
685 path: &Path,
686) -> Result<(), LoadError> {
687 let Some(fw) = &cfg.fitting_window else {
688 return Ok(());
689 };
690
691 if fw.volume_min_hm3.is_some() && fw.volume_min_percentile.is_some() {
692 return Err(LoadError::SchemaError {
693 path: path.to_path_buf(),
694 field: field_prefix.to_string(),
695 message: "mutually exclusive bounds: volume_min_hm3 and volume_min_percentile \
696 cannot both be set; use absolute bounds OR percentiles, not both"
697 .to_string(),
698 });
699 }
700
701 if fw.volume_max_hm3.is_some() && fw.volume_max_percentile.is_some() {
702 return Err(LoadError::SchemaError {
703 path: path.to_path_buf(),
704 field: field_prefix.to_string(),
705 message: "mutually exclusive bounds: volume_max_hm3 and volume_max_percentile \
706 cannot both be set; use absolute bounds OR percentiles, not both"
707 .to_string(),
708 });
709 }
710
711 Ok(())
712}
713
714fn validate_reference_volume(
719 rv: &RawReferenceVolume,
720 field_prefix: &str,
721 path: &Path,
722) -> Result<(), LoadError> {
723 if rv.volume_hm3.is_some() && rv.percentile.is_some() {
724 return Err(LoadError::SchemaError {
725 path: path.to_path_buf(),
726 field: field_prefix.to_string(),
727 message: "mutually exclusive fields: volume_hm3 and percentile cannot both be \
728 set; use an absolute volume OR a percentile, not both"
729 .to_string(),
730 });
731 }
732
733 if rv.volume_hm3.is_none() && rv.percentile.is_none() {
734 return Err(LoadError::SchemaError {
735 path: path.to_path_buf(),
736 field: field_prefix.to_string(),
737 message: "reference_volume must set exactly one of volume_hm3 or percentile"
738 .to_string(),
739 });
740 }
741
742 if let Some(vol) = rv.volume_hm3
743 && (!vol.is_finite() || vol <= 0.0)
744 {
745 return Err(LoadError::SchemaError {
746 path: path.to_path_buf(),
747 field: field_prefix.to_string(),
748 message: format!("volume_hm3 must be finite and > 0.0, got {vol}"),
749 });
750 }
751
752 if let Some(pct) = rv.percentile
753 && (!pct.is_finite() || !(0.0..=1.0).contains(&pct))
754 {
755 return Err(LoadError::SchemaError {
756 path: path.to_path_buf(),
757 field: field_prefix.to_string(),
758 message: format!("percentile must be finite and in [0.0, 1.0], got {pct}"),
759 });
760 }
761
762 Ok(())
763}
764
765fn validate_plane_reduction(
771 reduction: &RawPlaneReductionConfig,
772 path: &Path,
773) -> Result<(), LoadError> {
774 match reduction {
775 RawPlaneReductionConfig::Angle { tolerance_deg } => {
776 if !tolerance_deg.is_finite() || *tolerance_deg < 0.0 || *tolerance_deg > 90.0 {
777 return Err(LoadError::SchemaError {
778 path: path.to_path_buf(),
779 field: "fpha_plane_reduction".to_string(),
780 message: format!(
781 "angle tolerance_deg must be finite and in [0, 90], got {tolerance_deg}"
782 ),
783 });
784 }
785 }
786 RawPlaneReductionConfig::Distance {
787 tolerance_pct,
788 n_samples,
789 } => {
790 if !tolerance_pct.is_finite() || *tolerance_pct < 0.0 {
791 return Err(LoadError::SchemaError {
792 path: path.to_path_buf(),
793 field: "fpha_plane_reduction".to_string(),
794 message: format!(
795 "distance tolerance_pct must be finite and >= 0, got {tolerance_pct}"
796 ),
797 });
798 }
799 if *n_samples < 1 {
800 return Err(LoadError::SchemaError {
801 path: path.to_path_buf(),
802 field: "fpha_plane_reduction".to_string(),
803 message: format!("distance n_samples must be >= 1, got {n_samples}"),
804 });
805 }
806 }
807 }
808
809 Ok(())
810}
811
812fn convert_production_model(raw: RawProductionModel) -> ProductionModelConfig {
816 let selection_mode = match raw.selection {
817 RawSelectionMode::StageRanges { stage_ranges } => SelectionMode::StageRanges {
818 ranges: stage_ranges.into_iter().map(convert_stage_range).collect(),
819 },
820 RawSelectionMode::Seasonal {
821 default_model,
822 seasons,
823 } => SelectionMode::Seasonal {
824 default_model,
825 seasons: seasons.into_iter().map(convert_season_config).collect(),
826 },
827 };
828
829 ProductionModelConfig {
830 hydro_id: EntityId::from(raw.hydro_id),
831 selection_mode,
832 }
833}
834
835fn convert_stage_range(raw: RawStageRange) -> StageRange {
836 StageRange {
837 start_stage_id: raw.start_stage_id,
838 end_stage_id: raw.end_stage_id,
839 model: raw.model,
840 fpha_config: raw.fpha_config.map(convert_fpha_column_layout),
841 reference_volume: raw.reference_volume.as_ref().map(convert_reference_volume),
842 productivity_mw_per_m3s: raw.productivity_mw_per_m3s,
843 }
844}
845
846fn convert_season_config(raw: RawSeasonConfig) -> SeasonConfig {
847 SeasonConfig {
848 season_id: raw.season_id,
849 model: raw.model,
850 fpha_config: raw.fpha_config.map(convert_fpha_column_layout),
851 reference_volume: raw.reference_volume.as_ref().map(convert_reference_volume),
852 productivity_mw_per_m3s: raw.productivity_mw_per_m3s,
853 }
854}
855
856fn convert_reference_volume(raw: &RawReferenceVolume) -> ReferenceVolume {
862 match raw.volume_hm3 {
863 Some(vol) => ReferenceVolume::AbsoluteHm3(vol),
864 None => ReferenceVolume::Percentile(raw.percentile.unwrap_or_default()),
865 }
866}
867
868fn convert_fpha_column_layout(raw: RawFphaColumnLayout) -> FphaColumnLayout {
869 FphaColumnLayout {
870 source: raw.source,
871 volume_discretization_points: raw.volume_discretization_points,
872 turbine_discretization_points: raw.turbine_discretization_points,
873 spillage_discretization_points: raw.spillage_discretization_points,
874 max_planes_per_hydro: raw.max_planes_per_hydro,
875 fitting_window: raw.fitting_window.map(|fw| FittingWindow {
876 volume_min_hm3: fw.volume_min_hm3,
877 volume_max_hm3: fw.volume_max_hm3,
878 volume_min_percentile: fw.volume_min_percentile,
879 volume_max_percentile: fw.volume_max_percentile,
880 }),
881 }
882}
883
884fn convert_plane_reduction(raw: &RawPlaneReductionConfig) -> PlaneReductionConfig {
885 match raw {
886 RawPlaneReductionConfig::Angle { tolerance_deg } => PlaneReductionConfig::Angle {
887 tolerance_deg: *tolerance_deg,
888 },
889 RawPlaneReductionConfig::Distance {
890 tolerance_pct,
891 n_samples,
892 } => PlaneReductionConfig::Distance {
893 tolerance_pct: *tolerance_pct,
894 n_samples: *n_samples,
895 },
896 }
897}
898
899#[cfg(test)]
902#[allow(
903 clippy::doc_markdown,
904 clippy::expect_used,
905 clippy::match_wildcard_for_single_variants,
906 clippy::panic,
907 clippy::too_many_lines,
908 clippy::unwrap_used
909)]
910mod tests {
911 use super::*;
912 use std::io::Write;
913 use tempfile::NamedTempFile;
914
915 fn write_json(content: &str) -> NamedTempFile {
918 let mut f = NamedTempFile::new().unwrap();
919 f.write_all(content.as_bytes()).unwrap();
920 f
921 }
922
923 #[test]
928 fn test_valid_stage_ranges_mode() {
929 let json = r#"{
930 "production_models": [{
931 "hydro_id": 0,
932 "selection_mode": "stage_ranges",
933 "stage_ranges": [
934 {
935 "start_stage_id": 0, "end_stage_id": 24,
936 "model": "fpha",
937 "fpha_config": {
938 "source": "computed",
939 "volume_discretization_points": 7,
940 "turbine_discretization_points": 15,
941 "fitting_window": { "volume_min_hm3": null, "volume_max_hm3": null }
942 }
943 },
944 {
945 "start_stage_id": 25, "end_stage_id": null,
946 "model": "constant_productivity",
947 "productivity_mw_per_m3s": 0.9
948 }
949 ]
950 }]
951 }"#;
952 let f = write_json(json);
953 let models = parse_production_models(f.path()).unwrap().configs;
954
955 assert_eq!(models.len(), 1);
956 let m = &models[0];
957 assert_eq!(m.hydro_id, EntityId::from(0));
958 match &m.selection_mode {
959 SelectionMode::StageRanges { ranges } => {
960 assert_eq!(ranges.len(), 2);
961 assert_eq!(ranges[0].start_stage_id, 0);
962 assert_eq!(ranges[0].end_stage_id, Some(24));
963 assert_eq!(ranges[0].model, "fpha");
964 let fpha = ranges[0].fpha_config.as_ref().unwrap();
965 assert_eq!(fpha.source, "computed");
966 assert_eq!(fpha.volume_discretization_points, Some(7));
967 assert_eq!(fpha.turbine_discretization_points, Some(15));
968 let fw = fpha.fitting_window.as_ref().unwrap();
970 assert!(fw.volume_min_hm3.is_none());
971 assert!(fw.volume_max_hm3.is_none());
972
973 assert_eq!(ranges[1].start_stage_id, 25);
974 assert!(ranges[1].end_stage_id.is_none());
975 assert_eq!(ranges[1].model, "constant_productivity");
976 assert!(ranges[1].fpha_config.is_none());
977 assert_eq!(ranges[1].productivity_mw_per_m3s, Some(0.9));
978 }
979 other => panic!("expected StageRanges, got: {other:?}"),
980 }
981 }
982
983 #[test]
988 fn test_valid_seasonal_mode() {
989 let json = r#"{
990 "production_models": [{
991 "hydro_id": 5,
992 "selection_mode": "seasonal",
993 "default_model": "linearized_head",
994 "seasons": [
995 {
996 "season_id": 0,
997 "model": "fpha",
998 "fpha_config": { "source": "computed", "volume_discretization_points": 5 }
999 },
1000 {
1001 "season_id": 1, "model": "fpha",
1002 "fpha_config": { "source": "computed", "turbine_discretization_points": 10 }
1003 }
1004 ]
1005 }]
1006 }"#;
1007 let f = write_json(json);
1008 let models = parse_production_models(f.path()).unwrap().configs;
1009
1010 assert_eq!(models.len(), 1);
1011 let m = &models[0];
1012 assert_eq!(m.hydro_id, EntityId::from(5));
1013 match &m.selection_mode {
1014 SelectionMode::Seasonal {
1015 default_model,
1016 seasons,
1017 } => {
1018 assert_eq!(default_model, "linearized_head");
1019 assert_eq!(seasons.len(), 2);
1020 assert_eq!(seasons[0].season_id, 0);
1021 assert_eq!(seasons[0].model, "fpha");
1022 let fpha0 = seasons[0].fpha_config.as_ref().unwrap();
1023 assert_eq!(fpha0.source, "computed");
1024 assert_eq!(fpha0.volume_discretization_points, Some(5));
1025 assert!(fpha0.turbine_discretization_points.is_none());
1026
1027 assert_eq!(seasons[1].season_id, 1);
1028 let fpha1 = seasons[1].fpha_config.as_ref().unwrap();
1029 assert_eq!(fpha1.turbine_discretization_points, Some(10));
1030 assert!(fpha1.volume_discretization_points.is_none());
1031 }
1032 other => panic!("expected Seasonal, got: {other:?}"),
1033 }
1034 }
1035
1036 #[test]
1041 fn test_mixed_modes_sorted_by_hydro_id() {
1042 let json = r#"{
1043 "production_models": [
1044 {
1045 "hydro_id": 10,
1046 "selection_mode": "seasonal",
1047 "default_model": "constant_productivity",
1048 "seasons": []
1049 },
1050 {
1051 "hydro_id": 3,
1052 "selection_mode": "stage_ranges",
1053 "stage_ranges": [
1054 {
1055 "start_stage_id": 0, "end_stage_id": null,
1056 "model": "constant_productivity",
1057 "productivity_mw_per_m3s": 0.8
1058 }
1059 ]
1060 }
1061 ]
1062 }"#;
1063 let f = write_json(json);
1064 let models = parse_production_models(f.path()).unwrap().configs;
1065
1066 assert_eq!(models.len(), 2);
1067 assert_eq!(models[0].hydro_id, EntityId::from(3));
1069 assert_eq!(models[1].hydro_id, EntityId::from(10));
1070 assert!(matches!(
1071 models[0].selection_mode,
1072 SelectionMode::StageRanges { .. }
1073 ));
1074 assert!(matches!(
1075 models[1].selection_mode,
1076 SelectionMode::Seasonal { .. }
1077 ));
1078 }
1079
1080 #[test]
1084 fn test_duplicate_hydro_id() {
1085 let json = r#"{
1086 "production_models": [
1087 {
1088 "hydro_id": 5,
1089 "selection_mode": "stage_ranges",
1090 "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "fpha", "fpha_config": { "source": "computed" } }]
1091 },
1092 {
1093 "hydro_id": 5,
1094 "selection_mode": "stage_ranges",
1095 "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }]
1096 }
1097 ]
1098 }"#;
1099 let f = write_json(json);
1100 let err = parse_production_models(f.path()).unwrap_err();
1101 match &err {
1102 LoadError::SchemaError { field, message, .. } => {
1103 assert!(
1104 field.contains("hydro_id"),
1105 "field should mention hydro_id, got: {field}"
1106 );
1107 assert!(
1108 message.contains("duplicate"),
1109 "message should mention duplicate, got: {message}"
1110 );
1111 }
1112 other => panic!("expected SchemaError, got: {other:?}"),
1113 }
1114 }
1115
1116 #[test]
1121 fn test_invalid_stage_range_start_greater_than_end() {
1122 let json = r#"{
1123 "production_models": [{
1124 "hydro_id": 0,
1125 "selection_mode": "stage_ranges",
1126 "stage_ranges": [
1127 {
1128 "start_stage_id": 25, "end_stage_id": 10,
1129 "model": "constant_productivity",
1130 "productivity_mw_per_m3s": 0.9
1131 }
1132 ]
1133 }]
1134 }"#;
1135 let f = write_json(json);
1136 let err = parse_production_models(f.path()).unwrap_err();
1137 match &err {
1138 LoadError::SchemaError { field, message, .. } => {
1139 assert!(
1140 field.contains("stage_ranges"),
1141 "field should contain 'stage_ranges', got: {field}"
1142 );
1143 assert!(
1144 message.contains("start_stage_id"),
1145 "message should contain 'start_stage_id', got: {message}"
1146 );
1147 }
1148 other => panic!("expected SchemaError, got: {other:?}"),
1149 }
1150 }
1151
1152 #[test]
1156 fn test_stage_range_start_equals_end_is_valid() {
1157 let json = r#"{
1158 "production_models": [{
1159 "hydro_id": 0,
1160 "selection_mode": "stage_ranges",
1161 "stage_ranges": [
1162 {
1163 "start_stage_id": 5, "end_stage_id": 5,
1164 "model": "constant_productivity",
1165 "productivity_mw_per_m3s": 0.9
1166 }
1167 ]
1168 }]
1169 }"#;
1170 let f = write_json(json);
1171 let result = parse_production_models(f.path());
1172 assert!(
1173 result.is_ok(),
1174 "equal start==end should be valid, got: {result:?}"
1175 );
1176 }
1177
1178 #[test]
1183 fn test_mutually_exclusive_fitting_window_min() {
1184 let json = r#"{
1185 "production_models": [{
1186 "hydro_id": 0,
1187 "selection_mode": "stage_ranges",
1188 "stage_ranges": [{
1189 "start_stage_id": 0, "end_stage_id": null,
1190 "model": "fpha",
1191 "fpha_config": {
1192 "source": "computed",
1193 "fitting_window": {
1194 "volume_min_hm3": 1000.0,
1195 "volume_max_hm3": null,
1196 "volume_min_percentile": 0.1,
1197 "volume_max_percentile": null
1198 }
1199 }
1200 }]
1201 }]
1202 }"#;
1203 let f = write_json(json);
1204 let err = parse_production_models(f.path()).unwrap_err();
1205 match &err {
1206 LoadError::SchemaError { message, .. } => {
1207 assert!(
1208 message.contains("mutually exclusive"),
1209 "message should contain 'mutually exclusive', got: {message}"
1210 );
1211 }
1212 other => panic!("expected SchemaError, got: {other:?}"),
1213 }
1214 }
1215
1216 #[test]
1219 fn test_mutually_exclusive_fitting_window_max() {
1220 let json = r#"{
1221 "production_models": [{
1222 "hydro_id": 0,
1223 "selection_mode": "stage_ranges",
1224 "stage_ranges": [{
1225 "start_stage_id": 0, "end_stage_id": null,
1226 "model": "fpha",
1227 "fpha_config": {
1228 "source": "computed",
1229 "fitting_window": {
1230 "volume_min_hm3": null,
1231 "volume_max_hm3": 8000.0,
1232 "volume_min_percentile": null,
1233 "volume_max_percentile": 0.9
1234 }
1235 }
1236 }]
1237 }]
1238 }"#;
1239 let f = write_json(json);
1240 let err = parse_production_models(f.path()).unwrap_err();
1241 match &err {
1242 LoadError::SchemaError { message, .. } => {
1243 assert!(
1244 message.contains("mutually exclusive"),
1245 "message should contain 'mutually exclusive', got: {message}"
1246 );
1247 }
1248 other => panic!("expected SchemaError, got: {other:?}"),
1249 }
1250 }
1251
1252 #[test]
1254 fn test_mutually_exclusive_fitting_window_seasonal() {
1255 let json = r#"{
1256 "production_models": [{
1257 "hydro_id": 1,
1258 "selection_mode": "seasonal",
1259 "default_model": "constant_productivity",
1260 "seasons": [{
1261 "season_id": 0,
1262 "model": "fpha",
1263 "fpha_config": {
1264 "source": "computed",
1265 "fitting_window": {
1266 "volume_min_hm3": 500.0,
1267 "volume_min_percentile": 0.2
1268 }
1269 }
1270 }]
1271 }]
1272 }"#;
1273 let f = write_json(json);
1274 let err = parse_production_models(f.path()).unwrap_err();
1275 assert!(
1276 matches!(err, LoadError::SchemaError { .. }),
1277 "expected SchemaError, got: {err:?}"
1278 );
1279 }
1280
1281 #[test]
1288 fn test_file_not_found() {
1289 let path = Path::new("/nonexistent/path/hydro_production_models.json");
1290 let err = parse_production_models(path).unwrap_err();
1291 match &err {
1292 LoadError::IoError { path: p, .. } => {
1293 assert_eq!(p, path);
1294 }
1295 other => panic!("expected IoError, got: {other:?}"),
1296 }
1297 }
1298
1299 #[test]
1303 fn test_unknown_selection_mode() {
1304 let json = r#"{
1305 "production_models": [{
1306 "hydro_id": 0,
1307 "selection_mode": "unknown_mode"
1308 }]
1309 }"#;
1310 let f = write_json(json);
1311 let err = parse_production_models(f.path()).unwrap_err();
1312 assert!(
1313 matches!(err, LoadError::SchemaError { .. }),
1314 "expected SchemaError for unknown selection_mode, got: {err:?}"
1315 );
1316 }
1317
1318 #[test]
1322 fn test_empty_array_returns_empty_vec() {
1323 let json = r#"{ "production_models": [] }"#;
1324 let f = write_json(json);
1325 let models = parse_production_models(f.path()).unwrap().configs;
1326 assert!(models.is_empty());
1327 }
1328
1329 #[test]
1333 fn test_declaration_order_invariance() {
1334 let json_asc = r#"{
1335 "production_models": [
1336 { "hydro_id": 1, "selection_mode": "stage_ranges",
1337 "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
1338 { "hydro_id": 5, "selection_mode": "stage_ranges",
1339 "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
1340 { "hydro_id": 99, "selection_mode": "stage_ranges",
1341 "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] }
1342 ]
1343 }"#;
1344 let json_desc = r#"{
1345 "production_models": [
1346 { "hydro_id": 99, "selection_mode": "stage_ranges",
1347 "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
1348 { "hydro_id": 5, "selection_mode": "stage_ranges",
1349 "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] },
1350 { "hydro_id": 1, "selection_mode": "stage_ranges",
1351 "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "constant_productivity", "productivity_mw_per_m3s": 0.9 }] }
1352 ]
1353 }"#;
1354 let f_asc = write_json(json_asc);
1355 let f_desc = write_json(json_desc);
1356 let models_asc = parse_production_models(f_asc.path()).unwrap().configs;
1357 let models_desc = parse_production_models(f_desc.path()).unwrap().configs;
1358
1359 let ids_asc: Vec<i32> = models_asc.iter().map(|m| m.hydro_id.0).collect();
1360 let ids_desc: Vec<i32> = models_desc.iter().map(|m| m.hydro_id.0).collect();
1361 assert_eq!(
1362 ids_asc, ids_desc,
1363 "output order must be hydro_id-sorted regardless of input"
1364 );
1365 assert_eq!(ids_asc, vec![1, 5, 99]);
1366 }
1367
1368 #[test]
1372 fn test_fpha_config_without_fitting_window() {
1373 let json = r#"{
1374 "production_models": [{
1375 "hydro_id": 0,
1376 "selection_mode": "stage_ranges",
1377 "stage_ranges": [{
1378 "start_stage_id": 0, "end_stage_id": null,
1379 "model": "fpha",
1380 "fpha_config": { "source": "precomputed" }
1381 }]
1382 }]
1383 }"#;
1384 let f = write_json(json);
1385 let models = parse_production_models(f.path()).unwrap().configs;
1386 assert_eq!(models.len(), 1);
1387 match &models[0].selection_mode {
1388 SelectionMode::StageRanges { ranges } => {
1389 let fpha = ranges[0].fpha_config.as_ref().unwrap();
1390 assert_eq!(fpha.source, "precomputed");
1391 assert!(fpha.fitting_window.is_none());
1392 }
1393 other => panic!("expected StageRanges, got: {other:?}"),
1394 }
1395 }
1396
1397 #[test]
1402 fn constant_productivity_requires_coefficient() {
1403 let json = r#"{
1404 "production_models": [{
1405 "hydro_id": 0,
1406 "selection_mode": "stage_ranges",
1407 "stage_ranges": [
1408 {
1409 "start_stage_id": 0, "end_stage_id": 24,
1410 "model": "constant_productivity",
1411 "productivity_mw_per_m3s": 0.85
1412 }
1413 ]
1414 }]
1415 }"#;
1416 let f = write_json(json);
1417 let models = parse_production_models(f.path()).unwrap().configs;
1418 match &models[0].selection_mode {
1419 SelectionMode::StageRanges { ranges } => {
1420 assert_eq!(ranges[0].productivity_mw_per_m3s, Some(0.85));
1421 }
1422 other => panic!("expected StageRanges, got: {other:?}"),
1423 }
1424 }
1425
1426 #[test]
1429 fn test_non_fpha_stage_range_without_productivity_is_accepted() {
1430 let json = r#"{
1431 "production_models": [{
1432 "hydro_id": 0,
1433 "selection_mode": "stage_ranges",
1434 "stage_ranges": [
1435 {
1436 "start_stage_id": 0, "end_stage_id": 24,
1437 "model": "constant_productivity"
1438 }
1439 ]
1440 }]
1441 }"#;
1442 let f = write_json(json);
1443 let models = parse_production_models(f.path()).unwrap().configs;
1444 match &models[0].selection_mode {
1445 SelectionMode::StageRanges { ranges } => {
1446 assert!(
1447 ranges[0].productivity_mw_per_m3s.is_none(),
1448 "expected None when field is omitted, got: {:?}",
1449 ranges[0].productivity_mw_per_m3s
1450 );
1451 }
1452 other => panic!("expected StageRanges, got: {other:?}"),
1453 }
1454 }
1455
1456 #[test]
1459 fn test_non_fpha_stage_range_with_null_productivity_is_accepted() {
1460 let json = r#"{
1461 "production_models": [{
1462 "hydro_id": 0,
1463 "selection_mode": "stage_ranges",
1464 "stage_ranges": [
1465 {
1466 "start_stage_id": 0, "end_stage_id": 24,
1467 "model": "linearized_head",
1468 "productivity_mw_per_m3s": null
1469 }
1470 ]
1471 }]
1472 }"#;
1473 let f = write_json(json);
1474 let models = parse_production_models(f.path()).unwrap().configs;
1475 match &models[0].selection_mode {
1476 SelectionMode::StageRanges { ranges } => {
1477 assert!(
1478 ranges[0].productivity_mw_per_m3s.is_none(),
1479 "expected None when field is null, got: {:?}",
1480 ranges[0].productivity_mw_per_m3s
1481 );
1482 }
1483 other => panic!("expected StageRanges, got: {other:?}"),
1484 }
1485 }
1486
1487 #[test]
1490 fn fpha_rejects_coefficient() {
1491 let json = r#"{
1492 "production_models": [{
1493 "hydro_id": 0,
1494 "selection_mode": "stage_ranges",
1495 "stage_ranges": [
1496 {
1497 "start_stage_id": 0, "end_stage_id": 24,
1498 "model": "fpha",
1499 "fpha_config": { "source": "computed" },
1500 "productivity_mw_per_m3s": 1.0
1501 }
1502 ]
1503 }]
1504 }"#;
1505 let f = write_json(json);
1506 let err = parse_production_models(f.path()).unwrap_err();
1507 match &err {
1508 LoadError::SchemaError { field, message, .. } => {
1509 assert!(
1510 field.contains("productivity_mw_per_m3s"),
1511 "field should contain 'productivity_mw_per_m3s', got: {field}"
1512 );
1513 assert_eq!(
1514 message, "productivity_mw_per_m3s must not be set when model is 'fpha'",
1515 "message must match exactly"
1516 );
1517 }
1518 other => panic!("expected SchemaError, got: {other:?}"),
1519 }
1520 }
1521
1522 #[test]
1524 fn test_productivity_negative_rejected() {
1525 let json = r#"{
1526 "production_models": [{
1527 "hydro_id": 0,
1528 "selection_mode": "stage_ranges",
1529 "stage_ranges": [
1530 {
1531 "start_stage_id": 0, "end_stage_id": 24,
1532 "model": "constant_productivity",
1533 "productivity_mw_per_m3s": -1.0
1534 }
1535 ]
1536 }]
1537 }"#;
1538 let f = write_json(json);
1539 let err = parse_production_models(f.path()).unwrap_err();
1540 assert!(
1541 matches!(err, LoadError::SchemaError { .. }),
1542 "expected SchemaError, got: {err:?}"
1543 );
1544 }
1545
1546 #[test]
1548 fn test_productivity_zero_accepted() {
1549 let json = r#"{
1550 "production_models": [{
1551 "hydro_id": 0,
1552 "selection_mode": "stage_ranges",
1553 "stage_ranges": [
1554 {
1555 "start_stage_id": 0, "end_stage_id": 24,
1556 "model": "constant_productivity",
1557 "productivity_mw_per_m3s": 0.0
1558 }
1559 ]
1560 }]
1561 }"#;
1562 let f = write_json(json);
1563 let parsed = parse_production_models(f.path())
1564 .expect("zero productivity must be accepted as a planned-outage marker")
1565 .configs;
1566 let SelectionMode::StageRanges { ranges } = &parsed[0].selection_mode else {
1567 panic!("expected StageRanges");
1568 };
1569 assert_eq!(ranges[0].productivity_mw_per_m3s, Some(0.0));
1570 }
1571
1572 #[test]
1574 fn test_seasonal_productivity_mw_per_m3s() {
1575 let json = r#"{
1576 "production_models": [{
1577 "hydro_id": 0,
1578 "selection_mode": "seasonal",
1579 "default_model": "constant_productivity",
1580 "seasons": [
1581 {
1582 "season_id": 0,
1583 "model": "constant_productivity",
1584 "productivity_mw_per_m3s": 0.75
1585 }
1586 ]
1587 }]
1588 }"#;
1589 let f = write_json(json);
1590 let models = parse_production_models(f.path()).unwrap().configs;
1591 match &models[0].selection_mode {
1592 SelectionMode::Seasonal { seasons, .. } => {
1593 assert_eq!(seasons[0].productivity_mw_per_m3s, Some(0.75));
1594 }
1595 other => panic!("expected Seasonal, got: {other:?}"),
1596 }
1597 }
1598
1599 #[test]
1602 fn test_non_fpha_seasonal_without_productivity_is_accepted() {
1603 let json = r#"{
1604 "production_models": [{
1605 "hydro_id": 0,
1606 "selection_mode": "seasonal",
1607 "default_model": "constant_productivity",
1608 "seasons": [
1609 {
1610 "season_id": 0,
1611 "model": "constant_productivity"
1612 }
1613 ]
1614 }]
1615 }"#;
1616 let f = write_json(json);
1617 let models = parse_production_models(f.path()).unwrap().configs;
1618 match &models[0].selection_mode {
1619 SelectionMode::Seasonal { seasons, .. } => {
1620 assert!(
1621 seasons[0].productivity_mw_per_m3s.is_none(),
1622 "expected None when field is omitted, got: {:?}",
1623 seasons[0].productivity_mw_per_m3s
1624 );
1625 }
1626 other => panic!("expected Seasonal, got: {other:?}"),
1627 }
1628 }
1629
1630 #[test]
1632 fn test_fpha_stage_range_with_productivity_still_rejected() {
1633 let json = r#"{
1634 "production_models": [{
1635 "hydro_id": 0,
1636 "selection_mode": "stage_ranges",
1637 "stage_ranges": [
1638 {
1639 "start_stage_id": 0, "end_stage_id": 24,
1640 "model": "fpha",
1641 "fpha_config": { "source": "computed" },
1642 "productivity_mw_per_m3s": 0.9
1643 }
1644 ]
1645 }]
1646 }"#;
1647 let f = write_json(json);
1648 let err = parse_production_models(f.path()).unwrap_err();
1649 match &err {
1650 LoadError::SchemaError { message, .. } => {
1651 assert!(
1652 message.contains("must not be set when model is 'fpha'"),
1653 "message should mention fpha rejection, got: {message}"
1654 );
1655 }
1656 other => panic!("expected SchemaError, got: {other:?}"),
1657 }
1658 }
1659
1660 #[test]
1662 fn test_negative_productivity_still_rejected() {
1663 let json = r#"{
1664 "production_models": [{
1665 "hydro_id": 0,
1666 "selection_mode": "stage_ranges",
1667 "stage_ranges": [
1668 {
1669 "start_stage_id": 0, "end_stage_id": 24,
1670 "model": "constant_productivity",
1671 "productivity_mw_per_m3s": -0.1
1672 }
1673 ]
1674 }]
1675 }"#;
1676 let f = write_json(json);
1677 let err = parse_production_models(f.path()).unwrap_err();
1678 match &err {
1679 LoadError::SchemaError { message, .. } => {
1680 assert!(
1681 message.contains("productivity_mw_per_m3s must be finite and non-negative"),
1682 "message should mention non-negative requirement, got: {message}"
1683 );
1684 }
1685 other => panic!("expected SchemaError, got: {other:?}"),
1686 }
1687 }
1688
1689 #[test]
1693 fn test_plane_reduction_absent_is_none() {
1694 let json = r#"{
1695 "production_models": [{
1696 "hydro_id": 0,
1697 "selection_mode": "stage_ranges",
1698 "stage_ranges": [{ "start_stage_id": 0, "end_stage_id": null, "model": "fpha", "fpha_config": { "source": "computed" } }]
1699 }]
1700 }"#;
1701 let f = write_json(json);
1702 let file = parse_production_models(f.path()).unwrap();
1703 assert!(
1704 file.plane_reduction.is_none(),
1705 "absent block must resolve to None, got: {:?}",
1706 file.plane_reduction
1707 );
1708 assert_eq!(file.configs.len(), 1);
1709 }
1710
1711 #[test]
1713 fn test_plane_reduction_angle_valid() {
1714 let json = r#"{
1715 "production_models": [],
1716 "fpha_plane_reduction": { "method": "angle", "tolerance_deg": 5.0 }
1717 }"#;
1718 let f = write_json(json);
1719 let file = parse_production_models(f.path()).unwrap();
1720 assert_eq!(
1721 file.plane_reduction,
1722 Some(PlaneReductionConfig::Angle { tolerance_deg: 5.0 })
1723 );
1724 }
1725
1726 #[test]
1728 fn test_plane_reduction_distance_valid() {
1729 let json = r#"{
1730 "production_models": [],
1731 "fpha_plane_reduction": { "method": "distance", "tolerance_pct": 0.5, "n_samples": 64 }
1732 }"#;
1733 let f = write_json(json);
1734 let file = parse_production_models(f.path()).unwrap();
1735 assert_eq!(
1736 file.plane_reduction,
1737 Some(PlaneReductionConfig::Distance {
1738 tolerance_pct: 0.5,
1739 n_samples: 64
1740 })
1741 );
1742 }
1743
1744 #[test]
1746 fn test_plane_reduction_angle_out_of_range() {
1747 let json = r#"{
1748 "production_models": [],
1749 "fpha_plane_reduction": { "method": "angle", "tolerance_deg": 95.0 }
1750 }"#;
1751 let f = write_json(json);
1752 let err = parse_production_models(f.path()).unwrap_err();
1753 match &err {
1754 LoadError::SchemaError { field, message, .. } => {
1755 assert_eq!(field, "fpha_plane_reduction");
1756 assert!(
1757 message.contains("[0, 90]"),
1758 "message should name the [0, 90] range, got: {message}"
1759 );
1760 }
1761 other => panic!("expected SchemaError, got: {other:?}"),
1762 }
1763 }
1764
1765 #[test]
1767 fn test_plane_reduction_distance_negative_tolerance() {
1768 let json = r#"{
1769 "production_models": [],
1770 "fpha_plane_reduction": { "method": "distance", "tolerance_pct": -1.0, "n_samples": 64 }
1771 }"#;
1772 let f = write_json(json);
1773 let err = parse_production_models(f.path()).unwrap_err();
1774 match &err {
1775 LoadError::SchemaError { field, .. } => {
1776 assert_eq!(field, "fpha_plane_reduction");
1777 }
1778 other => panic!("expected SchemaError, got: {other:?}"),
1779 }
1780 }
1781
1782 #[test]
1784 fn test_plane_reduction_distance_zero_samples() {
1785 let json = r#"{
1786 "production_models": [],
1787 "fpha_plane_reduction": { "method": "distance", "tolerance_pct": 0.5, "n_samples": 0 }
1788 }"#;
1789 let f = write_json(json);
1790 let err = parse_production_models(f.path()).unwrap_err();
1791 match &err {
1792 LoadError::SchemaError { field, .. } => {
1793 assert_eq!(field, "fpha_plane_reduction");
1794 }
1795 other => panic!("expected SchemaError, got: {other:?}"),
1796 }
1797 }
1798
1799 #[test]
1801 fn test_plane_reduction_cross_method_field_rejected() {
1802 let json = r#"{
1803 "production_models": [],
1804 "fpha_plane_reduction": { "method": "angle", "tolerance_pct": 5.0 }
1805 }"#;
1806 let f = write_json(json);
1807 let err = parse_production_models(f.path()).unwrap_err();
1808 assert!(
1809 matches!(
1810 err,
1811 LoadError::SchemaError { .. } | LoadError::ParseError { .. }
1812 ),
1813 "cross-method field must be rejected, got: {err:?}"
1814 );
1815 }
1816
1817 #[test]
1822 fn test_plane_reduction_foreign_field_alongside_required_is_rejected() {
1823 let json = r#"{
1824 "production_models": [],
1825 "fpha_plane_reduction": { "method": "angle", "tolerance_deg": 2.0, "tolerance_pct": 5.0 }
1826 }"#;
1827 let f = write_json(json);
1828 let err = parse_production_models(f.path()).unwrap_err();
1829 assert!(
1830 matches!(
1831 err,
1832 LoadError::SchemaError { .. } | LoadError::ParseError { .. }
1833 ),
1834 "a foreign field alongside the required one must be rejected by deny_unknown_fields, got: {err:?}"
1835 );
1836 }
1837
1838 #[test]
1842 fn reference_volume_absolute_parses() {
1843 let json = r#"{
1844 "production_models": [{
1845 "hydro_id": 0,
1846 "selection_mode": "stage_ranges",
1847 "stage_ranges": [{
1848 "start_stage_id": 0, "end_stage_id": null,
1849 "model": "constant_productivity",
1850 "productivity_mw_per_m3s": 0.9,
1851 "reference_volume": { "volume_hm3": 1234.5 }
1852 }]
1853 }]
1854 }"#;
1855 let f = write_json(json);
1856 let models = parse_production_models(f.path()).unwrap().configs;
1857 match &models[0].selection_mode {
1858 SelectionMode::StageRanges { ranges } => {
1859 assert_eq!(
1860 ranges[0].reference_volume,
1861 Some(ReferenceVolume::AbsoluteHm3(1234.5))
1862 );
1863 }
1864 other => panic!("expected StageRanges, got: {other:?}"),
1865 }
1866 }
1867
1868 #[test]
1870 fn reference_volume_percentile_parses() {
1871 let json = r#"{
1872 "production_models": [{
1873 "hydro_id": 0,
1874 "selection_mode": "stage_ranges",
1875 "stage_ranges": [{
1876 "start_stage_id": 0, "end_stage_id": null,
1877 "model": "constant_productivity",
1878 "productivity_mw_per_m3s": 0.9,
1879 "reference_volume": { "percentile": 0.5 }
1880 }]
1881 }]
1882 }"#;
1883 let f = write_json(json);
1884 let models = parse_production_models(f.path()).unwrap().configs;
1885 match &models[0].selection_mode {
1886 SelectionMode::StageRanges { ranges } => {
1887 assert_eq!(
1888 ranges[0].reference_volume,
1889 Some(ReferenceVolume::Percentile(0.5))
1890 );
1891 }
1892 other => panic!("expected StageRanges, got: {other:?}"),
1893 }
1894 }
1895
1896 #[test]
1899 fn reference_volume_both_set_is_rejected() {
1900 let json = r#"{
1901 "production_models": [{
1902 "hydro_id": 0,
1903 "selection_mode": "seasonal",
1904 "default_model": "constant_productivity",
1905 "seasons": [{
1906 "season_id": 0,
1907 "model": "constant_productivity",
1908 "productivity_mw_per_m3s": 0.9,
1909 "reference_volume": { "volume_hm3": 1.0, "percentile": 0.5 }
1910 }]
1911 }]
1912 }"#;
1913 let f = write_json(json);
1914 let err = parse_production_models(f.path()).unwrap_err();
1915 match &err {
1916 LoadError::SchemaError { field, message, .. } => {
1917 assert!(
1918 message.contains("mutually exclusive"),
1919 "message should contain 'mutually exclusive', got: {message}"
1920 );
1921 assert!(
1922 field.contains("seasons"),
1923 "field should name seasons, got: {field}"
1924 );
1925 }
1926 other => panic!("expected SchemaError, got: {other:?}"),
1927 }
1928 }
1929
1930 #[test]
1932 fn reference_volume_neither_set_is_rejected() {
1933 let json = r#"{
1934 "production_models": [{
1935 "hydro_id": 0,
1936 "selection_mode": "stage_ranges",
1937 "stage_ranges": [{
1938 "start_stage_id": 0, "end_stage_id": null,
1939 "model": "constant_productivity",
1940 "productivity_mw_per_m3s": 0.9,
1941 "reference_volume": {}
1942 }]
1943 }]
1944 }"#;
1945 let f = write_json(json);
1946 let err = parse_production_models(f.path()).unwrap_err();
1947 match &err {
1948 LoadError::SchemaError { message, .. } => {
1949 assert!(
1950 message.contains("exactly one"),
1951 "message should require exactly one field, got: {message}"
1952 );
1953 }
1954 other => panic!("expected SchemaError, got: {other:?}"),
1955 }
1956 }
1957
1958 #[test]
1960 fn reference_volume_percentile_out_of_range_is_rejected() {
1961 let json = r#"{
1962 "production_models": [{
1963 "hydro_id": 0,
1964 "selection_mode": "stage_ranges",
1965 "stage_ranges": [{
1966 "start_stage_id": 0, "end_stage_id": null,
1967 "model": "constant_productivity",
1968 "productivity_mw_per_m3s": 0.9,
1969 "reference_volume": { "percentile": 1.5 }
1970 }]
1971 }]
1972 }"#;
1973 let f = write_json(json);
1974 let err = parse_production_models(f.path()).unwrap_err();
1975 match &err {
1976 LoadError::SchemaError { message, .. } => {
1977 assert!(
1978 message.contains("[0.0, 1.0]"),
1979 "message should cite the [0.0, 1.0] range, got: {message}"
1980 );
1981 }
1982 other => panic!("expected SchemaError, got: {other:?}"),
1983 }
1984 }
1985
1986 #[test]
1988 fn reference_volume_nonpositive_volume_is_rejected() {
1989 for bad in ["0.0", "-5.0"] {
1990 let json = format!(
1991 r#"{{
1992 "production_models": [{{
1993 "hydro_id": 0,
1994 "selection_mode": "stage_ranges",
1995 "stage_ranges": [{{
1996 "start_stage_id": 0, "end_stage_id": null,
1997 "model": "constant_productivity",
1998 "productivity_mw_per_m3s": 0.9,
1999 "reference_volume": {{ "volume_hm3": {bad} }}
2000 }}]
2001 }}]
2002 }}"#
2003 );
2004 let f = write_json(&json);
2005 let err = parse_production_models(f.path()).unwrap_err();
2006 match &err {
2007 LoadError::SchemaError { message, .. } => {
2008 assert!(
2009 message.contains("> 0.0"),
2010 "message should require > 0.0, got: {message}"
2011 );
2012 }
2013 other => panic!("expected SchemaError for volume_hm3={bad}, got: {other:?}"),
2014 }
2015 }
2016 }
2017
2018 #[test]
2020 fn reference_volume_on_season_entry_parses() {
2021 let json = r#"{
2022 "production_models": [{
2023 "hydro_id": 7,
2024 "selection_mode": "seasonal",
2025 "default_model": "constant_productivity",
2026 "seasons": [{
2027 "season_id": 0,
2028 "model": "constant_productivity",
2029 "productivity_mw_per_m3s": 0.9,
2030 "reference_volume": { "volume_hm3": 800.0 }
2031 }]
2032 }]
2033 }"#;
2034 let f = write_json(json);
2035 let models = parse_production_models(f.path()).unwrap().configs;
2036 match &models[0].selection_mode {
2037 SelectionMode::Seasonal { seasons, .. } => {
2038 assert_eq!(
2039 seasons[0].reference_volume,
2040 Some(ReferenceVolume::AbsoluteHm3(800.0))
2041 );
2042 }
2043 other => panic!("expected Seasonal, got: {other:?}"),
2044 }
2045 }
2046
2047 #[cfg(feature = "schema")]
2050 #[test]
2051 fn reference_volume_appears_in_generated_schema() {
2052 let schema = schemars::schema_for!(RawProductionModelFile);
2053 let value = serde_json::to_value(&schema).unwrap();
2054 let text = serde_json::to_string(&value).unwrap();
2055 assert!(
2056 text.contains("reference_volume"),
2057 "generated schema must expose the reference_volume property"
2058 );
2059 }
2060}